mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
fix(studio): harden composition timeline reliability (#2615)
* fix(studio): preserve composition playback continuity * feat(studio): drag compositions into the timeline * fix(studio): collapse expanded composition move aliases * fix(studio): make timeline cuts atomic * fix(studio): group inspector gesture history * test(studio): cover masked text selection * fix(studio): harden composition timeline reliability * fix(studio): satisfy CI source gates * fix(studio): harden composition mutation requests
This commit is contained in:
@@ -28,6 +28,7 @@ import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallb
|
||||
import type { TimelineProps } from "./TimelineTypes";
|
||||
import { useTrackGapMenu } from "./useTrackGapMenu";
|
||||
import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
|
||||
import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext";
|
||||
|
||||
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
|
||||
export {
|
||||
@@ -52,6 +53,7 @@ export const Timeline = memo(function Timeline({
|
||||
onFileDrop,
|
||||
onAssetDrop,
|
||||
onBlockDrop,
|
||||
onCompositionDrop,
|
||||
onDeleteElement: _onDeleteElement,
|
||||
onMoveElement: onMoveElementOverride,
|
||||
onMoveElements: onMoveElementsOverride,
|
||||
@@ -84,6 +86,11 @@ export const Timeline = memo(function Timeline({
|
||||
onSplitElement: onSplitElementOverride,
|
||||
});
|
||||
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
|
||||
const playbackContext = useStudioPlaybackContextOptional();
|
||||
const setRefreshKey = playbackContext?.setRefreshKey;
|
||||
const refreshAfterLaneMove = useCallback(() => {
|
||||
setRefreshKey?.((key) => key + 1);
|
||||
}, [setRefreshKey]);
|
||||
useMusicBeatAnalysis();
|
||||
const rawElements = usePlayerStore((s) => s.elements);
|
||||
const expandedElements = useExpandedTimelineElements();
|
||||
@@ -169,6 +176,7 @@ export const Timeline = memo(function Timeline({
|
||||
pinnedOnFileDrop,
|
||||
pinnedOnAssetDrop,
|
||||
pinnedOnBlockDrop,
|
||||
pinnedOnCompositionDrop,
|
||||
} = useTimelineEditPinning({
|
||||
ppsRef,
|
||||
fitPpsRef,
|
||||
@@ -179,6 +187,7 @@ export const Timeline = memo(function Timeline({
|
||||
onFileDrop,
|
||||
onAssetDrop,
|
||||
onBlockDrop,
|
||||
onCompositionDrop,
|
||||
});
|
||||
|
||||
const { readClipZIndex, applyStackingPatches, zSyncEnabled } = useTimelineStackingSync({
|
||||
@@ -223,6 +232,7 @@ export const Timeline = memo(function Timeline({
|
||||
setRangeSelectionRef,
|
||||
readZIndex: zSyncEnabled ? readClipZIndex : undefined,
|
||||
onStackingPatches: zSyncEnabled ? applyStackingPatches : undefined,
|
||||
refreshAfterLaneMove,
|
||||
});
|
||||
|
||||
const { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview } =
|
||||
@@ -234,6 +244,7 @@ export const Timeline = memo(function Timeline({
|
||||
onFileDrop: pinnedOnFileDrop,
|
||||
onAssetDrop: pinnedOnAssetDrop,
|
||||
onBlockDrop: pinnedOnBlockDrop,
|
||||
onCompositionDrop: pinnedOnCompositionDrop,
|
||||
});
|
||||
|
||||
const displayTrackOrder = useMemo(() => {
|
||||
@@ -399,7 +410,7 @@ export const Timeline = memo(function Timeline({
|
||||
<div
|
||||
ref={setContainerRef}
|
||||
aria-label="Timeline"
|
||||
className={`relative border-t select-none h-full overflow-hidden ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
||||
className={`relative border-t select-none h-full overflow-hidden ${isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
||||
onMouseMove={(e) => {
|
||||
if (activeTool === "razor" && scrollRef.current) {
|
||||
const rect = scrollRef.current.getBoundingClientRect();
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
||||
import { getTimelineEditCapabilities } from "./timelineEditing";
|
||||
import type { DraggedClipState } from "./timelineClipDragTypes";
|
||||
|
||||
/** Whether Studio may write timing to this clip (false for locked/implicit rows). */
|
||||
export function canMoveTimelineElement(element: TimelineElement): boolean {
|
||||
return getTimelineEditCapabilities({
|
||||
tag: element.tag,
|
||||
kind: element.kind,
|
||||
duration: element.duration,
|
||||
domId: element.domId,
|
||||
selector: element.selector,
|
||||
compositionSrc: element.compositionSrc,
|
||||
playbackStart: element.playbackStart,
|
||||
playbackStartAttr: element.playbackStartAttr,
|
||||
sourceDuration: element.sourceDuration,
|
||||
timingSource: element.timingSource,
|
||||
timelineLocked: element.timelineLocked,
|
||||
}).canMove;
|
||||
}
|
||||
|
||||
interface ExpandedHostAliasDeps {
|
||||
elements: TimelineElement[];
|
||||
selectedKeys?: ReadonlySet<string> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded children keep their own source-file identity for direct edits, but a
|
||||
* selection can briefly contain both a composition host and one of its visible
|
||||
* expanded children. That pair is one authored move target, not two. Resolve it
|
||||
* to the host before time/lane/collision commit so ordinary clip placement stays
|
||||
* the single owner of the gesture semantics.
|
||||
*/
|
||||
export function resolveExpandedHostAlias(
|
||||
drag: DraggedClipState,
|
||||
deps: ExpandedHostAliasDeps,
|
||||
): { drag: DraggedClipState; selectedKeys: ReadonlySet<string> } | null {
|
||||
const selectedKeys = deps.selectedKeys;
|
||||
if (!selectedKeys) return null;
|
||||
|
||||
const collapsedKeys = new Set(selectedKeys);
|
||||
const candidates = deps.elements.includes(drag.element)
|
||||
? deps.elements
|
||||
: [...deps.elements, drag.element];
|
||||
for (const element of candidates) {
|
||||
const hostKey = element.expandedHostKey;
|
||||
const childKey = getTimelineElementIdentity(element);
|
||||
if (hostKey && collapsedKeys.has(hostKey) && collapsedKeys.has(childKey)) {
|
||||
collapsedKeys.delete(childKey);
|
||||
}
|
||||
}
|
||||
|
||||
const hostKey = drag.element.expandedHostKey;
|
||||
const childKey = getTimelineElementIdentity(drag.element);
|
||||
if (!hostKey || collapsedKeys.has(childKey)) {
|
||||
if (collapsedKeys.size === selectedKeys.size) return null;
|
||||
return { drag, selectedKeys: collapsedKeys };
|
||||
}
|
||||
|
||||
const host = deps.elements.find((element) => getTimelineElementIdentity(element) === hostKey);
|
||||
if (!host || !canMoveTimelineElement(host)) return null;
|
||||
|
||||
const delta = drag.previewStart - drag.element.start;
|
||||
const mapTrack = (track: number | undefined): number | undefined =>
|
||||
track === drag.element.track ? host.track : track;
|
||||
return {
|
||||
drag: {
|
||||
...drag,
|
||||
element: host,
|
||||
previewStart: Math.max(0, Math.round((host.start + delta) * 1000) / 1000),
|
||||
previewTrack: mapTrack(drag.previewTrack) ?? host.track,
|
||||
desiredTrack: mapTrack(drag.desiredTrack),
|
||||
},
|
||||
selectedKeys: collapsedKeys,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
|
||||
const keyOf = (element: TimelineElement) => element.key ?? element.id;
|
||||
|
||||
/** Authored track numbers only compare within one source file. */
|
||||
export const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
|
||||
(a.sourceFile ?? null) === (b.sourceFile ?? null);
|
||||
|
||||
/** Translate a display lane into the source-file track to persist. */
|
||||
export function authoredTrackForLane(
|
||||
lane: number,
|
||||
elements: TimelineElement[],
|
||||
dragged: TimelineElement,
|
||||
): number {
|
||||
const dragKey = keyOf(dragged);
|
||||
const peers = elements.filter((element) => {
|
||||
return keyOf(element) !== dragKey && sameSourceFile(element, dragged);
|
||||
});
|
||||
const occupant = peers.find((element) => element.track === lane);
|
||||
if (occupant) return occupant.authoredTrack ?? occupant.track;
|
||||
|
||||
let nearest: TimelineElement | null = null;
|
||||
for (const peer of peers) {
|
||||
if (!nearest || Math.abs(peer.track - lane) < Math.abs(nearest.track - lane)) nearest = peer;
|
||||
}
|
||||
if (!nearest) return lane;
|
||||
// Synthetic expanded-child display rows can be fractional; authored tracks cannot.
|
||||
return Math.round((nearest.authoredTrack ?? nearest.track) + (lane - nearest.track));
|
||||
}
|
||||
@@ -22,6 +22,10 @@ export interface TimelineDropCallbacks {
|
||||
blockName: string,
|
||||
placement: { start: number; track: number },
|
||||
) => Promise<void> | void;
|
||||
onCompositionDrop?: (
|
||||
sourcePath: string,
|
||||
placement: { start: number; track: number },
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface TimelineEditCallbacks {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { DraggedClipState } from "./useTimelineClipDrag";
|
||||
import {
|
||||
commitDraggedClipMove,
|
||||
commitZMirrorLaneMove,
|
||||
persistMoveEdits,
|
||||
type DragCommitDeps,
|
||||
type TimelineMoveEdit,
|
||||
} from "./timelineClipDragCommit";
|
||||
@@ -314,6 +315,118 @@ describe("commitDraggedClipMove", () => {
|
||||
expect(map.c).toBeUndefined(); // unselected clips untouched
|
||||
});
|
||||
|
||||
it("collapses a selected expanded child onto its authored composition host", () => {
|
||||
const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
|
||||
const child = {
|
||||
...el("scene.html#title", 0.25, 12, 2),
|
||||
sourceFile: "scene.html",
|
||||
expandedParentStart: 10,
|
||||
expandedHostKey: "host",
|
||||
};
|
||||
const { updateElement, onMoveElement, onMoveElements } = runClipMove(
|
||||
drag(child, { previewStart: 15, previewTrack: child.track }),
|
||||
{
|
||||
elements: [host],
|
||||
trackOrder: [0, child.track],
|
||||
selectedKeys: new Set(["host", "scene.html#title"]),
|
||||
},
|
||||
);
|
||||
|
||||
expect(onMoveElements).not.toHaveBeenCalled();
|
||||
expect(onMoveElement).toHaveBeenCalledWith(host, { start: 13, track: 0 });
|
||||
expect(updateElement).toHaveBeenCalledWith("host", { start: 13, track: 0 });
|
||||
expect(updateElement).not.toHaveBeenCalledWith("scene.html#title", expect.anything());
|
||||
});
|
||||
|
||||
it("drops a selected expanded child alias when the authored host initiates the drag", () => {
|
||||
const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
|
||||
const child = {
|
||||
...el("scene.html#title", 0.25, 12, 2),
|
||||
sourceFile: "scene.html",
|
||||
expandedParentStart: 10,
|
||||
expandedHostKey: "host",
|
||||
};
|
||||
const { onMoveElement, onMoveElements } = runClipMove(
|
||||
drag(host, { previewStart: 13, previewTrack: host.track }),
|
||||
{
|
||||
elements: [host, child],
|
||||
trackOrder: [0, child.track],
|
||||
selectedKeys: new Set(["host", "scene.html#title"]),
|
||||
},
|
||||
);
|
||||
|
||||
expect(onMoveElements).not.toHaveBeenCalled();
|
||||
expect(onMoveElement).toHaveBeenCalledOnce();
|
||||
expect(onMoveElement).toHaveBeenCalledWith(host, { start: 13, track: 0 });
|
||||
});
|
||||
|
||||
it("keeps an expanded child as the edit target when its host is not selected", () => {
|
||||
const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
|
||||
const child = {
|
||||
...el("scene.html#title", 0.25, 12, 2),
|
||||
sourceFile: "scene.html",
|
||||
expandedParentStart: 10,
|
||||
expandedHostKey: "host",
|
||||
};
|
||||
const { onMoveElement, onMoveElements } = runClipMove(
|
||||
drag(child, { previewStart: 15, previewTrack: child.track }),
|
||||
{
|
||||
elements: [host],
|
||||
trackOrder: [0, child.track],
|
||||
selectedKeys: new Set(["scene.html#title"]),
|
||||
},
|
||||
);
|
||||
|
||||
expect(onMoveElements).not.toHaveBeenCalled();
|
||||
expect(onMoveElement).toHaveBeenCalledWith(child, { start: 15, track: child.track });
|
||||
});
|
||||
|
||||
it("moves a host alias and an ordinary selected clip once each in one batch", () => {
|
||||
const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
|
||||
const ordinary = el("ordinary", 1, 20, 3);
|
||||
const child = {
|
||||
...el("scene.html#title", 0.25, 12, 2),
|
||||
sourceFile: "scene.html",
|
||||
expandedParentStart: 10,
|
||||
expandedHostKey: "host",
|
||||
};
|
||||
const { onMoveElement, onMoveElements } = runClipMove(
|
||||
drag(child, { previewStart: 14, previewTrack: child.track }),
|
||||
{
|
||||
elements: [host, ordinary],
|
||||
trackOrder: [0, child.track, 1],
|
||||
selectedKeys: new Set(["host", "scene.html#title", "ordinary"]),
|
||||
},
|
||||
);
|
||||
|
||||
const map = expectAtomicMoveMap({ onMoveElement, onMoveElements });
|
||||
expect(map).toEqual({
|
||||
host: { start: 12, track: 0 },
|
||||
ordinary: { start: 22, track: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("applies an expanded-child vertical drag to the selected host lane", () => {
|
||||
const host = { ...el("host", 0, 10, 8), kind: "composition" as const };
|
||||
const child = {
|
||||
...el("scene.html#title", 0.25, 12, 2),
|
||||
sourceFile: "scene.html",
|
||||
expandedParentStart: 10,
|
||||
expandedHostKey: "host",
|
||||
};
|
||||
const { onMoveElement, onMoveElements } = runClipMove(
|
||||
drag(child, { previewStart: 12, previewTrack: 1, desiredTrack: 1 }),
|
||||
{
|
||||
elements: [host],
|
||||
trackOrder: [0, child.track, 1],
|
||||
selectedKeys: new Set(["host", "scene.html#title"]),
|
||||
},
|
||||
);
|
||||
|
||||
const map = expectAtomicMoveMap({ onMoveElement, onMoveElements });
|
||||
expect(map).toEqual({ host: { start: 10, track: 1 } });
|
||||
});
|
||||
|
||||
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.
|
||||
@@ -959,20 +1072,40 @@ describe("commitDraggedClipMove", () => {
|
||||
expectZLiftedToSix(onStackingPatches);
|
||||
});
|
||||
|
||||
it("refreshes the preview only after the complete lane and z transaction", async () => {
|
||||
const order: string[] = [];
|
||||
commitInsertAbove(overlapping(), {
|
||||
onMoveElements: vi.fn(async () => {
|
||||
order.push("lane");
|
||||
}),
|
||||
onStackingPatches: vi.fn(async () => {
|
||||
order.push("z");
|
||||
}),
|
||||
refreshAfterLaneMove: () => order.push("refresh"),
|
||||
});
|
||||
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(order).toEqual(["lane", "z", "refresh"]);
|
||||
});
|
||||
|
||||
it("rolls back the move and skips the z-sync when the persist fails", async () => {
|
||||
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const elements = overlapping();
|
||||
const onMoveElements = vi.fn(() => Promise.reject(new Error("write failed")));
|
||||
const onStackingPatches = vi.fn();
|
||||
const refreshAfterLaneMove = vi.fn();
|
||||
const updateElement = vi.fn();
|
||||
commitInsertAbove(elements, {
|
||||
updateElement,
|
||||
onMoveElements,
|
||||
onStackingPatches,
|
||||
refreshAfterLaneMove,
|
||||
});
|
||||
await flushMicrotasks();
|
||||
// Failed move → z patch never issued (no orphaned z change left behind)...
|
||||
expect(onStackingPatches).not.toHaveBeenCalled();
|
||||
expect(refreshAfterLaneMove).not.toHaveBeenCalled();
|
||||
// ...and the optimistic start/track edit for the dragged clip is rolled back.
|
||||
expect(updateElement).toHaveBeenCalledWith("a", { start: 0, track: 1 });
|
||||
errSpy.mockRestore();
|
||||
@@ -1300,3 +1433,46 @@ describe("commitZMirrorLaneMove", () => {
|
||||
expect(onMoveElements).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("persistMoveEdits convergence", () => {
|
||||
it("reasserts a saved lane after a stale runtime sync", async () => {
|
||||
const clip = { ...el("headline", 2, 0.5, 4.9), authoredTrack: 2 };
|
||||
let releaseSave: (() => void) | undefined;
|
||||
const pendingSave = new Promise<void>((resolve) => {
|
||||
releaseSave = resolve;
|
||||
});
|
||||
let liveTrack = clip.track;
|
||||
let liveAuthoredTrack = clip.authoredTrack;
|
||||
const updateElement = vi.fn((_key: string, updates: Partial<TimelineElement>) => {
|
||||
if (updates.track != null) liveTrack = updates.track;
|
||||
if (updates.authoredTrack != null) liveAuthoredTrack = updates.authoredTrack;
|
||||
});
|
||||
|
||||
const persisted = persistMoveEdits(
|
||||
[
|
||||
{
|
||||
element: clip,
|
||||
updates: { start: clip.start, track: 0 },
|
||||
persistTrack: 0,
|
||||
},
|
||||
],
|
||||
{
|
||||
elements: [clip],
|
||||
trackOrder: [0, 1, 2],
|
||||
updateElement,
|
||||
onMoveElements: () => pendingSave,
|
||||
},
|
||||
);
|
||||
expect([liveTrack, liveAuthoredTrack]).toEqual([0, 0]);
|
||||
|
||||
// Reproduce the real failure: the preview emits its cached pre-drag lane
|
||||
// while the file write is still pending.
|
||||
liveTrack = 2;
|
||||
liveAuthoredTrack = 2;
|
||||
releaseSave?.();
|
||||
|
||||
await expect(persisted).resolves.toBe(true);
|
||||
expect([liveTrack, liveAuthoredTrack]).toEqual([0, 0]);
|
||||
expect(updateElement).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,13 +5,18 @@ import type { DraggedClipState } from "./useTimelineClipDrag";
|
||||
import type { ZMirrorLaneMove } from "./timelineZMirror";
|
||||
import { classifyZone, normalizeToZones } from "./timelineZones";
|
||||
import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
|
||||
import { getTimelineEditCapabilities } from "./timelineEditing";
|
||||
import {
|
||||
canMoveTimelineElement as canMoveElement,
|
||||
resolveExpandedHostAlias,
|
||||
} from "./timelineAuthoredMoveTarget";
|
||||
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
|
||||
import {
|
||||
beginTimelineOptimisticGesture,
|
||||
isLatestTimelineOptimisticGesture,
|
||||
} from "./timelineOptimisticRevision";
|
||||
import { runLaneZGesture } from "../../components/nle/zLaneGesture";
|
||||
import { refreshAfterDurableLaneMove } from "./timelineLaneMoveRefresh";
|
||||
import { authoredTrackForLane, sameSourceFile } from "./timelineAuthoredTrack";
|
||||
|
||||
type StartTrack = Pick<TimelineElement, "start" | "track">;
|
||||
export interface TimelineMoveEdit {
|
||||
@@ -68,30 +73,15 @@ export interface DragCommitDeps {
|
||||
* research/STAGE3-NEEDED-WIRING.md.
|
||||
*/
|
||||
onStackingPatches?: (patches: StackingPatch[], coalesceKey?: string) => Promise<unknown> | void;
|
||||
/** Converge the preview manifest after the complete lane + z transaction. */
|
||||
refreshAfterLaneMove?: () => void;
|
||||
}
|
||||
|
||||
const keyOf = (e: TimelineElement) => e.key ?? e.id;
|
||||
const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
||||
|
||||
// One deterministic coalesce key shared by both records in a lane-change gesture.
|
||||
let laneChangeGestureSeq = 0;
|
||||
|
||||
/** Whether Studio may write timing to this clip (false for locked/implicit rows). */
|
||||
function canMoveElement(element: TimelineElement): boolean {
|
||||
return getTimelineEditCapabilities({
|
||||
tag: element.tag,
|
||||
duration: element.duration,
|
||||
domId: element.domId,
|
||||
selector: element.selector,
|
||||
compositionSrc: element.compositionSrc,
|
||||
playbackStart: element.playbackStart,
|
||||
playbackStartAttr: element.playbackStartAttr,
|
||||
sourceDuration: element.sourceDuration,
|
||||
timingSource: element.timingSource,
|
||||
timelineLocked: element.timelineLocked,
|
||||
}).canMove;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistically apply + persist a batch of moves with rollback on failure.
|
||||
*
|
||||
@@ -139,14 +129,15 @@ export function persistMoveEdits(
|
||||
// that written value into the store's `authoredTrack` so a SECOND drag before
|
||||
// any reload resolves authored tracks from what the file now says, not stale
|
||||
// pre-edit data. Pure time-moves leave authoredTrack untouched.
|
||||
for (const e of edits) {
|
||||
const applyEdit = (e: TimelineMoveEdit) => {
|
||||
const writtenTrack =
|
||||
e.persistTrack ?? (e.updates.track !== e.element.track ? e.updates.track : undefined);
|
||||
updateElement(
|
||||
keyOf(e.element),
|
||||
writtenTrack == null ? e.updates : { ...e.updates, authoredTrack: writtenTrack },
|
||||
);
|
||||
}
|
||||
};
|
||||
for (const e of edits) applyEdit(e);
|
||||
// The store above gets DISPLAY lanes; the file below gets the authored-space
|
||||
// track when one was resolved (see TimelineMoveEdit.persistTrack).
|
||||
const persistEdits = edits.map((e) =>
|
||||
@@ -158,7 +149,17 @@ export function persistMoveEdits(
|
||||
? onMoveElements(persistEdits, coalesceKey, operation, coalesceMs)
|
||||
: Promise.all(persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
|
||||
return Promise.resolve(persisted).then(
|
||||
() => true,
|
||||
() => {
|
||||
// Runtime timeline messages can arrive while the save is in flight and
|
||||
// restore the preview manifest's pre-gesture lane. Reassert the durable
|
||||
// result after persistence, but only while this remains the latest
|
||||
// optimistic gesture so an older save can never clobber a newer drag.
|
||||
for (const e of edits) {
|
||||
const key = keyOf(e.element);
|
||||
if (isLatestTimelineOptimisticGesture(updateElement, revision, key)) applyEdit(e);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
(error) => {
|
||||
for (const p of prev) {
|
||||
if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
|
||||
@@ -177,59 +178,6 @@ export function persistMoveEdits(
|
||||
* then compacts it to a distinct integer lane between its neighbours, and the
|
||||
* clips at/below the insert shift down by one — the sanctioned index-renumber.
|
||||
*/
|
||||
/** Same-source-file predicate: authored track numbers only compare within ONE
|
||||
* file's coordinate space (an expanded sub-comp child's authoredTrack is in ITS
|
||||
* file, not the host timeline's). `undefined` means the active composition. */
|
||||
export const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
|
||||
(a.sourceFile ?? null) === (b.sourceFile ?? null);
|
||||
|
||||
/**
|
||||
* Translate a DISPLAY lane into the AUTHORED (source-file) track to persist for
|
||||
* `dragged`. Occupants are consulted ONLY from the dragged clip's own source
|
||||
* file — an occupant from a different file (e.g. an expanded sub-comp child, or
|
||||
* a host clip next to expanded rows) carries authored values in a different
|
||||
* coordinate space, and borrowing them would write a foreign file's numbering.
|
||||
*
|
||||
* Lane semantics after normalizeToZones: each distinct authored track owns one
|
||||
* base lane, and time-overlapping same-track clips spill onto adjacent display
|
||||
* sub-lanes (packTrackLanes). A spill sub-lane IS a legal drop target (Timeline's
|
||||
* trackOrder lists it): its occupants share the base lane's authored track by
|
||||
* construction, so the same-file occupant lookup returns that authored track and
|
||||
* the drop persists as a same-track join. The clip may then DISPLAY on a
|
||||
* different sub-lane than it was dropped on — the spill re-packs
|
||||
* deterministically by stable id, first-fit — but the persisted track is
|
||||
* correct.
|
||||
*
|
||||
* Fallbacks when the lane has no same-file occupant (e.g. an expanded child
|
||||
* dropped on a lane holding only other files' clips — the display-lane integer
|
||||
* must NOT be persisted into a sparse file):
|
||||
* 1. Offset from the NEAREST same-file lane: authored(nearest) + lane distance,
|
||||
* preserving "one lane up = one authored track up" in the clip's own file.
|
||||
* 2. No same-file peers at all → the lane value itself (single-clip files:
|
||||
* display and authored spaces coincide for want of any other anchor).
|
||||
* Edge-created lanes (min-1 / max+1 inserts) route through the insert path,
|
||||
* never here.
|
||||
*/
|
||||
export function authoredTrackForLane(
|
||||
lane: number,
|
||||
elements: TimelineElement[],
|
||||
dragged: TimelineElement,
|
||||
): number {
|
||||
const dragKey = keyOf(dragged);
|
||||
const peers = elements.filter((e) => keyOf(e) !== dragKey && sameSourceFile(e, dragged));
|
||||
const occupant = peers.find((e) => e.track === lane);
|
||||
if (occupant) return occupant.authoredTrack ?? occupant.track;
|
||||
let nearest: TimelineElement | null = null;
|
||||
for (const p of peers) {
|
||||
if (!nearest || Math.abs(p.track - lane) < Math.abs(nearest.track - lane)) nearest = p;
|
||||
}
|
||||
if (!nearest) return lane;
|
||||
// Rounded: expanded children live on FRACTIONAL synthetic display rows (see
|
||||
// buildChildElements), so a lane distance measured against one can carry a
|
||||
// fraction — an authored data-track-index must stay an integer.
|
||||
return Math.round((nearest.authoredTrack ?? nearest.track) + (lane - nearest.track));
|
||||
}
|
||||
|
||||
function insertTrackValue(trackOrder: number[], insertRow: number): number {
|
||||
if (trackOrder.length === 0) return 0;
|
||||
if (insertRow <= 0) return trackOrder[0] - 0.5;
|
||||
@@ -284,6 +232,12 @@ function resolveMultiSelection(
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDeps): void {
|
||||
const hostAlias = resolveExpandedHostAlias(drag, deps);
|
||||
if (hostAlias) {
|
||||
commitDraggedClipMove(hostAlias.drag, { ...deps, selectedKeys: hostAlias.selectedKeys });
|
||||
return;
|
||||
}
|
||||
|
||||
const { elements, updateElement, onMoveElement } = deps;
|
||||
const dragKey = keyOf(drag.element);
|
||||
const isInsert = drag.insertRow != null;
|
||||
@@ -362,22 +316,28 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
|
||||
});
|
||||
const multiKeys = multi ? multi.keys : null;
|
||||
if (!isVertical || !deps.readZIndex || !deps.onStackingPatches) {
|
||||
void persistMoveEdits(edits, deps, coalesceKey, "lane-reorder");
|
||||
void refreshAfterDurableLaneMove(
|
||||
persistMoveEdits(edits, deps, coalesceKey, "lane-reorder"),
|
||||
deps,
|
||||
);
|
||||
return;
|
||||
}
|
||||
void runLaneZGesture({
|
||||
commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "lane-reorder"),
|
||||
commitZ: () =>
|
||||
syncStackingForEdit(
|
||||
candidate,
|
||||
dragKey,
|
||||
drag.element.track,
|
||||
drag.previewTrack,
|
||||
multiKeys,
|
||||
deps,
|
||||
coalesceKey,
|
||||
),
|
||||
}).catch(() => undefined);
|
||||
void refreshAfterDurableLaneMove(
|
||||
runLaneZGesture({
|
||||
commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "lane-reorder"),
|
||||
commitZ: () =>
|
||||
syncStackingForEdit(
|
||||
candidate,
|
||||
dragKey,
|
||||
drag.element.track,
|
||||
drag.previewTrack,
|
||||
multiKeys,
|
||||
deps,
|
||||
coalesceKey,
|
||||
),
|
||||
}),
|
||||
deps,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Build the one sanctioned multi-clip write: atomically insert and compact a
|
||||
@@ -486,23 +446,29 @@ function commitTrackInsert(
|
||||
|
||||
const coalesceKey = `clip-lane-move:${laneChangeGestureSeq++}`;
|
||||
if (!deps.readZIndex || !deps.onStackingPatches) {
|
||||
void persistMoveEdits(edits, deps, coalesceKey, "track-insert");
|
||||
void refreshAfterDurableLaneMove(
|
||||
persistMoveEdits(edits, deps, coalesceKey, "track-insert"),
|
||||
deps,
|
||||
);
|
||||
return;
|
||||
}
|
||||
void runLaneZGesture({
|
||||
commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "track-insert"),
|
||||
commitZ: () =>
|
||||
// Sync from the fractional drop intent, not the normalized persisted lanes.
|
||||
syncStackingForEdit(
|
||||
candidate,
|
||||
dragKey,
|
||||
drag.element.track,
|
||||
drag.insertRow!,
|
||||
multi ? multi.keys : null,
|
||||
deps,
|
||||
coalesceKey,
|
||||
),
|
||||
}).catch(() => undefined);
|
||||
void refreshAfterDurableLaneMove(
|
||||
runLaneZGesture({
|
||||
commitLane: () => persistMoveEdits(edits, deps, coalesceKey, "track-insert"),
|
||||
commitZ: () =>
|
||||
// Sync from the fractional drop intent, not the normalized persisted lanes.
|
||||
syncStackingForEdit(
|
||||
candidate,
|
||||
dragKey,
|
||||
drag.element.track,
|
||||
drag.insertRow!,
|
||||
multi ? multi.keys : null,
|
||||
deps,
|
||||
coalesceKey,
|
||||
),
|
||||
}),
|
||||
deps,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -544,11 +510,17 @@ export function commitZMirrorLaneMove(
|
||||
updates: { start: element.start, track: move.displayTrack },
|
||||
persistTrack: move.persistTrack,
|
||||
};
|
||||
return persistMoveEdits([edit], deps, coalesceKey, "lane-reorder", coalesceMs);
|
||||
return refreshAfterDurableLaneMove(
|
||||
persistMoveEdits([edit], deps, coalesceKey, "lane-reorder", coalesceMs),
|
||||
deps,
|
||||
);
|
||||
}
|
||||
const built = buildTrackInsertEdits(element, element.start, move.insertRow, null, deps);
|
||||
if (!built || built.edits.length === 0) return Promise.resolve(false);
|
||||
return persistMoveEdits(built.edits, deps, coalesceKey, "track-insert", coalesceMs);
|
||||
return refreshAfterDurableLaneMove(
|
||||
persistMoveEdits(built.edits, deps, coalesceKey, "track-insert", coalesceMs),
|
||||
deps,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { computeDragPreview, type DragPreviewContext } from "./timelineClipDragPreview";
|
||||
import {
|
||||
computeDragPreview,
|
||||
computeResizePreview,
|
||||
type DragPreviewContext,
|
||||
} from "./timelineClipDragPreview";
|
||||
import type { DraggedClipState } from "./timelineClipDragTypes";
|
||||
import { RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
|
||||
|
||||
@@ -142,3 +146,31 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse
|
||||
expect(next.insertRow).toBe(0); // a new TOP track will be created on drop
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeResizePreview — composition source continuity", () => {
|
||||
it("seeds a legacy composition offset and advances it at playback rate", () => {
|
||||
const element = {
|
||||
...clip("comp", 0, 2, 4, 0, "div"),
|
||||
kind: "composition" as const,
|
||||
playbackRate: 2,
|
||||
};
|
||||
const result = computeResizePreview(
|
||||
{
|
||||
element,
|
||||
edge: "start",
|
||||
originClientX: 0,
|
||||
previewStart: 2,
|
||||
previewDuration: 4,
|
||||
started: true,
|
||||
},
|
||||
100,
|
||||
{ scroll: fakeScroll(), pps: 100, buildSnapTargets: () => [] },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
previewStart: 3,
|
||||
previewDuration: 3,
|
||||
previewPlaybackStart: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -221,7 +221,8 @@ export function computeResizePreview(
|
||||
)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const normalizedTag = resize.element.tag.toLowerCase();
|
||||
const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video";
|
||||
const canSeedPlaybackStart =
|
||||
resize.element.kind === "composition" || 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
|
||||
|
||||
@@ -308,6 +308,24 @@ describe("resolveZoneDropPlacement (the whole drop decision, no same-track overl
|
||||
).toEqual({ track: 2, insertRow: null });
|
||||
});
|
||||
|
||||
it("crosses an occupied aim instead of snapping the dragged clip back to its origin", () => {
|
||||
expect(
|
||||
resolveZoneDropPlacement({
|
||||
...base,
|
||||
elements: [el("a", 0, 0, 5), el("b", 1, 0, 5), el("x", 2, 0, 5)],
|
||||
desiredTrack: 1,
|
||||
}),
|
||||
).toEqual({ track: 1, insertRow: 1 });
|
||||
|
||||
expect(
|
||||
resolveZoneDropPlacement({
|
||||
...base,
|
||||
elements: [el("x", 0, 0, 5), el("b", 1, 0, 5), el("a", 2, 0, 5)],
|
||||
desiredTrack: 1,
|
||||
}),
|
||||
).toEqual({ track: 1, insertRow: 2 });
|
||||
});
|
||||
|
||||
it("auto-creates a new track when EVERY lane in the zone is occupied at that time", () => {
|
||||
expect(
|
||||
resolveZoneDropPlacement({
|
||||
|
||||
@@ -115,7 +115,10 @@ export function resolveZoneDropPlacement(input: {
|
||||
trackOrder: zoneTracks,
|
||||
excludeKey: dragKey,
|
||||
});
|
||||
if (placement.needsInsert) {
|
||||
const originTrack = elements.find((element) => (element.key ?? element.id) === dragKey)?.track;
|
||||
const snappedBackToOrigin =
|
||||
originTrack != null && desired !== originTrack && placement.track === originTrack;
|
||||
if (placement.needsInsert || snappedBackToOrigin) {
|
||||
const desiredRow = order.indexOf(desired);
|
||||
if (desiredRow < 0) {
|
||||
return {
|
||||
@@ -123,14 +126,14 @@ export function resolveZoneDropPlacement(input: {
|
||||
insertRow: outOfRangeZoneInsertRow(order, zoneTracks, audioRow, desired),
|
||||
};
|
||||
}
|
||||
// Prefer the gap NEAREST the pointer: insert above the aimed row when the
|
||||
// pointer sits in its upper half AND that boundary is in the clip's own zone
|
||||
// (else the visual/audio split would be crossed) — otherwise fall to below.
|
||||
// `desired` is clamped into the zone, so both boundaries stay in-zone.
|
||||
const insertRow =
|
||||
preferInsertAbove && isInsertAllowedForZone(desiredRow, audioRow, isAudio)
|
||||
? desiredRow
|
||||
: desiredRow + 1;
|
||||
// When collision fallback found only the origin lane, insert on the far side
|
||||
// of the aimed lane so normalization cannot turn the gesture into a no-op.
|
||||
// Otherwise prefer the gap nearest the pointer, preserving normal insertion.
|
||||
const originRow = originTrack == null ? -1 : order.indexOf(originTrack);
|
||||
const insertAbove = snappedBackToOrigin
|
||||
? originRow > desiredRow
|
||||
: preferInsertAbove && isInsertAllowedForZone(desiredRow, audioRow, isAudio);
|
||||
const insertRow = insertAbove ? desiredRow : desiredRow + 1;
|
||||
return { track: desired, insertRow };
|
||||
}
|
||||
return { track: placement.track, insertRow: null };
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useCallback, useState, type RefObject } from "react";
|
||||
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
||||
import {
|
||||
parseTimelineCompositionPayload,
|
||||
TIMELINE_COMPOSITION_MIME,
|
||||
} from "../../utils/timelineCompositionDrop";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout";
|
||||
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
||||
@@ -32,6 +36,11 @@ function applyJsonDropPayload(
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDropStart(usePointerStart: boolean, pointerStart: number): number {
|
||||
if (usePointerStart) return pointerStart;
|
||||
return Math.max(0, usePlayerStore.getState().currentTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropping an asset/file/block onto the timeline places it at the PLAYHEAD —
|
||||
* start is the current playhead time, only the track comes from the drop y.
|
||||
@@ -48,6 +57,7 @@ export function useTimelineAssetDrop({
|
||||
onFileDrop,
|
||||
onAssetDrop,
|
||||
onBlockDrop,
|
||||
onCompositionDrop,
|
||||
}: UseTimelineAssetDropOptions) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
@@ -56,7 +66,8 @@ export function useTimelineAssetDrop({
|
||||
const hasFiles = types.includes("Files");
|
||||
const hasAsset = types.includes(TIMELINE_ASSET_MIME);
|
||||
const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
|
||||
if (!hasFiles && !hasAsset && !hasBlock) return;
|
||||
const hasComposition = types.includes(TIMELINE_COMPOSITION_MIME);
|
||||
if (!hasFiles && !hasAsset && !hasBlock && !hasComposition) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setIsDragOver(true);
|
||||
@@ -65,11 +76,10 @@ export function useTimelineAssetDrop({
|
||||
const clearDropPreview = useCallback(() => setIsDragOver(false), []);
|
||||
|
||||
const resolveDropPlacement = useCallback(
|
||||
(clientX: number, clientY: number): TimelinePlacement => {
|
||||
(clientX: number, clientY: number, usePointerStart = false): TimelinePlacement => {
|
||||
const scroll = scrollRef.current;
|
||||
const rect = scroll?.getBoundingClientRect();
|
||||
// Track comes from the vertical drop position; start is the playhead.
|
||||
const { track } = resolveTimelineAssetDrop(
|
||||
const pointer = resolveTimelineAssetDrop(
|
||||
{
|
||||
rectLeft: rect?.left ?? 0,
|
||||
rectTop: rect?.top ?? 0,
|
||||
@@ -77,14 +87,17 @@ export function useTimelineAssetDrop({
|
||||
scrollTop: scroll?.scrollTop ?? 0,
|
||||
pixelsPerSecond: ppsRef.current,
|
||||
duration: durationRef.current,
|
||||
clampStartToDuration: !usePointerStart,
|
||||
trackHeight: TRACK_H,
|
||||
trackOrder: trackOrderRef.current,
|
||||
},
|
||||
clientX,
|
||||
clientY,
|
||||
);
|
||||
const start = Math.max(0, usePlayerStore.getState().currentTime);
|
||||
return { start, track };
|
||||
return {
|
||||
start: resolveDropStart(usePointerStart, pointer.start),
|
||||
track: pointer.track,
|
||||
};
|
||||
},
|
||||
[scrollRef, ppsRef, durationRef, trackOrderRef],
|
||||
);
|
||||
@@ -93,6 +106,14 @@ export function useTimelineAssetDrop({
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const compositionPayload = parseTimelineCompositionPayload(
|
||||
e.dataTransfer.getData(TIMELINE_COMPOSITION_MIME),
|
||||
);
|
||||
if (compositionPayload && onCompositionDrop) {
|
||||
const placement = resolveDropPlacement(e.clientX, e.clientY, true);
|
||||
void onCompositionDrop(compositionPayload.sourcePath, placement);
|
||||
return;
|
||||
}
|
||||
const placement = resolveDropPlacement(e.clientX, e.clientY);
|
||||
|
||||
if (onFileDrop && e.dataTransfer.files.length > 0) {
|
||||
@@ -109,7 +130,7 @@ export function useTimelineAssetDrop({
|
||||
applyJsonDropPayload(blockPayload, (p) => p.name, onBlockDrop, placement);
|
||||
}
|
||||
},
|
||||
[resolveDropPlacement, onFileDrop, onAssetDrop, onBlockDrop],
|
||||
[resolveDropPlacement, onFileDrop, onAssetDrop, onBlockDrop, onCompositionDrop],
|
||||
);
|
||||
|
||||
return { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview };
|
||||
|
||||
@@ -6,11 +6,13 @@ export interface TimelineEditCapabilities {
|
||||
|
||||
function isDeterministicTimelineWindow(input: {
|
||||
tag: string;
|
||||
kind?: "video" | "audio" | "image" | "element" | "composition";
|
||||
compositionSrc?: string;
|
||||
playbackStartAttr?: "media-start" | "playback-start";
|
||||
sourceDuration?: number;
|
||||
}): boolean {
|
||||
if (input.compositionSrc || input.playbackStartAttr != null) return true;
|
||||
if (input.kind === "composition" || input.compositionSrc || input.playbackStartAttr != null)
|
||||
return true;
|
||||
if (
|
||||
input.sourceDuration != null &&
|
||||
Number.isFinite(input.sourceDuration) &&
|
||||
@@ -27,6 +29,7 @@ export function hasPatchableTimelineTarget(input: { domId?: string; selector?: s
|
||||
|
||||
export function getTimelineEditCapabilities(input: {
|
||||
tag: string;
|
||||
kind?: "video" | "audio" | "image" | "element" | "composition";
|
||||
duration: number;
|
||||
domId?: string;
|
||||
selector?: string;
|
||||
|
||||
@@ -53,6 +53,16 @@ describe("buildTimelineGroupResizeMembers (legacy 36413da7f semantics)", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("seeds legacy composition offsets and advances them at playback rate", () => {
|
||||
const a = el("a", { kind: "composition", tag: "div", start: 2, playbackRate: 2 });
|
||||
const b = el("b", { kind: "composition", tag: "div", start: 5, playbackRate: 0.5 });
|
||||
const members = buildTimelineGroupResizeMembers([a, b], keys("a", "b"), "a", "start")!;
|
||||
|
||||
expect(members.map((member) => member.playbackStart)).toEqual([0, 0]);
|
||||
const changes = resolveTimelineGroupResizeChanges(members, "start", 1);
|
||||
expect(changes.map((change) => change.playbackStart)).toEqual([2, 0.5]);
|
||||
});
|
||||
|
||||
it("does not seed playbackStart on the END edge", () => {
|
||||
const grabbed = el("a", { tag: "audio", start: 0, duration: 2 });
|
||||
const b = el("b", { tag: "audio", start: 3, duration: 2 });
|
||||
|
||||
@@ -181,14 +181,15 @@ function elementKey(element: TimelineElement): string {
|
||||
return element.key ?? element.id;
|
||||
}
|
||||
|
||||
function isMediaTimelineElement(element: TimelineElement): boolean {
|
||||
function hasSourcePlaybackOffset(element: TimelineElement): boolean {
|
||||
const tag = element.tag.toLowerCase();
|
||||
return tag === "audio" || tag === "video";
|
||||
return element.kind === "composition" || tag === "audio" || tag === "video";
|
||||
}
|
||||
|
||||
function canTrimEdge(element: TimelineElement, edge: TimelineGroupResizeEdge): boolean {
|
||||
const caps = getTimelineEditCapabilities({
|
||||
tag: element.tag,
|
||||
kind: element.kind,
|
||||
duration: element.duration,
|
||||
domId: element.domId,
|
||||
selector: element.selector,
|
||||
@@ -228,7 +229,7 @@ export function buildTimelineGroupResizeMembers(
|
||||
start: element.start,
|
||||
duration: element.duration,
|
||||
playbackStart:
|
||||
edge === "start" && isMediaTimelineElement(element)
|
||||
edge === "start" && hasSourcePlaybackOffset(element)
|
||||
? (element.playbackStart ?? 0)
|
||||
: element.playbackStart,
|
||||
playbackRate: element.playbackRate,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface LaneMoveRefreshDeps {
|
||||
refreshAfterLaneMove?: () => void;
|
||||
}
|
||||
|
||||
/** Refresh only after the complete lane transaction persisted successfully. */
|
||||
export function refreshAfterDurableLaneMove(
|
||||
pending: Promise<boolean>,
|
||||
deps: LaneMoveRefreshDeps,
|
||||
): Promise<boolean> {
|
||||
return pending.then((persisted) => {
|
||||
if (persisted) deps.refreshAfterLaneMove?.();
|
||||
return persisted;
|
||||
});
|
||||
}
|
||||
@@ -383,6 +383,7 @@ export function resolveTimelineAssetDrop(
|
||||
scrollTop: number;
|
||||
pixelsPerSecond: number;
|
||||
duration: number;
|
||||
clampStartToDuration?: boolean;
|
||||
trackHeight: number;
|
||||
trackOrder: number[];
|
||||
},
|
||||
@@ -391,9 +392,10 @@ export function resolveTimelineAssetDrop(
|
||||
): { start: number; track: number } {
|
||||
const x = clientX - input.rectLeft + input.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
|
||||
const contentY = clientY - input.rectTop + input.scrollTop;
|
||||
const pointerStart = Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100;
|
||||
const start = Math.max(
|
||||
0,
|
||||
Math.min(input.duration, Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100),
|
||||
input.clampStartToDuration === false ? pointerStart : Math.min(input.duration, pointerStart),
|
||||
);
|
||||
// Row from the shared row→y inverse so the top pad is honoured; a drop in the
|
||||
// pad above the first lane floors to row 0, a drop in the bottom pad rounds
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { classifyZone } from "./timelineZones";
|
||||
import { isLaneFree, timeRangesOverlap } from "./timelineCollision";
|
||||
import { authoredTrackForLane, sameSourceFile } from "./timelineClipDragCommit";
|
||||
import { authoredTrackForLane, sameSourceFile } from "./timelineAuthoredTrack";
|
||||
import { samePaintScope } from "./timelineStackingSync";
|
||||
|
||||
/**
|
||||
|
||||
@@ -77,6 +77,7 @@ interface UseTimelineClipDragInput {
|
||||
*/
|
||||
readZIndex?: (element: TimelineElement) => number;
|
||||
onStackingPatches?: (patches: StackingPatch[]) => Promise<unknown> | void;
|
||||
refreshAfterLaneMove?: () => void;
|
||||
}
|
||||
|
||||
export function useTimelineClipDrag({
|
||||
@@ -93,6 +94,7 @@ export function useTimelineClipDrag({
|
||||
setRangeSelectionRef,
|
||||
readZIndex,
|
||||
onStackingPatches,
|
||||
refreshAfterLaneMove,
|
||||
}: UseTimelineClipDragInput) {
|
||||
const updateElement = usePlayerStore((s) => s.updateElement);
|
||||
const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
|
||||
@@ -213,6 +215,8 @@ export function useTimelineClipDrag({
|
||||
readZIndexRef.current = readZIndex;
|
||||
const onStackingPatchesRef = useRef(onStackingPatches);
|
||||
onStackingPatchesRef.current = onStackingPatches;
|
||||
const refreshAfterLaneMoveRef = useRef(refreshAfterLaneMove);
|
||||
refreshAfterLaneMoveRef.current = refreshAfterLaneMove;
|
||||
|
||||
const clipDragScrollRaf = useRef(0);
|
||||
const clipDragPointerRef = useRef<{
|
||||
@@ -499,6 +503,7 @@ export function useTimelineClipDrag({
|
||||
// deps (Timeline.tsx). Absent → commitDraggedClipMove skips the z-sync.
|
||||
readZIndex: readZIndexRef.current,
|
||||
onStackingPatches: onStackingPatchesRef.current,
|
||||
refreshAfterLaneMove: refreshAfterLaneMoveRef.current,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ interface UseTimelineEditPinningInput {
|
||||
onFileDrop: TimelineDropCallbacks["onFileDrop"];
|
||||
onAssetDrop: TimelineDropCallbacks["onAssetDrop"];
|
||||
onBlockDrop: TimelineDropCallbacks["onBlockDrop"];
|
||||
onCompositionDrop: TimelineDropCallbacks["onCompositionDrop"];
|
||||
}
|
||||
|
||||
// Wrap every mutating timeline edit so the zoom pins to the current on-screen
|
||||
@@ -29,6 +30,7 @@ export function useTimelineEditPinning({
|
||||
onFileDrop,
|
||||
onAssetDrop,
|
||||
onBlockDrop,
|
||||
onCompositionDrop,
|
||||
}: UseTimelineEditPinningInput) {
|
||||
const pinTimelineZoom = usePlayerStore((s) => s.pinTimelineZoom);
|
||||
// Pin the timeline zoom to the current on-screen scale on the FIRST edit, so a
|
||||
@@ -106,6 +108,15 @@ export function useTimelineEditPinning({
|
||||
}),
|
||||
[onBlockDrop, pinZoomBeforeEdit],
|
||||
);
|
||||
const pinnedOnCompositionDrop = useMemo(
|
||||
() =>
|
||||
onCompositionDrop &&
|
||||
((...args: Parameters<typeof onCompositionDrop>) => {
|
||||
pinZoomBeforeEdit();
|
||||
return onCompositionDrop(...args);
|
||||
}),
|
||||
[onCompositionDrop, pinZoomBeforeEdit],
|
||||
);
|
||||
|
||||
return {
|
||||
pinZoomBeforeEdit,
|
||||
@@ -117,5 +128,6 @@ export function useTimelineEditPinning({
|
||||
pinnedOnFileDrop,
|
||||
pinnedOnAssetDrop,
|
||||
pinnedOnBlockDrop,
|
||||
pinnedOnCompositionDrop,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,9 +48,51 @@ describe("buildExpandedElements", () => {
|
||||
const out = buildExpandedElements(elements, manifest, parentMap, "s3", "s3");
|
||||
const child = out.find((e) => e.domId === "stat-1")!;
|
||||
expect(child.expandedParentStart).toBe(16);
|
||||
expect(child.expandedHostKey).toBe("s3");
|
||||
expect(child.sourceFile).toBe("stats.html");
|
||||
});
|
||||
|
||||
it("keeps repeated same-source composition hosts as distinct move identities", () => {
|
||||
const elements = [
|
||||
el({
|
||||
id: "host-a",
|
||||
key: "index.html#host-a",
|
||||
start: 0,
|
||||
duration: 5,
|
||||
compositionSrc: "scene.html",
|
||||
}),
|
||||
el({
|
||||
id: "host-b",
|
||||
key: "index.html#host-b",
|
||||
start: 8,
|
||||
duration: 5,
|
||||
compositionSrc: "scene.html",
|
||||
}),
|
||||
];
|
||||
const manifest = [
|
||||
clip({ id: "host-a", start: 0, duration: 5, compositionSrc: "scene.html" }),
|
||||
clip({ id: "child-a", start: 1, duration: 2 }),
|
||||
clip({ id: "host-b", start: 8, duration: 5, compositionSrc: "scene.html" }),
|
||||
clip({ id: "child-b", start: 9, duration: 2 }),
|
||||
];
|
||||
const parentMap = new Map([
|
||||
["child-a", "host-a"],
|
||||
["child-b", "host-b"],
|
||||
]);
|
||||
|
||||
const childA = buildExpandedElements(elements, manifest, parentMap, "host-a", "host-a").find(
|
||||
(element) => element.domId === "child-a",
|
||||
);
|
||||
const childB = buildExpandedElements(elements, manifest, parentMap, "host-b", "host-b").find(
|
||||
(element) => element.domId === "child-b",
|
||||
);
|
||||
|
||||
expect(childA?.sourceFile).toBe("scene.html");
|
||||
expect(childB?.sourceFile).toBe("scene.html");
|
||||
expect(childA?.expandedHostKey).toBe("index.html#host-a");
|
||||
expect(childB?.expandedHostKey).toBe("index.html#host-b");
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("rebases a 2-level child onto its NESTED host, not the top-level scene", () => {
|
||||
// top host A@10 (a.html) embeds host B@12 (b.html); child C lives in b.html.
|
||||
|
||||
@@ -133,6 +133,7 @@ function buildChildElements(
|
||||
siblings: ClipManifestClip[],
|
||||
display: DisplayBounds,
|
||||
editBasis: { start: number; sourceFile: string | undefined },
|
||||
expandedHostKey: string,
|
||||
): TimelineElement[] {
|
||||
const result: TimelineElement[] = [];
|
||||
for (const child of siblings) {
|
||||
@@ -182,6 +183,7 @@ function buildChildElements(
|
||||
authoredTrack: base.authoredTrack,
|
||||
stackingContextId: base.stackingContextId,
|
||||
expandedParentStart: editBasis.start,
|
||||
expandedHostKey,
|
||||
domId,
|
||||
selector,
|
||||
sourceFile: editBasis.sourceFile,
|
||||
@@ -260,6 +262,7 @@ export function buildExpandedElements(
|
||||
track: topLevelElement.track,
|
||||
},
|
||||
editBasis,
|
||||
parentKey,
|
||||
);
|
||||
if (expanded.length === 0) return filterToTopLevel(elements, parentMap);
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface ClipManifestClip {
|
||||
compositionAncestors?: string[];
|
||||
parentCompositionId: string | null;
|
||||
compositionSrc: string | null;
|
||||
playbackStart?: number;
|
||||
playbackRate?: number;
|
||||
assetUrl: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -110,7 +110,65 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTimelineFromDOM — canonical playback rate", () => {
|
||||
it.each([
|
||||
["10", 5],
|
||||
["0.01", 0.1],
|
||||
])("clamps authored rate %s to %s for trim and split math", (authored, expected) => {
|
||||
const doc = makeDoc(`
|
||||
<div data-composition-id="root">
|
||||
<div id="nested" class="clip" data-composition-src="scene.html"
|
||||
data-start="0" data-duration="5" data-playback-rate="${authored}"></div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const nested = parseTimelineFromDOM(doc, 10).find((entry) => entry.domId === "nested");
|
||||
|
||||
expect(nested?.playbackRate).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTimelineElementFromManifestClip — source-scoped selector identity", () => {
|
||||
it("preserves composition kind and source timing on first translation", () => {
|
||||
const doc = makeDoc(`
|
||||
<div data-composition-id="root" data-composition-file="index.html">
|
||||
<div id="host" data-composition-id="scene" data-composition-src="scene.html"
|
||||
data-playback-start="1.5" data-playback-rate="2"></div>
|
||||
</div>
|
||||
`);
|
||||
const host = doc.getElementById("host");
|
||||
|
||||
const element = createTimelineElementFromManifestClip({
|
||||
clip: {
|
||||
id: "host",
|
||||
label: "Scene",
|
||||
kind: "composition",
|
||||
tagName: "div",
|
||||
start: 2,
|
||||
duration: 4,
|
||||
track: 0,
|
||||
compositionId: "scene",
|
||||
parentCompositionId: "root",
|
||||
compositionSrc: "scene.html",
|
||||
playbackStart: 1.5,
|
||||
playbackRate: 2,
|
||||
assetUrl: null,
|
||||
},
|
||||
fallbackIndex: 0,
|
||||
doc,
|
||||
hostEl: host,
|
||||
});
|
||||
|
||||
expect(element).toMatchObject({
|
||||
kind: "composition",
|
||||
compositionSrc: "scene.html",
|
||||
playbackStart: 1.5,
|
||||
playbackStartAttr: "playback-start",
|
||||
playbackRate: 2,
|
||||
domId: "host",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores an index.html duplicate when indexing a scene.html selector", () => {
|
||||
const doc = makeDoc(`
|
||||
<div data-composition-id="root" data-composition-file="index.html">
|
||||
|
||||
@@ -111,6 +111,7 @@ export function createTimelineElementFromManifestClip(params: {
|
||||
id: identity.id,
|
||||
label,
|
||||
key: identity.key,
|
||||
kind: clip.kind,
|
||||
tag: resolveClipTag(clip),
|
||||
start: clip.start,
|
||||
duration: clip.duration,
|
||||
@@ -129,6 +130,8 @@ export function createTimelineElementFromManifestClip(params: {
|
||||
selector,
|
||||
selectorIndex,
|
||||
sourceFile,
|
||||
playbackStart: clip.playbackStart,
|
||||
playbackRate: clip.playbackRate,
|
||||
};
|
||||
|
||||
if (hostEl) {
|
||||
@@ -140,6 +143,8 @@ export function createTimelineElementFromManifestClip(params: {
|
||||
}
|
||||
if (clip.assetUrl) entry.src = clip.assetUrl;
|
||||
if (clip.kind === "composition" && clip.compositionId) {
|
||||
entry.playbackStart ??= 0;
|
||||
entry.playbackRate ??= 1;
|
||||
let resolvedSrc = clip.compositionSrc;
|
||||
if (!resolvedSrc) {
|
||||
hostEl =
|
||||
@@ -293,6 +298,14 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
||||
id: identity.id,
|
||||
label,
|
||||
key: identity.key,
|
||||
kind:
|
||||
compId && compId !== rootComp?.getAttribute("data-composition-id")
|
||||
? "composition"
|
||||
: tagLower === "video" || tagLower === "audio"
|
||||
? tagLower
|
||||
: tagLower === "img"
|
||||
? "image"
|
||||
: "element",
|
||||
tag: tagLower,
|
||||
start,
|
||||
duration: dur,
|
||||
@@ -308,13 +321,13 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
||||
};
|
||||
|
||||
const mediaEl = resolveMediaElement(el);
|
||||
applyMediaMetadataFromElement(entry, el);
|
||||
if (mediaEl) {
|
||||
if (mediaEl.tagName === "IMG") {
|
||||
entry.tag = "img";
|
||||
}
|
||||
const vol = el.getAttribute("data-volume") ?? mediaEl.getAttribute("data-volume");
|
||||
if (vol) entry.volume = parseFloat(vol);
|
||||
applyMediaMetadataFromElement(entry, el);
|
||||
// Override AFTER the helper (which sets the raw relative attribute) so the
|
||||
// resolved absolute URL wins — the Studio can then fetch the asset
|
||||
// regardless of whether the attribute value was relative or absolute.
|
||||
@@ -345,6 +358,10 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
||||
entry.tag = "video";
|
||||
}
|
||||
}
|
||||
if (entry.kind === "composition") {
|
||||
entry.playbackStart ??= 0;
|
||||
entry.playbackRate ??= 1;
|
||||
}
|
||||
|
||||
els.push(entry);
|
||||
});
|
||||
|
||||
@@ -76,6 +76,10 @@ function readDurationAttribute(el: Element | null | undefined): number {
|
||||
return isFinitePositive(duration) ? duration : 0;
|
||||
}
|
||||
|
||||
function normalizePlaybackRate(raw: number): number {
|
||||
return Number.isFinite(raw) && raw > 0 ? Math.max(0.1, Math.min(5, raw)) : 1;
|
||||
}
|
||||
|
||||
export function isTimelineIgnoredElement(el: Element): boolean {
|
||||
return Boolean(
|
||||
el.closest(
|
||||
@@ -149,19 +153,25 @@ export function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageEl
|
||||
: null;
|
||||
}
|
||||
|
||||
export function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
|
||||
const mediaStartAttr = el.getAttribute("data-playback-start")
|
||||
? "playback-start"
|
||||
: el.getAttribute("data-media-start")
|
||||
? "media-start"
|
||||
: undefined;
|
||||
const mediaStartValue =
|
||||
el.getAttribute("data-playback-start") ?? el.getAttribute("data-media-start");
|
||||
function applyPlaybackMetadataFromElement(entry: TimelineElement, el: Element): void {
|
||||
const playbackStartValue = el.getAttribute("data-playback-start");
|
||||
const legacyMediaStartValue = el.getAttribute("data-media-start");
|
||||
const mediaStartValue = playbackStartValue ?? legacyMediaStartValue;
|
||||
if (mediaStartValue != null) {
|
||||
const playbackStart = parseFloat(mediaStartValue);
|
||||
if (Number.isFinite(playbackStart)) entry.playbackStart = playbackStart;
|
||||
}
|
||||
if (mediaStartAttr) entry.playbackStartAttr = mediaStartAttr;
|
||||
if (playbackStartValue != null) entry.playbackStartAttr = "playback-start";
|
||||
else if (legacyMediaStartValue != null) entry.playbackStartAttr = "media-start";
|
||||
|
||||
const authoredPlaybackRate = Number.parseFloat(el.getAttribute("data-playback-rate") ?? "");
|
||||
if (Number.isFinite(authoredPlaybackRate) && authoredPlaybackRate > 0) {
|
||||
entry.playbackRate = normalizePlaybackRate(authoredPlaybackRate);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
|
||||
applyPlaybackMetadataFromElement(entry, el);
|
||||
|
||||
const mediaEl = resolveMediaElement(el);
|
||||
if (!mediaEl) return;
|
||||
@@ -182,8 +192,8 @@ export function applyMediaMetadataFromElement(entry: TimelineElement, el: Elemen
|
||||
}
|
||||
|
||||
const playbackRate = mediaEl.defaultPlaybackRate;
|
||||
if (Number.isFinite(playbackRate) && playbackRate > 0) {
|
||||
entry.playbackRate = playbackRate;
|
||||
if (entry.playbackRate == null && Number.isFinite(playbackRate) && playbackRate > 0) {
|
||||
entry.playbackRate = normalizePlaybackRate(playbackRate);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface TimelineElement {
|
||||
id: string;
|
||||
label?: string;
|
||||
key?: string;
|
||||
kind?: ClipManifestClip["kind"];
|
||||
tag: string;
|
||||
start: number;
|
||||
duration: number;
|
||||
@@ -82,8 +83,8 @@ export interface TimelineElement {
|
||||
* the child's local (sourceFile-relative) time. Works at any nesting depth.
|
||||
*/
|
||||
expandedParentStart?: number;
|
||||
expandedHostKey?: string;
|
||||
}
|
||||
|
||||
export type ZoomMode = "fit" | "manual";
|
||||
type TimelineTool = "select" | "razor";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user