mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
Rework the timeline row model from one-row-per-stacking-layer to NLE "lanes": a row holds a time-sequence of non-overlapping clips, ordered top = front. Stacking only matters between clips that overlap in time. - Lane packing: per stacking context, sort clips by effective z-index desc (DOM order tiebreak) and greedily pack each onto the first lane whose members don't overlap it in time; else open a new lane. Lane's representative z is the max member z. Audio stays one clip per lane. - Lane-aware drag: dropping onto a lane joins it (clip takes the lane's z) when there's no time-conflict; a conflicting drop is rejected and the preview snaps to the nearest valid lane or a new-lane insertion. Between/above/below still create a new lane at that stacking level. - Overlap checks use the drag-preview start/duration, so conflict is judged by where the clip lands, not where it started. Only explicit drags write z-index; authored z is preserved and data-track-index is never rewritten.
710 lines
17 KiB
TypeScript
710 lines
17 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import {
|
||
buildClipRangeSelection,
|
||
buildPromptCopyText,
|
||
buildTimelineElementAgentPrompt,
|
||
buildTimelineAgentPrompt,
|
||
getTimelineEditCapabilities,
|
||
hasPatchableTimelineTarget,
|
||
resolveBlockedTimelineEditIntent,
|
||
resolveTimelineAutoScroll,
|
||
resolveTimelineMove,
|
||
resolveTimelineResize,
|
||
snapKeyframePctToBeat,
|
||
type TimelinePromptElement,
|
||
} from "./timelineEditing";
|
||
import { buildStackingTimelineLayers } from "./timelineTrackOrder";
|
||
|
||
describe("resolveTimelineMove", () => {
|
||
it("moves timing based on horizontal drag and snaps to centiseconds", () => {
|
||
expect(
|
||
resolveTimelineMove(
|
||
{
|
||
start: 1.25,
|
||
track: 2,
|
||
duration: 2,
|
||
originClientX: 100,
|
||
originClientY: 200,
|
||
pixelsPerSecond: 100,
|
||
trackHeight: 72,
|
||
maxStart: 8,
|
||
trackOrder: [0, 1, 2, 3, 4],
|
||
},
|
||
245,
|
||
200,
|
||
),
|
||
).toEqual({ start: 2.7, track: 2 });
|
||
});
|
||
|
||
it("moves layers based on vertical drag and clamps to the allowed range", () => {
|
||
expect(
|
||
resolveTimelineMove(
|
||
{
|
||
start: 2,
|
||
track: 1,
|
||
duration: 3,
|
||
originClientX: 200,
|
||
originClientY: 200,
|
||
pixelsPerSecond: 100,
|
||
trackHeight: 72,
|
||
maxStart: 10,
|
||
trackOrder: [0, 1, 5, 9],
|
||
},
|
||
150,
|
||
390,
|
||
),
|
||
).toEqual({ start: 1.5, track: 9 });
|
||
});
|
||
|
||
it("prevents moving before zero or past the last valid start", () => {
|
||
expect(
|
||
resolveTimelineMove(
|
||
{
|
||
start: 0.2,
|
||
track: 0,
|
||
duration: 4,
|
||
originClientX: 300,
|
||
originClientY: 200,
|
||
pixelsPerSecond: 100,
|
||
trackHeight: 72,
|
||
maxStart: 6,
|
||
trackOrder: [0, 10, 20],
|
||
},
|
||
-100,
|
||
-200,
|
||
),
|
||
).toEqual({ start: 0, track: -1 });
|
||
|
||
expect(
|
||
resolveTimelineMove(
|
||
{
|
||
start: 5.8,
|
||
track: 10,
|
||
duration: 4,
|
||
originClientX: 300,
|
||
originClientY: 200,
|
||
pixelsPerSecond: 100,
|
||
trackHeight: 72,
|
||
maxStart: 6,
|
||
trackOrder: [0, 10, 20],
|
||
},
|
||
500,
|
||
200,
|
||
),
|
||
).toEqual({ start: 6, track: 10 });
|
||
});
|
||
|
||
it("creates a new top track when dragged past the first row threshold", () => {
|
||
expect(
|
||
resolveTimelineMove(
|
||
{
|
||
start: 1,
|
||
track: 0,
|
||
duration: 2,
|
||
originClientX: 100,
|
||
originClientY: 200,
|
||
pixelsPerSecond: 100,
|
||
trackHeight: 72,
|
||
maxStart: 8,
|
||
trackOrder: [0, 10, 20],
|
||
},
|
||
100,
|
||
150,
|
||
),
|
||
).toEqual({ start: 1, track: -1 });
|
||
});
|
||
|
||
it("creates a new bottom track when dragged past the last row threshold", () => {
|
||
expect(
|
||
resolveTimelineMove(
|
||
{
|
||
start: 1,
|
||
track: 20,
|
||
duration: 2,
|
||
originClientX: 100,
|
||
originClientY: 200,
|
||
pixelsPerSecond: 100,
|
||
trackHeight: 72,
|
||
maxStart: 8,
|
||
trackOrder: [0, 10, 20],
|
||
},
|
||
100,
|
||
250,
|
||
),
|
||
).toEqual({ start: 1, track: 21 });
|
||
});
|
||
|
||
it("accounts for scroll displacement while dragging", () => {
|
||
expect(
|
||
resolveTimelineMove(
|
||
{
|
||
start: 1,
|
||
track: 0,
|
||
duration: 2,
|
||
originClientX: 100,
|
||
originClientY: 200,
|
||
originScrollLeft: 0,
|
||
originScrollTop: 0,
|
||
currentScrollLeft: 100,
|
||
currentScrollTop: 144,
|
||
pixelsPerSecond: 100,
|
||
trackHeight: 72,
|
||
maxStart: 8,
|
||
trackOrder: [0, 1, 2, 3],
|
||
},
|
||
100,
|
||
200,
|
||
),
|
||
).toEqual({ start: 2, track: 2 });
|
||
});
|
||
|
||
it("snaps conflicting vertical stacking movement to a new lane without changing data-track-index", () => {
|
||
const stackingElements = [
|
||
{
|
||
id: "root-front",
|
||
tag: "div",
|
||
start: 0,
|
||
duration: 2,
|
||
track: 0,
|
||
zIndex: 2,
|
||
hasExplicitZIndex: true,
|
||
stackingContextId: "root",
|
||
parentCompositionId: null,
|
||
compositionAncestors: ["root"],
|
||
},
|
||
{
|
||
id: "root-back",
|
||
tag: "div",
|
||
start: 0,
|
||
duration: 2,
|
||
track: 1,
|
||
zIndex: 1,
|
||
hasExplicitZIndex: true,
|
||
stackingContextId: "root",
|
||
parentCompositionId: null,
|
||
compositionAncestors: ["root"],
|
||
},
|
||
];
|
||
const layers = buildStackingTimelineLayers(stackingElements).rows;
|
||
const result = resolveTimelineMove(
|
||
{
|
||
start: 0,
|
||
track: 1,
|
||
duration: 2,
|
||
originClientX: 0,
|
||
originClientY: 0,
|
||
pixelsPerSecond: 100,
|
||
trackHeight: 72,
|
||
maxStart: 8,
|
||
trackOrder: [0, 1],
|
||
layerOrder: layers.map((layer) => layer.id),
|
||
timelineLayers: layers,
|
||
stackingElement: stackingElements[1],
|
||
stackingElements,
|
||
},
|
||
0,
|
||
-72,
|
||
);
|
||
|
||
expect(result).toEqual({
|
||
start: 0,
|
||
track: 1,
|
||
previewLayerId: `preview:root-back:above:${layers[0]!.id}`,
|
||
previewLayerIndex: 0,
|
||
stackingReorder: {
|
||
contextKey: "root",
|
||
placement: { type: "above", layerId: layers[0]!.id },
|
||
zIndexChanges: [{ key: "root-back", zIndex: 3 }],
|
||
},
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("hasPatchableTimelineTarget", () => {
|
||
it("returns true when the clip has a DOM id", () => {
|
||
expect(hasPatchableTimelineTarget({ domId: "hero-card" })).toBe(true);
|
||
});
|
||
|
||
it("returns true when the clip has a selector", () => {
|
||
expect(hasPatchableTimelineTarget({ selector: ".hero-card" })).toBe(true);
|
||
});
|
||
|
||
it("returns false when the clip has no stable patch target", () => {
|
||
expect(hasPatchableTimelineTarget({})).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("getTimelineEditCapabilities", () => {
|
||
it("does not disable editable audio just because it spans multiple scenes", () => {
|
||
expect(
|
||
getTimelineEditCapabilities({
|
||
tag: "audio",
|
||
duration: 8,
|
||
selector: "#voiceover",
|
||
sourceDuration: 8,
|
||
}),
|
||
).toEqual({
|
||
canMove: true,
|
||
canTrimStart: true,
|
||
canTrimEnd: true,
|
||
});
|
||
});
|
||
|
||
it("allows full editing of generic motion clips with authored timing", () => {
|
||
expect(
|
||
getTimelineEditCapabilities({
|
||
tag: "section",
|
||
duration: 2,
|
||
selector: ".feature-card",
|
||
}),
|
||
).toEqual({
|
||
canMove: true,
|
||
canTrimStart: true,
|
||
canTrimEnd: true,
|
||
});
|
||
});
|
||
|
||
it("keeps implicit layout layers selectable but not timeline-editable", () => {
|
||
expect(
|
||
getTimelineEditCapabilities({
|
||
duration: 8,
|
||
selector: ".scene-shell",
|
||
tag: "div",
|
||
timingSource: "implicit",
|
||
}),
|
||
).toEqual({
|
||
canMove: false,
|
||
canTrimStart: false,
|
||
canTrimEnd: false,
|
||
});
|
||
});
|
||
|
||
it("allows move and both trims for patchable media clips with offset support", () => {
|
||
expect(
|
||
getTimelineEditCapabilities({
|
||
tag: "video",
|
||
duration: 2,
|
||
selector: "#media-card",
|
||
playbackStartAttr: "media-start",
|
||
sourceDuration: 10,
|
||
}),
|
||
).toEqual({
|
||
canMove: true,
|
||
canTrimStart: true,
|
||
canTrimEnd: true,
|
||
});
|
||
});
|
||
|
||
it("treats wrapped media clips with media metadata as deterministic", () => {
|
||
expect(
|
||
getTimelineEditCapabilities({
|
||
tag: "div",
|
||
duration: 2,
|
||
selector: "#media-card",
|
||
playbackStartAttr: "media-start",
|
||
sourceDuration: 10,
|
||
}),
|
||
).toEqual({
|
||
canMove: true,
|
||
canTrimStart: true,
|
||
canTrimEnd: true,
|
||
});
|
||
});
|
||
|
||
it("allows full editing for patchable composition hosts", () => {
|
||
expect(
|
||
getTimelineEditCapabilities({
|
||
tag: "div",
|
||
duration: 3,
|
||
selector: '[data-composition-id="intro"]',
|
||
compositionSrc: "compositions/intro.html",
|
||
}),
|
||
).toEqual({
|
||
canMove: true,
|
||
canTrimStart: true,
|
||
canTrimEnd: true,
|
||
});
|
||
});
|
||
|
||
it("locks all timeline edits for clips with data-timeline-locked", () => {
|
||
expect(
|
||
getTimelineEditCapabilities({
|
||
tag: "div",
|
||
duration: 8,
|
||
selector: '[data-composition-id="caption-highlight"]',
|
||
compositionSrc: "compositions/components/caption-highlight.html",
|
||
timelineLocked: true,
|
||
}),
|
||
).toEqual({
|
||
canMove: false,
|
||
canTrimStart: false,
|
||
canTrimEnd: false,
|
||
});
|
||
});
|
||
|
||
it("allows full editing of explicitly authored generic elements", () => {
|
||
expect(
|
||
getTimelineEditCapabilities({
|
||
tag: "div",
|
||
duration: 4,
|
||
selector: "#hero-card",
|
||
timingSource: "authored",
|
||
}),
|
||
).toEqual({
|
||
canMove: true,
|
||
canTrimStart: true,
|
||
canTrimEnd: true,
|
||
});
|
||
});
|
||
|
||
it("disables all timeline edits for clips without a patchable target", () => {
|
||
expect(
|
||
getTimelineEditCapabilities({
|
||
tag: "video",
|
||
duration: 2,
|
||
sourceDuration: 10,
|
||
}),
|
||
).toEqual({
|
||
canMove: false,
|
||
canTrimStart: false,
|
||
canTrimEnd: false,
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("resolveBlockedTimelineEditIntent", () => {
|
||
it("returns move when the clip body is blocked", () => {
|
||
expect(
|
||
resolveBlockedTimelineEditIntent({
|
||
width: 160,
|
||
offsetX: 80,
|
||
handleWidth: 18,
|
||
capabilities: {
|
||
canMove: false,
|
||
canTrimStart: false,
|
||
canTrimEnd: false,
|
||
},
|
||
}),
|
||
).toBe("move");
|
||
});
|
||
|
||
it("returns resize-start when the left edge is blocked", () => {
|
||
expect(
|
||
resolveBlockedTimelineEditIntent({
|
||
width: 160,
|
||
offsetX: 8,
|
||
handleWidth: 18,
|
||
capabilities: {
|
||
canMove: false,
|
||
canTrimStart: false,
|
||
canTrimEnd: true,
|
||
},
|
||
}),
|
||
).toBe("resize-start");
|
||
});
|
||
|
||
it("returns resize-end when the right edge is blocked", () => {
|
||
expect(
|
||
resolveBlockedTimelineEditIntent({
|
||
width: 160,
|
||
offsetX: 154,
|
||
handleWidth: 18,
|
||
capabilities: {
|
||
canMove: false,
|
||
canTrimStart: true,
|
||
canTrimEnd: false,
|
||
},
|
||
}),
|
||
).toBe("resize-end");
|
||
});
|
||
|
||
it("does not block the left edge when the clip can still be moved", () => {
|
||
expect(
|
||
resolveBlockedTimelineEditIntent({
|
||
width: 160,
|
||
offsetX: 8,
|
||
handleWidth: 18,
|
||
capabilities: {
|
||
canMove: true,
|
||
canTrimStart: false,
|
||
canTrimEnd: true,
|
||
},
|
||
}),
|
||
).toBe(null);
|
||
});
|
||
|
||
it("does not swallow the full surface of a narrow movable clip", () => {
|
||
expect(
|
||
resolveBlockedTimelineEditIntent({
|
||
width: 12,
|
||
offsetX: 6,
|
||
handleWidth: 18,
|
||
capabilities: {
|
||
canMove: true,
|
||
canTrimStart: false,
|
||
canTrimEnd: false,
|
||
},
|
||
}),
|
||
).toBe(null);
|
||
});
|
||
|
||
it("returns null when the relevant edit is supported", () => {
|
||
expect(
|
||
resolveBlockedTimelineEditIntent({
|
||
width: 160,
|
||
offsetX: 8,
|
||
handleWidth: 18,
|
||
capabilities: {
|
||
canMove: true,
|
||
canTrimStart: true,
|
||
canTrimEnd: true,
|
||
},
|
||
}),
|
||
).toBe(null);
|
||
});
|
||
});
|
||
|
||
describe("buildClipRangeSelection", () => {
|
||
it("anchors the full clip range at the click position", () => {
|
||
expect(
|
||
buildClipRangeSelection({ start: 1.25, duration: 3.5 }, { anchorX: 320, anchorY: 180 }),
|
||
).toEqual({
|
||
start: 1.25,
|
||
end: 4.75,
|
||
anchorX: 320,
|
||
anchorY: 180,
|
||
});
|
||
});
|
||
});
|
||
describe("resolveTimelineAutoScroll", () => {
|
||
it("does not scroll when the pointer stays away from the edges", () => {
|
||
expect(
|
||
resolveTimelineAutoScroll(
|
||
{
|
||
left: 100,
|
||
top: 100,
|
||
right: 500,
|
||
bottom: 400,
|
||
},
|
||
300,
|
||
250,
|
||
),
|
||
).toEqual({ x: 0, y: 0 });
|
||
});
|
||
|
||
it("scrolls upward and leftward near the top-left edge", () => {
|
||
expect(
|
||
resolveTimelineAutoScroll(
|
||
{
|
||
left: 100,
|
||
top: 100,
|
||
right: 500,
|
||
bottom: 400,
|
||
},
|
||
110,
|
||
120,
|
||
),
|
||
).toEqual({ x: -9, y: -6 });
|
||
});
|
||
|
||
it("scrolls downward and rightward near the bottom-right edge", () => {
|
||
expect(
|
||
resolveTimelineAutoScroll(
|
||
{
|
||
left: 100,
|
||
top: 100,
|
||
right: 500,
|
||
bottom: 400,
|
||
},
|
||
490,
|
||
380,
|
||
),
|
||
).toEqual({ x: 9, y: 6 });
|
||
});
|
||
});
|
||
|
||
describe("buildTimelineAgentPrompt", () => {
|
||
it("includes the selected range, elements, and user request", () => {
|
||
const elements: TimelinePromptElement[] = [
|
||
{ id: "title", tag: "div", start: 1, duration: 3, track: 0 },
|
||
{ id: "music", tag: "audio", start: 0, duration: 8, track: 2 },
|
||
];
|
||
|
||
const text = buildTimelineAgentPrompt({
|
||
rangeStart: 1,
|
||
rangeEnd: 4,
|
||
elements,
|
||
prompt: "Move the title later and lower the music",
|
||
});
|
||
|
||
expect(text).toContain("Time range: 0:01 — 0:04");
|
||
expect(text).toContain("#title (div)");
|
||
expect(text).toContain("#music (audio)");
|
||
expect(text).toContain("Move the title later and lower the music");
|
||
});
|
||
});
|
||
|
||
describe("buildTimelineElementAgentPrompt", () => {
|
||
it("includes the clip context and guidance for agent-based edits", () => {
|
||
expect(
|
||
buildTimelineElementAgentPrompt({
|
||
id: "feature-card",
|
||
tag: "section",
|
||
start: 1.4,
|
||
duration: 1.6,
|
||
track: 1,
|
||
sourceFile: "index.html",
|
||
selector: "#feature-card",
|
||
}),
|
||
).toContain("If this clip is animated with GSAP");
|
||
});
|
||
});
|
||
describe("resolveTimelineResize", () => {
|
||
it("shrinks clip duration from the right edge", () => {
|
||
expect(
|
||
resolveTimelineResize(
|
||
{
|
||
start: 1,
|
||
duration: 3,
|
||
originClientX: 100,
|
||
pixelsPerSecond: 100,
|
||
minStart: 0,
|
||
maxEnd: 10,
|
||
},
|
||
"end",
|
||
40,
|
||
),
|
||
).toEqual({ start: 1, duration: 2.4, playbackStart: undefined });
|
||
});
|
||
|
||
it("trims media from the left edge by advancing playback start and clip start", () => {
|
||
expect(
|
||
resolveTimelineResize(
|
||
{
|
||
start: 1,
|
||
duration: 3,
|
||
originClientX: 100,
|
||
pixelsPerSecond: 100,
|
||
minStart: 0,
|
||
maxEnd: 10,
|
||
playbackStart: 0.5,
|
||
playbackRate: 1,
|
||
},
|
||
"start",
|
||
150,
|
||
),
|
||
).toEqual({ start: 1.5, duration: 2.5, playbackStart: 1 });
|
||
});
|
||
|
||
it("can seed front trim from an implicit zero playback start", () => {
|
||
expect(
|
||
resolveTimelineResize(
|
||
{
|
||
start: 0,
|
||
duration: 8,
|
||
originClientX: 100,
|
||
pixelsPerSecond: 100,
|
||
minStart: 0,
|
||
maxEnd: 8,
|
||
playbackStart: 0,
|
||
playbackRate: 1,
|
||
},
|
||
"start",
|
||
200,
|
||
),
|
||
).toEqual({ start: 1, duration: 7, playbackStart: 1 });
|
||
});
|
||
|
||
it("prevents extending media left past available source before media-start", () => {
|
||
expect(
|
||
resolveTimelineResize(
|
||
{
|
||
start: 1,
|
||
duration: 3,
|
||
originClientX: 100,
|
||
pixelsPerSecond: 100,
|
||
minStart: 0,
|
||
maxEnd: 10,
|
||
playbackStart: 0.2,
|
||
playbackRate: 1,
|
||
},
|
||
"start",
|
||
0,
|
||
),
|
||
).toEqual({ start: 0.8, duration: 3.2, playbackStart: 0 });
|
||
});
|
||
|
||
it("trims generic element start without media offset", () => {
|
||
expect(
|
||
resolveTimelineResize(
|
||
{
|
||
start: 2,
|
||
duration: 4,
|
||
originClientX: 100,
|
||
pixelsPerSecond: 100,
|
||
minStart: 0,
|
||
maxEnd: 10,
|
||
},
|
||
"start",
|
||
200,
|
||
),
|
||
).toEqual({ start: 3, duration: 3, playbackStart: undefined });
|
||
});
|
||
|
||
it("extends generic element start leftward to time zero", () => {
|
||
expect(
|
||
resolveTimelineResize(
|
||
{
|
||
start: 1,
|
||
duration: 3,
|
||
originClientX: 100,
|
||
pixelsPerSecond: 100,
|
||
minStart: 0,
|
||
maxEnd: 10,
|
||
},
|
||
"start",
|
||
-200,
|
||
),
|
||
).toEqual({ start: 0, duration: 4, playbackStart: undefined });
|
||
});
|
||
});
|
||
|
||
describe("buildPromptCopyText", () => {
|
||
it("returns a trimmed prompt for the copy-prompt action", () => {
|
||
expect(buildPromptCopyText(" Tighten the headline timing ")).toBe(
|
||
"Tighten the headline timing",
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("snapKeyframePctToBeat", () => {
|
||
// el spans 0–10s, so clip-% maps to composition time as pct * 0.1s.
|
||
// At pps=100 the snap window is 8 / 100 = 0.08s.
|
||
const el = { start: 0, duration: 10 };
|
||
const beats = [2, 5, 8];
|
||
|
||
it("snaps a keyframe within ~8px of a beat exactly onto it", () => {
|
||
// pct 50.5 → 5.05s, 0.05s from the beat at 5s (inside 0.08s window) → 50%.
|
||
expect(snapKeyframePctToBeat(el, 50.5, beats, 100)).toBe(50);
|
||
});
|
||
|
||
it("leaves a keyframe unchanged when no beat is within the window", () => {
|
||
// pct 55 → 5.5s, 0.5s from the nearest beat → free.
|
||
expect(snapKeyframePctToBeat(el, 55, beats, 100)).toBe(55);
|
||
});
|
||
|
||
it("is a no-op when there are no beats", () => {
|
||
expect(snapKeyframePctToBeat(el, 50.5, [], 100)).toBe(50.5);
|
||
expect(snapKeyframePctToBeat(el, 50.5, undefined, 100)).toBe(50.5);
|
||
});
|
||
|
||
it("is a no-op for a zero-duration clip", () => {
|
||
expect(snapKeyframePctToBeat({ start: 0, duration: 0 }, 50.5, beats, 100)).toBe(50.5);
|
||
});
|
||
|
||
it("widens the snap window as zoom (pps) decreases", () => {
|
||
// pct 53 → 5.3s, 0.3s from the beat at 5s. At pps=20 the window is 0.4s → snaps to 50%.
|
||
expect(snapKeyframePctToBeat(el, 53, beats, 20)).toBe(50);
|
||
});
|
||
});
|