diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 24927f8c6..ed792040d 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -117,7 +117,7 @@ export function StudioApp() { }); const editHistory = usePersistentEditHistory({ projectId }); const domEditSaveTimestampRef = useRef(0); - const pendingTimelineEditPathRef = useRef(null); + const pendingTimelineEditPathRef = useRef(new Set()); const reloadPreview = useCallback(() => { setRefreshKey((k) => k + 1); }, []); diff --git a/packages/studio/src/hooks/usePreviewPersistence.ts b/packages/studio/src/hooks/usePreviewPersistence.ts index 35598ec42..eab2d755d 100644 --- a/packages/studio/src/hooks/usePreviewPersistence.ts +++ b/packages/studio/src/hooks/usePreviewPersistence.ts @@ -30,7 +30,7 @@ interface UsePreviewPersistenceParams { domEditSaveTimestampRef: React.MutableRefObject; /** Tracks in-flight timeline edits that patch the iframe DOM directly. File-change * events for these paths are always suppressed since the preview is already up-to-date. */ - pendingTimelineEditPathRef?: React.MutableRefObject; + pendingTimelineEditPathRef?: React.MutableRefObject>; /** Called to reload the preview after undo/redo or external file changes. */ reloadPreview: () => void; } @@ -167,9 +167,8 @@ export function usePreviewPersistence({ const changedPath = readStudioFileChangePath(payload); if (!changedPath) return; const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 4000; - const pendingPath = pendingTimelineEditPathRef?.current; - if (pendingPath && changedPath.endsWith(pendingPath)) { - pendingTimelineEditPathRef!.current = null; + if (pendingTimelineEditPathRef?.current.has(changedPath)) { + pendingTimelineEditPathRef.current.delete(changedPath); return; } if (!recentDomEditSave) { diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index fe52ecc93..86e76f9f9 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -39,7 +39,7 @@ interface UseTimelineEditingOptions { domEditSaveTimestampRef: React.MutableRefObject; reloadPreview: () => void; previewIframeRef: React.RefObject; - pendingTimelineEditPathRef: React.MutableRefObject; + pendingTimelineEditPathRef: React.MutableRefObject>; uploadProjectFiles: (files: Iterable, dir?: string) => Promise; } @@ -55,6 +55,9 @@ function buildPatchTarget(element: { domId?: string; selector?: string; selector return null; } +// The runtime re-reads data-start/data-duration from the DOM on each sync tick +// (packages/core/src/runtime/init.ts:1324-1368), so attribute mutations here are +// picked up automatically on the next frame without a rebind call. function patchIframeDomTiming( iframe: HTMLIFrameElement | null, element: TimelineElement, @@ -71,7 +74,7 @@ function patchIframeDomTiming( if (!el) return; for (const [name, value] of attrs) el.setAttribute(name, value); } catch { - // Cross-origin or mid-navigation — safe to ignore, file is already saved. + // Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort. } } @@ -112,7 +115,7 @@ interface PersistTimelineEditInput { writeProjectFile: (path: string, content: string) => Promise; recordEdit: (input: RecordEditInput) => Promise; domEditSaveTimestampRef: React.MutableRefObject; - pendingTimelineEditPathRef: React.MutableRefObject; + pendingTimelineEditPathRef: React.MutableRefObject>; } async function persistTimelineEdit(input: PersistTimelineEditInput): Promise { @@ -129,7 +132,7 @@ async function persistTimelineEdit(input: PersistTimelineEditInput): Promise { + ): Promise => { const pid = projectIdRef.current; - if (!pid) return; - editQueueRef.current = editQueueRef.current - .then(() => - persistTimelineEdit({ - projectId: pid, - element, - activeCompPath, - label, - buildPatches, - writeProjectFile, - recordEdit, - domEditSaveTimestampRef, - pendingTimelineEditPathRef, - }), - ) - .catch((error) => { - console.error(`[Timeline] Failed to persist: ${label}`, error); - }); + if (!pid) return Promise.resolve(); + const queued = editQueueRef.current.then(() => + persistTimelineEdit({ + projectId: pid, + element, + activeCompPath, + label, + buildPatches, + writeProjectFile, + recordEdit, + domEditSaveTimestampRef, + pendingTimelineEditPathRef, + }), + ); + editQueueRef.current = queued.catch((error) => { + console.error(`[Timeline] Failed to persist: ${label}`, error); + }); + return queued; }, [ activeCompPath, @@ -219,7 +222,7 @@ export function useTimelineEditing({ ["data-start", formatTimelineAttributeNumber(updates.start)], ["data-track-index", String(updates.track)], ]); - enqueueEdit(element, "Move timeline clip", (original, target) => { + return enqueueEdit(element, "Move timeline clip", (original, target) => { let patched = applyPatchByTarget(original, target, { type: "attribute", property: "start", @@ -244,7 +247,7 @@ export function useTimelineEditing({ ["data-start", formatTimelineAttributeNumber(updates.start)], ["data-duration", formatTimelineAttributeNumber(updates.duration)], ]); - enqueueEdit(element, "Resize timeline clip", (original, target) => { + return enqueueEdit(element, "Resize timeline clip", (original, target) => { const pbs = resolveResizePlaybackStart(original, target, element, updates); let patched = applyPatchByTarget(original, target, { type: "attribute", diff --git a/packages/studio/src/player/components/timelineEditing.test.ts b/packages/studio/src/player/components/timelineEditing.test.ts index 82a74afb9..1e945c19b 100644 --- a/packages/studio/src/player/components/timelineEditing.test.ts +++ b/packages/studio/src/player/components/timelineEditing.test.ts @@ -4,7 +4,6 @@ import { buildPromptCopyText, buildTimelineElementAgentPrompt, buildTimelineAgentPrompt, - canOffsetTrimClipStart, getTimelineEditCapabilities, hasPatchableTimelineTarget, resolveBlockedTimelineEditIntent, @@ -158,42 +157,6 @@ describe("resolveTimelineMove", () => { }); }); -describe("canOffsetTrimClipStart", () => { - it("allows front trim for clips that carry playback offset metadata", () => { - expect( - canOffsetTrimClipStart({ - tag: "div", - playbackStartAttr: "media-start", - }), - ).toBe(true); - }); - - it("allows front trim for media clips with source duration metadata", () => { - expect( - canOffsetTrimClipStart({ - tag: "video", - sourceDuration: 12, - }), - ).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({ - tag: "section", - }), - ).toBe(false); - }); -}); - describe("hasPatchableTimelineTarget", () => { it("returns true when the clip has a DOM id", () => { expect(hasPatchableTimelineTarget({ domId: "hero-card" })).toBe(true); diff --git a/packages/studio/src/player/components/timelineEditing.ts b/packages/studio/src/player/components/timelineEditing.ts index 059b01804..f837cb40b 100644 --- a/packages/studio/src/player/components/timelineEditing.ts +++ b/packages/studio/src/player/components/timelineEditing.ts @@ -201,18 +201,6 @@ export function hasPatchableTimelineTarget(input: { domId?: string; selector?: s return Boolean(input.domId || input.selector); } -export function canOffsetTrimClipStart(input: { - tag: string; - playbackStart?: number; - playbackStartAttr?: "media-start" | "playback-start"; - sourceDuration?: number; -}): boolean { - if (input.playbackStartAttr != null) return true; - if (input.playbackStart != null) return true; - const normalizedTag = input.tag.toLowerCase(); - return ["video", "audio"].includes(normalizedTag); -} - export function getTimelineEditCapabilities(input: { tag: string; duration: number; diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 53fa1def2..f503e8299 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -122,13 +122,14 @@ export function useTimelinePlayer() { needsProbe.map(async (el) => { const result = await probeMediaUrl(el.src!); if (!result) return; - const current = usePlayerStore.getState().elements; const key = el.key ?? el.id; - const idx = current.findIndex((e) => (e.key ?? e.id) === key); - if (idx === -1 || current[idx].sourceDuration != null) return; - const patched = current.slice(); - patched[idx] = { ...current[idx], sourceDuration: result.duration }; - setElements(patched); + usePlayerStore.setState((state) => { + const idx = state.elements.findIndex((e) => (e.key ?? e.id) === key); + if (idx === -1 || state.elements[idx].sourceDuration != null) return {}; + const patched = state.elements.slice(); + patched[idx] = { ...state.elements[idx], sourceDuration: result.duration }; + return { elements: patched }; + }); }), ); }