Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
docs/
DOCS_GUIDELINES.md
packages/producer/tests/
*.generated.ts
17 changes: 9 additions & 8 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 7 additions & 2 deletions packages/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
175 changes: 175 additions & 0 deletions packages/studio/src/player/components/CompositionThumbnail.tsx
Original file line number Diff line number Diff line change
@@ -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 <img> 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<Set<number>>(new Set());
const ioRef = useRef<IntersectionObserver | null>(null);
const roRef = useRef<ResizeObserver | null>(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 (
<div ref={setRef} className="absolute inset-0 overflow-hidden bg-neutral-950">
{/* Film strip */}
{visible && (
<div className="absolute inset-0 flex">
{Array.from({ length: frameCount }).map((_, i) => {
const uniqueIdx = i % uniqueFrames;
const t = timestamps[uniqueIdx];
const url = `${thumbnailBase}?t=${t.toFixed(2)}`;
return (
<div
key={i}
className="flex-shrink-0 h-full relative overflow-hidden bg-neutral-900"
style={{ width: frameW }}
>
<img
src={url}
alt=""
draggable={false}
loading="lazy"
onLoad={() => 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",
}}
/>
</div>
);
})}
</div>
)}

{/* Shimmer while loading */}
{(!visible || !hasAnyFrame) && (
<div
className="absolute inset-0 animate-pulse"
style={{
background:
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
}}
/>
)}

{/* Label */}
<div
className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
style={{
background:
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
}}
>
<span
className="text-[9px] font-semibold truncate block leading-tight"
style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
>
{label}
</span>
</div>
</div>
);
});
9 changes: 5 additions & 4 deletions packages/studio/src/player/components/Player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
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
Expand Down Expand Up @@ -83,8 +83,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
}
}
}
} catch {
// Cross-origin
} catch (err) {
console.warn("[Player] Could not read iframe dimensions (cross-origin)", err);
}

if (loadCountRef.current > 1) {
Expand All @@ -103,7 +103,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
return (
<div
ref={containerRef}
className="w-full h-full max-w-full max-h-full overflow-hidden shadow-float border border-neutral-800 bg-black flex items-center justify-center rounded-card-inner"
className="w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center"
>
<iframe
ref={ref}
Expand All @@ -117,6 +117,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
width: dims.w,
height: dims.h,
border: "none",
outline: "1px solid black",
transform: `scale(${scale})`,
transformOrigin: "center center",
flexShrink: 0,
Expand Down
Loading
Loading