feat(studio): single-frame composition thumbnail + shift+drag on clips

CompositionThumbnail now takes one screenshot at the midpoint and
stretches it across the clip (object-cover), same approach as After
Effects for precomps. Avoids 6x Puppeteer render cost per composition.

Timeline shift+drag range selection now works even when starting on a
clip — the [data-clip] early return was blocking the shiftKey check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Ángel
2026-03-31 02:01:22 +00:00
committed by Miguel Ángel
co-authored by Claude Opus 4.6
parent c7694c523a
commit 821da4fc9a
2 changed files with 26 additions and 97 deletions
@@ -1,18 +1,12 @@
/**
* CompositionThumbnail — Film-strip of server-rendered JPEG thumbnails.
* CompositionThumbnail — Single server-rendered JPEG stretched across the clip.
*
* 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.
*
* Uses ResizeObserver to adapt frame count when the clip width changes (zoom).
* Takes one screenshot at the midpoint of the clip and covers the full width —
* same approach as After Effects for precomps. This avoids the 1-2s per-frame
* Puppeteer cost of rendering multiple filmstrip frames.
*/
import { memo, useRef, useState, useCallback } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
const CLIP_HEIGHT = 66;
const MAX_UNIQUE_FRAMES = 6;
import { memo } from "react";
interface CompositionThumbnailProps {
previewUrl: string;
@@ -30,95 +24,27 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
labelColor,
seekTime = 2,
duration = 5,
width = 1920,
height = 1080,
}: CompositionThumbnailProps) {
const [containerWidth, setContainerWidth] = useState(0);
const roRef = useRef<ResizeObserver | null>(null);
const setRef = useCallback((el: HTMLDivElement | null) => {
roRef.current?.disconnect();
if (!el) return;
// Walk up to data-clip parent for accurate width
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);
});
roRef.current = new ResizeObserver(([entry]) => setContainerWidth(entry.contentRect.width));
roRef.current.observe(target);
}, []);
useMountEffect(() => () => {
roRef.current?.disconnect();
});
// Convert preview URL to thumbnail base URL
// Single screenshot at the midpoint of the clip
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);
// 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 pad = Math.min(0.5, duration * 0.05);
for (let i = 0; i < uniqueFrames; i++) {
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 midTime = seekTime + duration / 2;
const url = `${thumbnailBase}?t=${midTime.toFixed(2)}`;
return (
<div ref={setRef} className="absolute inset-0 overflow-hidden bg-neutral-950">
{/* 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-contain"
style={{ opacity: 0, transition: "opacity 200ms ease-out" }}
/>
</div>
);
})}
</div>
<div className="absolute inset-0 overflow-hidden bg-neutral-950">
<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" }}
/>
{/* Label */}
<div
@@ -338,12 +338,11 @@ export const Timeline = memo(function Timeline({
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
if ((e.target as HTMLElement).closest("[data-clip]")) return;
if (e.button !== 0) return;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
// Shift+click starts range selection
// Shift+click starts range selection — even on clips
if (e.shiftKey) {
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
isRangeSelecting.current = true;
setShowPopover(false);
const rect = scrollRef.current?.getBoundingClientRect();
@@ -356,6 +355,10 @@ export const Timeline = memo(function Timeline({
return;
}
// Normal click on a clip — let the clip handle it
if ((e.target as HTMLElement).closest("[data-clip]")) return;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
isDragging.current = true;
setRangeSelection(null);
setShowPopover(false);