From d7ab7572b4ce9c81974979eaa0d09ff5b8f9cf7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 23 Apr 2026 17:50:08 -0400 Subject: [PATCH 1/5] fix: harden studio timeline editing and local renders --- packages/core/src/runtime/init.ts | 1 + packages/studio/src/App.tsx | 85 +++++----- .../studio/src/components/nle/NLELayout.tsx | 4 + .../components/nle/TimelineEditorNotice.tsx | 156 ++++++++++++++++++ .../src/player/components/Timeline.test.ts | 11 ++ .../studio/src/player/components/Timeline.tsx | 122 ++++++++++++-- .../src/player/components/TimelineClip.tsx | 2 +- .../player/components/timelineEditing.test.ts | 119 +++++++++++++ .../src/player/components/timelineEditing.ts | 50 +++++- .../src/player/hooks/useTimelinePlayer.ts | 6 +- packages/studio/vite.config.ts | 42 ++++- 11 files changed, 529 insertions(+), 69 deletions(-) create mode 100644 packages/studio/src/components/nle/TimelineEditorNotice.tsx diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 7b1eed6ed..0f6ecf9bc 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -374,6 +374,7 @@ export function initSandboxRuntimeModular(): void { string, RuntimeTimelineLike | undefined >, + includeAuthoredTimingAttrs: true, }); return resolver.resolveDurationForElement(element); }; diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 2a3dc9174..889891204 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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(null); + const [appToast, setAppToast] = useState(null); const [timelineVisible, setTimelineVisible] = useState(true); const [timelineEditorHintDismissed, setTimelineEditorHintState] = useState( getTimelineEditorHintDismissed, ); const dragCounterRef = useRef(0); + const toastTimerRef = useRef | null>(null); + const lastBlockedTimelineToastAtRef = useRef(0); const previewHotkeyWindowRef = useRef(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 = (
- {timelineVisible && timelineElements.length > 0 && !timelineEditorHintDismissed && ( -
-
-
-
Timeline editor
-

- 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{" "} - - {TIMELINE_TOGGLE_SHORTCUT_LABEL} - - . -

-
- -
-
- )} -
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() { )}
+ {timelineElements.length > 0 && !timelineEditorHintDismissed && ( +
+ +
+ )} + {/* Lint modal */} {lintModal !== null && projectId && ( setLintModal(null)} /> @@ -1306,9 +1309,15 @@ export function StudioApp() {
)} - {uploadToast && ( -
- {uploadToast} + {appToast && ( +
+ {appToast.message}
)}
diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index f25a3e47d..5c2bc21c1 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -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, ) => Promise | void; + onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */ onCompIdToSrcChange?: (map: Map) => 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} /> {timelineFooter &&
{timelineFooter}
} diff --git a/packages/studio/src/components/nle/TimelineEditorNotice.tsx b/packages/studio/src/components/nle/TimelineEditorNotice.tsx new file mode 100644 index 000000000..b65da3f81 --- /dev/null +++ b/packages/studio/src/components/nle/TimelineEditorNotice.tsx @@ -0,0 +1,156 @@ +import { TIMELINE_TOGGLE_SHORTCUT_LABEL } from "../../utils/timelineDiscovery"; + +interface TimelineEditorNoticeProps { + onDismiss: () => void; +} + +export function TimelineEditorNotice({ onDismiss }: TimelineEditorNoticeProps) { + return ( + + ); +} diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index e777042d2..22ff783e9 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -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); + }); +}); diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 1e919e46f..d08ebfc92 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -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; + onBlockedEditAttempt?: ( + element: import("../store/playerStore").TimelineElement, + intent: BlockedTimelineEditIntent, + ) => void; theme?: Partial; } @@ -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(null); const [hoveredClip, setHoveredClip] = useState(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(null); const [draggedClip, setDraggedClip] = useState(null); const draggedClipRef = useRef(null); draggedClipRef.current = draggedClip; const [resizingClip, setResizingClip] = useState(null); const resizingClipRef = useRef(null); resizingClipRef.current = resizingClip; + const blockedClipRef = useRef(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 && (
- Drag to select range + Drag or click a clip to edit range
)} @@ -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) 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 ( - e.button !== 0 || - e.shiftKey || - !onMoveElement || - !capabilities.canMove - ) + 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 - + drag to edit range + + drag/click to edit range diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index e908811eb..8678fa4fd 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -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", diff --git a/packages/studio/src/player/components/timelineEditing.test.ts b/packages/studio/src/player/components/timelineEditing.test.ts index 703a117f0..b211c4eb3 100644 --- a/packages/studio/src/player/components/timelineEditing.test.ts +++ b/packages/studio/src/player/components/timelineEditing.test.ts @@ -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,81 @@ 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: true, + 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: true, + canTrimStart: true, + canTrimEnd: false, + }, + }), + ).toBe("resize-end"); + }); + + 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 +520,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( diff --git a/packages/studio/src/player/components/timelineEditing.ts b/packages/studio/src/player/components/timelineEditing.ts index ebff92530..62d93148b 100644 --- a/packages/studio/src/player/components/timelineEditing.ts +++ b/packages/studio/src/player/components/timelineEditing.ts @@ -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,40 @@ export function getTimelineEditCapabilities(input: { }; } +export function resolveBlockedTimelineEditIntent(input: { + width: number; + offsetX: number; + handleWidth: number; + capabilities: TimelineEditCapabilities; +}): BlockedTimelineEditIntent | 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"; + } + if (!input.capabilities.canMove) { + return "move"; + } + return null; +} + +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, diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 1cb1cab69..470c2da8b 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -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; } diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts index ece14897a..eb8f97fd0 100644 --- a/packages/studio/vite.config.ts +++ b/packages/studio/vite.config.ts @@ -1,5 +1,6 @@ import { defineConfig, type Plugin, type ViteDevServer } from "vite"; import react from "@vitejs/plugin-react"; +import { execFileSync } from "node:child_process"; import { readFileSync, readdirSync, @@ -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) | 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; + }> | null = null; const getBundler = async () => { if (!_bundler) { try { @@ -64,6 +78,27 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda return _bundler; }; + const getProducerModule = async () => { + if (!_producerModulePromise) { + _producerModulePromise = (async () => { + const producerDistEntry = resolve(__dirname, "../producer/dist/index.js"); + if (!existsSync(producerDistEntry)) { + console.warn( + "[Studio] @hyperframes/producer dist missing; building producer package for local renders...", + ); + execFileSync("bun", ["run", "--filter", "@hyperframes/producer", "build"], { + cwd: resolve(__dirname, "../.."), + stdio: "pipe", + env: process.env, + }); + } + const producerPkg = "@hyperframes/producer"; + return await import(/* @vite-ignore */ producerPkg); + })(); + } + return _producerModulePromise; + }; + return { listProjects() { const sessionsDir = resolve(dataDir, "../sessions"); @@ -167,12 +202,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", From bdf74d4235beee436e22ba752e81c695b5b7d017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 23 Apr 2026 17:54:27 -0400 Subject: [PATCH 2/5] test: cover studio local render fallback --- packages/studio/vite.config.ts | 14 +++--- packages/studio/vite.producer.test.ts | 61 +++++++++++++++++++++++++++ packages/studio/vite.producer.ts | 33 +++++++++++++++ 3 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 packages/studio/vite.producer.test.ts create mode 100644 packages/studio/vite.producer.ts diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts index eb8f97fd0..cccd9b1d2 100644 --- a/packages/studio/vite.config.ts +++ b/packages/studio/vite.config.ts @@ -1,6 +1,5 @@ import { defineConfig, type Plugin, type ViteDevServer } from "vite"; import react from "@vitejs/plugin-react"; -import { execFileSync } from "node:child_process"; import { readFileSync, readdirSync, @@ -15,6 +14,7 @@ import type { ResolvedProject, RenderJobState, } from "@hyperframes/core/studio-api"; +import { ensureProducerDist } from "./vite.producer"; // ── Shared Puppeteer browser ───────────────────────────────────────────────── @@ -81,16 +81,14 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda const getProducerModule = async () => { if (!_producerModulePromise) { _producerModulePromise = (async () => { - const producerDistEntry = resolve(__dirname, "../producer/dist/index.js"); - if (!existsSync(producerDistEntry)) { + const { built } = ensureProducerDist({ + studioDir: __dirname, + env: process.env, + }); + if (built) { console.warn( "[Studio] @hyperframes/producer dist missing; building producer package for local renders...", ); - execFileSync("bun", ["run", "--filter", "@hyperframes/producer", "build"], { - cwd: resolve(__dirname, "../.."), - stdio: "pipe", - env: process.env, - }); } const producerPkg = "@hyperframes/producer"; return await import(/* @vite-ignore */ producerPkg); diff --git a/packages/studio/vite.producer.test.ts b/packages/studio/vite.producer.test.ts new file mode 100644 index 000000000..5529bd3a6 --- /dev/null +++ b/packages/studio/vite.producer.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import { + 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: "/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: "/repo/packages/producer/dist/index.js", + }); + expect(exec).toHaveBeenCalledWith( + "bun", + ["run", "--filter", "@hyperframes/producer", "build"], + { + cwd: "/repo", + stdio: "pipe", + env, + }, + ); + }); +}); + +describe("producer path helpers", () => { + it("resolves the producer dist entry relative to studio", () => { + expect(resolveProducerDistEntry("/repo/packages/studio")).toBe( + "/repo/packages/producer/dist/index.js", + ); + }); + + it("resolves the workspace root relative to studio", () => { + expect(resolveWorkspaceRoot("/repo/packages/studio")).toBe("/repo"); + }); +}); diff --git a/packages/studio/vite.producer.ts b/packages/studio/vite.producer.ts new file mode 100644 index 000000000..7f03f75ea --- /dev/null +++ b/packages/studio/vite.producer.ts @@ -0,0 +1,33 @@ +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 }; +} From 8ef3923c3d4133a312cf52ce7186280e3a554762 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 23 Apr 2026 17:57:18 -0400 Subject: [PATCH 3/5] fix(studio): scale composition hover previews to stage size --- .../sidebar/CompositionsTab.test.ts | 37 +++++++++++++++ .../components/sidebar/CompositionsTab.tsx | 47 ++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 packages/studio/src/components/sidebar/CompositionsTab.test.ts diff --git a/packages/studio/src/components/sidebar/CompositionsTab.test.ts b/packages/studio/src/components/sidebar/CompositionsTab.test.ts new file mode 100644 index 000000000..25549f1ea --- /dev/null +++ b/packages/studio/src/components/sidebar/CompositionsTab.test.ts @@ -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); + }); +}); diff --git a/packages/studio/src/components/sidebar/CompositionsTab.tsx b/packages/studio/src/components/sidebar/CompositionsTab.tsx index e1f7400e0..12dc558da 100644 --- a/packages/studio/src/components/sidebar/CompositionsTab.tsx +++ b/packages/studio/src/components/sidebar/CompositionsTab.tsx @@ -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 | 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 (
{ + 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} /> From 4d2870933edcd98aacf573a7142fae3fe57c7978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 23 Apr 2026 18:10:13 -0400 Subject: [PATCH 4/5] test: normalize studio producer fallback paths --- packages/studio/vite.producer.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/studio/vite.producer.test.ts b/packages/studio/vite.producer.test.ts index 5529bd3a6..3aec2f483 100644 --- a/packages/studio/vite.producer.test.ts +++ b/packages/studio/vite.producer.test.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { ensureProducerDist, @@ -16,7 +17,7 @@ describe("ensureProducerDist", () => { expect(result).toEqual({ built: false, - producerDistEntry: "/repo/packages/producer/dist/index.js", + producerDistEntry: resolve("/repo/packages/producer/dist/index.js"), }); expect(exec).not.toHaveBeenCalled(); }); @@ -34,13 +35,13 @@ describe("ensureProducerDist", () => { expect(result).toEqual({ built: true, - producerDistEntry: "/repo/packages/producer/dist/index.js", + producerDistEntry: resolve("/repo/packages/producer/dist/index.js"), }); expect(exec).toHaveBeenCalledWith( "bun", ["run", "--filter", "@hyperframes/producer", "build"], { - cwd: "/repo", + cwd: resolve("/repo"), stdio: "pipe", env, }, @@ -51,11 +52,11 @@ describe("ensureProducerDist", () => { describe("producer path helpers", () => { it("resolves the producer dist entry relative to studio", () => { expect(resolveProducerDistEntry("/repo/packages/studio")).toBe( - "/repo/packages/producer/dist/index.js", + resolve("/repo/packages/producer/dist/index.js"), ); }); it("resolves the workspace root relative to studio", () => { - expect(resolveWorkspaceRoot("/repo/packages/studio")).toBe("/repo"); + expect(resolveWorkspaceRoot("/repo/packages/studio")).toBe(resolve("/repo")); }); }); From 9fc5b6e3b05e95381bc1bdf7435c9e842876b491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 23 Apr 2026 18:16:16 -0400 Subject: [PATCH 5/5] fix(studio): preserve move surface and retry render fallback --- .../player/components/timelineEditing.test.ts | 34 +++++++++++++++++-- .../src/player/components/timelineEditing.ts | 9 ++--- packages/studio/vite.config.ts | 6 ++-- packages/studio/vite.producer.test.ts | 24 +++++++++++++ packages/studio/vite.producer.ts | 14 ++++++++ 5 files changed, 78 insertions(+), 9 deletions(-) diff --git a/packages/studio/src/player/components/timelineEditing.test.ts b/packages/studio/src/player/components/timelineEditing.test.ts index b211c4eb3..2ca13fe59 100644 --- a/packages/studio/src/player/components/timelineEditing.test.ts +++ b/packages/studio/src/player/components/timelineEditing.test.ts @@ -347,7 +347,7 @@ describe("resolveBlockedTimelineEditIntent", () => { offsetX: 8, handleWidth: 18, capabilities: { - canMove: true, + canMove: false, canTrimStart: false, canTrimEnd: true, }, @@ -362,7 +362,7 @@ describe("resolveBlockedTimelineEditIntent", () => { offsetX: 154, handleWidth: 18, capabilities: { - canMove: true, + canMove: false, canTrimStart: true, canTrimEnd: false, }, @@ -370,6 +370,36 @@ describe("resolveBlockedTimelineEditIntent", () => { ).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({ diff --git a/packages/studio/src/player/components/timelineEditing.ts b/packages/studio/src/player/components/timelineEditing.ts index 62d93148b..9f8ffc8ae 100644 --- a/packages/studio/src/player/components/timelineEditing.ts +++ b/packages/studio/src/player/components/timelineEditing.ts @@ -245,6 +245,10 @@ export function resolveBlockedTimelineEditIntent(input: { 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); @@ -255,10 +259,7 @@ export function resolveBlockedTimelineEditIntent(input: { if (safeOffsetX >= Math.max(0, safeWidth - safeHandleWidth) && !input.capabilities.canTrimEnd) { return "resize-end"; } - if (!input.capabilities.canMove) { - return "move"; - } - return null; + return "move"; } export function buildClipRangeSelection( diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts index cccd9b1d2..93b70915b 100644 --- a/packages/studio/vite.config.ts +++ b/packages/studio/vite.config.ts @@ -14,7 +14,7 @@ import type { ResolvedProject, RenderJobState, } from "@hyperframes/core/studio-api"; -import { ensureProducerDist } from "./vite.producer"; +import { createRetryingModuleLoader, ensureProducerDist } from "./vite.producer"; // ── Shared Puppeteer browser ───────────────────────────────────────────────── @@ -80,7 +80,7 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda const getProducerModule = async () => { if (!_producerModulePromise) { - _producerModulePromise = (async () => { + _producerModulePromise = createRetryingModuleLoader(async () => { const { built } = ensureProducerDist({ studioDir: __dirname, env: process.env, @@ -94,7 +94,7 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda return await import(/* @vite-ignore */ producerPkg); })(); } - return _producerModulePromise; + return _producerModulePromise(); }; return { diff --git a/packages/studio/vite.producer.test.ts b/packages/studio/vite.producer.test.ts index 3aec2f483..8f8622acb 100644 --- a/packages/studio/vite.producer.test.ts +++ b/packages/studio/vite.producer.test.ts @@ -1,6 +1,7 @@ import { resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { + createRetryingModuleLoader, ensureProducerDist, resolveProducerDistEntry, resolveWorkspaceRoot, @@ -60,3 +61,26 @@ describe("producer path helpers", () => { 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>() + .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>().mockResolvedValue("ok"); + const getModule = createRetryingModuleLoader(load); + + await expect(getModule()).resolves.toBe("ok"); + await expect(getModule()).resolves.toBe("ok"); + expect(load).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/studio/vite.producer.ts b/packages/studio/vite.producer.ts index 7f03f75ea..9f00cf227 100644 --- a/packages/studio/vite.producer.ts +++ b/packages/studio/vite.producer.ts @@ -31,3 +31,17 @@ export function ensureProducerDist(opts: { return { built: true, producerDistEntry }; } + +export function createRetryingModuleLoader(load: () => Promise): () => Promise { + let promise: Promise | null = null; + + return async () => { + if (!promise) { + promise = load().catch((error) => { + promise = null; + throw error; + }); + } + return promise; + }; +}