diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index b56180bcf4..e96e35e470 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -103,6 +103,16 @@ "packages/cli/src/cloud/_gen/**", ], "ignoreExports": [ + // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. + { + "file": "packages/studio/src/hooks/timelineElementsMove.ts", + "exports": ["useTimelineElementsMove"], + }, + // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. + { + "file": "packages/studio/src/player/components/timelineClipDragPreview.ts", + "exports": ["computeDragPreview", "computeResizePreview"], + }, // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22. { "file": "packages/studio/src/player/lib/timelineElementHelpers.ts", @@ -429,6 +439,15 @@ // require intrusive middleware changes beyond this PR's scope. "minLines": 6, "ignore": [ + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/utils/timelineAssetDrop.ts", + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/components/nle/NLEContext.test.ts", + // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); + // studio-dnd pr22 removes this with the final config. + "packages/studio/src/components/nle/NLELayout.test.ts", // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original); // studio-dnd pr22 removes this with the final config. "packages/studio/src/components/editor/domEditOverlayGeometry.ts", diff --git a/packages/studio/src/hooks/timelineElementsMove.test.ts b/packages/studio/src/hooks/timelineElementsMove.test.ts new file mode 100644 index 0000000000..2aaa3e33f9 --- /dev/null +++ b/packages/studio/src/hooks/timelineElementsMove.test.ts @@ -0,0 +1,157 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { TimelineElement } from "../player"; +import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers"; +import { persistTimelineElementsMove, type TimelineElementMoveEdit } from "./timelineElementsMove"; + +function el(over: Partial & Pick): TimelineElement { + return { tag: "div", start: 0, duration: 1, track: 0, ...over }; +} + +// A bed mirroring the user's qa-clean: an 8s audio clip whose real end sits past a +// STALE root data-duration. `furthestClipEndFromSource` must read the raw 8, not +// the stale root or a truncated value. +const bed = (rootDuration: number, musicStart: number) => + ` +
+ + +
+ `; + +describe("furthestClipEndFromSource", () => { + it("reads the RAW data-duration, past a stale root duration", () => { + // audio 11.53 + 8 = 19.53, even though the root claims only 15.18 + expect(furthestClipEndFromSource(bed(15.18, 11.53))).toBeCloseTo(19.53, 5); + }); + + it("excludes the composition root itself", () => { + // root data-duration is huge; furthest CLIP end is the audio at 8.88 + const src = `
+ +
`; + expect(furthestClipEndFromSource(src)).toBeCloseTo(8.88, 5); + }); + + it("returns 0 when there are no clips", () => { + expect( + furthestClipEndFromSource( + `
`, + ), + ).toBe(0); + }); + + it("returns 0 for empty input", () => { + expect(furthestClipEndFromSource("")).toBe(0); + }); +}); + +// ── Persist-level test (the G2 + §6.1 feedback-loop boundary) ──────────────── +// Proves the batched move writes the root duration computed from the SAVED SOURCE +// (raw data-duration), not the stale root value and not a runtime-truncated store +// duration. Without the source-based calc this shrinks/stalls the composition. +const h = vi.hoisted(() => ({ source: "", savedFiles: [] as Record[] })); + +// Stub the player store so persist's optimistic setDuration() doesn't mutate the +// real store and leak duration into later suite tests (test isolation). +vi.mock("../player", () => ({ + usePlayerStore: { getState: () => ({ setDuration: () => {} }) }, +})); + +vi.mock("../utils/studioFileHistory", () => ({ + saveProjectFilesWithHistory: vi.fn(async (input: { files: Record }) => { + h.savedFiles.push(input.files); + }), +})); + +const readFileContentMock = vi.hoisted(() => + vi.fn(async (_projectId: string, _path: string): Promise => ""), +); + +vi.mock("./timelineEditingHelpers", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + readFileContent: readFileContentMock, + patchIframeDomTiming: vi.fn(), + shiftGsapPositionsBatch: vi.fn(async () => {}), + }; +}); + +function move( + element: TimelineElement, + start: number, + track = element.track, +): TimelineElementMoveEdit { + return { element, updates: { start, track } }; +} + +describe("persistTimelineElementsMove — writes source-derived duration", () => { + beforeEach(() => { + h.savedFiles.length = 0; + readFileContentMock.mockReset(); + readFileContentMock.mockImplementation(async () => h.source); + }); + + it("grows the root duration to the moved audio's real 8s end (not the stale 15.18)", async () => { + // Stale bed: root says 15.18; audio (8s) currently ends there at start 7.18. + h.source = bed(15.18, 7.18); + const music = el({ id: "hf-music", hfId: "hf-music", start: 7.18, duration: 8, track: 2 }); + + await persistTimelineElementsMove([move(music, 11.53)], { + projectId: "p", + activeCompPath: "index.html", + previewIframe: null, + writeProjectFile: async () => {}, + recordEdit: async () => {}, + reloadPreview: () => {}, + domEditSaveTimestampRef: { current: 0 }, + }); + + expect(h.savedFiles).toHaveLength(1); + const saved = h.savedFiles[0]["index.html"]; + expect(saved).toContain('data-start="11.53"'); // audio moved + expect(saved).toContain('data-duration="19.53"'); // grown to the raw 8s end + expect(saved).not.toContain('data-duration="15.18"'); // stale root gone + }); + + // PORT 2 — group-move rollback discipline. A move whose clips span two source + // files (e.g. a sub-comp) must land as ONE atomic save with ONE history entry, + // not one write-per-file. saveProjectFilesWithHistory already writes-all / + // records-one / rolls-back-all, so passing every file in a single call gives + // all-or-nothing on disk that matches the caller's all-or-nothing store rollback. + it("folds clips from two source files into ONE atomic save (single history entry)", async () => { + // Distinct source per path; a single move edit touches each file. + const perPath: Record = { + "index.html": bed(15.18, 7.18), + "scenes/intro.html": bed(15.18, 2), + }; + readFileContentMock.mockImplementation(async (_pid: string, path: string) => perPath[path]); + + const rootMusic = el({ id: "hf-music", hfId: "hf-music", start: 7.18, duration: 8, track: 2 }); + const subMusic = el({ + id: "hf-music", + hfId: "hf-music", + start: 2, + duration: 8, + track: 2, + sourceFile: "scenes/intro.html", + }); + + await persistTimelineElementsMove([move(rootMusic, 9), move(subMusic, 4)], { + projectId: "p", + activeCompPath: "index.html", + previewIframe: null, + writeProjectFile: async () => {}, + recordEdit: async () => {}, + reloadPreview: () => {}, + domEditSaveTimestampRef: { current: 0 }, + }); + + // ONE save call carrying BOTH files (not two per-file saves). + expect(h.savedFiles).toHaveLength(1); + expect(Object.keys(h.savedFiles[0]).sort()).toEqual(["index.html", "scenes/intro.html"]); + expect(h.savedFiles[0]["index.html"]).toContain('data-start="9"'); + expect(h.savedFiles[0]["scenes/intro.html"]).toContain('data-start="4"'); + }); +}); diff --git a/packages/studio/src/hooks/timelineElementsMove.ts b/packages/studio/src/hooks/timelineElementsMove.ts new file mode 100644 index 0000000000..cfd788261e --- /dev/null +++ b/packages/studio/src/hooks/timelineElementsMove.ts @@ -0,0 +1,308 @@ +import { useCallback } from "react"; +import type { MutableRefObject, RefObject } from "react"; +import { usePlayerStore } from "../player"; +import type { TimelineElement } from "../player"; +import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers"; +import type { EditHistoryKind } from "../utils/editHistory"; +import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; +import { setCompositionDurationToContent } from "../utils/timelineAssetDrop"; +import { + applyPatchByTarget, + buildPatchTarget, + formatTimelineAttributeNumber, + patchIframeDomTiming, + patchIframeRootDuration, + readFileContent, + shiftGsapPositionsBatch, + syncTimingEditPreview, + type GsapMutationOutcome, +} from "./timelineEditingHelpers"; + +/** One clip's timing change in a batched move. */ +export interface TimelineElementMoveEdit { + element: TimelineElement; + updates: Pick; +} + +export interface PersistTimelineElementsMoveDeps { + projectId: string; + activeCompPath: string | null; + previewIframe: HTMLIFrameElement | null; + writeProjectFile: (path: string, content: string) => Promise; + recordEdit: (input: { + label: string; + kind: EditHistoryKind; + coalesceKey?: string; + files: Record; + }) => Promise; + reloadPreview: () => void; + forceReloadSdkSession?: () => void; + domEditSaveTimestampRef: MutableRefObject; +} + +interface MoveGroupResult { + targetPath: string; + original: string; + patched: string; + groupEdits: TimelineElementMoveEdit[]; +} + +/** Group edits by owning source file (in practice one composition file). Each + * group becomes one atomic read/patch/write/shift/reload cycle. */ +function groupEditsByFile( + edits: TimelineElementMoveEdit[], + activeCompPath: string | null, +): Map { + const groups = new Map(); + for (const edit of edits) { + const targetPath = edit.element.sourceFile || activeCompPath || "index.html"; + const bucket = groups.get(targetPath); + if (bucket) bucket.push(edit); + else groups.set(targetPath, [edit]); + } + return groups; +} + +/** Patch one group's source in memory: every clip's start/track attributes. */ +function applyGroupTimingPatches(source: string, groupEdits: TimelineElementMoveEdit[]): string { + let patched = source; + for (const { element, updates } of groupEdits) { + const target = buildPatchTarget(element); + if (!target) continue; + patched = applyPatchByTarget(patched, target, { + type: "attribute", + property: "start", + value: formatTimelineAttributeNumber(updates.start), + }); + patched = applyPatchByTarget(patched, target, { + type: "attribute", + property: "track-index", + value: String(updates.track), + }); + } + return patched; +} + +/** + * Phase 1 — patch every group's source in memory (nothing written yet, so a + * patch failure or no-op can't leave disk half-updated). Also syncs the root's + * content-driven duration and optimistically reflects the displayed comp's new + * length in the store + live root. Returns the collected results for one atomic + * save. + */ +async function patchTimelineMoveGroups( + groups: Map, + deps: Pick, +): Promise<{ + groupResults: MoveGroupResult[]; + filesToSave: Record; + originals: Map; +}> { + const groupResults: MoveGroupResult[] = []; + const filesToSave: Record = {}; + const originals = new Map(); + for (const [targetPath, groupEdits] of groups) { + const original = await readFileContent(deps.projectId, targetPath); + let patched = applyGroupTimingPatches(original, groupEdits); + // Content-driven duration: sync root data-duration to the furthest clip end, + // read from the PATCHED SOURCE (raw data-duration), not the store — the store + // holds runtime-TRUNCATED durations, so measuring content from it would feed + // the truncated value back in and shrink the comp every move. + const contentEnd = furthestClipEndFromSource(patched); + patched = setCompositionDurationToContent(patched, contentEnd); + if (patched === original) continue; + + // Optimistically reflect the new length in the store + live root so the + // duration readout / seek bar update immediately (a batched move soft-reloads, + // so a stale root would revert this set). Only for the displayed comp. + if (contentEnd > 0 && targetPath === (deps.activeCompPath || "index.html")) { + usePlayerStore.getState().setDuration(contentEnd); + patchIframeRootDuration(deps.previewIframe, contentEnd); + } + + groupResults.push({ targetPath, original, patched, groupEdits }); + filesToSave[targetPath] = patched; + originals.set(targetPath, original); + } + return { groupResults, filesToSave, originals }; +} + +/** + * Phase 3 — post-save preview sync, per group. Non-fatal cosmetic steps (a GSAP + * shift miss re-syncs on the next reload), so they run after the durable write + * and never throw back into the caller's rollback path. + */ +async function syncTimelineMovePreviews( + groupResults: MoveGroupResult[], + deps: Pick, +): Promise { + for (const { targetPath, groupEdits } of groupResults) { + // One batched GSAP position shift for every clip whose start changed. + const shifts = groupEdits + .filter((e) => e.element.domId && e.updates.start - e.element.start !== 0) + .map((e) => ({ + elementId: e.element.domId as string, + delta: e.updates.start - e.element.start, + })); + let shiftOutcome: GsapMutationOutcome = { scriptText: null }; + if (shifts.length > 0) { + shiftOutcome = await shiftGsapPositionsBatch(deps.projectId, targetPath, shifts).catch( + (err) => { + console.error("[Timeline] Failed to batch-shift GSAP positions", err); + return { scriptText: null }; + }, + ); + } + + // Soft-reload with the batch's rewritten script — a multi-clip move is + // timing-only (DOM + store already patched), so swap the script in place to + // avoid the all-clips flash; full reload is the fallback. + syncTimingEditPreview( + deps.previewIframe, + shiftOutcome, + usePlayerStore.getState().currentTime, + deps.reloadPreview, + ); + } +} + +/** + * Persist a multi-clip timeline move as ONE atomic, single-undo operation. + * + * This is the fix for the per-clip persist race (research HANDOFF §7.1): the old + * path fired one fire-and-forget `onMoveElement` per clip, each doing its own + * source-write + GSAP shift + reload, and the concurrent GSAP round-trips + * corrupted the file. Here every affected clip is folded into a single read → + * patch-all → write → one batched GSAP shift → one reload. Clips are grouped per + * owning source file for patching + preview sync, but ALL groups are written in a + * single atomic save with ONE history entry, so a multi-file move (clips spanning + * sub-comps) is all-or-nothing on disk and one undo step. Mirrors the atomic + * pattern already used by delete / asset-drop. + * + * Note: this always takes the server path. Single-clip moves keep their existing + * SDK-cutover-aware handler; multi-clip ripple/insert is new behavior with no + * prior SDK expectation. When SDK cutover ships, add an sdkSession.batch() branch + * here for single-undo atomicity on that path too. + * + * Throws if a source write fails (so the caller can roll back its optimistic + * store update). GSAP-shift failures are logged, not thrown (matches the + * single-clip path — a shift miss is non-fatal, the next reload re-syncs). + */ +export async function persistTimelineElementsMove( + edits: TimelineElementMoveEdit[], + deps: PersistTimelineElementsMoveDeps, +): Promise { + if (edits.length === 0) return; + const { + projectId, + activeCompPath, + previewIframe, + writeProjectFile, + recordEdit, + reloadPreview, + forceReloadSdkSession, + domEditSaveTimestampRef, + } = deps; + + // 1. Optimistic live DOM patch — instant feedback before the write lands. + for (const { element, updates } of edits) { + patchIframeDomTiming(previewIframe, element, [ + ["data-start", formatTimelineAttributeNumber(updates.start)], + ["data-track-index", String(updates.track)], + ]); + } + + // Phase 1 — group by owning file and patch each group's source in memory. + const groups = groupEditsByFile(edits, activeCompPath); + const { groupResults, filesToSave, originals } = await patchTimelineMoveGroups(groups, { + projectId, + activeCompPath, + previewIframe, + }); + + if (groupResults.length === 0) return; + + // Phase 2 — ONE atomic write of every affected file + ONE history entry. + // saveProjectFilesWithHistory writes all files then records a single edit, + // and rolls back every written file if any write fails. This is the fix for + // the per-group persist gap: a multi-file move (clips spanning sub-comps) + // previously did N writes + N history entries, so a partial failure left + // earlier files written + a stray history entry while the caller rolled the + // store all the way back. All-or-nothing on disk now matches the caller's + // all-or-nothing store rollback, and undo is a single step. + domEditSaveTimestampRef.current = Date.now(); + await saveProjectFilesWithHistory({ + projectId, + label: edits.length > 1 ? "Move timeline clips" : "Move timeline clip", + kind: "timeline", + files: filesToSave, + readFile: async (path) => originals.get(path) ?? "", + writeFile: writeProjectFile, + recordEdit, + }); + + // Phase 3 — post-save preview sync, per group. + forceReloadSdkSession?.(); + await syncTimelineMovePreviews(groupResults, { projectId, previewIframe, reloadPreview }); +} + +export interface UseTimelineElementsMoveDeps { + projectIdRef: MutableRefObject; + activeCompPath: string | null; + previewIframeRef: RefObject; + writeProjectFile: (path: string, content: string) => Promise; + recordEdit: PersistTimelineElementsMoveDeps["recordEdit"]; + reloadPreview: () => void; + forceReloadSdkSession?: () => void; + domEditSaveTimestampRef: MutableRefObject; + isRecordingRef?: RefObject; + showToast: (message: string, tone?: "error" | "info") => void; +} + +/** React wrapper: guards (recording / no-project) + binds the current refs. */ +export function useTimelineElementsMove(deps: UseTimelineElementsMoveDeps) { + const { + projectIdRef, + activeCompPath, + previewIframeRef, + writeProjectFile, + recordEdit, + reloadPreview, + forceReloadSdkSession, + domEditSaveTimestampRef, + isRecordingRef, + showToast, + } = deps; + return useCallback( + (edits: TimelineElementMoveEdit[]): Promise => { + if (isRecordingRef?.current) { + showToast("Cannot edit timeline while recording", "error"); + return Promise.resolve(); + } + const pid = projectIdRef.current; + if (!pid) return Promise.resolve(); + return persistTimelineElementsMove(edits, { + projectId: pid, + activeCompPath, + previewIframe: previewIframeRef.current, + writeProjectFile, + recordEdit, + reloadPreview, + forceReloadSdkSession, + domEditSaveTimestampRef, + }); + }, + [ + projectIdRef, + activeCompPath, + previewIframeRef, + writeProjectFile, + recordEdit, + reloadPreview, + forceReloadSdkSession, + domEditSaveTimestampRef, + isRecordingRef, + showToast, + ], + ); +} diff --git a/packages/studio/src/player/components/timelineClipDragCommit.test.ts b/packages/studio/src/player/components/timelineClipDragCommit.test.ts new file mode 100644 index 0000000000..72dbcaa82a --- /dev/null +++ b/packages/studio/src/player/components/timelineClipDragCommit.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it, vi, type Mock } from "vitest"; +import type { TimelineElement } from "../store/playerStore"; +import type { DraggedClipState } from "./useTimelineClipDrag"; +import { + commitDraggedClipMove, + type DragCommitDeps, + type TimelineMoveEdit, +} from "./timelineClipDragCommit"; + +function el( + id: string, + track: number, + start: number, + duration: number, + tag = "video", +): TimelineElement { + return { id, key: id, tag, start, duration, track }; +} + +function drag( + element: TimelineElement, + opts: { previewStart: number; previewTrack: number; insertRow?: number | null }, +): DraggedClipState { + return { + element, + originClientX: 0, + originClientY: 0, + originScrollLeft: 0, + originScrollTop: 0, + pointerClientX: 0, + pointerClientY: 0, + pointerOffsetX: 0, + pointerOffsetY: 0, + previewStart: opts.previewStart, + previewTrack: opts.previewTrack, + insertRow: opts.insertRow ?? null, + snapTime: null, + snapType: null, + started: true, + }; +} + +function editMap(edits: TimelineMoveEdit[]): Record { + const out: Record = {}; + for (const e of edits) + out[e.element.key ?? e.element.id] = { start: e.updates.start, track: e.updates.track }; + return out; +} + +/** + * Run a drag commit with fresh spies for the three persist callbacks, returning + * them so a test can assert on whichever path it exercised. Any other dep + * (trackOrder, selectedKeys, readZIndex, onStackingPatches, …) is passed through. + */ +function runClipMove( + dragState: DraggedClipState, + deps: Omit, +): { updateElement: Mock; onMoveElement: Mock; onMoveElements: Mock } { + const updateElement = vi.fn(); + const onMoveElement = vi.fn(); + const onMoveElements = vi.fn(); + commitDraggedClipMove(dragState, { ...deps, updateElement, onMoveElement, onMoveElements }); + return { updateElement, onMoveElement, onMoveElements }; +} + +/** Assert a lane change persisted atomically (single onMoveElements, no single + * onMoveElement) and return the resulting id → {start, track} edit map. */ +function expectAtomicMoveMap(spies: { + onMoveElement: Mock; + onMoveElements: Mock; +}): Record { + expect(spies.onMoveElement).not.toHaveBeenCalled(); + expect(spies.onMoveElements).toHaveBeenCalledTimes(1); + return editMap(spies.onMoveElements.mock.calls[0][0]); +} + +describe("commitDraggedClipMove", () => { + it("pure time-move (same lane) persists just the dragged clip (single, SDK-aware)", () => { + const elements = [el("v1", 1, 0, 5)]; + // previewTrack === element.track → no topology change → single move. + const { onMoveElement, onMoveElements } = runClipMove( + drag(elements[0], { previewStart: 6, previewTrack: 1 }), + { elements, trackOrder: [1] }, + ); + expect(onMoveElements).not.toHaveBeenCalled(); + expect(onMoveElement).toHaveBeenCalledTimes(1); + expect(onMoveElement.mock.calls[0][1]).toEqual({ start: 6, track: 1 }); + }); + + it("a lane change re-normalizes and persists EVERY clip atomically (fixes raw-vs-normalized collision)", () => { + // Move 'a' from lane 0 down onto lane 1 (b's lane) at a non-overlapping time. + const elements = [el("a", 0, 0, 3), el("b", 1, 10, 3)]; + const { onMoveElement, onMoveElements } = runClipMove( + drag(elements[0], { previewStart: 20, previewTrack: 1 }), + { elements, trackOrder: [0, 1] }, + ); + // BOTH clips persist atomically with consistent normalized tracks (both visual → lane 0). + const map = expectAtomicMoveMap({ onMoveElement, onMoveElements }); + expect(map.a).toEqual({ start: 20, track: 0 }); + expect(map.b).toEqual({ start: 10, track: 0 }); + }); + + it("multi-selection time-move shifts EVERY selected clip by the drag delta (atomic)", () => { + const elements = [el("a", 0, 2, 3), el("b", 1, 10, 3), el("c", 2, 20, 3)]; + // Drag 'a' +5s on its own lane while {a, b} are marquee-selected. + const { onMoveElement, onMoveElements } = runClipMove( + drag(elements[0], { previewStart: 7, previewTrack: 0 }), + { elements, trackOrder: [0, 1, 2], selectedKeys: new Set(["a", "b"]) }, + ); + const map = expectAtomicMoveMap({ onMoveElement, onMoveElements }); + expect(map.a).toEqual({ start: 7, track: 0 }); + expect(map.b).toEqual({ start: 15, track: 1 }); // same +5 delta, keeps its lane + expect(map.c).toBeUndefined(); // unselected clips untouched + }); + + it("multi-selection move clamps shifted clips at 0 and applies the store update optimistically", () => { + const elements = [el("a", 0, 6, 3), el("b", 1, 2, 3)]; + // Drag 'a' −5s: b would land at −3 → clamps to 0. + const { updateElement, onMoveElements } = runClipMove( + drag(elements[0], { previewStart: 1, previewTrack: 0 }), + { elements, trackOrder: [0, 1], selectedKeys: new Set(["a", "b"]) }, + ); + const map = editMap(onMoveElements.mock.calls[0][0]); + expect(map.a).toEqual({ start: 1, track: 0 }); + expect(map.b).toEqual({ start: 0, track: 1 }); + expect(updateElement).toHaveBeenCalledWith("a", { start: 1, track: 0 }); + expect(updateElement).toHaveBeenCalledWith("b", { start: 0, track: 1 }); + }); + + it("a multi-selection that does NOT include the dragged clip moves only the dragged clip", () => { + const elements = [el("a", 0, 0, 3), el("b", 1, 10, 3)]; + const { onMoveElement, onMoveElements } = runClipMove( + drag(elements[0], { previewStart: 6, previewTrack: 0 }), + { elements, trackOrder: [0, 1], selectedKeys: new Set(["b", "x"]) }, + ); + expect(onMoveElements).not.toHaveBeenCalled(); + expect(onMoveElement).toHaveBeenCalledTimes(1); + expect(onMoveElement.mock.calls[0][1]).toEqual({ start: 6, track: 0 }); + }); + + it("multi-selection lane change: dragged clip changes track, the rest of the selection shifts in time only", () => { + const elements = [el("a", 0, 0, 3), el("b", 1, 10, 3), el("c", 2, 20, 3)]; + // Drag 'a' +4s down onto lane 1 (non-overlapping with b) while {a, c} selected. + const { onMoveElements } = runClipMove( + drag(elements[0], { previewStart: 4, previewTrack: 1 }), + { elements, trackOrder: [0, 1, 2], selectedKeys: new Set(["a", "c"]) }, + ); + const map = editMap(onMoveElements.mock.calls[0][0]); + expect(map.a.start).toBe(4); // dragged: new time + new (normalized) lane + expect(map.c.start).toBe(24); // selected passenger: same +4 delta + expect(map.c.track).not.toBe(map.a.track); // passenger keeps its own lane + expect(map.b.start).toBe(10); // unselected: time untouched + }); + + it("inserting a new lane re-packs the whole set into contiguous lanes (single atomic persist)", () => { + // a,b,c all start=0 dur=5 → mutually overlapping, all equal z (absent ⇒ 0). + // The insert drops c at a fractional lane between a and b. Under the per-clip + // constrained pack, equal-z overlapping clips lay out by DOM order (later on + // top): c (last in DOM) → lane 0, b → lane 1, a → lane 2. (Was pinned to + // a=0/c=1/b=2 by the old whole-track packer, which used the fractional-track + // value for ordering; the new pack encodes insert INTENT via the z-sync path + // instead, and lanes reflect true canvas paint order.) Contiguous 0..2, one + // atomic persist for all three. + const elements = [el("a", 0, 0, 5), el("b", 1, 0, 5), el("c", 2, 0, 5)]; + // insert a new lane at row 1 (between a and b) with c. + const { onMoveElements } = runClipMove( + drag(elements[2], { previewStart: 0, previewTrack: 2, insertRow: 1 }), + { elements, trackOrder: [0, 1, 2] }, + ); + expect(onMoveElements).toHaveBeenCalledTimes(1); + const map = editMap(onMoveElements.mock.calls[0][0]); + // Lanes are contiguous and distinct (no two overlapping clips share a lane). + expect(new Set([map.a.track, map.b.track, map.c.track])).toEqual(new Set([0, 1, 2])); + expect(map.c.track).toBe(0); // last in DOM → top lane + expect(map.b.track).toBe(1); + expect(map.a.track).toBe(2); + }); + + describe("lane ↔ stacking sync", () => { + it("lane change raises the edited clip's z above a time-overlapping lower-lane clip", () => { + // a & b overlap in time. Elements carry their authored z (as real discovery + // populates TimelineElement.zIndex from the DOM), so the per-clip pack lays + // them out by z: b (z=5) tops, a (z=1) below. The user drags a UP onto the + // TOP lane (row 0, above b) via an insert — expressing "a should stack above + // b". The z-sync must lift a above b (5) → 6 so the lane move is realised. + // (Was: equal-z candidate + drop onto b's track; that relied on the old + // key-order tie-break placing a on top, which contradicted canvas paint for + // equal z — the elements now carry z and the drop intent is an insert above.) + const elements: TimelineElement[] = [ + { id: "a", key: "a", tag: "video", start: 0, duration: 10, track: 1, zIndex: 1 }, + { id: "b", key: "b", tag: "video", start: 0, duration: 10, track: 0, zIndex: 5 }, + ]; + const z: Record = { a: 1, b: 5 }; + const onStackingPatches = vi.fn(); + // Insert a new lane at row 0 (above the top lane) with a → a lands above b. + runClipMove(drag(elements[0], { previewStart: 0, previewTrack: 1, insertRow: 0 }), { + elements, + trackOrder: [0, 1], + readZIndex: (e) => z[e.key ?? e.id] ?? 0, + onStackingPatches, + }); + // Only `a` (the edited clip) is patched, lifted above b(5) → 6. + expect(onStackingPatches).toHaveBeenCalledTimes(1); + expect(onStackingPatches.mock.calls[0][0]).toEqual([{ key: "a", zIndex: 6 }]); + }); + + it("no z-sync deps → no stacking side-effects (pure time-move path safe)", () => { + const elements = [el("a", 1, 0, 10), el("b", 0, 0, 10)]; + // No readZIndex/onStackingPatches supplied → must not throw, no patches. + runClipMove(drag(elements[0], { previewStart: 0, previewTrack: 0 }), { + elements, + trackOrder: [0, 1], + }); + // (nothing to assert beyond "did not throw") + }); + + it("no time overlap → no stacking patch even on a lane change", () => { + const elements = [el("a", 1, 0, 5), el("b", 0, 10, 5)]; + const onStackingPatches = vi.fn(); + runClipMove(drag(elements[0], { previewStart: 0, previewTrack: 0 }), { + elements, + trackOrder: [0, 1], + readZIndex: () => 0, + onStackingPatches, + }); + expect(onStackingPatches).not.toHaveBeenCalled(); + }); + + it("pure time-move (no lane change) never triggers a stacking patch", () => { + const elements = [el("a", 0, 0, 10), el("b", 0, 0, 10)]; + const onStackingPatches = vi.fn(); + // same track → not a topology change → z-sync branch not reached. + runClipMove(drag(elements[0], { previewStart: 3, previewTrack: 0 }), { + elements, + trackOrder: [0], + readZIndex: () => 0, + onStackingPatches, + }); + expect(onStackingPatches).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/studio/src/player/components/timelineClipDragCommit.ts b/packages/studio/src/player/components/timelineClipDragCommit.ts new file mode 100644 index 0000000000..b02ac1bb03 --- /dev/null +++ b/packages/studio/src/player/components/timelineClipDragCommit.ts @@ -0,0 +1,198 @@ +import type { TimelineElement } from "../store/playerStore"; +import type { DraggedClipState } from "./useTimelineClipDrag"; +import { classifyZone, normalizeToZones } from "./timelineZones"; +import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync"; + +type StartTrack = Pick; +export interface TimelineMoveEdit { + element: TimelineElement; + updates: StartTrack; +} + +export interface DragCommitDeps { + elements: TimelineElement[]; + trackOrder: number[]; + updateElement: (key: string, updates: Partial) => void; + /** Single-clip, SDK-cutover-aware persist (pure time-moves keep this path). */ + onMoveElement?: (element: TimelineElement, updates: StartTrack) => Promise | void; + /** Atomic multi-clip persist (single undo) for lane changes + track inserts. */ + onMoveElements?: (edits: TimelineMoveEdit[]) => Promise | void; + /** + * The current multi-selection (store.selectedElementIds). When the dragged + * clip is part of a multi-selection (size > 1), the WHOLE selection moves by + * the dragged clip's time delta — the standard NLE gesture. Track changes + * apply to the dragged clip only; the others keep their lanes. + */ + selectedKeys?: ReadonlySet | null; + /** + * Lane ↔ stacking unification. When a lane change happens, the edited clip(s) + * get z-index patches so their stacking matches lane order (higher lane = on + * top) relative to time-overlapping clips — see timelineStackingSync. Both + * deps must be supplied to engage; if either is absent the z-sync is skipped + * (pure time-moves never restack). `readZIndex` returns the clip's current + * z-index (from the live DOM inline style / computed; "auto" ⇒ 0). + */ + readZIndex?: (element: TimelineElement) => number; + /** + * Apply the computed z-index patches. Wiring (in the drag hook, which owns the + * DOM/persist plumbing) forwards these to the SAME atomic style-patch persist + * the canvas z-order commit uses (handleDomZIndexReorderCommit). Documented in + * research/STAGE3-NEEDED-WIRING.md. + */ + onStackingPatches?: (patches: StackingPatch[]) => void; +} + +const keyOf = (e: TimelineElement) => e.key ?? e.id; +const round3 = (v: number) => Math.round(v * 1000) / 1000; + +/** + * Optimistically apply + persist a batch of moves with rollback on failure. + * + * Fire-and-forget: the persistence promise is intentionally NOT awaited (the caller + * returns void). The DOM is updated synchronously up front and the `.catch` rolls the + * optimistic edits back if the async write rejects, so the UI never blocks on I/O. + */ +function persistMoveEdits(edits: TimelineMoveEdit[], deps: DragCommitDeps): void { + if (edits.length === 0) return; + const { updateElement, onMoveElement, onMoveElements } = deps; + const prev = edits.map((e) => ({ + key: keyOf(e.element), + start: e.element.start, + track: e.element.track, + })); + for (const e of edits) updateElement(keyOf(e.element), e.updates); + const persisted = onMoveElements + ? onMoveElements(edits) + : Promise.all(edits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates)))); + Promise.resolve(persisted).catch((error) => { + for (const p of prev) updateElement(p.key, { start: p.start, track: p.track }); + console.error("[Timeline] Failed to persist clip edits", error); + }); +} + +/** + * A fractional track value for a NEW lane inserted at boundary `insertRow` in + * `trackOrder` (0 = above the top, `length` = below the bottom). normalizeToZones + * then compacts it to a distinct integer lane between its neighbours. + */ +function insertTrackValue(trackOrder: number[], insertRow: number): number { + if (trackOrder.length === 0) return 0; + if (insertRow <= 0) return trackOrder[0] - 0.5; + if (insertRow >= trackOrder.length) return trackOrder[trackOrder.length - 1] + 0.5; + return (trackOrder[insertRow - 1] + trackOrder[insertRow]) / 2; +} + +/** + * Commit a finished clip drag. + * + * - **Pure time-move** (same lane): persist just the dragged clip's start via the + * SDK-aware single-clip handler. + * - **Lane change / new track**: apply the move (a fractional track for an insert), + * RE-NORMALIZE the whole element set (normalizeToZones) so display track indices + * are contiguous + kind-grouped, and persist EVERY clip atomically (single undo). + * This is the fix for the raw-vs-normalized collision: persisting only the dragged + * clip left other clips' unchanged source indices to clash on reload → overlap. + */ +// fallow-ignore-next-line complexity +export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDeps): void { + const { elements, trackOrder, updateElement, onMoveElement, selectedKeys } = deps; + const dragKey = keyOf(drag.element); + const isTopologyChange = drag.insertRow != null || drag.previewTrack !== drag.element.track; + // Multi-selection drag: engaged only when the dragged clip is itself part of + // a multi-selection. Every selected clip shifts by the same time delta + // (clamped ≥ 0); only the dragged clip changes track. + const multiKeys = + selectedKeys && selectedKeys.size > 1 && selectedKeys.has(dragKey) ? selectedKeys : null; + const delta = drag.previewStart - drag.element.start; + const movedStart = (e: TimelineElement): number => + keyOf(e) === dragKey ? drag.previewStart : Math.max(0, round3(e.start + delta)); + + // ── Pure time-move (same lane) ────────────────────────────────────────────── + if (!isTopologyChange) { + if (delta === 0) return; + if (multiKeys) { + const edits: TimelineMoveEdit[] = elements + .filter((e) => multiKeys.has(keyOf(e))) + .map((e) => ({ element: e, updates: { start: movedStart(e), track: e.track } })) + .filter((e) => e.updates.start !== e.element.start); + persistMoveEdits(edits, deps); + return; + } + const updates = { start: drag.previewStart, track: drag.element.track }; + const prev = { start: drag.element.start, track: drag.element.track }; + updateElement(dragKey, updates); + Promise.resolve(onMoveElement?.(drag.element, updates)).catch((error) => { + updateElement(dragKey, prev); + console.error("[Timeline] Failed to persist clip edit", error); + }); + return; + } + + // ── Lane change / new track: normalize the whole set, persist all atomically ─ + const targetTrack = + drag.insertRow != null ? insertTrackValue(trackOrder, drag.insertRow) : drag.previewTrack; + const candidate = elements.map((e) => { + if (keyOf(e) === dragKey) return { ...e, start: drag.previewStart, track: targetTrack }; + if (multiKeys?.has(keyOf(e))) return { ...e, start: movedStart(e) }; + return e; + }); + const normalized = normalizeToZones(candidate); + const bySrc = new Map(elements.map((e) => [keyOf(e), e])); + const edits: TimelineMoveEdit[] = []; + for (const norm of normalized) { + const src = bySrc.get(keyOf(norm)); + if (!src) continue; + const start = + keyOf(norm) === dragKey || multiKeys?.has(keyOf(norm)) ? movedStart(src) : src.start; + edits.push({ element: src, updates: { start, track: norm.track } }); + } + persistMoveEdits(edits, deps); + + // Lane ↔ stacking: patch the edited clip(s)' z-index so their stacking matches + // the user's DROP-INTENT lane order relative to time-overlapping clips. We must + // reason on `candidate` (the drop-intent tracks) — NOT `normalized` — because + // normalizeToZones is purely z/DOM-driven and re-packs a lane-drag that + // contradicts z straight back down; reading the post-normalize lane would make + // the sync see the reverted layout and never realise the user's move. The + // fractional drop track (e.g. −0.5 for "above the top lane") preserves the drop + // order against the other clips' tracks. Only the edited clip(s) change; the + // untouched clips' authored z stays sacred (unless a cascade is unavoidable). + syncStackingForEdit(candidate, dragKey, multiKeys, deps); +} + +/** + * Compute + apply z-index patches for the edited clip(s) after a lane change. + * Projects the DROP-INTENT element set (`candidate`: edited clip at its dropped + * fractional track, others at their current tracks) onto StackingElement using + * the caller-supplied live z-index reader, then delegates the minimal-z + * resolution to computeStackingPatches. No-op unless both z-sync deps are present. + */ +function syncStackingForEdit( + candidate: TimelineElement[], + dragKey: string, + multiKeys: ReadonlySet | null, + deps: DragCommitDeps, +): void { + const { readZIndex, onStackingPatches } = deps; + if (!readZIndex || !onStackingPatches) return; + + // `candidate` is in discovery order, so its array index IS the DOM document + // position. Equal-z clips paint by DOM order, so the sync needs it to decide + // "is A above B" (see StackingElement.domIndex) — without it a bottom-lane drag + // over an equal-z neighbour could no-op on canvas. + const stackingEls = candidate.map((el, domIndex) => ({ + key: keyOf(el), + start: el.start, + duration: el.duration, + track: el.track, + zIndex: readZIndex(el), + isAudio: classifyZone(el) === "audio", + domIndex, + })); + + const editedKeys = [dragKey]; + if (multiKeys) for (const k of multiKeys) if (k !== dragKey) editedKeys.push(k); + + const patches = computeStackingPatches(stackingEls, editedKeys); + if (patches.length > 0) onStackingPatches(patches); +} diff --git a/packages/studio/src/player/components/timelineClipDragPreview.ts b/packages/studio/src/player/components/timelineClipDragPreview.ts index c9899fbbf4..ec4e8ff808 100644 --- a/packages/studio/src/player/components/timelineClipDragPreview.ts +++ b/packages/studio/src/player/components/timelineClipDragPreview.ts @@ -1,26 +1,290 @@ -import type { TimelineElement } from "../store/playerStore"; -import type { TimelineStackingReorderIntent } from "./timelineEditing"; import type { TimelineLayerId } from "./timelineTrackOrder"; +import type { TimelineStackingReorderIntent } from "./timelineEditing"; +import { resolveTimelineMove, resolveTimelineResize } from "./timelineEditing"; +import type { TimelineElement } from "../store/playerStore"; +import { TRACK_H, getTimelineRowFromY } from "./timelineLayout"; +import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector"; +import { + TIMELINE_SNAP_PX, + snapMoveToTargets, + snapTimelineTime, + type TimelineSnapTarget, +} from "./timelineSnapping"; +import { resolveInsertRow, resolveZoneDropPlacement } from "./timelineCollision"; +import { clampGroupMoveDelta } from "./timelineMultiDragPreview"; +import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes"; -interface DragPreviewState { - element: Pick; - // Optional during the NLE coexistence window: the intermediate - // DraggedClipState carries these only on the legacy drag path. - previewLayerId?: TimelineLayerId; - previewLayerIndex?: number; +/** Snap-target builder closure supplied by the hook (closes over refs + store). */ +type BuildSnapTargets = ( + excludeElementKey: string | null, + includeBeats: boolean, +) => TimelineSnapTarget[]; + +export interface DragPreviewContext { + scroll: HTMLDivElement | null; + pps: number; + duration: number; + trackOrder: number[]; + elements: TimelineElement[]; + selectedKeys: ReadonlySet; + buildSnapTargets: BuildSnapTargets; } -export interface TimelineMovePreview { - start: number; - track: number; - previewLayerId?: TimelineLayerId; - previewLayerIndex?: number; - stackingReorder?: TimelineStackingReorderIntent | null; +/** + * Max start a drag may reach. Allow dragging past the current content into the + * rendered timeline extent (the viewport-fill keeps that ≥ the viewport width). + * The composition grows to fit on commit (content-driven duration), so don't + * cap at content length. + */ +function resolveDragMaxStart(scroll: HTMLDivElement | null, pps: number, duration: number): number { + return Math.max(duration, scroll && pps > 0 ? scroll.scrollWidth / pps : duration); } -export interface TimelineGroupMovePreview { - active: boolean; +/** + * Rigid group move: when the grabbed clip is part of a multi-selection, the + * WHOLE formation shifts by its delta on commit (see timelineClipDragCommit). + * Clamp that delta here — against every selected member's start — so the + * grabbed clip can't out-run the group: it STOPS the instant any member would + * cross 0, exactly as it lands on commit. Lane changes still apply to the + * grabbed clip only, so only the start (x) is constrained. + */ +function resolveGroupClampedStart( + snapStart: number, + element: TimelineElement, + dragKey: string, + elements: TimelineElement[], + selectedKeys: ReadonlySet, +): number { + if (selectedKeys.size <= 1 || !selectedKeys.has(dragKey)) return snapStart; + const memberStarts = elements.filter((e) => selectedKeys.has(e.key ?? e.id)).map((e) => e.start); + const clampedDelta = clampGroupMoveDelta(snapStart - element.start, memberStarts); + return element.start + clampedDelta; +} + +/** + * The whole drop decision (no same-track overlap, zone-respecting, relocate or + * create) — one tested pure function, so what runs here is what's verified. + */ +function resolveDropPlacement( + drag: DraggedClipState, + clientY: number, + previewStart: number, + desiredTrack: number, + ctx: DragPreviewContext, +): { track: number; insertRow: number | null } { + const { scroll, trackOrder, elements } = ctx; + // rowFloat = the pointer's position in track-heights from the top lane; a + // near-boundary hover requests a deliberate new-track insert. Uses the + // shared row→y inverse so the top breathing pad is subtracted consistently. + const rowFloat = scroll + ? getTimelineRowFromY(clientY - scroll.getBoundingClientRect().top + scroll.scrollTop) + : 0; + const rawInsertRow = resolveInsertRow(rowFloat, trackOrder.length); + const audioTracks = new Set(elements.filter(isAudioTimelineElement).map((e) => e.track)); + return resolveZoneDropPlacement({ + order: trackOrder, + audioTracks, + elements, + desiredTrack, + deliberateInsertRow: rawInsertRow, + start: previewStart, + duration: drag.element.duration, + dragKey: drag.element.key ?? drag.element.id, + isAudio: isAudioTimelineElement(drag.element), + }); +} + +/** Recompute the dragged-clip preview (move + snap + group clamp + drop placement). */ +export function computeDragPreview( + drag: DraggedClipState, + clientX: number, + clientY: number, + ctx: DragPreviewContext, +): DraggedClipState { + const { scroll, pps, duration, trackOrder, elements, selectedKeys, buildSnapTargets } = ctx; + const dragMaxStart = resolveDragMaxStart(scroll, pps, duration); + const nextMove = resolveTimelineMove( + { + start: drag.element.start, + track: drag.element.track, + duration: drag.element.duration, + originClientX: drag.originClientX, + originClientY: drag.originClientY, + originScrollLeft: drag.originScrollLeft, + originScrollTop: drag.originScrollTop, + currentScrollLeft: scroll?.scrollLeft ?? drag.originScrollLeft, + currentScrollTop: scroll?.scrollTop ?? drag.originScrollTop, + pixelsPerSecond: pps, + trackHeight: TRACK_H, + maxStart: dragMaxStart, + trackOrder, + }, + clientX, + clientY, + ); + // The music track defines the beats, so it must not snap to them — + // but it still snaps to the playhead and other clip edges. + const targets = buildSnapTargets( + drag.element.key ?? drag.element.id, + !isMusicTrack(drag.element), + ); + const snap = snapMoveToTargets( + nextMove.start, + drag.element.duration, + targets, + pps, + // Relaxed clamp: allow the snapped start past the content, up to the + // rendered extent (see dragMaxStart) — the composition grows on commit. + dragMaxStart + drag.element.duration, + ); + const dragKey = drag.element.key ?? drag.element.id; + const previewStart = resolveGroupClampedStart( + snap.start, + drag.element, + dragKey, + elements, + selectedKeys, + ); + const { track: previewTrack, insertRow } = resolveDropPlacement( + drag, + clientY, + previewStart, + nextMove.track, + ctx, + ); + return { + ...drag, + started: true, + pointerClientX: clientX, + pointerClientY: clientY, + previewStart, + previewTrack, + insertRow, + snapTime: snap.snapTime, + snapType: snap.snapType, + }; +} + +export interface ResizePreviewContext { + scroll: HTMLDivElement | null; + pps: number; + buildSnapTargets: BuildSnapTargets; +} + +export interface ResizePreviewResult { + originScrollLeft: number; previewStart: number; + previewDuration: number; + previewPlaybackStart?: number; +} + +/** Compute the trim preview for a pointer x (pure — the hook applies the state). */ +// fallow-ignore-next-line complexity +export function computeResizePreview( + resize: ResizingClipState, + clientX: number, + ctx: ResizePreviewContext, +): ResizePreviewResult { + const { scroll, pps, buildSnapTargets } = ctx; + // Scroll compensation: auto-scroll moves the content while the pointer stays + // put, so fold the scroll delta into the pointer x (mirrors + // resolveTimelineMove's originScrollLeft handling). + const originScrollLeft = resize.originScrollLeft ?? scroll?.scrollLeft ?? 0; + const effectiveClientX = clientX + ((scroll?.scrollLeft ?? originScrollLeft) - originScrollLeft); + + const sourceRemaining = + resize.element.sourceDuration != null + ? Math.max( + 0, + (resize.element.sourceDuration - (resize.element.playbackStart ?? 0)) / + Math.max(resize.element.playbackRate ?? 1, 0.1), + ) + : Number.POSITIVE_INFINITY; + const normalizedTag = resize.element.tag.toLowerCase(); + const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video"; + const playbackRate = Math.max(resize.element.playbackRate ?? 1, 0.1); + // Trim limit = available source media only — NOT the composition length. + // Duration is content-driven (the comp grows/shrinks to fit on commit), so + // capping a trim at the current comp end both blocked extending the last clip + // rightward and, after a far move, collapsed a clip to the sliver between its + // start and the comp end (the 8s→0.95s audio incident). Images/text/shapes + // have no source, so they extend freely. + const maxEnd = resize.element.start + sourceRemaining; + let nextResize = resolveTimelineResize( + { + start: resize.element.start, + duration: resize.element.duration, + originClientX: resize.originClientX, + pixelsPerSecond: pps, + minStart: 0, + maxEnd, + playbackStart: + resize.edge === "start" && canSeedPlaybackStart + ? (resize.element.playbackStart ?? 0) + : resize.element.playbackStart, + playbackRate: resize.element.playbackRate, + }, + resize.edge, + effectiveClientX, + ); + + // Snap edge to unified targets (beats + clip edges + playhead) when available. + // The snap must stay inside the same limits resolveTimelineResize enforces, or + // it would push the edge past the available source media / composition end. + // The music track defines the beats, so it must not snap to them — but it + // still snaps to the playhead and other clip edges. + const trimTargets = buildSnapTargets( + resize.element.key ?? resize.element.id, + !isMusicTrack(resize.element), + ); + if (trimTargets.length > 0) { + const snapSecs = TIMELINE_SNAP_PX / Math.max(pps, 1); + if (resize.edge === "end") { + const edgeTime = nextResize.start + nextResize.duration; + const snapped = snapTimelineTime(edgeTime, trimTargets, snapSecs).time; + // Stay within [start+minDuration, maxEnd] so the snap can't create a + // degenerate clip or run past the source/composition limit. + const snappedDuration = Math.round((snapped - nextResize.start) * 1000) / 1000; + if (snapped !== edgeTime && snapped <= maxEnd + 1e-6 && snappedDuration >= 0.05) { + nextResize = { ...nextResize, duration: snappedDuration }; + } + } else { + const snapped = snapTimelineTime(nextResize.start, trimTargets, snapSecs).time; + const delta = nextResize.start - snapped; // >0 when snapping left + // Leftward snap reveals more source; cap so playbackStart can't go < 0. + const maxLeftDelta = + nextResize.playbackStart != null + ? nextResize.playbackStart / playbackRate + : Number.POSITIVE_INFINITY; + // Also require the resulting duration to stay >= minDuration so a rightward + // snap (delta < 0) can't collapse the clip to zero/negative. + const snappedDuration = Math.round((nextResize.duration + delta) * 1000) / 1000; + if ( + snapped !== nextResize.start && + snapped >= 0 && + delta <= maxLeftDelta + 1e-6 && + snappedDuration >= 0.05 + ) { + nextResize = { + ...nextResize, + start: snapped, + duration: snappedDuration, + playbackStart: + nextResize.playbackStart != null + ? Math.round(Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000) / + 1000 + : undefined, + }; + } + } + } + + return { + originScrollLeft, + previewStart: nextResize.start, + previewDuration: nextResize.duration, + previewPlaybackStart: nextResize.playbackStart, + }; } export function resolveDragPreviewPlacement( @@ -52,3 +316,22 @@ export function resolveDragPreviewPlacement( previewStackingReorder: nextMove.stackingReorder ?? null, }; } + +interface DragPreviewState { + element: Pick; + previewLayerId?: TimelineLayerId; + previewLayerIndex?: number; +} + +export interface TimelineMovePreview { + start: number; + track: number; + previewLayerId?: TimelineLayerId; + previewLayerIndex?: number; + stackingReorder?: TimelineStackingReorderIntent | null; +} + +export interface TimelineGroupMovePreview { + active: boolean; + previewStart: number; +} diff --git a/packages/studio/src/utils/studioFileHistory.ts b/packages/studio/src/utils/studioFileHistory.ts index 95e0a2a395..c29693869f 100644 --- a/packages/studio/src/utils/studioFileHistory.ts +++ b/packages/studio/src/utils/studioFileHistory.ts @@ -5,6 +5,7 @@ interface SaveProjectFilesWithHistoryInput { label: string; kind: EditHistoryKind; coalesceKey?: string; + coalesceMs?: number; files: Record; readFile: (path: string) => Promise; writeFile: (path: string, content: string) => Promise; @@ -12,6 +13,7 @@ interface SaveProjectFilesWithHistoryInput { label: string; kind: EditHistoryKind; coalesceKey?: string; + coalesceMs?: number; files: Record; }) => Promise; } @@ -20,6 +22,7 @@ export async function saveProjectFilesWithHistory({ label, kind, coalesceKey, + coalesceMs, files, readFile, writeFile, @@ -43,7 +46,7 @@ export async function saveProjectFilesWithHistory({ writtenPaths.push(path); } - await recordEdit({ label, kind, coalesceKey, files: snapshots }); + await recordEdit({ label, kind, coalesceKey, coalesceMs, files: snapshots }); } catch (error) { try { for (const path of writtenPaths.reverse()) {