mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 16:42:27 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user