mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
## Problem Closes #555. Studio users could inspect the preview, but there was no first-class way to capture the current rendered frame as an image. ## What this fixes - Adds a `Capture` action to the Studio header toolbar so it does not cover the video preview. - Downloads the current composition frame as a PNG using the current player time. - Extends the existing thumbnail route and Studio/CLI thumbnail generators with an explicit PNG format path while preserving JPEG thumbnails for existing previews. - Adds URL/filename utility coverage plus thumbnail route coverage for PNG requests. ## Root cause Studio already had frame thumbnail generation, but the API path was JPEG-oriented and the editor UI only used it for previews. There was no current-frame capture affordance wired to the player state. ## Verification ### Local - `bun run --filter @hyperframes/core test src/studio-api/routes/thumbnail.test.ts` - `bun run --filter @hyperframes/studio test src/utils/frameCapture.test.ts src/player/components/PlayerControls.test.ts` - `bun run --filter @hyperframes/studio typecheck` - `bun run --filter @hyperframes/core typecheck` - `bun run --filter @hyperframes/cli typecheck` - `bunx oxlint packages/cli/src/server/studioServer.ts packages/core/src/studio-api/routes/thumbnail.test.ts packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/types.ts packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/vite.config.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/utils/frameCapture.test.ts` - `bunx oxfmt --check packages/cli/src/server/studioServer.ts packages/core/src/studio-api/routes/thumbnail.test.ts packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/types.ts packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/vite.config.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/utils/frameCapture.test.ts` - `git diff --check` ### Browser <img width="1027" height="910" alt="image" src="https://github.com/user-attachments/assets/71973af4-0279-4074-9 <img width="1026" height="902" alt="Screenshot 2026-04-29 at 16 17 22" src="https://github.com/user-attachments/assets/a32e1c19-b793-40b9-82f8-de8bbb11f123" /> 060-130839a2d419" />
459 lines
18 KiB
TypeScript
459 lines
18 KiB
TypeScript
import { useState, useCallback, useRef, useEffect, memo, type ReactNode } from "react";
|
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
|
|
import type { TimelineElement } from "../../player";
|
|
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
|
import { NLEPreview } from "./NLEPreview";
|
|
import { CompositionBreadcrumb, type CompositionLevel } from "./CompositionBreadcrumb";
|
|
import {
|
|
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
|
getTimelineToggleTitle,
|
|
} from "../../utils/timelineDiscovery";
|
|
|
|
interface NLELayoutProps {
|
|
projectId: string;
|
|
portrait?: boolean;
|
|
/** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */
|
|
previewOverlay?: ReactNode;
|
|
/** Slot rendered above the timeline tracks (toolbar with split, delete, zoom) */
|
|
timelineToolbar?: ReactNode;
|
|
/** Slot rendered below the timeline tracks */
|
|
timelineFooter?: ReactNode;
|
|
/** Increment to force the preview to reload (e.g., after file writes) */
|
|
refreshKey?: number;
|
|
/** Navigate to a specific composition path (e.g., "compositions/intro.html") */
|
|
activeCompositionPath?: string | null;
|
|
/** Callback to expose the iframe ref (for element picker, etc.) */
|
|
onIframeRef?: (iframe: HTMLIFrameElement | null) => void;
|
|
/** Callback when the viewed composition changes (drill-down/back) */
|
|
onCompositionChange?: (compositionPath: string | null) => void;
|
|
/** Custom clip content renderer for timeline (thumbnails, waveforms, etc.) */
|
|
renderClipContent?: (
|
|
element: TimelineElement,
|
|
style: { clip: string; label: string },
|
|
) => ReactNode;
|
|
onFileDrop?: (
|
|
files: File[],
|
|
placement?: Pick<TimelineElement, "start" | "track">,
|
|
) => Promise<void> | void;
|
|
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
|
|
onAssetDrop?: (
|
|
assetPath: string,
|
|
placement: Pick<TimelineElement, "start" | "track">,
|
|
) => Promise<void> | void;
|
|
/** Persist timeline move actions back into source HTML */
|
|
onMoveElement?: (
|
|
element: TimelineElement,
|
|
updates: Pick<TimelineElement, "start" | "track">,
|
|
) => Promise<void> | void;
|
|
onResizeElement?: (
|
|
element: TimelineElement,
|
|
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
) => Promise<void> | void;
|
|
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
|
|
onCompIdToSrcChange?: (map: Map<string, string>) => void;
|
|
/** Whether the timeline panel is visible (default: true) */
|
|
timelineVisible?: boolean;
|
|
/** Callback to toggle timeline visibility */
|
|
onToggleTimeline?: () => void;
|
|
}
|
|
|
|
const MIN_TIMELINE_H = 100;
|
|
const DEFAULT_TIMELINE_H = 220;
|
|
const MIN_PREVIEW_H = 120;
|
|
|
|
export const NLELayout = memo(function NLELayout({
|
|
projectId,
|
|
portrait,
|
|
previewOverlay,
|
|
timelineToolbar,
|
|
timelineFooter,
|
|
refreshKey,
|
|
activeCompositionPath,
|
|
onIframeRef,
|
|
onCompositionChange,
|
|
renderClipContent,
|
|
onFileDrop,
|
|
onDeleteElement,
|
|
onAssetDrop,
|
|
onMoveElement,
|
|
onResizeElement,
|
|
onBlockedEditAttempt,
|
|
onCompIdToSrcChange,
|
|
timelineVisible,
|
|
onToggleTimeline,
|
|
}: NLELayoutProps) {
|
|
const {
|
|
iframeRef,
|
|
togglePlay,
|
|
seek,
|
|
onIframeLoad: baseOnIframeLoad,
|
|
refreshPlayer,
|
|
saveSeekPosition,
|
|
} = useTimelinePlayer();
|
|
|
|
// Reset timeline state when the project changes to prevent stale data from a
|
|
// previous project leaking into the new one.
|
|
const prevProjectIdRef = useRef(projectId);
|
|
if (prevProjectIdRef.current !== projectId) {
|
|
prevProjectIdRef.current = projectId;
|
|
// Only reset Zustand state during render (safe — pure state update).
|
|
// Imperative cleanup (RAF, intervals) happens in resetPlayer's store reset.
|
|
usePlayerStore.getState().reset();
|
|
}
|
|
|
|
// Refresh the existing iframe in place when source files change.
|
|
const prevRefreshKeyRef = useRef(refreshKey);
|
|
useEffect(() => {
|
|
if (refreshKey === prevRefreshKeyRef.current) return;
|
|
prevRefreshKeyRef.current = refreshKey;
|
|
refreshPlayer();
|
|
}, [refreshKey, refreshPlayer]);
|
|
|
|
// Wrap onIframeLoad to also notify parent of iframe ref
|
|
const onIframeLoad = useCallback(() => {
|
|
baseOnIframeLoad();
|
|
onIframeRef?.(iframeRef.current);
|
|
}, [baseOnIframeLoad, iframeRef, onIframeRef]);
|
|
|
|
// Composition ID → actual file path mapping, built from the raw index.html
|
|
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
|
useMountEffect(() => {
|
|
fetch(`/api/projects/${projectId}/files/index.html`)
|
|
.then((r) => r.json())
|
|
.then((data: { content?: string }) => {
|
|
const html = data.content || "";
|
|
const map = new Map<string, string>();
|
|
const re =
|
|
/data-composition-id=["']([^"']+)["'][^>]*data-composition-src=["']([^"']+)["']|data-composition-src=["']([^"']+)["'][^>]*data-composition-id=["']([^"']+)["']/g;
|
|
let match;
|
|
while ((match = re.exec(html)) !== null) {
|
|
const id = match[1] || match[4];
|
|
const src = match[2] || match[3];
|
|
if (id && src) map.set(id, src);
|
|
}
|
|
setCompIdToSrc(map);
|
|
onCompIdToSrcChange?.(map);
|
|
})
|
|
.catch(() => {});
|
|
});
|
|
|
|
// Patch elements with compositionSrc whenever elements or compIdToSrc change.
|
|
// The runtime strips data-composition-src from the DOM after loading, so elements
|
|
// arrive without it. This bridges the gap using the map built from raw HTML.
|
|
// Map keys are composition IDs (e.g. "dark-intro"), while element IDs may be
|
|
// DOM IDs with suffixes (e.g. "dark-intro-host"), so we try multiple lookups.
|
|
const compIdToSrcRef = useRef(compIdToSrc);
|
|
compIdToSrcRef.current = compIdToSrc;
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
useEffect(() => {
|
|
if (compIdToSrc.size === 0) return;
|
|
const patchElements = (elements: TimelineElement[]): TimelineElement[] | null => {
|
|
const map = compIdToSrcRef.current;
|
|
if (map.size === 0) return null;
|
|
let patched = false;
|
|
const updated = elements.map((el) => {
|
|
if (el.compositionSrc) return el;
|
|
// Try exact match, then strip common suffixes (-host, -comp, -layer)
|
|
const src = map.get(el.id) ?? map.get(el.id.replace(/-(host|comp|layer)$/, ""));
|
|
if (src) {
|
|
patched = true;
|
|
return { ...el, compositionSrc: src };
|
|
}
|
|
return el;
|
|
});
|
|
return patched ? updated : null;
|
|
};
|
|
// Patch current elements immediately
|
|
const patched = patchElements(usePlayerStore.getState().elements);
|
|
if (patched) usePlayerStore.getState().setElements(patched);
|
|
// Subscribe for future element updates — use a flag to prevent re-entrant patching
|
|
let patching = false;
|
|
return usePlayerStore.subscribe((state, prev) => {
|
|
if (patching) return;
|
|
if (state.elements === prev.elements || state.elements.length === 0) return;
|
|
// Skip if all elements already have compositionSrc
|
|
if (state.elements.every((el) => el.compositionSrc)) return;
|
|
patching = true;
|
|
const result = patchElements(state.elements);
|
|
if (result) state.setElements(result);
|
|
patching = false;
|
|
});
|
|
}, [compIdToSrc]);
|
|
|
|
// Composition drill-down stack
|
|
const [compositionStack, setCompositionStack] = useState<CompositionLevel[]>([
|
|
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
|
|
]);
|
|
|
|
// Wrap setCompositionStack to auto-notify parent on composition change
|
|
const onCompositionChangeRef = useRef(onCompositionChange);
|
|
onCompositionChangeRef.current = onCompositionChange;
|
|
const updateCompositionStack: typeof setCompositionStack = useCallback((action) => {
|
|
setCompositionStack((prev) => {
|
|
const next = typeof action === "function" ? action(prev) : action;
|
|
const id = next[next.length - 1]?.id;
|
|
queueMicrotask(() => onCompositionChangeRef.current?.(id === "master" ? null : id));
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// Resizable timeline height
|
|
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
|
|
const isTimelineVisible = timelineVisible ?? true;
|
|
const isDragging = useRef(false);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Current preview URL — derived from composition stack
|
|
const currentLevel = compositionStack[compositionStack.length - 1];
|
|
const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
|
|
|
|
// Save master seek position before drilling down so we can restore it on back-navigation.
|
|
// saveSeekPosition() sets pendingSeekRef in useTimelinePlayer which onIframeLoad reads.
|
|
const masterSeekRef = useRef(0);
|
|
|
|
// Drill-down: push a sub-composition onto the stack
|
|
const iframeRef_ = iframeRef; // stable ref for the callback
|
|
const handleDrillDown = useCallback(
|
|
(element: TimelineElement) => {
|
|
if (!element.compositionSrc) return;
|
|
// Save current master playback position for back-navigation
|
|
masterSeekRef.current = usePlayerStore.getState().currentTime;
|
|
saveSeekPosition();
|
|
// compositionSrc may be a full URL (from runtime manifest) or a relative path
|
|
// Extract the element's composition ID from its timeline ID
|
|
const compId = element.id;
|
|
|
|
// 1. Check compIdToSrc map (from index.html)
|
|
// 2. Scan the current iframe DOM for data-composition-src attribute
|
|
// 3. Fall back to stripping the compositionSrc to a relative path
|
|
let resolvedPath = compIdToSrc.get(compId);
|
|
if (!resolvedPath) {
|
|
try {
|
|
const doc = iframeRef_.current?.contentDocument;
|
|
if (doc) {
|
|
const host = doc.querySelector(
|
|
`[data-composition-id="${compId}"][data-composition-src]`,
|
|
);
|
|
if (host) {
|
|
resolvedPath = host.getAttribute("data-composition-src") || undefined;
|
|
}
|
|
}
|
|
} catch {
|
|
/* cross-origin */
|
|
}
|
|
}
|
|
if (!resolvedPath) {
|
|
// Strip full URL to relative path if needed
|
|
const src = element.compositionSrc;
|
|
const compMatch = src.match(/compositions\/.*\.html/);
|
|
resolvedPath = compMatch ? compMatch[0] : src;
|
|
}
|
|
|
|
usePlayerStore.getState().setElements([]);
|
|
|
|
// Toggle: if already viewing this composition, go back to parent (like Premiere)
|
|
updateCompositionStack((prev) => {
|
|
const currentId = prev[prev.length - 1].id;
|
|
if (currentId === resolvedPath && prev.length > 1) {
|
|
return prev.slice(0, -1);
|
|
}
|
|
// Extract a clean label from the path (strip directories and extension)
|
|
const label =
|
|
resolvedPath
|
|
.split("/")
|
|
.pop()
|
|
?.replace(/\.html$/, "") || resolvedPath;
|
|
const previewUrl = `/api/projects/${projectId}/preview/comp/${resolvedPath}`;
|
|
return [...prev, { id: resolvedPath, label, previewUrl }];
|
|
});
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[projectId, compIdToSrc],
|
|
);
|
|
|
|
// Navigate back to a specific breadcrumb level
|
|
const handleNavigateComposition = useCallback((index: number) => {
|
|
// When going back to master (index 0), restore the saved master position
|
|
if (index === 0 && masterSeekRef.current > 0) {
|
|
usePlayerStore.getState().setCurrentTime(masterSeekRef.current);
|
|
}
|
|
saveSeekPosition();
|
|
usePlayerStore.getState().setElements([]);
|
|
updateCompositionStack((prev) => prev.slice(0, index + 1));
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Navigate to a composition when activeCompositionPath changes.
|
|
// Uses useEffect to ensure state updates happen after render commit,
|
|
// avoiding render-time mutations that React can swallow during batching.
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
useEffect(() => {
|
|
if (activeCompositionPath === "index.html") {
|
|
usePlayerStore.getState().setElements([]);
|
|
updateCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
|
|
} else if (activeCompositionPath && activeCompositionPath.startsWith("compositions/")) {
|
|
const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
|
|
const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`;
|
|
usePlayerStore.getState().setElements([]);
|
|
updateCompositionStack((prev) => {
|
|
if (prev[prev.length - 1]?.id === activeCompositionPath) return prev;
|
|
return [
|
|
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
|
|
{ id: activeCompositionPath, label, previewUrl },
|
|
];
|
|
});
|
|
} else if (!activeCompositionPath) {
|
|
usePlayerStore.getState().setElements([]);
|
|
}
|
|
}, [activeCompositionPath, projectId, updateCompositionStack]);
|
|
|
|
// Resize divider handlers
|
|
const handleDividerPointerDown = useCallback((e: React.PointerEvent) => {
|
|
e.preventDefault();
|
|
isDragging.current = true;
|
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
}, []);
|
|
|
|
const handleDividerPointerMove = useCallback((e: React.PointerEvent) => {
|
|
if (!isDragging.current || !containerRef.current) return;
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
const mouseY = e.clientY - rect.top;
|
|
const containerH = rect.height;
|
|
const newTimelineH = Math.max(
|
|
MIN_TIMELINE_H,
|
|
Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
|
|
);
|
|
setTimelineH(newTimelineH);
|
|
}, []);
|
|
|
|
const handleDividerPointerUp = useCallback(() => {
|
|
isDragging.current = false;
|
|
}, []);
|
|
|
|
// Keyboard: Escape to pop composition level
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
if (e.key === "Escape" && compositionStack.length > 1) {
|
|
updateCompositionStack((prev) => prev.slice(0, -1));
|
|
}
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[compositionStack.length],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="flex flex-col h-full min-h-0 bg-neutral-950"
|
|
onKeyDown={handleKeyDown}
|
|
tabIndex={-1}
|
|
>
|
|
{/* Preview + player controls — takes remaining space above timeline */}
|
|
<div className="flex-1 min-h-0 flex flex-col">
|
|
<div className="flex-1 min-h-0 relative">
|
|
<NLEPreview
|
|
projectId={projectId}
|
|
iframeRef={iframeRef}
|
|
onIframeLoad={onIframeLoad}
|
|
portrait={portrait}
|
|
directUrl={directUrl}
|
|
refreshKey={refreshKey}
|
|
/>
|
|
{previewOverlay}
|
|
</div>
|
|
{/* Player controls always visible, regardless of timeline state */}
|
|
<div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">
|
|
{compositionStack.length > 1 && (
|
|
<CompositionBreadcrumb
|
|
stack={compositionStack}
|
|
onNavigate={handleNavigateComposition}
|
|
/>
|
|
)}
|
|
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
|
|
</div>
|
|
</div>
|
|
|
|
{isTimelineVisible ? (
|
|
<>
|
|
{/* Resize divider */}
|
|
<div
|
|
className="group h-2 flex-shrink-0 cursor-row-resize flex items-center justify-center z-10"
|
|
style={{ touchAction: "none" }}
|
|
onPointerDown={handleDividerPointerDown}
|
|
onPointerMove={handleDividerPointerMove}
|
|
onPointerUp={handleDividerPointerUp}
|
|
>
|
|
<div className="h-px w-full bg-white/10 transition-colors group-hover:bg-white/16 group-active:bg-white/22" />
|
|
</div>
|
|
|
|
{/* Timeline section — fixed height, resizable */}
|
|
<div className="flex flex-col flex-shrink-0" style={{ height: timelineH }}>
|
|
{/* Timeline tracks */}
|
|
<div
|
|
// flex-col: toolbar takes natural height, Timeline fills remainder.
|
|
className="flex flex-col flex-1 min-h-0 overflow-hidden bg-neutral-950"
|
|
onDoubleClick={(e) => {
|
|
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
|
if (compositionStack.length > 1) {
|
|
updateCompositionStack((prev) => prev.slice(0, -1));
|
|
}
|
|
}}
|
|
>
|
|
<div className="flex-shrink-0">{timelineToolbar}</div>
|
|
<Timeline
|
|
onSeek={seek}
|
|
onDrillDown={handleDrillDown}
|
|
renderClipContent={renderClipContent}
|
|
onFileDrop={onFileDrop}
|
|
onDeleteElement={onDeleteElement}
|
|
onAssetDrop={onAssetDrop}
|
|
onMoveElement={onMoveElement}
|
|
onResizeElement={onResizeElement}
|
|
onBlockedEditAttempt={onBlockedEditAttempt}
|
|
/>
|
|
</div>
|
|
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
|
|
</div>
|
|
</>
|
|
) : onToggleTimeline ? (
|
|
<div className="flex-shrink-0 border-t border-neutral-800/50 bg-neutral-950/96">
|
|
<div className="flex h-10 items-center justify-between px-3">
|
|
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
|
|
Timeline
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onToggleTimeline}
|
|
className="flex h-7 items-center gap-1.5 rounded-md border border-neutral-800 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-700 hover:bg-neutral-900 hover:text-neutral-100"
|
|
title={getTimelineToggleTitle(false)}
|
|
aria-label="Show timeline editor"
|
|
>
|
|
<svg
|
|
width="13"
|
|
height="13"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.7"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<rect x="3" y="13" width="18" height="8" rx="1" />
|
|
<path d="M7 9h10" />
|
|
<path d="M8 5h8" />
|
|
</svg>
|
|
<span>Show</span>
|
|
<span className="hidden rounded bg-white/5 px-1 py-0.5 font-mono text-[9px] text-neutral-500 sm:inline">
|
|
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
});
|