mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 16:42:27 +00:00
fix: stabilize studio preview and runtime sync (#389)
## Summary Stabilize the Studio preview/runtime path so timeline data, preview rendering, and thumbnails stay in sync. This PR includes: - preview hot-refresh without remounting the iframe - runtime duration/timeline fixes so Studio stops drifting from playback state - thumbnail and selector-based preview fixes - local Studio runtime serving and player-resolution fixes so dev/CI do not depend on prebuilt player artifacts - tests around preview identity and thumbnail/runtime behavior ## Why This PR Exists This is the foundation layer for timeline editing. Without it, the editor was prone to: - iframe remount flashes after saves - duration mismatches between preview and timeline - stale or incorrect thumbnails - CI/test failures when `@hyperframes/player` artifacts were not prebuilt ## Verification - `bun run --filter @hyperframes/studio test` - `bun run --filter @hyperframes/studio typecheck` - `bun run --filter @hyperframes/core typecheck` - `bunx oxlint packages/cli/src/server/studioServer.ts packages/core/src/runtime/timeline.ts packages/core/src/runtime/timeline.test.ts packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/routes/thumbnail.test.ts packages/core/src/studio-api/types.ts packages/studio/src/components/nle/NLELayout.tsx packages/studio/src/components/nle/NLEPreview.tsx packages/studio/src/components/nle/NLEPreview.test.ts packages/studio/src/player/components/CompositionThumbnail.tsx packages/studio/src/player/components/Player.tsx packages/studio/src/player/hooks/useTimelinePlayer.ts packages/studio/src/player/store/playerStore.ts packages/studio/vite.config.ts` - `bunx oxfmt --check packages/cli/src/server/studioServer.ts packages/core/src/runtime/timeline.ts packages/core/src/runtime/timeline.test.ts packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/routes/thumbnail.test.ts packages/core/src/studio-api/types.ts packages/studio/src/components/nle/NLELayout.tsx packages/studio/src/components/nle/NLEPreview.tsx packages/studio/src/components/nle/NLEPreview.test.ts packages/studio/src/player/components/CompositionThumbnail.tsx packages/studio/src/player/components/Player.tsx packages/studio/src/player/hooks/useTimelinePlayer.ts packages/studio/src/player/store/playerStore.ts packages/studio/vite.config.ts` ## Stack - base of stack - followed by `feat: add studio timeline editing` - followed by `fix: smooth scrubber end seeking`
This commit is contained in:
@@ -1,52 +1,129 @@
|
||||
/**
|
||||
* CompositionThumbnail — Single server-rendered JPEG stretched across the clip.
|
||||
*
|
||||
* 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 } from "react";
|
||||
import { memo, useCallback, useState, useRef } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
|
||||
interface CompositionThumbnailProps {
|
||||
previewUrl: string;
|
||||
label: string;
|
||||
labelColor: string;
|
||||
accentColor?: string;
|
||||
selector?: string;
|
||||
seekTime?: number;
|
||||
duration?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const CLIP_HEIGHT = 66;
|
||||
const THUMBNAIL_URL_VERSION = "v2";
|
||||
|
||||
export const CompositionThumbnail = memo(function CompositionThumbnail({
|
||||
previewUrl,
|
||||
label,
|
||||
labelColor,
|
||||
accentColor = "#6B7280",
|
||||
selector,
|
||||
seekTime = 2,
|
||||
duration = 5,
|
||||
}: CompositionThumbnailProps) {
|
||||
// Single screenshot at the midpoint of the clip
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [aspect, setAspect] = useState(16 / 9);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
roRef.current?.disconnect();
|
||||
if (!el) return;
|
||||
|
||||
const measured = el.parentElement?.clientWidth || el.clientWidth;
|
||||
setContainerWidth(measured);
|
||||
|
||||
const target = el.parentElement || el;
|
||||
roRef.current = new ResizeObserver(([entry]) => {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
});
|
||||
roRef.current.observe(target);
|
||||
}, []);
|
||||
|
||||
useMountEffect(() => () => {
|
||||
roRef.current?.disconnect();
|
||||
});
|
||||
|
||||
const thumbnailBase = previewUrl
|
||||
.replace("/preview/comp/", "/thumbnail/")
|
||||
.replace(/\/preview$/, "/thumbnail/index.html");
|
||||
const midTime = seekTime + duration / 2;
|
||||
const url = `${thumbnailBase}?t=${midTime.toFixed(2)}`;
|
||||
const thumbnailUrl = new URL(thumbnailBase, window.location.origin);
|
||||
thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
|
||||
thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
|
||||
if (selector) thumbnailUrl.searchParams.set("selector", selector);
|
||||
const url = thumbnailUrl.toString();
|
||||
const frameW = Math.max(48, Math.round(CLIP_HEIGHT * aspect));
|
||||
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden bg-neutral-950">
|
||||
<div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
onLoad={(e) => {
|
||||
(e.target as HTMLImageElement).style.opacity = "1";
|
||||
const img = e.currentTarget;
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||
setAspect(img.naturalWidth / img.naturalHeight);
|
||||
}
|
||||
setLoaded(true);
|
||||
}}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
style={{ opacity: 0, transition: "opacity 200ms ease-out" }}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Label */}
|
||||
{loaded ? (
|
||||
<div className="absolute inset-0 flex">
|
||||
{Array.from({ length: frameCount }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative h-full flex-shrink-0 overflow-hidden"
|
||||
style={{ width: frameW }}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-60"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<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%)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: `linear-gradient(120deg, ${accentColor}2e, transparent 34%), linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.08))`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="absolute left-2 top-2 z-10">
|
||||
<span
|
||||
className="block max-w-full truncate rounded-md px-1.5 py-0.5 text-[9px] font-semibold uppercase leading-none"
|
||||
style={{
|
||||
color: labelColor,
|
||||
background: `${accentColor}2e`,
|
||||
boxShadow: `inset 0 0 0 1px ${accentColor}40`,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
|
||||
style={{
|
||||
@@ -55,7 +132,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-[9px] font-semibold truncate block leading-tight"
|
||||
className="block truncate text-[9px] font-semibold leading-tight"
|
||||
style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
|
||||
>
|
||||
{label}
|
||||
|
||||
Reference in New Issue
Block a user