mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
committed by
Miguel Ángel
co-authored by
Claude Opus 4.6
parent
c7694c523a
commit
821da4fc9a
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user