PageSourceSearch

https://mukba.ng/assets/recent.js?v=1582b25b

js mukba.ng collected 2026-09-25 03:38:16 UTC 31,535 bytes, 902 lines download raw bytes

1import * as THREE from 'three';
2import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
3
4// Rim Glow Shader for model outline effect (copied from post-3d.js)
5const RimGlowShader = {
6    uniforms: {
7        time: { value: 0 },
8        glowColor: { value: new THREE.Color(1.0, 1.0, 1.0) },
9        rimPower: { value: 2.5 },
10        pulseSpeed: { value: 2.0 },
11        pulseMin: { value: 0.3 },
12        pulseMax: { value: 0.6 },
13        glowStrength: { value: 1.1 },
14        baseMap: { value: null },
15        hasBaseMap: { value: 0.0 }
16    },
17    vertexShader: `
18        varying vec3 vNormal;
19        varying vec3 vViewDir;
20        varying vec2 vUv;
21
22        void main() {
23            vUv = uv;
24            vNormal = normalize(normalMatrix * normal);
25            vec4 worldPos = modelMatrix * vec4(position, 1.0);
26            vViewDir = normalize(cameraPosition - worldPos.xyz);
27            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
28        }
29    `,
30    fragmentShader: `
31        uniform float time;
32        uniform vec3 glowColor;
33        uniform float rimPower;
34        uniform float pulseSpeed;
35        uniform float pulseMin;
36        uniform float pulseMax;
37        uniform float glowStrength;
38        uniform sampler2D baseMap;
39        uniform float hasBaseMap;
40
41        varying vec3 vNormal;
42        varying vec3 vViewDir;
43        varying vec2 vUv;
44
45        void main() {
46            float fresnel = 1.0 - abs(dot(normalize(vNormal), normalize(vViewDir)));
47            fresnel = pow(fresnel, rimPower);
48
49            float pulse = sin(time * pulseSpeed);
50            pulse = pulseMin + (pulseMax - pulseMin) * (pulse * 0.5 + 0.5);
51
52            float rimGlow = fresnel * pulse * glowStrength;
53
54            float surfaceAlpha = hasBaseMap > 0.5 ? texture2D(baseMap, vUv).a : 1.0;
55
56            gl_FragColor = vec4(glowColor * rimGlow, rimGlow * surfaceAlpha);
57        }
58    `
59};
60
61/**
62 * Apply rim glow material to model for outline effect.
63 */
64function applyRimGlowMaterial(model, rimMaterials) {
65    model.traverse((child) => {
66        if (child.isMesh) {
67            const originalMaterial = child.material;
68            const rimMaterial = new THREE.ShaderMaterial({
69                uniforms: {
70                    time: { value: 0 },
71                    glowColor: { value: new THREE.Color(1.0, 1.0, 1.0) },
72                    rimPower: { value: 2.5 },
73                    pulseSpeed: { value: 2.0 },
74                    pulseMin: { value: 0.3 },
75                    pulseMax: { value: 0.6 },
76                    glowStrength: { value: 1.1 },
77                    baseMap: { value: originalMaterial.map || null },
78                    hasBaseMap: { value: originalMaterial.map ? 1.0 : 0.0 }
79                },
80                vertexShader: RimGlowShader.vertexShader,
81                fragmentShader: RimGlowShader.fragmentShader,
82                transparent: true,
83                depthWrite: false,
84                side: THREE.DoubleSide
85            });
86            child.material = rimMaterial;
87            rimMaterials.push(rimMaterial);
88        }
89    });
90}
91
92
93/**
94 * Find the optimal frame where the model center projects closest to the video center.
95 * Algorithm matches backend: distance = sqrt(dx² + dy²) / max(width, height)
96 *
97 * @param {Object} framePoses - The pose data from the GLB
98 * @param {number} fov - Camera field of view in degrees
99 * @param {number} width - Image width
100 * @param {number} height - Image height
101 * @returns {Object} - { timestamp, poseIndex, distance, pose }
102 */
103function findOptimalFrame(framePoses, fov, width, height) {
104    if (!framePoses || !framePoses.poses || framePoses.poses.length === 0) {
105        return null;
106    }
107
108    const poses = framePoses.poses;
109
110    // Create a temporary camera for projection
111    const camera = new THREE.PerspectiveCamera(fov, width / height, 0.1, 1000);
112
113    // Temporary objects for decomposition
114    const tempMatrix = new THREE.Matrix4();
115    const tempPos = new THREE.Vector3();
116    const tempQuat = new THREE.Quaternion();
117    const tempScale = new THREE.Vector3();
118
119    // Model center is at origin (0, 0, 0)
120    const modelCenter = new THREE.Vector3(0, 0, 0);
121
122    let bestPose = null;
123    let bestDistance = Infinity;
124    let bestIndex = 0;
125
126    for (let i = 0; i < poses.length; i++) {
127        const pose = poses[i];
128
129        // Decompose the transform matrix to get camera position and rotation
130        tempMatrix.fromArray(pose.transform);
131        tempMatrix.decompose(tempPos, tempQuat, tempScale);
132
133        // Set camera position and rotation
134        camera.position.copy(tempPos);
135        camera.quaternion.copy(tempQuat);
136        camera.updateMatrixWorld();
137
138        // Project model center to normalized device coordinates (-1 to 1)
139        const projected = modelCenter.clone().project(camera);
140
141        // Check if behind camera (z >= 1 in NDC means behind)
142        if (projected.z >= 1) {
143            continue;
144        }
145
146        // Convert NDC to pixel space for distance calculation
147        const screenX = (projected.x + 1) * 0.5 * width;
148        const screenY = (1 - projected.y) * 0.5 * height;
149
150        // Distance from screen center, normalized by max dimension
151        const dx = screenX - width / 2;
152        const dy = screenY - height / 2;
153        const distance = Math.sqrt(dx * dx + dy * dy) / Math.max(width, height);
154
155        if (distance < bestDistance) {
156            bestDistance = distance;
157            bestPose = pose;
158            bestIndex = i;
159        }
160    }
161
162    if (bestPose) {
163        return {
164            timestamp: bestPose.timestampSeconds,
165            poseIndex: bestIndex,
166            distance: bestDistance,
167            pose: bestPose
168        };
169    }
170
171    return null;
172}
173
174
175/**
176 * Load a GLB model and extract pose data.
177 *
178 * @param {string} modelUrl - URL of the GLB file
179 * @returns {Promise<Object>} - { model, framePoses, cameraIntrinsics }
180 */
181async function loadModelWithPoseData(modelUrl) {
182    return new Promise((resolve, reject) => {
183        const loader = new GLTFLoader();
184
185        loader.load(
186            modelUrl,
187            (gltf) => {
188                // Extract pose data from GLB extras
189                const extras = gltf.parser?.json?.extras || gltf.userData || {};
190
191                let framePoses = null;
192                let cameraIntrinsics = null;
193
194                if (extras.framePoses && extras.framePoses.poses && extras.framePoses.poses.length > 0) {
195                    framePoses = extras.framePoses;
196                    cameraIntrinsics = extras.framePoses.cameraIntrinsics;
197                } else if (extras.poses && extras.poses.length > 0) {
198                    framePoses = {
199                        poses: extras.poses,
200                        videoFps: extras.videoFps,
201                        videoDurationSeconds: extras.videoDurationSeconds
202                    };
203                    cameraIntrinsics = extras.cameraIntrinsics;
204                }
205
206                resolve({
207                    gltf,
208                    model: gltf.scene,
209                    framePoses,
210                    cameraIntrinsics
211                });
212            },
213            undefined,
214            (error) => {
215                reject(error);
216            }
217        );
218    });
219}
220
221/**
222 * Render the 3D model onto a canvas from a specific camera pose.
223 * This mirrors the exact logic from ModelOutlineOverlay in post-3d.js
224 *
225 * @param {THREE.Object3D} model - The 3D model
226 * @param {Object} pose - The camera pose
227 * @param {number} fov - Camera FOV
228 * @param {number} width - Canvas width
229 * @param {number} height - Canvas height
230 * @returns {HTMLCanvasElement} - Canvas with rendered model
231 */
232function renderModelAtPose(model, pose, fov, width, height) {
233    // Create scene
234    const scene = new THREE.Scene();
235    scene.background = null;
236
237    // Clone and normalize model (steps 1-2 from ModelOutlineOverlay)
238    const modelClone = model.clone();
239
240    // Step 1: Calculate normalization scale
241    const originalBox = new THREE.Box3().setFromObject(modelClone);
242    const originalSize = originalBox.getSize(new THREE.Vector3());
243    const originalMaxDim = Math.max(originalSize.x, originalSize.y, originalSize.z);
244    const normalizeScale = 3 / originalMaxDim;
245
246    // Step 2: Apply scale
247    modelClone.scale.multiplyScalar(normalizeScale);
248
249    // Center at origin
250    const finalBox = new THREE.Box3().setFromObject(modelClone);
251    const finalCenter = finalBox.getCenter(new THREE.Vector3());
252    modelClone.position.sub(finalCenter);
253
254    // Calculate modelCenterOffset (step 4 prep)
255    const poseScaleFactor = normalizeScale;
256    const modelCenterOffset = finalCenter.clone().multiplyScalar(poseScaleFactor);
257
258    scene.add(modelClone);
259
260    // Lighting
261    const ambientLight = new THREE.AmbientLight(0xffffff, 1.0);
262    scene.add(ambientLight);
263
264    const directionalLight1 = new THREE.DirectionalLight(0xffffff, 1.2);
265    directionalLight1.position.set(5, 10, 7.5);
266    scene.add(directionalLight1);
267
268    const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.5);
269    directionalLight2.position.set(-5, -5, -5);
270    scene.add(directionalLight2);
271
272    // Camera setup
273    const camera = new THREE.PerspectiveCamera(fov, width / height, 0.01, 1000);
274
275    // Apply pose transform
276    const tempMatrix = new THREE.Matrix4();
277    const tempQuat = new THREE.Quaternion();
278    const tempScale = new THREE.Vector3();
279
280    tempMatrix.fromArray(pose.transform);
281    tempMatrix.decompose(camera.position, tempQuat, tempScale);
282    camera.quaternion.copy(tempQuat);
283
284    // Match ModelOutlineOverlay exactly
285    camera.position.multiplyScalar(poseScaleFactor);
286    camera.position.sub(modelCenterOffset);
287
288    camera.updateProjectionMatrix();
289
290    // Renderer
291    const renderer = new THREE.WebGLRenderer({
292        antialias: true,
293        alpha: true,
294        preserveDrawingBuffer: true
295    });
296    renderer.setSize(width, height);
297    renderer.setClearColor(0x000000, 0);
298    renderer.toneMapping = THREE.ACESFilmicToneMapping;
299    renderer.toneMappingExposure = 1.2;
300
301    // Render
302    renderer.render(scene, camera);
303
304    // Get canvas
305    const canvas = renderer.domElement;
306
307    // Cleanup
308    renderer.dispose();
309
310    return canvas;
311}
312
313/**
314 * Add 3D model overlay to an existing card element.
315 */
316function add3DOverlayToCard(card, model, pose, imgWidth, imgHeight) {
317    // Remove loading indicator
318    const loading = card.querySelector('.card-loading');
319    if (loading) loading.remove();
320
321    // Create 3D model canvas
322    const modelCanvas = document.createElement('canvas');
323    modelCanvas.className = 'card-model';
324    card.appendChild(modelCanvas);
325
326    // Set up scene
327    const scene = new THREE.Scene();
328    scene.background = null;
329
330    const modelClone = model.clone();
331
332    // Clone materials for the model
333    modelClone.traverse((child) => {
334        if (child.isMesh && child.material) {
335            child.material = child.material.clone();
336        }
337    });
338
339    // Center model at origin
340    const box = new THREE.Box3().setFromObject(modelClone);
341    const center = box.getCenter(new THREE.Vector3());
342    const size = box.getSize(new THREE.Vector3());
343    modelClone.position.sub(center);
344
345    scene.add(modelClone);
346
347    // Lighting
348    scene.add(new THREE.AmbientLight(0xffffff, 1.0));
349    const dir1 = new THREE.DirectionalLight(0xffffff, 1.2);
350    dir1.position.set(5, 10, 7.5);
351    scene.add(dir1);
352    const dir2 = new THREE.DirectionalLight(0xffffff, 0.5);
353    dir2.position.set(-5, -5, -5);
354    scene.add(dir2);
355
356    // Camera - use pose rotation but fixed distance (centered)
357    const fov = 50;
358    const camera = new THREE.PerspectiveCamera(fov, imgWidth / imgHeight, 0.01, 1000);
359
360    // Extract rotation from pose
361    const tempMatrix = new THREE.Matrix4();
362    const tempPos = new THREE.Vector3();
363    const tempQuat = new THREE.Quaternion();
364    const tempScale = new THREE.Vector3();
365    tempMatrix.fromArray(pose.transform);
366    tempMatrix.decompose(tempPos, tempQuat, tempScale);
367
368    // Calculate distance to fit model in view (with some padding)
369    const maxDim = Math.max(size.x, size.y, size.z);
370    const fovRad = fov * Math.PI / 180;
371    const distance = (maxDim / 2) / Math.tan(fovRad / 2) * 1.5;
372
373    // Apply pose rotation, but use fixed distance from origin
374    camera.quaternion.copy(tempQuat);
375    const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(tempQuat);
376    camera.position.copy(forward).multiplyScalar(-distance);
377    camera.updateProjectionMatrix();
378
379    // Renderer
380    const renderer = new THREE.WebGLRenderer({ canvas: modelCanvas, antialias: true, alpha: true });
381    renderer.setSize(imgWidth, imgHeight, false);
382    renderer.setClearColor(0x000000, 0);
383
384    // Camera up vector for rotation
385    const cameraUp = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion);
386
387    // Rotation around camera's up vector (screen Y)
388    const DEG_TO_RAD = Math.PI / 180;
389    const baseCameraPos = camera.position.clone();
390    const baseCameraQuat = camera.quaternion.clone();
391    const MAX_ANGLE = 30 * DEG_TO_RAD;
392    const DURATION = 350; // ms
393
394    let tweenStart = null;
395    let tweenFromAngle = 0;
396    let tweenToAngle = 0;
397    let currentAngle = 0;
398    let animationRunning = false;
399
400    // Ease-in-out cubic
401    function easeInOutCubic(t) {
402        return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
403    }
404
405    function animate(time) {
406        // Time-based tween for rotation
407        if (tweenStart !== null) {
408            const elapsed = time - tweenStart;
409            const t = Math.min(elapsed / DURATION, 1);
410            const eased = easeInOutCubic(t);
411            currentAngle = tweenFromAngle + (tweenToAngle - tweenFromAngle) * eased;
412            if (t >= 1) tweenStart = null;
413        }
414
415        if (Math.abs(currentAngle) > 0.001) {
416            // Rotate camera around its own up vector (screen Y)
417            const rotationQuat = new THREE.Quaternion().setFromAxisAngle(cameraUp, -currentAngle);
418            camera.position.copy(baseCameraPos).applyQuaternion(rotationQuat);
419            camera.quaternion.copy(baseCameraQuat).premultiply(rotationQuat);
420        } else {
421            camera.position.copy(baseCameraPos);
422            camera.quaternion.copy(baseCameraQuat);
423        }
424
425        renderer.render(scene, camera);
426
427        // Only continue animation if tweening or not at rest
428        if (tweenStart !== null || Math.abs(currentAngle) > 0.001) {
429            requestAnimationFrame(animate);
430        } else {
431            animationRunning = false;
432        }
433    }
434
435    function startAnimation() {
436        if (!animationRunning) {
437            animationRunning = true;
438            requestAnimationFrame(animate);
439        }
440    }
441
442    // Initial render (single frame)
443    renderer.render(scene, camera);
444
445    // Rotation animation on hover (desktop)
446    card.addEventListener('mouseenter', () => {
447        tweenFromAngle = currentAngle;
448        tweenToAngle = MAX_ANGLE;
449        tweenStart = performance.now();
450        startAnimation();
451    });
452
453    card.addEventListener('mouseleave', () => {
454        tweenFromAngle = currentAngle;
455        tweenToAngle = 0;
456        tweenStart = performance.now();
457        startAnimation();
458    });
459
460    // Touch drag rotation (mobile)
461    let touchStartX = null;
462    let touchStartAngle = 0;
463    let isDragging = false;
464
465    card.addEventListener('touchstart', (e) => {
466        if (e.touches.length === 1) {
467            touchStartX = e.touches[0].clientX;
468            touchStartAngle = currentAngle;
469            isDragging = true;
470            tweenStart = null; // Stop any ongoing tween
471            card.style.transition = 'none';
472            startAnimation();
473        }
474    }, { passive: true });
475
476    card.addEventListener('touchmove', (e) => {
477        if (!isDragging || touchStartX === null) return;
478        const deltaX = e.touches[0].clientX - touchStartX;
479        const sensitivity = 0.005;
480        currentAngle = Math.max(-MAX_ANGLE, Math.min(MAX_ANGLE, touchStartAngle + deltaX * sensitivity));
481        card.style.transform = `rotateY(${currentAngle / DEG_TO_RAD}deg)`;
482        startAnimation();
483    }, { passive: true });
484
485    const endTouch = () => {
486        if (isDragging) {
487            isDragging = false;
488            touchStartX = null;
489            card.style.transition = '';
490            card.style.transform = '';
491            tweenFromAngle = currentAngle;
492            tweenToAngle = 0;
493            tweenStart = performance.now();
494            startAnimation();
495        }
496    };
497
498    card.addEventListener('touchend', endTouch, { passive: true });
499    card.addEventListener('touchcancel', endTouch, { passive: true });
500}
501
502
503const API_BASE = 'https://mrjbcfjfavhvyxnbmhex.supabase.co/functions/v1';
504
505// Cached post data and cards
506let imagePosts = null;  // null = not fetched yet
507let videoPosts = null;
508let imageCards = [];     // { container, card, postData, post }
509let videoCards = [];
510let videoFetchPromise = null;
511
512/**
513 * Fetch recent posts from API.
514 * @param {string} type - "all" | "image" | "video"
515 */
516async function fetchRecentPosts(type) {
517    const res = await fetch(`${API_BASE}/list-recent-posts`, {
518        method: 'POST',
519        headers: { 'Content-Type': 'application/json' },
520        body: JSON.stringify({ type, limit: 4 })
521    });
522    if (!res.ok) throw new Error('Failed to fetch posts');
523    const data = await res.json();
524    return data.posts || [];
525}
526
527/**
528 * Load an image and return it as an HTMLImageElement.
529 */
530function loadImage(url) {
531    return new Promise((resolve, reject) => {
532        const img = new Image();
533        img.crossOrigin = 'anonymous';
534        img.onload = () => resolve(img);
535        img.onerror = reject;
536        img.src = url;
537    });
538}
539
540/**
541 * Create an empty placeholder card.
542 */
543function createCard(post) {
544    const container = document.createElement('div');
545    container.className = 'video-card-container';
546    container.dataset.postType = post.postType;
547
548    const card = document.createElement('div');
549    card.className = 'video-card';
550
551    const thumbnail = document.createElement('div');
552    thumbnail.className = 'thumbnail';
553    thumbnail.style.background = '#1a1a1a';
554    card.appendChild(thumbnail);
555
556    const loading = document.createElement('div');
557    loading.className = 'card-loading';
558    loading.textContent = 'Loading...';
559    card.appendChild(loading);
560
561    if (post.likes) {
562        const likeCount = document.createElement('div');
563        likeCount.className = 'like-count';
564        likeCount.innerHTML = `
565            <svg viewBox="0 0 24 24" fill="currentColor">
566                <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
567            </svg>
568            ${post.likes}
569        `;
570        card.appendChild(likeCount);
571    }
572
573    container.appendChild(card);
574
575    card.addEventListener('click', () => {
576        window.location.href = `/p?id=${post.shortId}`;
577    });
578
579    const authorRow = document.createElement('a');
580    authorRow.className = 'author-row';
581    authorRow.href = post.creatorUrl;
582    authorRow.innerHTML = `
583        <div class="avatar"></div>
584        <span class="username">${post.creatorName}</span>
585    `;
586    container.appendChild(authorRow);
587
588    return { container, card };
589}
590
591function postDataToPost(postData) {
592    return {
593        id: postData.id,
594        shortId: postData.short_id,
595        postType: postData.post_type,
596        createdAt: postData.created_at,
597        creatorName: postData.username ? `@${postData.username}` : (postData.display_name || 'Anonymous'),
598        creatorUrl: `/@${postData.username || postData.poster_id}`,
599        likes: postData.num_likes || ''
600    };
601}
602
603/**
604 * Load thumbnail for a card. Returns a promise that resolves when done.
605 */
606async function loadThumbnail(card, postData) {
607    if (!postData.preview_image_url) {
608        const loading = card.querySelector('.card-loading');
609        if (loading) loading.remove();
610        return;
611    }
612
613    const oldThumbnail = card.querySelector('.thumbnail');
614
615    // Load LQIP first if available (tiny, instant)
616    if (postData.image_lqip_url) {
617        const lqip = document.createElement('img');
618        lqip.className = 'thumbnail';
619        lqip.src = postData.image_lqip_url;
620        await new Promise((resolve) => {
621            lqip.onload = resolve;
622            lqip.onerror = resolve; // fall through on error
623        });
624        if (lqip.naturalWidth) {
625            card.replaceChild(lqip, oldThumbnail);
626        }
627    }
628
629    // Load full image
630    const img = document.createElement('img');
631    img.className = 'thumbnail';
632    img.src = postData.preview_image_url;
633
634    await new Promise((resolve, reject) => {
635        img.onload = resolve;
636        img.onerror = (e) => reject(new Error(`Image failed to load: ${postData.preview_image_url}`));
637    });
638
639    const imgWidth = img.naturalWidth;
640    const imgHeight = img.naturalHeight;
641
642    const current = card.querySelector('.thumbnail');
643    card.replaceChild(img, current);
644
645    return { imgWidth, imgHeight };
646}
647
648/**
649 * Load 3D overlay for a video post card.
650 */
651async function load3DOverlay(card, postData, imgWidth, imgHeight) {
652    const loading = card.querySelector('.card-loading');
653    if (loading) loading.textContent = 'Loading 3D...';
654
655    try {
656        const { model, framePoses, cameraIntrinsics } = await loadModelWithPoseData(postData.preview_model_url);
657        const fov = cameraIntrinsics?.horizontalFovDegrees || 75;
658
659        let pose = null;
660        if (framePoses) {
661            const optimalFrame = findOptimalFrame(framePoses, fov, imgWidth, imgHeight);
662            if (optimalFrame) pose = optimalFrame.pose;
663        }
664        if (!pose) {
665            pose = { transform: [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,5,1], timestampSeconds: 0 };
666        }
667
668        add3DOverlayToCard(card, model, pose, imgWidth, imgHeight);
669    } catch (err) {
670        console.warn(`Failed to load 3D model:`, err);
671        if (loading) loading.remove();
672    }
673}
674
675/**
676 * Build cards from post data array. Does not add to DOM.
677 */
678function buildCards(postsData) {
679    return postsData.map(postData => {
680        const post = postDataToPost(postData);
681        const { container, card } = createCard(post);
682        return { container, card, postData, post };
683    });
684}
685
686/**
687 * Load thumbnails and (for video posts) 3D overlays.
688 * Returns a promise that resolves when all thumbnails are loaded.
689 */
690async function loadCardAssets(cards) {
691    const isMobile = window.innerWidth <= 768;
692    let videoOverlayCount = 0;
693
694    const thumbnailPromises = cards.map(async ({ card, postData, post }) => {
695        try {
696            const result = await loadThumbnail(card, postData);
697
698            if (post.postType === 'image' || post.postType === 'vid3o') {
699                const loading = card.querySelector('.card-loading');
700                if (loading) loading.remove();
701                return;
702            }
703
704            // Video post: load 3D overlay
705            videoOverlayCount++;
706            if (isMobile && videoOverlayCount > 2) {
707                const loading = card.querySelector('.card-loading');
708                if (loading) loading.remove();
709                return;
710            }
711
712            if (result) {
713                await load3DOverlay(card, postData, result.imgWidth, result.imgHeight);
714            }
715        } catch (err) {
716            console.warn(`Failed to load card assets:`, err);
717        }
718    });
719
720    await Promise.allSettled(thumbnailPromises);
721}
722
723/**
724 * Ensure video posts are fetched and built.
725 */
726async function ensureVideoPosts() {
727    if (videoPosts !== null) return;
728    if (videoFetchPromise) {
729        await videoFetchPromise;
730        return;
731    }
732    videoFetchPromise = (async () => {
733        videoPosts = await fetchRecentPosts('video');
734        videoCards = buildCards(videoPosts);
735        loadCardAssets(videoCards); // fire and forget - loads in background
736    })();
737    await videoFetchPromise;
738}
739
740/**
741 * Apply the current filter to the grid.
742 */
743function applyFilter(type) {
744    const grid = document.getElementById('videoGrid');
745    grid.innerHTML = '';
746
747    if (type === 'image') {
748        if (imageCards.length === 0) {
749            grid.innerHTML = '<div class="loading-state">No posts yet</div>';
750            return;
751        }
752        imageCards.forEach(c => grid.appendChild(c.container));
753    } else if (type === 'video') {
754        if (videoCards.length === 0 && videoPosts !== null) {
755            grid.innerHTML = '<div class="loading-state">No posts yet</div>';
756            return;
757        }
758        if (videoPosts === null) {
759            grid.innerHTML = '<div class="loading-state">Loading...</div>';
760            return;
761        }
762        videoCards.forEach(c => grid.appendChild(c.container));
763    } else {
764        // All: intersperse by created_at
765        const all = [...imageCards, ...videoCards];
766        all.sort((a, b) => new Date(b.post.createdAt) - new Date(a.post.createdAt));
767        if (all.length === 0) {
768            grid.innerHTML = '<div class="loading-state">No posts yet</div>';
769            return;
770        }
771        all.forEach(c => grid.appendChild(c.container));
772    }
773}
774
775/**
776 * Set up filter chip handlers.
777 */
778function setupFilterChips() {
779    const chips = document.querySelectorAll('.filter-chip');
780    const uploadStepText = document.getElementById('uploadStepText');
781    chips.forEach(chip => {
782        chip.addEventListener('click', async () => {
783            const type = chip.dataset.type;
784            chips.forEach(c => c.classList.remove('active'));
785            chip.classList.add('active');
786
787            // Update URL hash
788            const hashMap = { image: '#images', video: '#videos', all: '#all' };
789            history.replaceState(null, '', hashMap[type] || '');
790
791            // Update sidebar "How it works" text
792            if (uploadStepText) {
793                if (type === 'video') uploadStepText.textContent = 'Upload a video';
794                else if (type === 'all') uploadStepText.textContent = 'Upload a photo or video';
795                else uploadStepText.textContent = 'Upload a photo';
796            }
797
798            // Ensure video posts are fetched if needed
799            if (type === 'video' || type === 'all') {
800                await ensureVideoPosts();
801            }
802
803            applyFilter(type);
804        });
805    });
806}
807
808async function init() {
809    const t0 = performance.now();
810    const ms = () => (performance.now() - t0).toFixed(0);
811    const timing = { apiStart: 0, apiEnd: 0, domReady: 0, images: [], done: 0 };
812
813    const grid = document.getElementById('videoGrid');
814    setupFilterChips();
815
816    try {
817        timing.apiStart = performance.now() - t0;
818        imagePosts = await fetchRecentPosts('image');
819        timing.apiEnd = performance.now() - t0;
820
821        imageCards = buildCards(imagePosts);
822        applyFilter('image');
823
824        // Apply initial filter from URL hash
825        const hashType = { '#images': 'image', '#videos': 'video', '#all': 'all' }[location.hash];
826        if (hashType && hashType !== 'image') {
827            const chip = document.querySelector(`.filter-chip[data-type="${hashType}"]`);
828            if (chip) chip.click();
829        }
830
831        timing.domReady = performance.now() - t0;
832
833        // Load thumbnails with per-image timing
834        const isMobile = window.innerWidth <= 768;
835        let videoOverlayCount = 0;
836
837        await Promise.allSettled(imageCards.map(async ({ card, postData, post }, i) => {
838            const imgTiming = { index: i, shortId: postData.short_id || '?', thumbStart: 0, thumbEnd: 0, size: '?', overlayStart: null, overlayEnd: null, error: null };
839            timing.images.push(imgTiming);
840            try {
841                imgTiming.thumbStart = performance.now() - t0;
842                const result = await loadThumbnail(card, postData);
843                imgTiming.thumbEnd = performance.now() - t0;
844                const perf = performance.getEntriesByName(postData.preview_image_url).pop();
845                imgTiming.size = perf?.encodedBodySize ? (perf.encodedBodySize / 1024 / 1024).toFixed(2) : '?';
846
847                if (post.postType === 'image' || post.postType === 'vid3o') {
848                    const loading = card.querySelector('.card-loading');
849                    if (loading) loading.remove();
850                    return;
851                }
852
853                videoOverlayCount++;
854                if (isMobile && videoOverlayCount > 2) {
855                    const loading = card.querySelector('.card-loading');
856                    if (loading) loading.remove();
857                    return;
858                }
859
860                if (result) {
861                    imgTiming.overlayStart = performance.now() - t0;
862                    await load3DOverlay(card, postData, result.imgWidth, result.imgHeight);
863                    imgTiming.overlayEnd = performance.now() - t0;
864                }
865            } catch (err) {
866                imgTiming.error = err.message;
867            }
868        }));
869
870        timing.done = performance.now() - t0;
871        ensureVideoPosts(); // fire and forget
872
873        // Print summary
874        const fmt = (v) => v !== null && v !== undefined ? `${Math.round(v)}ms` : '-';
875        const pad = (s, n) => String(s).padEnd(n);
876        timing.images.sort((a, b) => a.index - b.index);
877        console.log(`
878╔════════════════════════════════════════════════════════╗
879║              DISCOVER PAGE TIMING                      ║
880╠════════════════════════════════════════════════════════╣
881║ API call         │ ${pad(fmt(timing.apiStart) + ' → ' + fmt(timing.apiEnd), 36)}║
882║ API latency      │ ${pad(fmt(timing.apiEnd - timing.apiStart), 36)}║
883║ Cards in DOM     │ ${pad(fmt(timing.domReady), 36)}║
884╠════════════════════════════════════════════════════════╣
885║ #  │ Short ID        │ Size    │ Thumb DL   │ 3D / Error  ║
886╠────┼─────────────────┼─────────┼────────────┼─────────────╣${timing.images.map(img => `
887║ ${pad(img.index, 2)} │ ${pad(img.shortId, 15)} │ ${pad(img.size + 'MB', 7)} │ ${pad(img.thumbEnd > 0 ? fmt(img.thumbEnd - img.thumbStart) : 'FAIL', 10)} │ ${pad(img.error ? img.error.substring(0, 11) : (img.overlayStart !== null ? fmt(img.overlayEnd - img.overlayStart) : '-'), 11)} ║`).join('')}
888╠════════════════════════════════════════════════════════╣
889║ Total            │ ${pad(fmt(timing.done), 36)}║
890╚════════════════════════════════════════════════════════╝`);
891
892    } catch (error) {
893        console.error(error);
894        grid.innerHTML = `<div class="loading-state">Error: ${error.message}</div>`;
895    }
896}
897
898if (document.readyState === 'loading') {
899    document.addEventListener('DOMContentLoaded', init);
900} else {
901    init();
902}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.