mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
fix: harden studio timeline editing and local renders (#463)
* fix: harden studio timeline editing and local renders * test: cover studio local render fallback * fix(studio): scale composition hover previews to stage size * test: normalize studio producer fallback paths * fix(studio): preserve move surface and retry render fallback
This commit is contained in:
+47
-38
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react";
|
||||
import { useMountEffect } from "./hooks/useMountEffect";
|
||||
import { NLELayout } from "./components/nle/NLELayout";
|
||||
import { TimelineEditorNotice } from "./components/nle/TimelineEditorNotice";
|
||||
import { SourceEditor } from "./components/editor/SourceEditor";
|
||||
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
|
||||
import { RenderQueue } from "./components/renders/RenderQueue";
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
getTimelineZoomPercent,
|
||||
} from "./player/components/timelineZoom";
|
||||
import {
|
||||
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
||||
getTimelineEditorHintDismissed,
|
||||
getTimelineToggleTitle,
|
||||
setTimelineEditorHintDismissed,
|
||||
@@ -40,6 +40,11 @@ interface EditingFile {
|
||||
content: string | null;
|
||||
}
|
||||
|
||||
interface AppToast {
|
||||
message: string;
|
||||
tone: "error" | "info";
|
||||
}
|
||||
|
||||
// ── Main App ──
|
||||
|
||||
export function StudioApp() {
|
||||
@@ -201,12 +206,14 @@ export function StudioApp() {
|
||||
}
|
||||
}, [captionHasSelection, captionEditMode]);
|
||||
const [globalDragOver, setGlobalDragOver] = useState(false);
|
||||
const [uploadToast, setUploadToast] = useState<string | null>(null);
|
||||
const [appToast, setAppToast] = useState<AppToast | null>(null);
|
||||
const [timelineVisible, setTimelineVisible] = useState(true);
|
||||
const [timelineEditorHintDismissed, setTimelineEditorHintState] = useState(
|
||||
getTimelineEditorHintDismissed,
|
||||
);
|
||||
const dragCounterRef = useRef(0);
|
||||
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastBlockedTimelineToastAtRef = useRef(0);
|
||||
const previewHotkeyWindowRef = useRef<Window | null>(null);
|
||||
const panelDragRef = useRef<{
|
||||
side: "left" | "right";
|
||||
@@ -238,6 +245,9 @@ export function StudioApp() {
|
||||
const toggleTimelineVisibility = useCallback(() => {
|
||||
setTimelineVisible((visible) => !visible);
|
||||
}, []);
|
||||
useMountEffect(() => () => {
|
||||
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
||||
});
|
||||
const dismissTimelineEditorHint = useCallback(() => {
|
||||
setTimelineEditorHintState(true);
|
||||
setTimelineEditorHintDismissed(true);
|
||||
@@ -380,31 +390,6 @@ export function StudioApp() {
|
||||
);
|
||||
const timelineToolbar = (
|
||||
<div className="border-b border-neutral-800/40 bg-neutral-950/96">
|
||||
{timelineVisible && timelineElements.length > 0 && !timelineEditorHintDismissed && (
|
||||
<div className="px-3 pt-3">
|
||||
<div className="flex items-start justify-between gap-3 rounded-xl border border-studio-accent/20 bg-studio-accent/[0.07] px-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold text-neutral-100">Timeline editor</div>
|
||||
<p className="mt-1 text-[11px] leading-5 text-neutral-300">
|
||||
Drag clips to move timing, and drag clip edges to resize them when handles are
|
||||
available. Hide the panel anytime and bring it back with{" "}
|
||||
<span className="font-mono text-[10px] text-studio-accent">
|
||||
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismissTimelineEditorHint}
|
||||
className="flex-shrink-0 rounded-md border border-neutral-700 px-2 py-1 text-[10px] font-medium text-neutral-300 transition-colors hover:border-neutral-500 hover:text-neutral-100"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
|
||||
Timeline
|
||||
@@ -855,11 +840,22 @@ export function StudioApp() {
|
||||
|
||||
const handleMoveFile = handleRenameFile;
|
||||
|
||||
const showUploadToast = useCallback((msg: string) => {
|
||||
setUploadToast(msg);
|
||||
setTimeout(() => setUploadToast(null), 4000);
|
||||
const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
|
||||
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
||||
setAppToast({ message, tone });
|
||||
toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
|
||||
}, []);
|
||||
|
||||
const handleBlockedTimelineEdit = useCallback(
|
||||
(_element: TimelineElement) => {
|
||||
const now = Date.now();
|
||||
if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
|
||||
lastBlockedTimelineToastAtRef.current = now;
|
||||
showToast("This clip can’t be moved or resized from the timeline yet.", "info");
|
||||
},
|
||||
[showToast],
|
||||
);
|
||||
|
||||
const handleImportFiles = useCallback(
|
||||
async (files: FileList, dir?: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
@@ -879,20 +875,20 @@ export function StudioApp() {
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.skipped?.length) {
|
||||
showUploadToast(`Skipped (too large): ${data.skipped.join(", ")}`);
|
||||
showToast(`Skipped (too large): ${data.skipped.join(", ")}`);
|
||||
}
|
||||
await refreshFileTree();
|
||||
setRefreshKey((k) => k + 1);
|
||||
} else if (res.status === 413) {
|
||||
showUploadToast("Upload rejected: payload too large");
|
||||
showToast("Upload rejected: payload too large");
|
||||
} else {
|
||||
showUploadToast(`Upload failed (${res.status})`);
|
||||
showToast(`Upload failed (${res.status})`);
|
||||
}
|
||||
} catch {
|
||||
showUploadToast("Upload failed: network error");
|
||||
showToast("Upload failed: network error");
|
||||
}
|
||||
},
|
||||
[refreshFileTree, showUploadToast],
|
||||
[refreshFileTree, showToast],
|
||||
);
|
||||
|
||||
const handleLint = useCallback(async () => {
|
||||
@@ -1157,6 +1153,7 @@ export function StudioApp() {
|
||||
renderClipContent={renderClipContent}
|
||||
onMoveElement={handleTimelineElementMove}
|
||||
onResizeElement={handleTimelineElementResize}
|
||||
onBlockedEditAttempt={handleBlockedTimelineEdit}
|
||||
onCompIdToSrcChange={setCompIdToSrc}
|
||||
onCompositionChange={(compPath) => {
|
||||
// Sync activeCompPath when user drills down via timeline double-click
|
||||
@@ -1267,6 +1264,12 @@ export function StudioApp() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{timelineElements.length > 0 && !timelineEditorHintDismissed && (
|
||||
<div className="pointer-events-none absolute bottom-5 left-5 z-[140]">
|
||||
<TimelineEditorNotice onDismiss={dismissTimelineEditorHint} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lint modal */}
|
||||
{lintModal !== null && projectId && (
|
||||
<LintModal findings={lintModal} projectId={projectId} onClose={() => setLintModal(null)} />
|
||||
@@ -1306,9 +1309,15 @@ export function StudioApp() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{uploadToast && (
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg bg-red-900/90 border border-red-700/50 text-sm text-red-200 shadow-lg animate-in fade-in slide-in-from-bottom-2">
|
||||
{uploadToast}
|
||||
{appToast && (
|
||||
<div
|
||||
className={`absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg border text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2 ${
|
||||
appToast.tone === "error"
|
||||
? "bg-red-900/90 border-red-700/50 text-red-200"
|
||||
: "bg-neutral-900/95 border-neutral-700/60 text-neutral-100"
|
||||
}`}
|
||||
>
|
||||
{appToast.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect, memo, type ReactNode } from "
|
||||
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";
|
||||
|
||||
@@ -36,6 +37,7 @@ interface NLELayoutProps {
|
||||
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) */
|
||||
@@ -61,6 +63,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
renderClipContent,
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
onBlockedEditAttempt,
|
||||
onCompIdToSrcChange,
|
||||
timelineVisible,
|
||||
onToggleTimeline,
|
||||
@@ -392,6 +395,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
renderClipContent={renderClipContent}
|
||||
onMoveElement={onMoveElement}
|
||||
onResizeElement={onResizeElement}
|
||||
onBlockedEditAttempt={onBlockedEditAttempt}
|
||||
/>
|
||||
</div>
|
||||
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { TIMELINE_TOGGLE_SHORTCUT_LABEL } from "../../utils/timelineDiscovery";
|
||||
|
||||
interface TimelineEditorNoticeProps {
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function TimelineEditorNotice({ onDismiss }: TimelineEditorNoticeProps) {
|
||||
return (
|
||||
<aside
|
||||
aria-live="polite"
|
||||
className="pointer-events-none relative w-[320px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-white/10 bg-[#0f1115]/88 text-neutral-100 shadow-[0_18px_40px_rgba(0,0,0,0.3),0_4px_14px_rgba(0,0,0,0.18)] backdrop-blur-xl"
|
||||
>
|
||||
<style>{`
|
||||
@keyframes hfTimelineNoticeClipNudge {
|
||||
0%, 100% { transform: translate3d(0, 0, 0); }
|
||||
20% { transform: translate3d(0, 0, 0); }
|
||||
52% { transform: translate3d(12px, 0, 0); }
|
||||
72% { transform: translate3d(12px, 0, 0); }
|
||||
100% { transform: translate3d(0, 0, 0); }
|
||||
}
|
||||
|
||||
@keyframes hfTimelineNoticePlayheadSweep {
|
||||
0% { transform: translateX(0); opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
75% { opacity: 1; }
|
||||
100% { transform: translateX(218px); opacity: 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hf-timeline-notice-clip,
|
||||
.hf-timeline-notice-playhead {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss timeline editor notice"
|
||||
className="pointer-events-auto absolute right-3 top-3 z-10 flex h-7 w-7 items-center justify-center rounded-lg text-neutral-500 transition-colors duration-150 hover:bg-white/[0.06] hover:text-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-studio-accent/50"
|
||||
>
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.25"
|
||||
strokeLinecap="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-3 px-4 py-3.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="mb-3 overflow-hidden rounded-[14px] bg-[#0d1117] p-2.5"
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-[11px] bg-[#0f141c] px-2.5 pb-2 pt-1.5">
|
||||
<div className="mb-1.5 flex items-center justify-between pl-6 pr-1 text-[8px] font-medium text-[#7f8796]">
|
||||
<span>0:00</span>
|
||||
<span>0:05</span>
|
||||
<span>0:10</span>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute inset-x-0 top-[18px] h-px bg-white/[0.04]" />
|
||||
<div
|
||||
className="hf-timeline-notice-playhead pointer-events-none absolute left-[31px] top-[18px] h-[70px] w-0"
|
||||
style={{
|
||||
animation:
|
||||
"hfTimelineNoticePlayheadSweep 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 bottom-0"
|
||||
style={{
|
||||
left: "50%",
|
||||
width: 2,
|
||||
marginLeft: -1,
|
||||
background: "var(--hf-accent, #3CE6AC)",
|
||||
boxShadow: "0 0 8px rgba(60,230,172,0.5)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute"
|
||||
style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: "6px solid transparent",
|
||||
borderRight: "6px solid transparent",
|
||||
borderTop: "8px solid var(--hf-accent, #3CE6AC)",
|
||||
filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{[0, 1, 2].map((trackIndex) => (
|
||||
<div
|
||||
key={trackIndex}
|
||||
className="relative h-6 overflow-hidden rounded-[10px] bg-white/[0.035]"
|
||||
>
|
||||
<div className="absolute inset-y-0 left-[24px] w-px bg-white/[0.035]" />
|
||||
<div className="absolute inset-y-0 left-[100px] w-px bg-white/[0.035]" />
|
||||
<div className="absolute inset-y-0 left-[176px] w-px bg-white/[0.035]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute inset-x-0 top-[21px] h-[70px]">
|
||||
<div className="absolute left-[34px] top-[3px] h-[18px] w-[56px] rounded-[9px] bg-white/[0.07]" />
|
||||
<div
|
||||
className="hf-timeline-notice-clip absolute left-[82px] top-[27px] h-[18px] w-[110px] rounded-[9px] bg-studio-accent/18 ring-1 ring-inset ring-studio-accent/28"
|
||||
style={{
|
||||
animation:
|
||||
"hfTimelineNoticeClipNudge 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute left-[52px] top-[51px] h-[18px] w-[72px] rounded-[9px] bg-white/[0.07]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 pr-9">
|
||||
<p className="text-[12px] font-semibold leading-none tracking-tight text-neutral-100">
|
||||
Timeline editing is on
|
||||
</p>
|
||||
<p className="mt-1.5 text-[12px] leading-5 text-neutral-300">
|
||||
Drag clips to move timing, use{" "}
|
||||
<span className="font-mono text-[11px] text-studio-accent">Shift</span> + click to
|
||||
edit a full clip range, and watch for resize handles only on clips Studio can patch
|
||||
safely. Toggle the timeline with{" "}
|
||||
<span className="rounded-md border border-white/8 bg-white/[0.04] px-1.5 py-0.5 font-mono text-[11px] text-studio-accent">
|
||||
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-[10px] leading-none text-neutral-500">
|
||||
Dismiss once and it stays hidden.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCompositionPreviewScale } from "./CompositionsTab";
|
||||
|
||||
describe("resolveCompositionPreviewScale", () => {
|
||||
it("scales a 16:9 stage to fit the composition card", () => {
|
||||
expect(
|
||||
resolveCompositionPreviewScale({
|
||||
cardWidth: 80,
|
||||
cardHeight: 45,
|
||||
stageWidth: 1920,
|
||||
stageHeight: 1080,
|
||||
}),
|
||||
).toBeCloseTo(80 / 1920);
|
||||
});
|
||||
|
||||
it("scales non-16:9 stages against their actual dimensions", () => {
|
||||
expect(
|
||||
resolveCompositionPreviewScale({
|
||||
cardWidth: 80,
|
||||
cardHeight: 45,
|
||||
stageWidth: 1280,
|
||||
stageHeight: 720,
|
||||
}),
|
||||
).toBeCloseTo(80 / 1280);
|
||||
});
|
||||
|
||||
it("falls back to the default stage when dimensions are invalid", () => {
|
||||
expect(
|
||||
resolveCompositionPreviewScale({
|
||||
cardWidth: 80,
|
||||
cardHeight: 45,
|
||||
stageWidth: 0,
|
||||
stageHeight: Number.NaN,
|
||||
}),
|
||||
).toBeCloseTo(80 / 1920);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,27 @@ interface CompositionsTabProps {
|
||||
onSelect: (comp: string) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_PREVIEW_STAGE = { width: 1920, height: 1080 };
|
||||
|
||||
export function resolveCompositionPreviewScale(input: {
|
||||
cardWidth: number;
|
||||
cardHeight: number;
|
||||
stageWidth: number;
|
||||
stageHeight: number;
|
||||
}): number {
|
||||
const safeStageWidth =
|
||||
Number.isFinite(input.stageWidth) && input.stageWidth > 0
|
||||
? input.stageWidth
|
||||
: DEFAULT_PREVIEW_STAGE.width;
|
||||
const safeStageHeight =
|
||||
Number.isFinite(input.stageHeight) && input.stageHeight > 0
|
||||
? input.stageHeight
|
||||
: DEFAULT_PREVIEW_STAGE.height;
|
||||
const scaleX = input.cardWidth / safeStageWidth;
|
||||
const scaleY = input.cardHeight / safeStageHeight;
|
||||
return Math.min(scaleX, scaleY);
|
||||
}
|
||||
|
||||
function CompCard({
|
||||
projectId,
|
||||
comp,
|
||||
@@ -19,6 +40,7 @@ function CompCard({
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
|
||||
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const handleEnter = () => {
|
||||
hoverTimer.current = setTimeout(() => setHovered(true), 300);
|
||||
@@ -33,6 +55,12 @@ function CompCard({
|
||||
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
|
||||
const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=2`;
|
||||
const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
|
||||
const previewScale = resolveCompositionPreviewScale({
|
||||
cardWidth: 80,
|
||||
cardHeight: 45,
|
||||
stageWidth: stageSize.width,
|
||||
stageHeight: stageSize.height,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -51,10 +79,25 @@ function CompCard({
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
className="absolute inset-0 w-[1920px] h-[1080px] border-none pointer-events-none"
|
||||
className="absolute left-0 top-0 border-none pointer-events-none"
|
||||
style={{
|
||||
transformOrigin: "0 0",
|
||||
transform: `scale(${80 / 1920})`,
|
||||
width: stageSize.width,
|
||||
height: stageSize.height,
|
||||
transform: `scale(${previewScale})`,
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
try {
|
||||
const iframe = e.currentTarget;
|
||||
const root = iframe.contentDocument?.querySelector("[data-composition-id]");
|
||||
const width =
|
||||
Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
|
||||
const height =
|
||||
Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
|
||||
setStageSize({ width, height });
|
||||
} catch {
|
||||
setStageSize(DEFAULT_PREVIEW_STAGE);
|
||||
}
|
||||
}}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
generateTicks,
|
||||
getTimelineCanvasHeight,
|
||||
getTimelinePlayheadLeft,
|
||||
getTimelineScrollLeftForZoomTransition,
|
||||
shouldAutoScrollTimeline,
|
||||
@@ -151,3 +152,13 @@ describe("getTimelinePlayheadLeft", () => {
|
||||
expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(32);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTimelineCanvasHeight", () => {
|
||||
it("includes bottom scroll buffer below the last track", () => {
|
||||
expect(getTimelineCanvasHeight(3)).toBeGreaterThan(24 + 3 * 72);
|
||||
});
|
||||
|
||||
it("still keeps ruler space when there are no tracks", () => {
|
||||
expect(getTimelineCanvasHeight(0)).toBeGreaterThan(24);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,10 +10,14 @@ import { formatTime } from "../lib/time";
|
||||
import { TimelineClip } from "./TimelineClip";
|
||||
import { EditPopover } from "./EditModal";
|
||||
import {
|
||||
buildClipRangeSelection,
|
||||
getTimelineEditCapabilities,
|
||||
resolveBlockedTimelineEditIntent,
|
||||
resolveTimelineAutoScroll,
|
||||
resolveTimelineMove,
|
||||
resolveTimelineResize,
|
||||
type BlockedTimelineEditIntent,
|
||||
type TimelineRangeSelection,
|
||||
} from "./timelineEditing";
|
||||
import {
|
||||
defaultTimelineTheme,
|
||||
@@ -29,6 +33,8 @@ const GUTTER = 32;
|
||||
const TRACK_H = 72;
|
||||
const RULER_H = 24;
|
||||
const CLIP_Y = 3; // vertical inset inside track
|
||||
const CLIP_HANDLE_W = 18;
|
||||
const TIMELINE_SCROLL_BUFFER = 24;
|
||||
|
||||
interface TrackVisualStyle extends TimelineTrackStyle {
|
||||
icon: ReactNode;
|
||||
@@ -130,6 +136,10 @@ export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number):
|
||||
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
|
||||
}
|
||||
|
||||
export function getTimelineCanvasHeight(trackCount: number): number {
|
||||
return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
|
||||
}
|
||||
|
||||
/* ── Component ──────────────────────────────────────────────────── */
|
||||
interface TimelineProps {
|
||||
/** Called when user seeks via ruler/track click or playhead drag */
|
||||
@@ -157,6 +167,10 @@ interface TimelineProps {
|
||||
"start" | "duration" | "playbackStart"
|
||||
>,
|
||||
) => Promise<void> | void;
|
||||
onBlockedEditAttempt?: (
|
||||
element: import("../store/playerStore").TimelineElement,
|
||||
intent: BlockedTimelineEditIntent,
|
||||
) => void;
|
||||
theme?: Partial<TimelineTheme>;
|
||||
}
|
||||
|
||||
@@ -185,6 +199,14 @@ interface ResizingClipState {
|
||||
started: boolean;
|
||||
}
|
||||
|
||||
interface BlockedClipState {
|
||||
element: TimelineElement;
|
||||
intent: BlockedTimelineEditIntent;
|
||||
originClientX: number;
|
||||
originClientY: number;
|
||||
started: boolean;
|
||||
}
|
||||
|
||||
export const Timeline = memo(function Timeline({
|
||||
onSeek,
|
||||
onDrillDown,
|
||||
@@ -193,6 +215,7 @@ export const Timeline = memo(function Timeline({
|
||||
onFileDrop,
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
onBlockedEditAttempt,
|
||||
theme: themeOverrides,
|
||||
}: TimelineProps = {}) {
|
||||
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
|
||||
@@ -210,6 +233,11 @@ export const Timeline = memo(function Timeline({
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
|
||||
const isDragging = useRef(false);
|
||||
const shiftClickClipRef = useRef<{
|
||||
element: TimelineElement;
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
} | null>(null);
|
||||
// Range selection (Shift+drag)
|
||||
const [shiftHeld, setShiftHeld] = useState(false);
|
||||
useMountEffect(() => {
|
||||
@@ -227,18 +255,14 @@ export const Timeline = memo(function Timeline({
|
||||
});
|
||||
const isRangeSelecting = useRef(false);
|
||||
const rangeAnchorTime = useRef(0);
|
||||
const [rangeSelection, setRangeSelection] = useState<{
|
||||
start: number;
|
||||
end: number;
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
} | null>(null);
|
||||
const [rangeSelection, setRangeSelection] = useState<TimelineRangeSelection | null>(null);
|
||||
const [draggedClip, setDraggedClip] = useState<DraggedClipState | null>(null);
|
||||
const draggedClipRef = useRef<DraggedClipState | null>(null);
|
||||
draggedClipRef.current = draggedClip;
|
||||
const [resizingClip, setResizingClip] = useState<ResizingClipState | null>(null);
|
||||
const resizingClipRef = useRef<ResizingClipState | null>(null);
|
||||
resizingClipRef.current = resizingClip;
|
||||
const blockedClipRef = useRef<BlockedClipState | null>(null);
|
||||
const onMoveElementRef = useRef(onMoveElement);
|
||||
onMoveElementRef.current = onMoveElement;
|
||||
const onResizeElementRef = useRef(onResizeElement);
|
||||
@@ -546,6 +570,7 @@ export const Timeline = memo(function Timeline({
|
||||
const handleWindowPointerMove = (e: PointerEvent) => {
|
||||
const drag = draggedClipRef.current;
|
||||
const resize = resizingClipRef.current;
|
||||
const blocked = blockedClipRef.current;
|
||||
if (resize) {
|
||||
const distance = Math.abs(e.clientX - resize.originClientX);
|
||||
if (!resize.started && distance < 2) return;
|
||||
@@ -561,6 +586,8 @@ export const Timeline = memo(function Timeline({
|
||||
Math.max(resize.element.playbackRate ?? 1, 0.1),
|
||||
)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const normalizedTag = resize.element.tag.toLowerCase();
|
||||
const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video";
|
||||
const nextResize = resolveTimelineResize(
|
||||
{
|
||||
start: resize.element.start,
|
||||
@@ -569,7 +596,10 @@ export const Timeline = memo(function Timeline({
|
||||
pixelsPerSecond: ppsRef.current,
|
||||
minStart: 0,
|
||||
maxEnd: Math.min(durationRef.current, resize.element.start + sourceRemaining),
|
||||
playbackStart: resize.element.playbackStart,
|
||||
playbackStart:
|
||||
resize.edge === "start" && canSeedPlaybackStart
|
||||
? (resize.element.playbackStart ?? 0)
|
||||
: resize.element.playbackStart,
|
||||
playbackRate: resize.element.playbackRate,
|
||||
},
|
||||
resize.edge,
|
||||
@@ -589,6 +619,23 @@ export const Timeline = memo(function Timeline({
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (blocked) {
|
||||
const distance = Math.hypot(
|
||||
e.clientX - blocked.originClientX,
|
||||
e.clientY - blocked.originClientY,
|
||||
);
|
||||
const threshold = blocked.intent === "move" ? 4 : 2;
|
||||
if (!blocked.started && distance < threshold) return;
|
||||
if (!blocked.started) {
|
||||
blocked.started = true;
|
||||
blockedClipRef.current = blocked;
|
||||
suppressClickRef.current = true;
|
||||
setShowPopover(false);
|
||||
setRangeSelection(null);
|
||||
onBlockedEditAttempt?.(blocked.element, blocked.intent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!drag) return;
|
||||
|
||||
const distance = Math.hypot(e.clientX - drag.originClientX, e.clientY - drag.originClientY);
|
||||
@@ -644,6 +691,14 @@ export const Timeline = memo(function Timeline({
|
||||
return;
|
||||
}
|
||||
|
||||
const blocked = blockedClipRef.current;
|
||||
if (blocked) {
|
||||
blockedClipRef.current = null;
|
||||
if (!blocked.started) return;
|
||||
clearSuppressedClick();
|
||||
return;
|
||||
}
|
||||
|
||||
const drag = draggedClipRef.current;
|
||||
if (!drag) return;
|
||||
draggedClipRef.current = null;
|
||||
@@ -707,6 +762,7 @@ export const Timeline = memo(function Timeline({
|
||||
return;
|
||||
}
|
||||
|
||||
shiftClickClipRef.current = null;
|
||||
// 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);
|
||||
@@ -740,8 +796,14 @@ export const Timeline = memo(function Timeline({
|
||||
const handlePointerUp = useCallback(() => {
|
||||
if (isRangeSelecting.current) {
|
||||
isRangeSelecting.current = false;
|
||||
// Show popover if range is meaningful (> 0.2s)
|
||||
const pendingShiftClick = shiftClickClipRef.current;
|
||||
shiftClickClipRef.current = null;
|
||||
setRangeSelection((prev) => {
|
||||
if (prev && pendingShiftClick && Math.abs(prev.end - prev.start) <= 0.2) {
|
||||
setShowPopover(true);
|
||||
return buildClipRangeSelection(pendingShiftClick.element, pendingShiftClick);
|
||||
}
|
||||
// Show popover if range is meaningful (> 0.2s)
|
||||
if (prev && Math.abs(prev.end - prev.start) > 0.2) {
|
||||
setShowPopover(true);
|
||||
return prev;
|
||||
@@ -869,7 +931,7 @@ export const Timeline = memo(function Timeline({
|
||||
);
|
||||
}
|
||||
|
||||
const totalH = RULER_H + displayTrackOrder.length * TRACK_H;
|
||||
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
||||
const draggedElement = draggedClip?.element ?? null;
|
||||
const activeDraggedElement =
|
||||
draggedClip?.started === true && draggedElement
|
||||
@@ -990,7 +1052,7 @@ export const Timeline = memo(function Timeline({
|
||||
{shiftHeld && !rangeSelection && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||
<span className="text-[9px] font-medium" style={{ color: theme.textSecondary }}>
|
||||
Drag to select range
|
||||
Drag or click a clip to edit range
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -1108,6 +1170,7 @@ export const Timeline = memo(function Timeline({
|
||||
if (edge === "start" && !capabilities.canTrimStart) return;
|
||||
if (edge === "end" && !capabilities.canTrimEnd) return;
|
||||
e.stopPropagation();
|
||||
blockedClipRef.current = null;
|
||||
setShowPopover(false);
|
||||
setRangeSelection(null);
|
||||
setResizingClip({
|
||||
@@ -1121,16 +1184,41 @@ export const Timeline = memo(function Timeline({
|
||||
});
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (
|
||||
e.button !== 0 ||
|
||||
e.shiftKey ||
|
||||
!onMoveElement ||
|
||||
!capabilities.canMove
|
||||
)
|
||||
if (e.button !== 0) return;
|
||||
if (e.shiftKey) {
|
||||
shiftClickClipRef.current = {
|
||||
element: el,
|
||||
anchorX: e.clientX,
|
||||
anchorY: e.clientY,
|
||||
};
|
||||
return;
|
||||
}
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const blockedIntent = resolveBlockedTimelineEditIntent({
|
||||
width: rect.width,
|
||||
offsetX: e.clientX - rect.left,
|
||||
handleWidth: CLIP_HANDLE_W,
|
||||
capabilities,
|
||||
});
|
||||
if (
|
||||
blockedIntent &&
|
||||
((blockedIntent === "move" && onMoveElement) ||
|
||||
(blockedIntent !== "move" && onResizeElement))
|
||||
) {
|
||||
blockedClipRef.current = {
|
||||
element: el,
|
||||
intent: blockedIntent,
|
||||
originClientX: e.clientX,
|
||||
originClientY: e.clientY,
|
||||
started: false,
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (!onMoveElement || !capabilities.canMove) return;
|
||||
blockedClipRef.current = null;
|
||||
setShowPopover(false);
|
||||
setRangeSelection(null);
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setDraggedClip({
|
||||
element: el,
|
||||
originClientX: e.clientX,
|
||||
@@ -1270,7 +1358,7 @@ export const Timeline = memo(function Timeline({
|
||||
Shift
|
||||
</kbd>
|
||||
<span className="text-[9px]" style={{ color: theme.textSecondary }}>
|
||||
+ drag to edit range
|
||||
+ drag/click to edit range
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,7 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 18,
|
||||
opacity: showHandles ? 1 : 0,
|
||||
opacity: showHandles && capabilities.canTrimEnd ? 1 : 0,
|
||||
pointerEvents: onResizeStart && capabilities.canTrimEnd ? "auto" : "none",
|
||||
zIndex: 4,
|
||||
transition: "opacity 120ms ease-out",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildClipRangeSelection,
|
||||
buildPromptCopyText,
|
||||
buildTimelineElementAgentPrompt,
|
||||
buildTimelineAgentPrompt,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
canOffsetTrimClipStart,
|
||||
getTimelineEditCapabilities,
|
||||
hasPatchableTimelineTarget,
|
||||
resolveBlockedTimelineEditIntent,
|
||||
resolveTimelineAutoScroll,
|
||||
resolveTimelineMove,
|
||||
resolveTimelineResize,
|
||||
@@ -199,6 +201,14 @@ describe("canOffsetTrimClipStart", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows front trim for plain audio clips even before media-start exists", () => {
|
||||
expect(
|
||||
canOffsetTrimClipStart({
|
||||
tag: "audio",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks front trim for generic motion clips", () => {
|
||||
expect(
|
||||
canOffsetTrimClipStart({
|
||||
@@ -223,6 +233,21 @@ describe("hasPatchableTimelineTarget", () => {
|
||||
});
|
||||
|
||||
describe("getTimelineEditCapabilities", () => {
|
||||
it("does not disable editable audio just because it spans multiple scenes", () => {
|
||||
expect(
|
||||
getTimelineEditCapabilities({
|
||||
tag: "audio",
|
||||
duration: 8,
|
||||
selector: "#voiceover",
|
||||
sourceDuration: 8,
|
||||
}),
|
||||
).toEqual({
|
||||
canMove: true,
|
||||
canTrimStart: true,
|
||||
canTrimEnd: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("disables move and trims for generic motion clips even when patchable", () => {
|
||||
expect(
|
||||
getTimelineEditCapabilities({
|
||||
@@ -299,6 +324,111 @@ describe("getTimelineEditCapabilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBlockedTimelineEditIntent", () => {
|
||||
it("returns move when the clip body is blocked", () => {
|
||||
expect(
|
||||
resolveBlockedTimelineEditIntent({
|
||||
width: 160,
|
||||
offsetX: 80,
|
||||
handleWidth: 18,
|
||||
capabilities: {
|
||||
canMove: false,
|
||||
canTrimStart: false,
|
||||
canTrimEnd: false,
|
||||
},
|
||||
}),
|
||||
).toBe("move");
|
||||
});
|
||||
|
||||
it("returns resize-start when the left edge is blocked", () => {
|
||||
expect(
|
||||
resolveBlockedTimelineEditIntent({
|
||||
width: 160,
|
||||
offsetX: 8,
|
||||
handleWidth: 18,
|
||||
capabilities: {
|
||||
canMove: false,
|
||||
canTrimStart: false,
|
||||
canTrimEnd: true,
|
||||
},
|
||||
}),
|
||||
).toBe("resize-start");
|
||||
});
|
||||
|
||||
it("returns resize-end when the right edge is blocked", () => {
|
||||
expect(
|
||||
resolveBlockedTimelineEditIntent({
|
||||
width: 160,
|
||||
offsetX: 154,
|
||||
handleWidth: 18,
|
||||
capabilities: {
|
||||
canMove: false,
|
||||
canTrimStart: true,
|
||||
canTrimEnd: false,
|
||||
},
|
||||
}),
|
||||
).toBe("resize-end");
|
||||
});
|
||||
|
||||
it("does not block the left edge when the clip can still be moved", () => {
|
||||
expect(
|
||||
resolveBlockedTimelineEditIntent({
|
||||
width: 160,
|
||||
offsetX: 8,
|
||||
handleWidth: 18,
|
||||
capabilities: {
|
||||
canMove: true,
|
||||
canTrimStart: false,
|
||||
canTrimEnd: true,
|
||||
},
|
||||
}),
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it("does not swallow the full surface of a narrow movable clip", () => {
|
||||
expect(
|
||||
resolveBlockedTimelineEditIntent({
|
||||
width: 12,
|
||||
offsetX: 6,
|
||||
handleWidth: 18,
|
||||
capabilities: {
|
||||
canMove: true,
|
||||
canTrimStart: false,
|
||||
canTrimEnd: false,
|
||||
},
|
||||
}),
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null when the relevant edit is supported", () => {
|
||||
expect(
|
||||
resolveBlockedTimelineEditIntent({
|
||||
width: 160,
|
||||
offsetX: 8,
|
||||
handleWidth: 18,
|
||||
capabilities: {
|
||||
canMove: true,
|
||||
canTrimStart: true,
|
||||
canTrimEnd: true,
|
||||
},
|
||||
}),
|
||||
).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildClipRangeSelection", () => {
|
||||
it("anchors the full clip range at the click position", () => {
|
||||
expect(
|
||||
buildClipRangeSelection({ start: 1.25, duration: 3.5 }, { anchorX: 320, anchorY: 180 }),
|
||||
).toEqual({
|
||||
start: 1.25,
|
||||
end: 4.75,
|
||||
anchorX: 320,
|
||||
anchorY: 180,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTimelineAutoScroll", () => {
|
||||
it("does not scroll when the pointer stays away from the edges", () => {
|
||||
expect(
|
||||
@@ -420,6 +550,25 @@ describe("resolveTimelineResize", () => {
|
||||
).toEqual({ start: 1.5, duration: 2.5, playbackStart: 1 });
|
||||
});
|
||||
|
||||
it("can seed front trim from an implicit zero playback start", () => {
|
||||
expect(
|
||||
resolveTimelineResize(
|
||||
{
|
||||
start: 0,
|
||||
duration: 8,
|
||||
originClientX: 100,
|
||||
pixelsPerSecond: 100,
|
||||
minStart: 0,
|
||||
maxEnd: 8,
|
||||
playbackStart: 0,
|
||||
playbackRate: 1,
|
||||
},
|
||||
"start",
|
||||
200,
|
||||
),
|
||||
).toEqual({ start: 1, duration: 7, playbackStart: 1 });
|
||||
});
|
||||
|
||||
it("prevents extending media left past available source before media-start", () => {
|
||||
expect(
|
||||
resolveTimelineResize(
|
||||
|
||||
@@ -175,6 +175,15 @@ export interface TimelineEditCapabilities {
|
||||
canTrimEnd: boolean;
|
||||
}
|
||||
|
||||
export type BlockedTimelineEditIntent = "move" | "resize-start" | "resize-end";
|
||||
|
||||
export interface TimelineRangeSelection {
|
||||
start: number;
|
||||
end: number;
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
}
|
||||
|
||||
function isDeterministicTimelineWindow(input: {
|
||||
tag: string;
|
||||
compositionSrc?: string;
|
||||
@@ -207,12 +216,7 @@ export function canOffsetTrimClipStart(input: {
|
||||
if (input.playbackStartAttr != null) return true;
|
||||
if (input.playbackStart != null) return true;
|
||||
const normalizedTag = input.tag.toLowerCase();
|
||||
if (!["video", "audio"].includes(normalizedTag)) return false;
|
||||
return (
|
||||
input.sourceDuration != null &&
|
||||
Number.isFinite(input.sourceDuration) &&
|
||||
input.sourceDuration > 0
|
||||
);
|
||||
return ["video", "audio"].includes(normalizedTag);
|
||||
}
|
||||
|
||||
export function getTimelineEditCapabilities(input: {
|
||||
@@ -235,6 +239,41 @@ export function getTimelineEditCapabilities(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveBlockedTimelineEditIntent(input: {
|
||||
width: number;
|
||||
offsetX: number;
|
||||
handleWidth: number;
|
||||
capabilities: TimelineEditCapabilities;
|
||||
}): BlockedTimelineEditIntent | null {
|
||||
if (input.capabilities.canMove) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const safeWidth = Math.max(0, input.width);
|
||||
const safeOffsetX = clamp(input.offsetX, 0, safeWidth);
|
||||
const safeHandleWidth = Math.max(0, input.handleWidth);
|
||||
|
||||
if (safeOffsetX <= safeHandleWidth && !input.capabilities.canTrimStart) {
|
||||
return "resize-start";
|
||||
}
|
||||
if (safeOffsetX >= Math.max(0, safeWidth - safeHandleWidth) && !input.capabilities.canTrimEnd) {
|
||||
return "resize-end";
|
||||
}
|
||||
return "move";
|
||||
}
|
||||
|
||||
export function buildClipRangeSelection(
|
||||
clip: { start: number; duration: number },
|
||||
anchor: { anchorX: number; anchorY: number },
|
||||
): TimelineRangeSelection {
|
||||
return {
|
||||
start: clip.start,
|
||||
end: clip.start + clip.duration,
|
||||
anchorX: anchor.anchorX,
|
||||
anchorY: anchor.anchorY,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTimelineAgentPrompt({
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
|
||||
@@ -559,7 +559,11 @@ export function useTimelinePlayer() {
|
||||
|
||||
// Convert a runtime timeline message (from iframe postMessage) into TimelineElements
|
||||
const processTimelineMessage = useCallback(
|
||||
(data: { clips: ClipManifestClip[]; durationInFrames: number }) => {
|
||||
(data: {
|
||||
clips: ClipManifestClip[];
|
||||
durationInFrames: number;
|
||||
scenes?: Array<{ id: string; label: string; start: number; duration: number }>;
|
||||
}) => {
|
||||
if (!data.clips || data.clips.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ResolvedProject,
|
||||
RenderJobState,
|
||||
} from "@hyperframes/core/studio-api";
|
||||
import { createRetryingModuleLoader, ensureProducerDist } from "./vite.producer";
|
||||
|
||||
// ── Shared Puppeteer browser ─────────────────────────────────────────────────
|
||||
|
||||
@@ -51,6 +52,19 @@ const THUMBNAIL_CACHE_VERSION = "v2";
|
||||
function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAdapter {
|
||||
// Lazy-load the bundler via Vite's SSR module loader
|
||||
let _bundler: ((dir: string) => Promise<string>) | null = null;
|
||||
let _producerModulePromise: Promise<{
|
||||
createRenderJob: (config: {
|
||||
fps: 24 | 30 | 60;
|
||||
quality: "draft" | "standard" | "high";
|
||||
format: string;
|
||||
}) => unknown;
|
||||
executeRenderJob: (
|
||||
job: unknown,
|
||||
projectDir: string,
|
||||
outputPath: string,
|
||||
onProgress?: (job: { progress: number; currentStage?: string }) => void,
|
||||
) => Promise<void>;
|
||||
}> | null = null;
|
||||
const getBundler = async () => {
|
||||
if (!_bundler) {
|
||||
try {
|
||||
@@ -64,6 +78,25 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
|
||||
return _bundler;
|
||||
};
|
||||
|
||||
const getProducerModule = async () => {
|
||||
if (!_producerModulePromise) {
|
||||
_producerModulePromise = createRetryingModuleLoader(async () => {
|
||||
const { built } = ensureProducerDist({
|
||||
studioDir: __dirname,
|
||||
env: process.env,
|
||||
});
|
||||
if (built) {
|
||||
console.warn(
|
||||
"[Studio] @hyperframes/producer dist missing; building producer package for local renders...",
|
||||
);
|
||||
}
|
||||
const producerPkg = "@hyperframes/producer";
|
||||
return await import(/* @vite-ignore */ producerPkg);
|
||||
})();
|
||||
}
|
||||
return _producerModulePromise();
|
||||
};
|
||||
|
||||
return {
|
||||
listProjects() {
|
||||
const sessionsDir = resolve(dataDir, "../sessions");
|
||||
@@ -167,12 +200,7 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
|
||||
].find((p) => existsSync(p));
|
||||
if (systemChrome) process.env.PRODUCER_HEADLESS_SHELL_PATH = systemChrome;
|
||||
}
|
||||
// Dynamic import hidden from esbuild's static analysis (vite.config.ts is
|
||||
// bundled by esbuild at startup; a bare specifier would fail the externalize-deps plugin).
|
||||
const producerPkg = "@hyperframes/producer";
|
||||
const { createRenderJob, executeRenderJob } = await import(
|
||||
/* @vite-ignore */ producerPkg
|
||||
);
|
||||
const { createRenderJob, executeRenderJob } = await getProducerModule();
|
||||
const job = createRenderJob({
|
||||
fps: opts.fps as 24 | 30 | 60,
|
||||
quality: opts.quality as "draft" | "standard" | "high",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createRetryingModuleLoader,
|
||||
ensureProducerDist,
|
||||
resolveProducerDistEntry,
|
||||
resolveWorkspaceRoot,
|
||||
} from "./vite.producer";
|
||||
|
||||
describe("ensureProducerDist", () => {
|
||||
it("does nothing when the producer dist entry already exists", () => {
|
||||
const exec = vi.fn();
|
||||
const result = ensureProducerDist({
|
||||
studioDir: "/repo/packages/studio",
|
||||
existsSyncImpl: () => true,
|
||||
execFileSyncImpl: exec as never,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
built: false,
|
||||
producerDistEntry: resolve("/repo/packages/producer/dist/index.js"),
|
||||
});
|
||||
expect(exec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("builds producer when the dist entry is missing", () => {
|
||||
const exec = vi.fn();
|
||||
const env = { TEST: "1" } as NodeJS.ProcessEnv;
|
||||
|
||||
const result = ensureProducerDist({
|
||||
studioDir: "/repo/packages/studio",
|
||||
existsSyncImpl: () => false,
|
||||
execFileSyncImpl: exec as never,
|
||||
env,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
built: true,
|
||||
producerDistEntry: resolve("/repo/packages/producer/dist/index.js"),
|
||||
});
|
||||
expect(exec).toHaveBeenCalledWith(
|
||||
"bun",
|
||||
["run", "--filter", "@hyperframes/producer", "build"],
|
||||
{
|
||||
cwd: resolve("/repo"),
|
||||
stdio: "pipe",
|
||||
env,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("producer path helpers", () => {
|
||||
it("resolves the producer dist entry relative to studio", () => {
|
||||
expect(resolveProducerDistEntry("/repo/packages/studio")).toBe(
|
||||
resolve("/repo/packages/producer/dist/index.js"),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves the workspace root relative to studio", () => {
|
||||
expect(resolveWorkspaceRoot("/repo/packages/studio")).toBe(resolve("/repo"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRetryingModuleLoader", () => {
|
||||
it("retries after an initial load failure instead of caching the rejection", async () => {
|
||||
const load = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValueOnce("ok");
|
||||
const getModule = createRetryingModuleLoader(load);
|
||||
|
||||
await expect(getModule()).rejects.toThrow("boom");
|
||||
await expect(getModule()).resolves.toBe("ok");
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reuses the same promise after a successful load", async () => {
|
||||
const load = vi.fn<() => Promise<string>>().mockResolvedValue("ok");
|
||||
const getModule = createRetryingModuleLoader(load);
|
||||
|
||||
await expect(getModule()).resolves.toBe("ok");
|
||||
await expect(getModule()).resolves.toBe("ok");
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export function resolveProducerDistEntry(studioDir: string): string {
|
||||
return resolve(studioDir, "../producer/dist/index.js");
|
||||
}
|
||||
|
||||
export function resolveWorkspaceRoot(studioDir: string): string {
|
||||
return resolve(studioDir, "../..");
|
||||
}
|
||||
|
||||
export function ensureProducerDist(opts: {
|
||||
studioDir: string;
|
||||
existsSyncImpl?: (path: string) => boolean;
|
||||
execFileSyncImpl?: typeof execFileSync;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): { built: boolean; producerDistEntry: string } {
|
||||
const producerDistEntry = resolveProducerDistEntry(opts.studioDir);
|
||||
const exists = opts.existsSyncImpl ?? existsSync;
|
||||
if (exists(producerDistEntry)) {
|
||||
return { built: false, producerDistEntry };
|
||||
}
|
||||
|
||||
const exec = opts.execFileSyncImpl ?? execFileSync;
|
||||
exec("bun", ["run", "--filter", "@hyperframes/producer", "build"], {
|
||||
cwd: resolveWorkspaceRoot(opts.studioDir),
|
||||
stdio: "pipe",
|
||||
env: opts.env,
|
||||
});
|
||||
|
||||
return { built: true, producerDistEntry };
|
||||
}
|
||||
|
||||
export function createRetryingModuleLoader<T>(load: () => Promise<T>): () => Promise<T> {
|
||||
let promise: Promise<T> | null = null;
|
||||
|
||||
return async () => {
|
||||
if (!promise) {
|
||||
promise = load().catch((error) => {
|
||||
promise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return promise;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user