feat(studio): render queue, layout restructure, home page, hover preview (#95)

## Summary
- Add render queue panel with progress tracking, download, and delete actions
- Restructure App layout: home page with project picker, session-based routing
- Add ExpandOnHover component for preview-on-hover interactions (uses motion/react)
- CompositionsTab now supports hover preview with expanded iframe view
- Vite config: guard setInterval cleanup to dev-only (fixes CI build timeout)
- Add favicon and update studio package deps

## Test plan
- [x] Render queue shows progress, completes, and allows download
- [x] Home page lists projects and navigates to session view
- [x] ExpandOnHover shows expanded preview on mouse hover with spring animation
- [x] `vite build` exits cleanly (no hanging process from setInterval)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Miguel Ángel
2026-03-28 21:28:43 +01:00
committed by GitHub
parent 9ae239d177
commit 40bd159103
24 changed files with 2495 additions and 796 deletions
@@ -5,11 +5,11 @@
* 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).
* Uses ResizeObserver to adapt frame count when the clip width changes (zoom).
*/
import { memo, useRef, useState, useCallback, useEffect } from "react";
import { memo, useRef, useState, useCallback } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
const CLIP_HEIGHT = 66;
const MAX_UNIQUE_FRAMES = 6;
@@ -33,18 +33,14 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
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)
// Walk up to data-clip parent for accurate width
let target: HTMLElement = el;
let parent = el.parentElement;
let depth = 0;
@@ -59,32 +55,13 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
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();
},
[],
);
useMountEffect(() => () => {
roRef.current?.disconnect();
});
// Convert preview URL to thumbnail base URL
const thumbnailBase = previewUrl
@@ -97,63 +74,51 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
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.
// Each frame tile represents a real position in the clip.
// Offset slightly (0.5s) into each segment to avoid landing on transition
// points where content is invisible due to fade-in/fade-out animations.
const timestamps: number[] = [];
const startOffset = duration * 0.3;
const endOffset = duration * 0.9;
const range = endOffset - startOffset;
const pad = Math.min(0.5, duration * 0.05);
for (let i = 0; i < uniqueFrames; i++) {
const frac = uniqueFrames === 1 ? 0 : i / (uniqueFrames - 1);
timestamps.push(seekTime + startOffset + frac * range);
const frac = uniqueFrames === 1 ? 0.5 : i / (uniqueFrames - 1);
const raw = seekTime + frac * duration;
// Clamp to [pad, duration - pad] to stay inside visible content
timestamps.push(seekTime + Math.max(pad, Math.min(duration - pad, raw - seekTime)));
}
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%)",
}}
/>
)}
{/* Film strip — each tile maps to its real timeline position */}
<div className="absolute inset-0 flex">
{Array.from({ length: frameCount }).map((_, i) => {
// Map this tile's visual position to a timestamp
const tileFrac = frameCount === 1 ? 0.5 : i / (frameCount - 1);
const t = seekTime + tileFrac * duration;
// Use the nearest cached unique frame
const uniqueIdx = Math.min(Math.round(tileFrac * (uniqueFrames - 1)), uniqueFrames - 1);
const cachedT = timestamps[uniqueIdx];
const url = `${thumbnailBase}?t=${(cachedT ?? 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={(e) => {
(e.target as HTMLImageElement).style.opacity = "1";
}}
className="absolute inset-0 w-full h-full object-cover"
style={{ opacity: 0, transition: "opacity 200ms ease-out" }}
/>
</div>
);
})}
</div>
{/* Label */}
<div