diff --git a/.gitignore b/.gitignore index 90df44029..37c0f754f 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,9 @@ packages/producer/src/services/fontData.generated.ts # Test artifacts my-video/ packages/studio/data/ + +# QA artifacts +qa-*.webm +scorecard.png +.worktrees/ +.desloppify/ diff --git a/.prettierignore b/.prettierignore index c9f8cfdbb..d70887435 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ docs/ DOCS_GUIDELINES.md packages/producer/tests/ +*.generated.ts diff --git a/bun.lock b/bun.lock index c7ddcb1e7..bac954be4 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.1.4", + "version": "0.1.10", "bin": { "hyperframes": "./dist/cli.js", }, @@ -57,7 +57,7 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.1.4", + "version": "0.1.10", "devDependencies": { "@types/jsdom": "^28.0.0", "@types/node": "^24.10.13", @@ -74,7 +74,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.1.4", + "version": "0.1.6", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -91,7 +91,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.1.4", + "version": "0.1.6", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -121,7 +121,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.1.4", + "version": "0.1.10", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -143,9 +143,12 @@ "@vitejs/plugin-react": "^4.0.0", "autoprefixer": "^10.4.0", "postcss": "^8.4.0", + "puppeteer-core": "^24.40.0", "tailwindcss": "^3.4.0", "typescript": "^5.0.0", "vite": "^5.0.0", + "vitest": "^3.2.4", + "zustand": "^5.0.0", }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", @@ -1361,8 +1364,6 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "3.2.0", "picocolors": "1.1.1" }, "peerDependencies": { "browserslist": "4.28.1" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "18.3.1" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "0.21.5", "postcss": "8.5.8", "rollup": "4.59.0" }, "optionalDependencies": { "@types/node": "22.19.15", "fsevents": "2.3.3" }, "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], @@ -1419,7 +1420,7 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "1.6.0" }, "optionalDependencies": { "@types/react": "19.2.14", "react": "18.3.1" } }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + "zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="], "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], diff --git a/packages/studio/package.json b/packages/studio/package.json index 8eb308ceb..2f3976122 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -21,7 +21,9 @@ "scripts": { "dev": "vite", "build": "vite build", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@codemirror/autocomplete": "^6.20.1", @@ -44,9 +46,12 @@ "@vitejs/plugin-react": "^4.0.0", "autoprefixer": "^10.4.0", "postcss": "^8.4.0", + "puppeteer-core": "^24.40.0", "tailwindcss": "^3.4.0", "typescript": "^5.0.0", - "vite": "^5.0.0" + "vite": "^5.0.0", + "vitest": "^3.2.4", + "zustand": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", diff --git a/packages/studio/src/player/components/CompositionThumbnail.tsx b/packages/studio/src/player/components/CompositionThumbnail.tsx new file mode 100644 index 000000000..8a81c8922 --- /dev/null +++ b/packages/studio/src/player/components/CompositionThumbnail.tsx @@ -0,0 +1,175 @@ +/** + * CompositionThumbnail — Film-strip of server-rendered JPEG thumbnails. + * + * Requests multiple thumbnails at different timestamps across the clip duration + * and tiles them horizontally — like VideoThumbnail does for video clips. + * Each frame is a separate from /api/projects/:id/thumbnail/:path?t=X. + * + * Lazy-loaded via IntersectionObserver. Uses ResizeObserver to adapt frame count + * when the clip width changes (zoom). + */ + +import { memo, useRef, useState, useCallback, useEffect } from "react"; + +const CLIP_HEIGHT = 66; +const MAX_UNIQUE_FRAMES = 6; + +interface CompositionThumbnailProps { + previewUrl: string; + label: string; + labelColor: string; + seekTime?: number; + duration?: number; + width?: number; + height?: number; +} + +export const CompositionThumbnail = memo(function CompositionThumbnail({ + previewUrl, + label, + labelColor, + seekTime = 0.4, + duration = 5, + width = 1920, + height = 1080, +}: CompositionThumbnailProps) { + const [visible, setVisible] = useState(false); + const [containerWidth, setContainerWidth] = useState(0); + const [loadedFrames, setLoadedFrames] = useState>(new Set()); + const ioRef = useRef(null); + const roRef = useRef(null); + + const setRef = useCallback((el: HTMLDivElement | null) => { + ioRef.current?.disconnect(); + roRef.current?.disconnect(); + if (!el) return; + + // Walk up to data-clip parent for accurate width (max 5 levels to avoid overshoot) + let target: HTMLElement = el; + let parent = el.parentElement; + let depth = 0; + while (parent && !parent.hasAttribute("data-clip") && depth < 5) { + parent = parent.parentElement; + depth++; + } + if (parent?.hasAttribute("data-clip")) target = parent; + + requestAnimationFrame(() => { + const w = target.clientWidth || target.getBoundingClientRect().width; + if (w > 0) setContainerWidth(w); + }); + + ioRef.current = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setVisible(true); + ioRef.current?.disconnect(); + requestAnimationFrame(() => { + const w = target.clientWidth || target.getBoundingClientRect().width; + if (w > 0) setContainerWidth(w); + }); + } + }, + { rootMargin: "300px" }, + ); + ioRef.current.observe(el); + + roRef.current = new ResizeObserver(([entry]) => setContainerWidth(entry.contentRect.width)); + roRef.current.observe(target); + }, []); + + useEffect( + () => () => { + ioRef.current?.disconnect(); + roRef.current?.disconnect(); + }, + [], + ); + + // Convert preview URL to thumbnail base URL + const thumbnailBase = previewUrl + .replace("/preview/comp/", "/thumbnail/") + .replace(/\/preview$/, "/thumbnail/index.html"); + + // Calculate frame layout + const aspect = width / height; + const frameW = Math.round(CLIP_HEIGHT * aspect); + const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1; + const uniqueFrames = Math.min(frameCount, MAX_UNIQUE_FRAMES); + + // Generate timestamps spread across the clip duration. + // Start at 30% into the scene to skip entrance animations (opacity:0 → 1). + // End at 90% to avoid catching exit animations. + const timestamps: number[] = []; + const startOffset = duration * 0.3; + const endOffset = duration * 0.9; + const range = endOffset - startOffset; + for (let i = 0; i < uniqueFrames; i++) { + const frac = uniqueFrames === 1 ? 0 : i / (uniqueFrames - 1); + timestamps.push(seekTime + startOffset + frac * range); + } + + const hasAnyFrame = loadedFrames.size > 0; + + return ( +
+ {/* Film strip */} + {visible && ( +
+ {Array.from({ length: frameCount }).map((_, i) => { + const uniqueIdx = i % uniqueFrames; + const t = timestamps[uniqueIdx]; + const url = `${thumbnailBase}?t=${t.toFixed(2)}`; + return ( +
+ setLoadedFrames((prev) => new Set(prev).add(uniqueIdx))} + className="absolute inset-0 w-full h-full object-cover" + style={{ + opacity: loadedFrames.has(uniqueIdx) ? 1 : 0, + transition: "opacity 200ms ease-out", + }} + /> +
+ ); + })} +
+ )} + + {/* Shimmer while loading */} + {(!visible || !hasAnyFrame) && ( +
+ )} + + {/* Label */} +
+ + {label} + +
+
+ ); +}); diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx index 61d39ed8b..618dda722 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -39,7 +39,7 @@ export const Player = forwardRef( const handleMessage = (e: MessageEvent) => { const data = e.data; if ( - (data?.source === "hf-preview" || data?.source === "hf-preview") && + data?.source === "hf-preview" && data?.type === "stage-size" && data.width > 0 && data.height > 0 @@ -83,8 +83,8 @@ export const Player = forwardRef( } } } - } catch { - // Cross-origin + } catch (err) { + console.warn("[Player] Could not read iframe dimensions (cross-origin)", err); } if (loadCountRef.current > 1) { @@ -103,7 +103,7 @@ export const Player = forwardRef( return (