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.