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:
Miguel Ángel
2026-07-17 14:15:30 -04:00
committed by GitHub
parent 2be8a62c00
commit 2b65b4efce
93 changed files with 3925 additions and 758 deletions
@@ -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">
+18 -1
View File
@@ -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";