feat(studio): preview timeline track insertion (#4907)

* feat(studio): preview new tracks at drop boundaries

* fix(studio): preserve group ownership across overlapping moves

* refactor(studio): share timeline drop decisions and fixtures

* fix(studio): open an empty audio zone below visual tracks

* fix(studio): cancel thumbnails when project pages leave

* refactor(studio): share thumbnail regression setup

* test(studio): gate thumbnail socket release in browser CI

* chore(studio): keep optimistic revision comments within ratchet

* fix(studio): pin regression proxy requests to its backend

* test(studio): bound the proxy origin probe

* test(studio): probe proxy origin through the ready document
This commit is contained in:
Miguel Ángel
2026-10-02 08:04:29 -07:00
committed by GitHub
parent f2eb6d9cc1
commit 67dbcf7bdb
50 changed files with 2278 additions and 290 deletions
+4 -2
View File
@@ -958,7 +958,7 @@ jobs:
node packages/studio/tests/e2e/perf-ratchet.mjs check \
packages/studio/tests/e2e/perf-ceilings.json studio-open /tmp/studio-open-counts.json \
/tmp/base-perf-ceilings.json
- name: Measure the fade handle tabs and click every Renders select
- name: Check fade handles, render selects and thumbnail connection release
if: success() || failure()
run: |
set -euo pipefail
@@ -973,12 +973,14 @@ jobs:
echo "FAIL: studio dev server did not start on port 5316"
exit 1
fi
# Both checks run even when the first fails.
# All checks run even when an earlier one fails.
rc=0
STUDIO_URL="http://localhost:5316/#project/fade-handles" \
node packages/studio/tests/e2e/fade-handles.mjs || rc=1
STUDIO_URL="http://localhost:5316/#project/fade-handles?tab=renders" \
node packages/studio/tests/e2e/render-selects.mjs || rc=1
STUDIO_URL="http://localhost:5316/" \
node packages/studio/tests/e2e/thumbnail-save.mjs || rc=1
exit "$rc"
- name: Measure the caret of a text edited in place
if: success() || failure()
@@ -0,0 +1,67 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>New-track membership contract</title><style>
:root{color-scheme:light dark;
--bg:#fbfbfa;--card:#ffffff;--line:#e6e4df;--ink:#1c1c1a;--dim:#6b6b66;--faint:#9a9a94;
--go:#178a5a;--go-bg:#e8f6ee;--warn:#a6690a;--warn-bg:#fbf1dc;--bad:#c0392b;--bad-bg:#fbe7e4;--info:#2a5db0;--info-bg:#e8eefb;
--mono:ui-monospace,SFMono-Regular,Menlo,monospace;--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif}
@media (prefers-color-scheme:dark){:root{--bg:#111213;--card:#191b1d;--line:#2a2d31;--ink:#ecebe8;--dim:#a2a29c;--faint:#6f6f6a;
--go:#5fd39a;--go-bg:#12291f;--warn:#e6b35a;--warn-bg:#2b2311;--bad:#ff7b6b;--bad-bg:#2d1714;--info:#7fa9ff;--info-bg:#15203a}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.55 var(--sans)}
.wrap{max-width:900px;margin:0 auto;padding:56px 24px 96px}
header h1{font-size:30px;line-height:1.15;letter-spacing:-.4px;margin:0 0 10px}
header .what{font-size:18px;color:var(--dim);margin:0 0 18px;max-width:70ch}
.strip{display:flex;gap:10px;flex-wrap:wrap;align-items:center;font:13px var(--mono);color:var(--faint)}
.strip .sep{opacity:.5}
.pill{display:inline-flex;align-items:center;gap:6px;font:600 12px/1 var(--mono);padding:6px 10px;border-radius:999px;white-space:nowrap}
.pill.go{color:var(--go);background:var(--go-bg)}.pill.warn{color:var(--warn);background:var(--warn-bg)}
.pill.bad{color:var(--bad);background:var(--bad-bg)}.pill.info{color:var(--info);background:var(--info-bg)}
.pill b{font-size:13px}
section{margin-top:56px}
h2{font-size:22px;letter-spacing:-.3px;margin:0 0 6px}
.sub{color:var(--dim);margin:0 0 18px;font-size:15px}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px 22px;margin:14px 0}
.breath{border-left:4px solid var(--go);font-size:18px;line-height:1.5}
.breath b{color:var(--go)}
.plain{color:var(--dim);font-size:15px;margin:0 0 10px}
.plain::before{content:"In plain words ";font:600 11px var(--mono);letter-spacing:1px;color:var(--faint);text-transform:uppercase}
pre{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:14px 16px;overflow:auto;font:13px/1.55 var(--mono);margin:10px 0}
code{font:13px var(--mono);background:var(--bg);border:1px solid var(--line);border-radius:5px;padding:1px 6px}
.cite{font:12px var(--mono);color:var(--faint);margin-top:8px;display:block}
.states{display:flex;gap:8px;margin:12px 0 4px;flex-wrap:wrap}
table{width:100%;border-collapse:collapse;margin:10px 0;font-size:14.5px}
th{text-align:left;font:600 11px/1.4 var(--mono);text-transform:uppercase;letter-spacing:.7px;color:var(--faint);border-bottom:1px solid var(--line);padding:8px 10px}
td{padding:10px;border-bottom:1px solid var(--line);vertical-align:top}
tr:last-child td{border-bottom:0}
td.k{font:13px var(--mono);white-space:nowrap}
.decision{border-left:4px solid var(--info)}
.decision h3{margin:0 0 8px;font-size:17px}
.decision .ask{color:var(--info);font-weight:600}
blockquote{margin:12px 0;padding:8px 0 8px 16px;border-left:2px solid var(--line);color:var(--dim);font-style:italic;font-size:14.5px}
blockquote .src{display:block;font-style:normal;font:12px var(--mono);color:var(--faint);margin-top:6px}
ul,ol{padding-left:22px}li{margin:6px 0}
.flow{display:flex;flex-wrap:wrap;gap:10px;align-items:center;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px;margin:14px 0;overflow:auto}
.flow span{border:1px solid var(--line);padding:12px;border-radius:8px}
footer{margin-top:72px;padding-top:16px;border-top:1px solid var(--line);font:12px/1.7 var(--mono);color:var(--faint)}
</style></head><body><main class="wrap"><header><h1>New-track drops leave an audio group</h1><p class="what">A clip lands exactly where the ghost showed. One undo restores its track and membership.</p><div class="strip"><span class="pill go">audited 5</span><span class="pill warn">trusting 0</span><span class="pill bad">not exercised 1</span><span class="pill info">open decisions 0</span><span>hyperframes @ de13693179f53c3f625b7b501f1f5fc7b083b6c9, working changes, 2026-10-02</span></div></header>
<section><div class="card breath"><b>In one breath.</b> At the audited base, the move transaction saved track and timing but dropped membership before persistence. Detachment must travel through that transaction, rather than become a second save.</div></section>
<section><h2>Where the value travels</h2><div class="flow" aria-label="One transaction carries drop fields into undo"><span>Drop edits</span> → <span>Move adapter</span> → <span>Source patch</span> → <span>File + history</span> → <span>Undo restore</span></div></section>
<section><h2>The contract, per boundary</h2>
<div class="card"><h3>Move payload</h3><p class="plain">At the audited base, membership was absent from the move payload. The adapter explicitly selects only these fields.</p><pre>edits.map(({ element, updates }) =&gt; ({
element,
start: updates.start,
track: operation === "timing" ? undefined : updates.track,
}))</pre><span class="cite">producer src/player/components/timelineClipDragCommit.ts:153; consumer src/hooks/timelineMoveAdapter.ts:37. Paths relative to packages/studio.</span><div class="states"><span class="pill bad">membership: absent</span><span class="pill bad">reachable: no</span><span class="pill bad">written: no</span></div></div>
<div class="card"><h3>Atomic source edit</h3><p class="plain">The group move turns all clip changes into source patches inside one queued file transaction. Track changes bypass the timing-only SDK path.</p><pre>buildTimelineMoveTimingPatch(
original,
target,
toAuthoredStart(change.element, change.start),
change.element.duration,
change.track,
)</pre><span class="cite">producer src/hooks/useTimelineGroupEditing.ts:311; consumer src/hooks/timelineEditingHelpers.ts:235. SDK eligibility src/hooks/useTimelineGroupEditing.ts:299.</span><div class="states"><span class="pill go">track: exists</span><span class="pill go">reachable</span><span class="pill go">written</span></div></div>
<div class="card"><h3>Undo owns whole-file snapshots</h3><p class="plain">One transaction records the complete before and after source. Including membership in the same patch gives undo its original value.</p><pre>snapshots[path] = { before, after };
await recordEdit({ label, coalesceKey, coalesceMs, files: snapshots });</pre><span class="cite">producer src/utils/studioFileHistory.ts:128,144; undo consumer src/hooks/usePersistentEditHistory.ts:81,99; preview sync consumer src/hooks/useEditHistoryActions.ts:112.</span><div class="states"><span class="pill go">snapshots: exist</span><span class="pill go">reachable</span><span class="pill go">written and restored</span></div></div></section>
<section><h2>How it says no</h2><table><tr><th>Signal</th><th>Meaning and response</th></tr><tr><td>Locked shifted clip</td><td>Insertion returns without persistence. Existing permission rules remain the owner.</td></tr><tr><td>Save rejection</td><td>The file transaction restores already-written snapshots and rethrows. The optimistic clip rollback must restore membership alongside timing and track.</td></tr><tr><td>Undo content mismatch</td><td>History refuses to overwrite a file changed since the edit and reports the conflict.</td></tr></table></section>
<section><h2>Decision already supplied</h2><div class="card decision">New tracks outside a group detach the dragged clip. Drops onto member tracks retain existing behavior. Cmd+Z restores membership. There is no open product decision.</div></section>
<section><h2>Coverage, honestly</h2><table><tr><th>Label</th><th>Coverage</th></tr><tr><td>Audited</td><td>Drop edits, adapter, group move, source patch, snapshot and undo consumers opened at the named base.</td></tr><tr><td>Not exercised</td><td>This page records the pre-implementation audit. Runtime proof is reported separately against the implementation head.</td></tr></table></section>
<section><h2>What would prove this page wrong</h2><p>Drop a grouped clip at a group edge, reload the file, then Cmd+Z. The clip must lose membership after the drop, occupy the promised lane, and regain its original track and membership in one undo. A member-track drop must retain membership.</p></section></main></body></html>
@@ -0,0 +1,59 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Thumbnail document lifetime</title><style>
:root{color-scheme:light dark;
--bg:#fbfbfa;--card:#ffffff;--line:#e6e4df;--ink:#1c1c1a;--dim:#6b6b66;--faint:#9a9a94;
--go:#178a5a;--go-bg:#e8f6ee;--warn:#a6690a;--warn-bg:#fbf1dc;--bad:#c0392b;--bad-bg:#fbe7e4;--info:#2a5db0;--info-bg:#e8eefb;
--mono:ui-monospace,SFMono-Regular,Menlo,monospace;--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif}
@media (prefers-color-scheme:dark){:root{--bg:#111213;--card:#191b1d;--line:#2a2d31;--ink:#ecebe8;--dim:#a2a29c;--faint:#6f6f6a;
--go:#5fd39a;--go-bg:#12291f;--warn:#e6b35a;--warn-bg:#2b2311;--bad:#ff7b6b;--bad-bg:#2d1714;--info:#7fa9ff;--info-bg:#15203a}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.55 var(--sans)}
.wrap{max-width:900px;margin:0 auto;padding:56px 24px 96px}
header h1{font-size:30px;line-height:1.15;letter-spacing:-.4px;margin:0 0 10px}
header .what{font-size:18px;color:var(--dim);margin:0 0 18px;max-width:70ch}
.strip{display:flex;gap:10px;flex-wrap:wrap;align-items:center;font:13px var(--mono);color:var(--faint)}
.strip .sep{opacity:.5}
.pill{display:inline-flex;align-items:center;gap:6px;font:600 12px/1 var(--mono);padding:6px 10px;border-radius:999px;white-space:nowrap}
.pill.go{color:var(--go);background:var(--go-bg)}.pill.warn{color:var(--warn);background:var(--warn-bg)}
.pill.bad{color:var(--bad);background:var(--bad-bg)}.pill.info{color:var(--info);background:var(--info-bg)}
.pill b{font-size:13px}
section{margin-top:56px}
h2{font-size:22px;letter-spacing:-.3px;margin:0 0 6px}
.sub{color:var(--dim);margin:0 0 18px;font-size:15px}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px 22px;margin:14px 0}
.breath{border-left:4px solid var(--go);font-size:18px;line-height:1.5}
.breath b{color:var(--go)}
.plain{color:var(--dim);font-size:15px;margin:0 0 10px}
.plain::before{content:"In plain words ";font:600 11px var(--mono);letter-spacing:1px;color:var(--faint);text-transform:uppercase}
pre{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:14px 16px;overflow:auto;font:13px/1.55 var(--mono);margin:10px 0}
code{font:13px var(--mono);background:var(--bg);border:1px solid var(--line);border-radius:5px;padding:1px 6px}
.cite{font:12px var(--mono);color:var(--faint);margin-top:8px;display:block}
.states{display:flex;gap:8px;margin:12px 0 4px;flex-wrap:wrap}
table{width:100%;border-collapse:collapse;margin:10px 0;font-size:14.5px}
th{text-align:left;font:600 11px/1.4 var(--mono);text-transform:uppercase;letter-spacing:.7px;color:var(--faint);border-bottom:1px solid var(--line);padding:8px 10px}
td{padding:10px;border-bottom:1px solid var(--line);vertical-align:top}
tr:last-child td{border-bottom:0}
td.k{font:13px var(--mono);white-space:nowrap}
.decision{border-left:4px solid var(--info)}
.decision h3{margin:0 0 8px;font-size:17px}
.decision .ask{color:var(--info);font-weight:600}
blockquote{margin:12px 0;padding:8px 0 8px 16px;border-left:2px solid var(--line);color:var(--dim);font-style:italic;font-size:14.5px}
blockquote .src{display:block;font-style:normal;font:12px var(--mono);color:var(--faint);margin-top:6px}
ul,ol{padding-left:22px}li{margin:6px 0}
.flow{display:flex;flex-wrap:wrap;gap:10px;align-items:center;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px;margin:14px 0;overflow:auto}
.flow span{border:1px solid var(--line);padding:12px;border-radius:8px}
footer{margin-top:72px;padding-top:16px;border-top:1px solid var(--line);font:12px/1.7 var(--mono);color:var(--faint)}
</style></head><body><main class="wrap"><header><h1>Thumbnail connections leave with their document</h1><p class="what">Saves must not queue behind media from a document the person already left.</p><div class="strip">2026-10-02 · source 0337620 · audited 4 · trusting 0 · not exercised 1 · decisions 0</div></header>
<section><h2>In one breath</h2><div class="card breath">The scheduler bounds work in one document, but old documents retain thumbnail connections after navigation; the lifecycle actor must abort that work on pagehide and resume retained leases on pageshow.</div></section>
<section><h2>Where the value travels</h2><div class="flow"><span>pagehide / pageshow</span> → <span>One lifecycle actor</span> → <span>Thumbnail scheduler</span> → <span>AbortSignal</span> → <span>fetch / media decoder</span></div></section>
<section><h2>The verified boundaries</h2><div class="card"><h3>Scheduler owns work</h3><pre>entry.controller = new AbortController();
entry.request.load(controller.signal)</pre><span class="cite">Producer packages/studio/src/player/lib/thumbnailScheduler.ts:291-301; consumer packages/studio/src/player/components/CompositionThumbnail.tsx:104-112.</span><p>The controller exists, is passed to the actual fetch, and is already aborted when the final lease leaves. Document navigation currently has no scheduler actor.</p></div>
<div class="card"><h3>Abort and retain</h3><pre>entry.preempted = true;
entry.controller?.abort();</pre><span class="cite">Producer thumbnailScheduler.ts:228-231; consumer catch/requeue/pump at :301-320 and :344-351. Paths relative to packages/studio/src/player/lib.</span><p>Existing preview reload preempts composition jobs and retains leases. Pagehide and document visibility suspension must be a separate cause: pageshow cannot release a preview-reload hold. While suspended, no queued thumbnail kind may start.</p></div>
<div class="card"><h3>The receiving HTTP route</h3><pre>await thumbnailGenerationCoordinator.acquire(
cachePath, c.req.raw.signal, async (signal) =&gt; adapter.generateThumbnail({ signal, ... })
)</pre><span class="cite">packages/studio-server/src/routes/thumbnail.ts:227-244; client fetch signal at CompositionThumbnail.tsx:108-110.</span><p>Client abort releases its browser connection. Server cancellation propagation is a separate host concern; the dev HTTP bridge must forward response disconnects into that signal so queued renders lose their leases. The endpoint is a one-shot image response, not a stream or long poll. Its serial queue had no server deadline; the existing client deadline is 30 seconds, but old-document timers may stop after navigation. Lifecycle abort plus propagated disconnect removes abandoned work instead of waiting for that timer.</p></div></section>
<section><h2>How it says no</h2><table><tr><th>Signal</th><th>Meaning</th></tr><tr><td>AbortError</td><td>Requested cancellation; retained leases return to the queue. No thumbnail error state.</td></tr><tr><td>Document hidden</td><td>Abort active work and hold all queued thumbnail kinds.</td></tr><tr><td>Preview reload remains active</td><td>Pageshow permits other thumbnail kinds, but composition work retains its existing hold.</td></tr></table></section>
<section><h2>Decision already supplied</h2><div class="card">Saves never queue behind media. Fix the held connections, preserve a tight persistence bound. No new product decision.</div></section>
<section><h2>Coverage, honestly</h2><p>Audited: scheduler, real composition fetch, preview-reload caller, server consumer. Runtime: Chrome NetLog shows all six sockets occupied by thumbnails, four from previous documents and two current, at a PUT stalled by the per-host socket limit. A real HTTP regression holds render responses open across hidden project documents and verifies a durable drop within 15 seconds. Removing the lifecycle binding fails that exact persistence assertion. Unit checks cover all thumbnail kinds, resumption, independent preview holds and actual HTTP disconnects.</p></section>
<section><h2>What would prove this page wrong</h2><p>Hold real thumbnail render responses open, leave two project documents, then drop a clip. Old-document render requests must cancel before the next document saves; the drop must persist within 15 seconds while current thumbnails remain unfinished. Cache-only poster reads return an image or 404 without entering the render queue. Retained leases must resume and preview-reload holds must survive pageshow.</p></section></main></body></html>
+1
View File
@@ -78,6 +78,7 @@
"test:edit-accuracy": "bun tests/e2e/edit-accuracy/run.mjs",
"test:filmstrip-controls": "node tests/e2e/filmstrip-controls.mjs",
"test:webmcp-edit-loop": "node tests/e2e/webmcp-edit-loop.mjs",
"test:thumbnail-save": "node tests/e2e/thumbnail-save.mjs",
"test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs",
"test:watch": "vitest",
"report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts",
@@ -238,6 +238,7 @@ export function buildTimelineMoveTimingPatch(
start: number,
duration: number,
track?: number,
audioGroup?: null,
): string {
if (!Number.isFinite(start) || !Number.isFinite(duration)) {
console.warn(
@@ -257,6 +258,12 @@ export function buildTimelineMoveTimingPatch(
value: formatTimelineAttributeNumber(track),
});
}
if (audioGroup === null)
patched = applyPatchByTarget(patched, target, {
type: "attribute",
property: "data-audio-group",
value: null,
});
// Content-driven duration: sync data-duration to the furthest clip end read
// from the PATCHED SOURCE (raw data-duration), so it grows if a clip moved
// past the end and shrinks if the furthest clip moved left. Measured from the
@@ -68,6 +68,18 @@ describe("persistTimelineMoveEditsAtomically", () => {
});
});
it("carries explicit detachment through the atomic move", async () => {
const edit = {
element: { ...element("voice", 1), audioGroup: "group" },
updates: { start: 1, track: 0, audioGroup: null },
};
const handle = await runMove([edit], "move", "track-insert");
expect(handle).toHaveBeenCalledWith(
[{ element: edit.element, start: 1, track: 0, audioGroup: null }],
{ coalesceKey: "move" },
);
});
it("rejects without retrying individual members when the atomic batch fails", async () => {
const failure = new Error("batch failed");
const handleTimelineGroupMove = vi.fn().mockRejectedValue(failure);
@@ -5,9 +5,13 @@ import type {
TimelineGroupMoveChange,
} from "./useTimelineGroupEditing";
export interface TimelineMoveEdit {
export type TimelineAtomicMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
audioGroup?: null;
};
export interface TimelineAtomicMoveEdit {
element: TimelineElement;
updates: Pick<TimelineElement, "start" | "track">;
updates: TimelineAtomicMoveUpdates;
}
interface AtomicMoveDeps {
@@ -20,14 +24,14 @@ interface AtomicMoveDeps {
export type TimelineMoveOperation = "timing" | "lane-reorder" | "track-insert";
export type TimelineMoveEditsHandler = (
edits: TimelineMoveEdit[],
edits: TimelineAtomicMoveEdit[],
coalesceKey?: string,
operation?: TimelineMoveOperation,
coalesceMs?: number,
) => Promise<void>;
export function persistTimelineMoveEditsAtomically(
edits: TimelineMoveEdit[],
edits: TimelineAtomicMoveEdit[],
coalesceKey: string | undefined,
operation: TimelineMoveOperation,
deps: AtomicMoveDeps,
@@ -37,6 +41,7 @@ export function persistTimelineMoveEditsAtomically(
edits.map(({ element, updates }) => ({
element,
start: updates.start,
audioGroup: updates.audioGroup,
// Stable track lanes: a lane is the authored data-track-index, so every
// vertical gesture (lane-reorder AND track-insert) must persist the track;
// z is paint order only and is synced separately. Plain horizontal moves
@@ -4,6 +4,7 @@ import { act } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Root } from "react-dom/client";
import type { TimelineElement } from "../player";
import { persistTimelineMoveEditsAtomically } from "./timelineMoveAdapter";
import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
import { installReactActEnvironment, mountReactHarness } from "./domSelectionTestHarness";
@@ -72,3 +73,79 @@ describe("useTimelineGroupEditing: handleTimelineGroupMove suppressFailureToast"
expect(showToast).toHaveBeenCalledTimes(1);
});
});
it.each([false, true])(
"keeps detachment and the lane atomic, including a refused history write ($0)",
async (refused) => {
const before =
'<main data-composition-id="root" data-duration="4"><audio id="voice" data-start="0" data-duration="2" data-track-index="1" data-audio-group="group"></audio></main>';
let disk = before;
const failure = new Error("history refused");
const recordEdit = refused ? vi.fn().mockRejectedValue(failure) : vi.fn();
const iframe = document.createElement("iframe");
document.body.append(iframe);
iframe.contentDocument!.body.innerHTML = before;
const writeProjectFile = vi.fn(async (_path: string, content: string) => {
disk = content;
});
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(JSON.stringify({ content: disk }), {
headers: { "content-type": "application/json" },
}),
),
);
let hook: ReturnType<typeof useTimelineGroupEditing>;
function Harness() {
hook = useTimelineGroupEditing({
activeCompPath: "index.html",
editQueueRef: { current: Promise.resolve() },
pendingTimelineEditPathRef: { current: new Set() },
previewIframeRef: { current: iframe },
projectIdRef: { current: "fixture" },
recordEdit,
writeProjectFile,
reloadPreview: vi.fn(),
showToast: vi.fn(),
});
return null;
}
const root = mountReactHarness(<Harness />);
try {
await act(async () => {
const moved = persistTimelineMoveEditsAtomically(
[
{
element: { ...el("voice", 0, 2, 1), tag: "audio", audioGroup: "group" },
updates: { start: 0, track: 0, audioGroup: null },
},
],
"insert",
"track-insert",
{ handleTimelineGroupMove: hook!.handleTimelineGroupMove },
);
if (refused) await expect(moved).rejects.toBe(failure);
else await moved;
});
expect(
iframe.contentDocument!.getElementById("voice")?.getAttribute("data-audio-group"),
).toBe("group");
if (refused) {
expect(disk).toBe(before);
expect(writeProjectFile).toHaveBeenCalledTimes(2);
return;
}
expect(writeProjectFile).toHaveBeenCalledOnce();
expect(recordEdit).toHaveBeenCalledOnce();
expect(disk).not.toContain("data-audio-group");
expect(disk).toContain('data-track-index="0"');
expect(recordEdit.mock.calls[0][0].files["index.html"]).toEqual({ before, after: disk });
} finally {
act(() => root.unmount());
iframe.remove();
vi.unstubAllGlobals();
}
},
);
@@ -37,6 +37,7 @@ export interface TimelineGroupMoveChange {
element: TimelineElement;
start: number;
track?: number;
audioGroup?: null;
}
export interface TimelineGroupResizeChange {
@@ -296,7 +297,9 @@ export function useTimelineGroupEditing({
sdkChanges: toSdkTimingChanges(changes, (change) => ({
start: toAuthoredStart(change.element, change.start),
})),
eligible: changes.every((change) => change.track == null),
eligible: changes.every(
(change) => change.track == null && change.audioGroup === undefined,
),
needsExtension,
label,
coalesceKey,
@@ -315,6 +318,7 @@ export function useTimelineGroupEditing({
toAuthoredStart(change.element, change.start),
change.element.duration,
change.track,
change.audioGroup,
),
})),
coalesceKey,
+4
View File
@@ -7,8 +7,12 @@ import { StudioErrorBoundary } from "./components/StudioErrorBoundary";
import { readIconTokens } from "./styles/iconTokens";
import { trackStudioEvent } from "./utils/studioTelemetry";
import { prefetchPreviewForHash } from "./utils/previewPrefetch";
import { bindThumbnailPageLifecycle } from "./player/lib/thumbnailPageLifecycle";
import "./styles/studio.css";
const unbindThumbnailPageLifecycle = bindThumbnailPageLifecycle(window, document);
import.meta.hot?.dispose(unbindThumbnailPageLifecycle);
prefetchPreviewForHash(window.location.hash);
window.addEventListener("hashchange", () => prefetchPreviewForHash(window.location.hash));
trackStudioEvent("session_start");
@@ -1,14 +1,14 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { act } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Timeline } from "./Timeline";
import {
mountThreeTrackTimeline as mountThreeTracks,
dragTimelineFixtureAsset,
pointerTimelineFixture as pointer,
} from "./timelineMountFixtures";
import { installTimelineMountEnv } from "./timelineMountTestEnv";
import { usePlayerStore } from "../store/playerStore";
import { CLIP_Y, TRACK_H, getTimelineRowTop } from "./timelineLayout";
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
import type { TimelineProps } from "./TimelineTypes";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -21,32 +21,6 @@ afterEach(() => {
const GHOST_MID = getTimelineRowTop(3) + TRACK_H / 2;
const ROW1_MID = getTimelineRowTop(1) + TRACK_H / 2;
async function mountThreeTracks(props: TimelineProps = {}) {
usePlayerStore.setState({
duration: 10,
currentTime: 0,
timelineReady: true,
gsapAnimations: new Map(),
selectedElementId: null,
selectedElementIds: new Set(),
elements: [0, 1, 2].map((track) => ({
id: `c${track}`,
key: `c${track}`,
domId: `c${track}`,
tag: "div",
start: 0,
duration: 2,
track,
})),
});
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
await act(async () => root.render(<Timeline {...props} />));
const ghost = () => host.querySelector<HTMLElement>('[data-testid="timeline-ghost-lane"]');
return { host, root, ghost };
}
describe("Timeline ghost lane", () => {
it("draws one empty track below the last with a drop hint", async () => {
const { root, ghost } = await mountThreeTracks({ onAssetDrop: vi.fn() });
@@ -70,22 +44,8 @@ describe("Timeline ghost lane", () => {
const onAssetDrop = vi.fn();
const { host, root, ghost } = await mountThreeTracks({ onAssetDrop });
const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
const drag = (type: string, clientY: number) => {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperties(event, {
clientX: { value: 400 },
clientY: { value: clientY },
dataTransfer: {
value: {
types: [TIMELINE_ASSET_MIME],
files: [],
dropEffect: "none",
getData: (mime: string) => (mime === TIMELINE_ASSET_MIME ? '{"path":"a.png"}' : ""),
},
},
});
act(() => void viewport.dispatchEvent(event));
};
const drag = (type: string, clientY: number) =>
dragTimelineFixtureAsset(viewport, type, clientY);
drag("dragover", ROW1_MID);
expect(ghost()?.dataset.active).toBeUndefined();
drag("dragover", GHOST_MID);
@@ -100,19 +60,6 @@ describe("Timeline ghost lane", () => {
onMoveElement: vi.fn(),
onResizeElement: vi.fn(),
});
const pointer = (target: EventTarget, type: string, clientY: number) =>
act(
() =>
void target.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
button: 0,
pointerId: 1,
clientX: 400,
clientY,
}),
),
);
const clipMid = getTimelineRowTop(track) + TRACK_H / 2;
pointer(host.querySelector(`[data-clip][data-el-id="c${track}"]`)!, "pointerdown", clipMid);
pointer(window, "pointermove", ROW1_MID);
@@ -7,7 +7,11 @@ import { Timeline } from "./Timeline";
import { installTimelineMountEnv } from "./timelineMountTestEnv";
import { usePlayerStore } from "../store/playerStore";
import { CLIP_Y, RULER_H, TRACK_H, TRACKS_BOTTOM_PAD, TRACKS_TOP_PAD } from "./timelineLayout";
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
import {
mountThreeTrackTimeline,
dragTimelineFixtureAsset,
pointerTimelineFixture as pointer,
} from "./timelineMountFixtures";
import type { TimelineProps } from "./TimelineTypes";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -59,29 +63,7 @@ describe("Timeline trackPadding", () => {
const hostPads = { top: 0, bottom: TRACK_H };
async function mountThreeTracks(props: TimelineProps = {}) {
usePlayerStore.setState({
duration: 10,
currentTime: 0,
timelineReady: true,
gsapAnimations: new Map(),
selectedElementId: null,
selectedElementIds: new Set(),
elements: [0, 1, 2].map((track) => ({
id: `c${track}`,
key: `c${track}`,
domId: `c${track}`,
tag: "div",
start: 0,
duration: 2,
track,
})),
});
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
await act(async () => root.render(<Timeline trackPadding={hostPads} {...props} />));
const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
return { host, root, viewport };
return mountThreeTrackTimeline({ trackPadding: hostPads, ...props });
}
const top = (el: Element | null | undefined) => (el as HTMLElement | null)?.style.top;
@@ -94,22 +76,8 @@ describe("Timeline trackPadding", () => {
`${RULER_H + TRACK_H + CLIP_Y}px`,
);
const drag = (type: string, clientY: number) => {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperties(event, {
clientX: { value: 400 },
clientY: { value: clientY },
dataTransfer: {
value: {
types: [TIMELINE_ASSET_MIME],
files: [],
dropEffect: "none",
getData: (mime: string) => (mime === TIMELINE_ASSET_MIME ? '{"path":"a.png"}' : ""),
},
},
});
act(() => void viewport.dispatchEvent(event));
};
const drag = (type: string, clientY: number) =>
dragTimelineFixtureAsset(viewport, type, clientY);
drag("dragover", ROW1_MID);
expect(top(host.querySelector('[data-testid="timeline-drop-preview"]'))).toBe(
`${RULER_H + TRACK_H + CLIP_Y}px`,
@@ -128,19 +96,6 @@ describe("Timeline trackPadding", () => {
onMoveElement: vi.fn(),
onResizeElement: vi.fn(),
});
const pointer = (target: EventTarget, type: string, clientY: number) =>
act(
() =>
void target.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
button: 0,
pointerId: 1,
clientX: 400,
clientY,
}),
),
);
const clip = host.querySelector('[data-clip][data-el-id="c0"]')!;
pointer(clip, "pointerdown", RULER_H + TRACK_H / 2);
pointer(window, "pointermove", ROW1_MID);
@@ -149,9 +104,9 @@ describe("Timeline trackPadding", () => {
).find((el) => el.style.zIndex === "30" && !el.dataset.testid);
expect(top(ghost)).toBe(`${RULER_H + TRACK_H + CLIP_Y}px`);
pointer(window, "pointermove", RULER_H + 3 * TRACK_H + TRACK_H / 2);
expect(top(host.querySelector('[data-testid="timeline-insert-line"]'))).toBe(
`${RULER_H + 3 * TRACK_H - 0.5}px`,
);
const lane = host.querySelector<HTMLElement>("[data-timeline-new-track-lane]");
expect(top(lane)).toBe(`${RULER_H + 3 * TRACK_H}px`);
expect(lane?.style.height).toBe(`${TRACK_H}px`);
act(() => root.unmount());
});
});
@@ -16,7 +16,9 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
const props = state.canvas;
const { draggedClip, scrollRef, displayTrackOrder } = props;
const draggedRowIndex =
draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1;
draggedClip?.started === true
? (draggedClip.insertRow ?? displayTrackOrder.indexOf(draggedClip.previewTrack))
: -1;
const dropTrackIndex = props.dropPreview
? displayTrackOrder.indexOf(props.dropPreview.track)
: -1;
@@ -168,7 +170,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
{/* Insertion line — a new track will be inserted at this boundary on drop.
Shown while the pointer is near a lane boundary (insert mode). */}
{insertLineRow != null && (
{insertLineRow != null && !draggedClip?.started && (
<div
data-testid="timeline-insert-line"
className="absolute pointer-events-none"
@@ -211,6 +213,8 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
scrollRef={scrollRef}
pixelsPerSecond={props.pps}
rowHeight={draggedClipHeight}
rowGeometry={props.rowGeometry}
contentOrigin={props.contentOrigin}
selectedElementId={props.selectedElementId}
currentTime={props.currentTime}
theme={props.theme}
@@ -141,6 +141,12 @@ export const TimelineClip = memo(function TimelineClip({
tabIndex={isGestureActor ? undefined : tabIndex}
aria-label={`${displayLabel}, ${startLabel} to ${endLabel} seconds`}
aria-pressed={isGestureActor ? undefined : isSelected}
aria-keyshortcuts={isGestureActor || !capabilities.canMove ? undefined : "Space"}
aria-description={
isGestureActor || !capabilities.canMove
? undefined
: "Space picks up. Up and Down choose a new track. Enter drops. Escape cancels."
}
className={clipClassName}
style={style}
title={
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { defaultTimelineTheme } from "./timelineTheme";
import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
import type { DraggedClipState } from "./timelineClipDragTypes";
import { createTimelineRowGeometry } from "./timelineLayout";
import { getTrackStyle } from "./useTimelineTrackLayout";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
@@ -32,6 +33,40 @@ const drag: DraggedClipState = {
afterEach(() => document.body.replaceChildren());
describe("TimelineGestureOverlay", () => {
it("anchors an insert actor inside the opened seam at its drop time", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() =>
root.render(
<TimelineGestureOverlay
drag={{ ...drag, insertRow: 1 }}
scrollRef={{
current: {
scrollLeft: 0,
scrollTop: 0,
getBoundingClientRect: () => ({ left: 0, top: 0 }),
} as HTMLDivElement,
}}
pixelsPerSecond={100}
contentOrigin={232}
rowGeometry={createTimelineRowGeometry([0, -1, 1], [48, 48, 48], { top: 0 })}
rowHeight={42}
selectedElementId="hero"
currentTime={4}
theme={defaultTimelineTheme}
getTrackStyle={getTrackStyle}
/>,
),
);
const actor = host.querySelector<HTMLElement>("[data-timeline-gesture-actor]");
expect(actor?.style.left).toBe("632px");
expect(actor?.style.top).toBe("75px");
expect(host.querySelector("[data-timeline-new-track-label]")?.textContent).toContain(
"+ New track",
);
act(() => root.unmount());
});
it("keeps the drag actor mounted without a source-row node", () => {
const host = document.createElement("div");
document.body.append(host);
@@ -47,6 +82,8 @@ describe("TimelineGestureOverlay", () => {
drag={drag}
scrollRef={{ current: scroll }}
pixelsPerSecond={100}
contentOrigin={232}
rowGeometry={createTimelineRowGeometry([0, 1, 2], [48, 48, 48])}
rowHeight={42}
selectedElementId="hero"
currentTime={4}
@@ -82,6 +119,8 @@ describe("TimelineGestureOverlay", () => {
} as HTMLDivElement,
}}
pixelsPerSecond={100}
contentOrigin={232}
rowGeometry={createTimelineRowGeometry([0, 1, 2], [48, 48, 48])}
rowHeight={42}
selectedElementId="hero"
currentTime={4}
@@ -109,6 +148,8 @@ describe("TimelineGestureOverlay", () => {
drag={null}
scrollRef={{ current: null }}
pixelsPerSecond={100}
contentOrigin={232}
rowGeometry={createTimelineRowGeometry([0, 1, 2], [48, 48, 48])}
rowHeight={42}
selectedElementId={null}
currentTime={0}
@@ -1,6 +1,7 @@
import { memo } from "react";
import type { TimelineTheme } from "./timelineTheme";
import { getRenderedTimelineElement } from "./timelineTheme";
import { CLIP_Y, type TimelineRowGeometry } from "./timelineLayout";
import { TimelineClip } from "./TimelineClip";
import { getTimelineEditCapabilities } from "./timelineEditing";
import { renderClipChildren } from "./timelineClipChildren";
@@ -15,6 +16,8 @@ interface TimelineGestureOverlayProps {
scrollRef: React.RefObject<HTMLDivElement | null>;
pixelsPerSecond: number;
rowHeight: number;
contentOrigin: number;
rowGeometry: TimelineRowGeometry;
selectedElementId: string | null;
currentTime: number;
theme: TimelineTheme;
@@ -29,6 +32,8 @@ export const TimelineGestureOverlay = memo(function TimelineGestureOverlay({
scrollRef,
pixelsPerSecond,
rowHeight,
contentOrigin,
rowGeometry,
selectedElementId,
currentTime,
theme,
@@ -36,30 +41,68 @@ export const TimelineGestureOverlay = memo(function TimelineGestureOverlay({
renderClipContent,
renderClipOverlay,
}: TimelineGestureOverlayProps) {
const element =
drag?.started === true
? getRenderedTimelineElement({
element: drag.element,
draggedElementId: drag.element.key ?? drag.element.id,
previewStart: drag.previewStart,
previewTrack: drag.previewTrack,
})
: null;
const position = drag ? getTimelineDragOverlayPosition(drag, scrollRef.current) : null;
if (!drag?.started)
return <div data-timeline-gesture-overlay className="absolute inset-0 pointer-events-none" />;
const element = getRenderedTimelineElement({
element: drag.element,
draggedElementId: drag.element.key ?? drag.element.id,
previewStart: drag.previewStart,
previewTrack: drag.previewTrack,
});
let position = getTimelineDragOverlayPosition(drag, scrollRef.current);
const insertRow = drag.insertRow;
if (insertRow !== null)
position = {
left: contentOrigin + drag.previewStart * pixelsPerSecond,
top: rowGeometry.getRowTop(insertRow) + CLIP_Y,
};
const clipWidth = Math.max(element.duration * pixelsPerSecond, 4);
const viewport = scrollRef.current;
let labelLeft = clipWidth + 10;
if (
position &&
viewport &&
position.left + labelLeft + 100 > viewport.scrollLeft + viewport.clientWidth
) {
labelLeft = -100;
if (position.left + labelLeft < viewport.scrollLeft + contentOrigin)
labelLeft = Math.max(0, Math.min(clipWidth - 100, drag.pointerOffsetX));
}
return (
<div data-timeline-gesture-overlay className="absolute inset-0 pointer-events-none">
{element && position && (
{position && (
<div
data-timeline-gesture-actor={element.key ?? element.id}
className="absolute"
style={{
top: position.top,
left: position.left,
width: Math.max(element.duration * pixelsPerSecond, 4),
width: clipWidth,
height: rowHeight,
zIndex: 40,
}}
>
{insertRow !== null && (
<span
data-timeline-new-track-label
role="status"
className="absolute whitespace-nowrap"
style={{
left: labelLeft,
top: (rowHeight - 22) / 2,
height: 22,
padding: "2px 8px",
borderRadius: 999,
fontSize: 11,
color: "var(--timeline-accent)",
background: theme.rowBackground,
border: "1px solid var(--timeline-accent)",
zIndex: 25,
}}
>
+ New track<span className="sr-only">, before track {insertRow + 1}</span>
</span>
)}
<TimelineClip
el={{ ...element, start: 0 }}
pps={pixelsPerSecond}
@@ -54,7 +54,6 @@ export function TimelineLanes({
pinnedClipIdentities,
trackOrder,
tracks,
trackStyles,
groups,
laneCounts,
selectedElementId,
@@ -197,9 +196,26 @@ export function TimelineLanes({
actorWindows,
)
: els;
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
const isPendingTrack =
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
if (isPendingTrack)
return (
<div
key={rowKey}
data-timeline-new-track-lane={row}
aria-hidden="true"
className={rowsVirtualized ? "absolute" : "relative"}
style={{
top: rowsVirtualized ? rowGeometry.getRowTop(row) : undefined,
marginLeft: contentOrigin,
width: trackContentWidth,
height: TRACK_H,
border: "1px dashed var(--timeline-accent)",
background: "color-mix(in srgb, var(--timeline-accent) 5%, transparent)",
pointerEvents: "none",
}}
/>
);
const rowBackground = theme.rowBackground;
const beatStripOnTrack = trackShowsBeatStrip(els, beatAnalysis?.beatTimes, {
selectedElementId,
@@ -343,20 +359,6 @@ export function TimelineLanes({
renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
/>
)}
{isPendingTrack && (
<div
className="absolute inset-0 flex items-center"
style={{
paddingLeft: 16,
color: ts.label,
fontSize: 11,
letterSpacing: "0.06em",
textTransform: "uppercase",
}}
>
New track
</div>
)}
{
// fallow-ignore-next-line complexity
renderElements.map((el) => {
@@ -1,8 +1,10 @@
// fallow-ignore-file code-duplication
// fallow-ignore-file dead-code
import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
import type { TimelineElement } from "../store/playerStore";
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
import type {
TimelineMoveOperation,
TimelineAtomicMoveUpdates,
} from "../../hooks/timelineMoveAdapter";
import type { BlockedTimelineEditIntent } from "./timelineEditing";
import type { PropertyGroupName } from "@hyperframes/core/gsap-parser";
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
@@ -50,7 +52,7 @@ export type TimelineLinkEdit =
export interface TimelineEditCallbacks {
onMoveElement?: (
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "track">,
updates: TimelineAtomicMoveUpdates,
) => Promise<void> | void;
/** Atomic multi-clip move (single undo) for main-track ripple + track-insert.
* `coalesceKey` (drag-commit gesture id) merges the move history entry with a
@@ -58,7 +60,7 @@ export interface TimelineEditCallbacks {
* widens that entry's fold window when a server round-trip separates the
* gesture's records (per-gesture-unique keys keep the fold gesture-scoped). */
onMoveElements?: (
edits: Array<{ element: TimelineElement; updates: Pick<TimelineElement, "start" | "track"> }>,
edits: Array<{ element: TimelineElement; updates: TimelineAtomicMoveUpdates }>,
coalesceKey?: string,
operation?: TimelineMoveOperation,
coalesceMs?: number,
@@ -1483,6 +1483,57 @@ describe("persistMoveEdits: a nested row dropped before its host", () => {
});
describe("persistMoveEdits convergence", () => {
it.each([
{ successor: "timing", detach: false, expectedGroup: "G" },
{ successor: "detach", detach: true, expectedGroup: undefined },
])(
"preserves newer $successor while rolling back a refused detach",
async ({ detach, expectedGroup }) => {
let current: TimelineElement = { ...el("grouped", 0, 0, 4), audioGroup: "G" };
let rejectDetach!: (error: Error) => void;
let resolveSuccessor!: () => void;
const refused = new Promise<void>((_resolve, reject) => {
rejectDetach = reject;
});
const successor = new Promise<void>((resolve) => {
resolveSuccessor = resolve;
});
const updateElement = (_key: string, updates: Partial<TimelineElement>) => {
current = { ...current, ...updates };
};
const deps = { elements: [current], trackOrder: [0, 1], updateElement };
const first = persistMoveEdits(
[{ element: current, updates: { start: 0, track: 1, audioGroup: null } }],
{ ...deps, onMoveElements: () => refused },
undefined,
"track-insert",
);
expect(current.audioGroup).toBeUndefined();
const second = persistMoveEdits(
[
{
element: current,
updates: { start: 5, track: 1, ...(detach ? { audioGroup: null } : {}) },
},
],
{ ...deps, onMoveElements: () => successor },
);
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
try {
rejectDetach(new Error("save refused"));
await expect(first).resolves.toBe(false);
expect(current).toMatchObject({ start: 5, track: 1 });
expect(current.audioGroup).toBe(expectedGroup);
resolveSuccessor();
await expect(second).resolves.toBe(true);
expect(current).toMatchObject({ start: 5, track: 1 });
expect(current.audioGroup).toBe(expectedGroup);
} finally {
consoleError.mockRestore();
}
},
);
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;
@@ -6,12 +6,17 @@ import type { DraggedClipState } from "./useTimelineClipDrag";
import type { ZMirrorLaneMove } from "./timelineZMirror";
import { classifyZone } from "./timelineZones";
import { layoutAfterTrackInsert } from "./timelineDragLanding";
import type { TimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
import {
canMoveTimelineElement as canMoveElement,
resolveExpandedHostAlias,
} from "./timelineAuthoredMoveTarget";
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
import type {
TimelineMoveOperation,
TimelineAtomicMoveUpdates,
TimelineAtomicMoveEdit,
} from "../../hooks/timelineMoveAdapter";
import {
beginTimelineOptimisticGesture,
isLatestTimelineOptimisticGesture,
@@ -21,10 +26,8 @@ import { refreshAfterDurableLaneMove } from "./timelineLaneMoveRefresh";
import { authoredTrackForLane } from "./timelineAuthoredTrack";
import { resolveGroupMovers } from "./timelineMultiDragPreview";
type StartTrack = Pick<TimelineElement, "start" | "track">;
export interface TimelineMoveEdit {
element: TimelineElement;
updates: StartTrack;
type StartTrack = TimelineAtomicMoveUpdates;
export interface TimelineMoveEdit extends TimelineAtomicMoveEdit {
/**
* File-space track override for the persist. The store's `updates.track` is a
* DISPLAY lane; when the source file's numbering is sparse (authored tracks
@@ -37,6 +40,7 @@ export interface TimelineMoveEdit {
export interface DragCommitDeps {
elements: TimelineElement[];
trackOrder: number[];
trackInsertLayout?: TimelineTrackInsertLayout;
updateElement: (key: string, updates: Partial<TimelineElement>) => void;
/** Single-clip, SDK-cutover-aware persist (pure time-moves keep this path). */
onMoveElement?: (element: TimelineElement, updates: StartTrack) => Promise<void> | void;
@@ -85,6 +89,17 @@ 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;
function moveStoreUpdates(edit: TimelineMoveEdit) {
const writtenTrack =
edit.persistTrack ??
(edit.updates.track !== edit.element.track ? edit.updates.track : undefined);
const { audioGroup, ...timing } = edit.updates;
return {
timing: writtenTrack == null ? timing : { ...timing, authoredTrack: writtenTrack },
detach: audioGroup === null,
};
}
/**
* Optimistically apply + persist a batch of moves with rollback on failure.
*
@@ -123,26 +138,42 @@ export function persistMoveEdits(
}
const prev = edits.map((e) => ({
key: keyOf(e.element),
start: e.element.start,
track: e.element.track,
authoredTrack: e.element.authoredTrack,
updates: {
start: e.element.start,
track: e.element.track,
authoredTrack: e.element.authoredTrack,
...(e.updates.audioGroup === null ? { audioGroup: e.element.audioGroup } : {}),
},
}));
const revision = beginTimelineOptimisticGesture(
updateElement,
edits.map((edit) => keyOf(edit.element)),
);
const membershipRevision = beginTimelineOptimisticGesture(
updateElement,
edits.filter((edit) => edit.updates.audioGroup === null).map((edit) => keyOf(edit.element)),
"membership",
);
// The file write below targets `persistTrack` (authored space) when supplied,
// or `updates.track` on a genuine lane write (track insert renumber). Mirror
// 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.
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 },
);
const applyEdit = (e: TimelineMoveEdit, reassert = false) => {
const { timing, detach } = moveStoreUpdates(e);
const key = keyOf(e.element);
const updates: Partial<TimelineElement> = {};
if (!reassert || isLatestTimelineOptimisticGesture(updateElement, revision, key)) {
Object.assign(updates, timing);
}
if (
detach &&
(!reassert ||
isLatestTimelineOptimisticGesture(updateElement, membershipRevision, key, "membership"))
) {
updates.audioGroup = undefined;
}
if (Object.keys(updates).length) updateElement(key, updates);
};
for (const e of edits) applyEdit(e);
// The store above gets DISPLAY lanes; the file below gets the authored-space
@@ -161,17 +192,21 @@ export function persistMoveEdits(
// 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);
}
for (const e of edits) applyEdit(e, true);
return true;
},
(error) => {
for (const p of prev) {
const { audioGroup, ...timing } = p.updates;
const updates: Partial<TimelineElement> = {};
if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
updateElement(p.key, { start: p.start, track: p.track, authoredTrack: p.authoredTrack });
Object.assign(updates, timing);
}
if (
isLatestTimelineOptimisticGesture(updateElement, membershipRevision, p.key, "membership")
)
updates.audioGroup = audioGroup;
if (Object.keys(updates).length) updateElement(p.key, updates);
}
console.error("[Timeline] Failed to persist clip edits", error);
return false;
@@ -370,7 +405,14 @@ function buildTrackInsertEdits(
let start = src.start;
if (normKey === editKey) start = previewStart;
else if (multi?.keys.has(normKey)) start = multi.movedStart(src);
edits.push({ element: src, updates: { start, track: norm.track } });
edits.push({
element: src,
updates: {
start,
track: norm.track,
...(normKey === editKey && src.audioGroup ? { audioGroup: null } : {}),
},
});
}
return { candidate, edits };
}
@@ -1,3 +1,4 @@
import type { TimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
import type { Dispatch, RefObject, SetStateAction } from "react";
import { resolveTimelineDragEscape } from "./timelineEditing";
import { commitDraggedClipMove } from "./timelineClipDragCommit";
@@ -37,6 +38,8 @@ interface TimelineGestureCommit {
type UpdateElement = ReturnType<typeof usePlayerStore.getState>["updateElement"];
interface TimelineClipDragGestureLifecycleInput {
commitGestureRef?: RefObject<() => void>;
trackInsertLayoutRef?: RefObject<TimelineTrackInsertLayout | undefined>;
lifecycleRef: RefObject<TimelineGestureLifecycle>;
sessionEpochRef: RefObject<number>;
cancelGestureRef: RefObject<
@@ -81,6 +84,8 @@ interface TimelineClipDragGestureLifecycleInput {
export function mountTimelineClipDragGestureLifecycle({
// The explicit destructuring mirrors the single call site's dependency object by design.
// fallow-ignore-next-line code-duplication
commitGestureRef,
trackInsertLayoutRef,
lifecycleRef,
sessionEpochRef,
cancelGestureRef,
@@ -120,6 +125,7 @@ export function mountTimelineClipDragGestureLifecycle({
};
const pointerMatchesGesture = (event: PointerEvent): boolean => {
if (draggedClipRef.current?.pointerId === null) return false;
const pointerId = lifecycleRef.current.pointerId;
return pointerId === null || event.pointerId === pointerId;
};
@@ -293,6 +299,7 @@ export function mountTimelineClipDragGestureLifecycle({
commitDraggedClipMove(drag, {
elements: elementsRef.current,
trackOrder: trackOrderRef.current,
trackInsertLayout: trackInsertLayoutRef?.current,
updateElement,
onMoveElement: onMoveElementRef.current,
onMoveElements: onMoveElementsRef.current,
@@ -315,10 +322,12 @@ export function mountTimelineClipDragGestureLifecycle({
);
};
const claimActiveGesture = (event: PointerEvent): TimelineGestureCommit | "ignored" | null => {
const claimActiveGesture = (
event: PointerEvent | null,
): TimelineGestureCommit | "ignored" | null => {
const lifecycle = lifecycleRef.current;
if (lifecycle.phase !== "active") return null;
if (!pointerMatchesGesture(event)) return "ignored";
if (event && !pointerMatchesGesture(event)) return "ignored";
if (lifecycle.sessionEpoch !== sessionEpochRef.current) {
cancelGesture();
return "ignored";
@@ -358,6 +367,12 @@ export function mountTimelineClipDragGestureLifecycle({
}
};
if (commitGestureRef)
commitGestureRef.current = () => {
const claimed = claimActiveGesture(null);
if (claimed && claimed !== "ignored") commitClaimedGesture(claimed);
};
const handleWindowPointerUp = (event: PointerEvent) => {
if (releasedOutsideWindow(event)) return handleWindowPointerCancel(event);
const claimed = claimActiveGesture(event);
@@ -411,6 +426,7 @@ export function mountTimelineClipDragGestureLifecycle({
return () => {
cancelGesture({ updateReact: false });
cancelGestureRef.current = () => false;
if (commitGestureRef) commitGestureRef.current = () => {};
window.removeEventListener("pointermove", handleWindowPointerMove);
window.removeEventListener("pointerup", handleWindowPointerUp);
window.removeEventListener("pointercancel", handleWindowPointerCancel);
@@ -7,8 +7,18 @@ import {
type DragPreviewContext,
} from "./timelineClipDragPreview";
import type { DraggedClipState } from "./timelineClipDragTypes";
import { commitDraggedClipMove, persistMoveEdits } from "./timelineClipDragCommit";
import { LANE_H, RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
import {
commitDraggedClipMove,
persistMoveEdits,
type TimelineMoveEdit,
} from "./timelineClipDragCommit";
import {
LANE_H,
RULER_H,
TRACKS_TOP_PAD,
TRACK_H,
createTimelineRowGeometry,
} from "./timelineLayout";
import { isMultiDragPassenger } from "./timelineMultiDragPreview";
import { resolveMultiDragPreview } from "./timelineProviderStateBuilders";
@@ -110,6 +120,20 @@ function horizontalDrag(
}
describe("computeDragPreview — plain horizontal drag never arms a phantom insert (BUG 1)", () => {
it("opens the physical seam below a zero-padding ruler and keeps it armed inside the new lane", () => {
const geometry = createTimelineRowGeometry([0, 1, 2], [104, 48, 48], { top: 0 });
const context = { ...ctx(geometry.rowHeights), rowGeometry: geometry };
const { drag, clientX } = horizontalDrag(moodboard, 0.5, 2);
const top = computeDragPreview(drag, clientX, 24, context);
expect(top.insertRow).toBe(0);
const between = computeDragPreview(drag, clientX, 128, context);
expect(between.insertRow).toBe(1);
const inside = computeDragPreview(between, clientX, 150, context);
expect(inside.insertRow).toBe(1);
const below = computeDragPreview(inside, clientX, 202, context);
expect(below.insertRow).toBeNull();
});
it("dragging v-moodboard +2s while grabbing its clip body keeps it a pure time move", () => {
const { drag, clientX, clientY } = horizontalDrag(moodboard, 0.5, 2);
const next = computeDragPreview(drag, clientX, clientY, ctx());
@@ -470,11 +494,10 @@ describe("computeDragPreview — the ghost start is the committed start", () =>
expect(committedStart(ghost, tag, rows)).toBe(10);
});
it("the edge of a row is still that row, not a new track", () => {
it("a clip aimed at a seam opens a new track at that boundary", () => {
for (const edge of [0.02, 0.98, 1.02]) {
const ghost = preview(tag, rows, 2, edge);
expect(ghost.insertRow).toBeNull();
expect(ghost.previewTrack).toBe(Math.floor(edge));
expect(ghost.insertRow).toBe(Math.round(edge));
}
});
});
@@ -550,6 +573,23 @@ describe("computeDragPreview — the ghost start is the committed start", () =>
});
describe("computeDragPreview — a group move keeps its shape", () => {
function commitPreview(
ghost: DraggedClipState,
elements: TimelineElement[],
selectedKeys: ReadonlySet<string>,
) {
const onMoveElements = vi.fn<(edits: TimelineMoveEdit[]) => void>();
commitDraggedClipMove(ghost, {
elements,
trackOrder: [0, 1, 2],
updateElement: vi.fn(),
onMoveElement: vi.fn(),
onMoveElements,
selectedKeys,
});
return onMoveElements.mock.calls[0][0];
}
it("never bumps the grabbed clip further left than the group's 0 limit", () => {
const a = clip("a", 1, 0.5, 1, 1);
const b = clip("b", 2, 7, 2, 1);
@@ -563,19 +603,7 @@ describe("computeDragPreview — a group move keeps its shape", () => {
selectedKeys,
});
expect(ghost).toMatchObject({ previewTrack: 0, insertRow: null, previewStart: 10 });
const onMoveElements = vi.fn();
commitDraggedClipMove(ghost, {
elements,
trackOrder: [0, 1, 2],
updateElement: vi.fn(),
onMoveElement: vi.fn(),
onMoveElements,
selectedKeys,
});
const edits = onMoveElements.mock.calls[0][0] as Array<{
element: TimelineElement;
updates: { start: number };
}>;
const edits = commitPreview(ghost, elements, selectedKeys);
const moved = Object.fromEntries(
edits.map((e) => [e.element.id, e.updates.start - e.element.start]),
);
@@ -595,19 +623,7 @@ describe("computeDragPreview — a group move keeps its shape", () => {
...ctx(undefined, elements),
selectedKeys,
});
const onMoveElements = vi.fn();
commitDraggedClipMove(ghost, {
elements,
trackOrder: [0, 1, 2],
updateElement: vi.fn(),
onMoveElement: vi.fn(),
onMoveElements,
selectedKeys,
});
const edits = onMoveElements.mock.calls[0][0] as Array<{
element: TimelineElement;
updates: { start: number; track: number };
}>;
const edits = commitPreview(ghost, elements, selectedKeys);
return Object.fromEntries(edits.map((e) => [e.element.id, e.updates]));
}
@@ -6,7 +6,12 @@ import {
} from "./timelineGroupEditing";
import type { TimelineElement } from "../store/playerStore";
import { clampToHostStart } from "../store/timelineElement";
import { getTimelineRowFromY } from "./timelineLayout";
import {
CLIP_Y,
TRACK_H,
getTimelineRowGeometry,
type TimelineRowGeometry,
} from "./timelineLayout";
import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector";
import {
TIMELINE_SNAP_PX,
@@ -38,6 +43,9 @@ export interface DragPreviewContext {
duration: number;
trackOrder: number[];
rowHeights?: readonly number[];
rowGeometry?: TimelineRowGeometry;
allowedInsertRows?: ReadonlySet<number>;
groupTracks?: ReadonlyMap<number, readonly number[]>;
elements: TimelineElement[];
selectedKeys: ReadonlySet<string>;
buildSnapTargets: BuildSnapTargets;
@@ -50,6 +58,33 @@ export interface DragPreviewContext {
audioTracks?: ReadonlySet<number>;
}
export function createKeyboardClipDrag(
element: TimelineElement,
insertRow: number,
scrollLeft: number,
scrollTop: number,
): DraggedClipState {
return {
pointerId: null,
element,
insertRow,
started: true,
previewStart: element.start,
previewTrack: element.track,
desiredTrack: element.track,
originClientX: 0,
originClientY: 0,
originScrollLeft: scrollLeft,
originScrollTop: scrollTop,
pointerClientX: 0,
pointerClientY: 0,
pointerOffsetX: 0,
pointerOffsetY: 0,
snapTime: null,
snapType: null,
};
}
/** Content-space position for the stable viewport drag actor. */
export function getTimelineDragOverlayPosition(
drag: DraggedClipState,
@@ -73,22 +108,50 @@ function resolveDragMaxStart(scroll: HTMLDivElement | null, pps: number, duratio
return Math.max(duration, scroll && pps > 0 ? scroll.scrollWidth / pps : duration);
}
const INSERT_SEAM_PX = CLIP_Y;
function physicalInsertRow(
rowFloat: number,
y: number,
ctx: Pick<DragPreviewContext, "trackOrder" | "allowedInsertRows">,
geometry: TimelineRowGeometry,
) {
const boundary = Math.round(rowFloat);
const nearSeam = Math.abs(y - geometry.getRowTop(boundary)) <= INSERT_SEAM_PX;
const row = nearSeam
? Math.max(0, Math.min(ctx.trackOrder.length, boundary))
: resolveInsertRow(rowFloat, ctx.trackOrder.length);
if (row !== null && ctx.allowedInsertRows && !ctx.allowedInsertRows.has(row)) return null;
return row;
}
function dragRowAim(
drag: DraggedClipState,
clientY: number,
ctx: DragPreviewContext,
geometry: TimelineRowGeometry,
) {
let y = clientY - (ctx.scroll?.getBoundingClientRect().top ?? 0) + (ctx.scroll?.scrollTop ?? 0);
if (drag.started && drag.insertRow !== null) {
const top = geometry.getRowTop(drag.insertRow);
if (y >= top - INSERT_SEAM_PX && y <= top + TRACK_H + INSERT_SEAM_PX)
return { rowFloat: drag.insertRow, insertRow: drag.insertRow };
if (y > top + TRACK_H) y -= TRACK_H;
}
const rowFloat = geometry.getRowFromY(y);
return { rowFloat, insertRow: physicalInsertRow(rowFloat, y, ctx, geometry) };
}
/** The drop decision for the pointer's row (see resolveZoneDropPlacement). */
function resolveDropPlacement(
drag: DraggedClipState,
clientY: number,
aim: ReturnType<typeof dragRowAim>,
previewStart: number,
desiredTrack: number,
ctx: DragPreviewContext,
group: GroupDrag,
): { track: number; insertRow: number | null; start: number } {
const { scroll, trackOrder, rowHeights, elements } = ctx;
const rowFloat = scroll
? getTimelineRowFromY(
clientY - scroll.getBoundingClientRect().top + scroll.scrollTop,
rowHeights,
)
: 0;
const { trackOrder, elements } = ctx;
const dragKey = drag.element.key ?? drag.element.id;
const audioTracks =
ctx.audioTracks ?? new Set(elements.filter(isAudioTimelineElement).map((e) => e.track));
@@ -97,7 +160,9 @@ function resolveDropPlacement(
audioTracks,
elements: group.obstacles,
desiredTrack,
deliberateInsertRow: resolveInsertRow(rowFloat, trackOrder.length),
deliberateInsertRow: aim.insertRow,
allowedInsertRows: ctx.allowedInsertRows,
groupTracks: ctx.groupTracks,
start: previewStart,
duration: drag.element.duration,
dragKey,
@@ -144,13 +209,12 @@ export function computeDragPreview(
): DraggedClipState {
const { scroll, pps, duration, trackOrder, buildSnapTargets } = ctx;
const dragMaxStart = resolveDragMaxStart(scroll, pps, duration);
const scrollTop = scroll?.scrollTop ?? drag.originScrollTop;
const scrollRectTop = scroll?.getBoundingClientRect().top ?? 0;
const originRow = getTimelineRowFromY(
drag.originClientY - scrollRectTop + drag.originScrollTop,
ctx.rowHeights,
);
const currentRow = getTimelineRowFromY(clientY - scrollRectTop + scrollTop, ctx.rowHeights);
const geometry =
ctx.rowGeometry ?? getTimelineRowGeometry(ctx.rowHeights ?? ctx.trackOrder.map(() => TRACK_H));
const originRow = geometry.getRowFromY(drag.originClientY - scrollRectTop + drag.originScrollTop);
const aim = dragRowAim(drag, clientY, ctx, geometry);
const currentRow = aim.rowFloat;
// resolveTimelineMove's vertical axis is row indices, which is why the pointer
// and scroll pixels are folded into originRow/currentRow above.
const nextMove = resolveTimelineMove(
@@ -196,7 +260,7 @@ export function computeDragPreview(
const origin = drag.element.start;
const floored = Math.max(snap.start, group.floor);
const previewStart = origin + Math.max(shift.min, Math.min(floored - origin, shift.max));
const placement = resolveDropPlacement(drag, clientY, previewStart, nextMove.track, ctx, group);
const placement = resolveDropPlacement(drag, aim, previewStart, nextMove.track, ctx, group);
const placedShift = placement.start - origin;
if (placedShift < shift.min - SHIFT_EPSILON_S || placedShift > shift.max + SHIFT_EPSILON_S) {
return { ...drag, started: true };
@@ -4,8 +4,8 @@ import type { BlockedTimelineEditIntent } from "./timelineEditing";
/* ── Shared clip-drag state types ───────────────────────────────── */
export interface DraggedClipState {
/** Pointer captured by the stable viewport coordinator for this gesture. */
pointerId: number;
/** Pointer captured by the viewport coordinator; null for a keyboard pickup. */
pointerId: number | null;
element: TimelineElement;
originClientX: number;
originClientY: number;
@@ -26,7 +26,7 @@ export interface DraggedClipState {
/**
* When non-null, the drop inserts a NEW track at this visual row boundary
* (0 = above the top lane, trackOrder.length = below the bottom) instead of
* landing on previewTrack. Drives the insertion-line indicator.
* landing on previewTrack. Drives the opened preview lane.
*/
insertRow: number | null;
/** Snap target the clip will land on, for the guide highlight. */
@@ -1,5 +1,17 @@
import type { TimelineElement } from "../store/playerStore";
/** Audio member tracks mark the audio zone, including members hidden by a group header. */
export function timelineAudioRow(
order: number[],
audioTracks: ReadonlySet<number>,
groupTracks?: ReadonlyMap<number, readonly number[]>,
): number {
return order.findIndex(
(track) =>
audioTracks.has(track) || groupTracks?.get(track)?.some((member) => audioTracks.has(member)),
);
}
/**
* Keep a landing track inside the dragged clip's kind-zone: visual clips stay in
* the rows ABOVE the first audio lane; audio clips stay AT/BELOW it. Prevents a
@@ -31,7 +43,10 @@ export function isInsertAllowedForZone(
insertRow: number,
audioRow: number,
isAudio: boolean,
allowedRows?: ReadonlySet<number>,
): boolean {
if (allowedRows && !allowedRows.has(insertRow)) return false;
while (audioRow > 0 && allowedRows && !allowedRows.has(audioRow)) audioRow--;
if (audioRow < 0) return true;
return isAudio ? insertRow >= audioRow : insertRow <= audioRow;
}
@@ -52,9 +67,8 @@ function outOfRangeZoneInsertRow(
audioRow: number,
desired: number,
): number {
// No lane of this kind yet: fall to the split (audioRow) or the very top.
// A visual-only timeline has audioRow -1 (top); an all-audio one has it at 0.
if (zoneTracks.length === 0) return audioRow < 0 ? 0 : audioRow;
// An empty audio zone opens below visual rows; an empty visual zone opens above audio.
if (zoneTracks.length === 0) return audioRow < 0 ? order.length : audioRow;
// zoneTracks preserves `order` sequence, so its ends map to the zone boundary
// rows: above the zone's min lane → its top boundary, else its bottom.
const zoneTop = order.indexOf(zoneTracks[0]);
@@ -104,14 +118,31 @@ export function resolveNearestFreeStart(
return clear ? origin : best;
}
// Where a dragged clip lands: on the aimed row of its kind, at the nearest free time there. Only an aim outside all
// rows, or into a kind with no row yet, opens a track (`insertRow`).
function groupHeaderAim(
input: Parameters<typeof resolveZoneDropPlacement>[0],
):
| { kind: "ordinary" | "member"; track: number }
| { kind: "origin"; track: number; start: number } {
const members = input.groupTracks?.get(input.desiredTrack);
if (!members) return { kind: "ordinary", track: input.desiredTrack };
const memberTracks = new Set(members);
const member = input.order.find(
(track) => memberTracks.has(track) && input.audioTracks.has(track) === input.isAudio,
);
if (member !== undefined) return { kind: "member", track: member };
if (!input.origin) throw new Error("Group-header drop requires the clip origin");
return { kind: "origin", ...input.origin };
}
// A deliberate seam opens a track; other aims land at the nearest free time on a row of the clip's kind.
export function resolveZoneDropPlacement(input: {
order: number[];
audioTracks: ReadonlySet<number>;
elements: TimelineElement[];
desiredTrack: number;
deliberateInsertRow: number | null;
allowedInsertRows?: ReadonlySet<number>;
groupTracks?: ReadonlyMap<number, readonly number[]>;
start: number;
duration: number;
dragKey: string;
@@ -123,24 +154,33 @@ export function resolveZoneDropPlacement(input: {
}): { track: number; insertRow: number | null; start: number } {
const { order, audioTracks, elements, desiredTrack, deliberateInsertRow } = input;
const { start, duration, dragKey, isAudio, minStart } = input;
const audioRow = order.findIndex((t) => audioTracks.has(t));
const audioRow = timelineAudioRow(order, audioTracks, input.groupTracks);
if (
deliberateInsertRow !== null &&
isInsertAllowedForZone(deliberateInsertRow, audioRow, isAudio)
isInsertAllowedForZone(deliberateInsertRow, audioRow, isAudio, input.allowedInsertRows)
) {
return { track: desiredTrack, insertRow: deliberateInsertRow, start };
}
const desired = clampTrackToZone(desiredTrack, order, audioRow, isAudio);
const zoneTracks = order.filter((t) => audioTracks.has(t) === isAudio);
const aim = groupHeaderAim(input);
if (aim.kind === "origin") return { track: aim.track, start: aim.start, insertRow: null };
const firstVisibleAudioRow = order.findIndex((track) => audioTracks.has(track));
const zoneBoundary = isAudio ? firstVisibleAudioRow : audioRow;
const desired =
aim.kind === "member" ? aim.track : clampTrackToZone(aim.track, order, zoneBoundary, isAudio);
const zoneTracks = order.filter(
(t) => !input.groupTracks?.has(t) && audioTracks.has(t) === isAudio,
);
// Only when the aim is outside the rows, or the clip's zone has no row yet.
if (!zoneTracks.includes(desired)) {
const desiredRow = order.indexOf(desired);
const insertRow =
desiredRow < 0
let insertRow =
desiredRow < 0 || zoneTracks.length === 0
? outOfRangeZoneInsertRow(order, zoneTracks, audioRow, desired)
: desiredRow + 1;
while (insertRow > 0 && input.allowedInsertRows && !input.allowedInsertRows.has(insertRow))
insertRow--;
return { track: desired, insertRow, start };
}
const origin = input.origin?.track === desired ? input.origin.start : null;
@@ -24,7 +24,7 @@ export function layoutAfterTrackInsert(
keys: ReadonlySet<string>;
movedStart: (e: TimelineElement) => number;
} | null,
deps: Pick<DragCommitDeps, "elements" | "trackOrder">,
deps: Pick<DragCommitDeps, "elements" | "trackOrder" | "trackInsertLayout">,
): {
normalized: TimelineElement[];
targetTrack: number;
@@ -37,18 +37,32 @@ export function layoutAfterTrackInsert(
const writableZone = classifyZone(element);
const writable = (src: TimelineElement): boolean =>
sameSourceFile(src, element) && classifyZone(src) === writableZone;
const topologyOrder = [...new Set(elements.filter(writable).map((e) => e.track))].sort(
let topologyOrder = [...new Set(elements.filter(writable).map((e) => e.track))].sort(
(a, b) => a - b,
);
const topologyInsertRow = topologyOrder.filter((track) => track < targetTrack).length;
const topologyTargetTrack = insertTrackValue(topologyOrder, topologyInsertRow);
let topologyInsertRow = topologyOrder.filter((track) => track < targetTrack).length;
const grouped = deps.trackInsertLayout;
let ranks: Map<number, number> | undefined;
if (grouped) {
const writableTracks = new Set(topologyOrder);
topologyOrder = grouped.trackOrder.filter((track) => writableTracks.has(track));
topologyInsertRow = grouped.trackOrder
.slice(0, grouped.topologyRows[insertRow])
.filter((track) => writableTracks.has(track)).length;
ranks = new Map(topologyOrder.map((track, index) => [track, index]));
}
const topologyTargetTrack = ranks
? topologyInsertRow - 0.5
: insertTrackValue(topologyOrder, topologyInsertRow);
const normalized = normalizeToZones(
elements.filter(writable).map((e) => {
if (keyOf(e) === editKey) {
return { ...e, start: previewStart, track: topologyTargetTrack };
}
if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
return e;
const track = ranks ? ranks.get(e.track) : e.track;
if (track === undefined) throw new Error("Writable track missing from insertion topology");
const start = multi?.keys.has(keyOf(e)) ? multi.movedStart(e) : e.start;
return track === e.track && start === e.start ? e : { ...e, start, track };
}),
);
return { normalized, targetTrack, writable };
@@ -0,0 +1,65 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { Timeline } from "./Timeline";
import type { TimelineProps } from "./TimelineTypes";
import { usePlayerStore } from "../store/playerStore";
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
export async function mountThreeTrackTimeline(props: TimelineProps = {}) {
usePlayerStore.setState({
duration: 10,
currentTime: 0,
timelineReady: true,
gsapAnimations: new Map(),
selectedElementId: null,
selectedElementIds: new Set(),
elements: [0, 1, 2].map((track) => ({
id: `c${track}`,
key: `c${track}`,
domId: `c${track}`,
tag: "div",
start: 0,
duration: 2,
track,
})),
});
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
await act(async () => root.render(<Timeline {...props} />));
const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
const ghost = () => host.querySelector<HTMLElement>('[data-testid="timeline-ghost-lane"]');
return { host, root, viewport, ghost };
}
export function dragTimelineFixtureAsset(target: EventTarget, type: string, clientY: number): void {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperties(event, {
clientX: { value: 400 },
clientY: { value: clientY },
dataTransfer: {
value: {
types: [TIMELINE_ASSET_MIME],
files: [],
dropEffect: "none",
getData: (mime: string) => (mime === TIMELINE_ASSET_MIME ? '{"path":"a.png"}' : ""),
},
},
});
act(() => void target.dispatchEvent(event));
}
export function pointerTimelineFixture(target: EventTarget, type: string, clientY: number): void {
act(
() =>
void target.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
button: 0,
pointerId: 1,
clientX: 400,
clientY,
}),
),
);
}
@@ -1,16 +1,23 @@
import type { TimelineElement } from "../store/playerStore";
type UpdateElement = (key: string, updates: Partial<TimelineElement>) => void;
const revisionsByUpdater = new WeakMap<UpdateElement, Map<string, number>>();
type RevisionScope = "timing" | "membership";
const revisionsByUpdater = new WeakMap<UpdateElement, Map<RevisionScope, Map<string, number>>>();
export function beginTimelineOptimisticGesture(
updateElement: UpdateElement,
keys: readonly string[],
scope: RevisionScope = "timing",
): Map<string, number> {
let revisions = revisionsByUpdater.get(updateElement);
let scopes = revisionsByUpdater.get(updateElement);
if (!scopes) {
scopes = new Map();
revisionsByUpdater.set(updateElement, scopes);
}
let revisions = scopes.get(scope);
if (!revisions) {
revisions = new Map();
revisionsByUpdater.set(updateElement, revisions);
scopes.set(scope, revisions);
}
const gesture = new Map<string, number>();
for (const key of keys) {
@@ -25,8 +32,12 @@ export function isLatestTimelineOptimisticGesture(
updateElement: UpdateElement,
gesture: ReadonlyMap<string, number>,
key: string,
scope: RevisionScope = "timing",
): boolean {
return revisionsByUpdater.get(updateElement)?.get(key) === gesture.get(key);
return (
gesture.has(key) &&
revisionsByUpdater.get(updateElement)?.get(scope)?.get(key) === gesture.get(key)
);
}
export function rollbackLatestTimelineOptimisticGesture(
@@ -0,0 +1,249 @@
import { expect, it, vi } from "vitest";
import { buildTimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
import { computeDragPreview } from "./timelineClipDragPreview";
import { commitDraggedClipMove } from "./timelineClipDragCommit";
import { timelineAudioRow, resolveZoneDropPlacement } from "./timelineCollision";
import { createTimelineRowGeometry } from "./timelineLayout";
import type { TimelineElement } from "../store/playerStore";
import type { DraggedClipState } from "./timelineClipDragTypes";
it.each([
{ order: [0, 0.5, 1, 3, 2, 4], seam: 4 },
{ order: [0, 0.5, 2, 4], seam: 2 },
])(
"inserts below an expanded or collapsed group block in displayed order ($seam)",
async ({ order, seam }) => {
const layout = buildTimelineTrackInsertLayout(order, [
{ anchorKey: 0.5, memberTracks: [1, 3] },
]);
const elements: TimelineElement[] = [0, 1, 2, 3, 4].map((track) => ({
id: `clip-${track}`,
domId: `clip-${track}`,
tag: "audio",
start: 0,
duration: 1,
track,
audioGroup: track === 1 || track === 3 ? "group" : undefined,
}));
elements.push({ id: "tail", domId: "tail", tag: "audio", start: 3, duration: 1, track: 4 });
const hero = elements[4];
const drag: DraggedClipState = {
pointerId: 1,
element: hero,
originClientX: 100,
originClientY: 48 + order.indexOf(hero.track) * 48,
originScrollLeft: 0,
originScrollTop: 0,
pointerClientX: 100,
pointerClientY: 48 + order.indexOf(hero.track) * 48,
pointerOffsetX: 0,
pointerOffsetY: 0,
previewStart: 0,
previewTrack: 4,
desiredTrack: 4,
insertRow: null,
snapTime: null,
snapType: null,
started: true,
};
const geometry = createTimelineRowGeometry(
order,
order.map(() => 48),
{ top: 0 },
);
const context = {
scroll: null,
pps: 100,
duration: 10,
trackOrder: order,
rowGeometry: geometry,
elements,
selectedKeys: new Set<string>(),
buildSnapTargets: () => [],
allowedInsertRows: layout.allowedRows,
groupTracks: layout.groupTracks,
};
const ontoHeader = computeDragPreview(drag, 100, geometry.getRowTop(1) + 24, context);
expect(ontoHeader.insertRow).toBeNull();
expect(ontoHeader.previewTrack).toBe(order.length === 6 ? 1 : 4);
if (order.length === 6) {
expect(layout.allowedRows.has(2)).toBe(false);
expect(layout.allowedRows.has(3)).toBe(false);
const ontoMember = computeDragPreview(drag, 100, 130, context);
expect(ontoMember.insertRow).toBeNull();
expect(ontoMember.previewTrack).toBe(1);
}
const preview = computeDragPreview(drag, 100, geometry.getRowTop(seam), context);
expect(preview.insertRow).toBe(seam);
const moved = new Map(elements.map((element) => [element.id, element]));
const persist = vi.fn();
commitDraggedClipMove(preview, {
elements,
trackOrder: order,
trackInsertLayout: layout,
updateElement: (id, updates) => {
moved.set(id, { ...moved.get(id)!, ...updates });
},
onMoveElements: persist,
});
await Promise.resolve();
expect(persist).toHaveBeenCalledOnce();
expect(moved.get("clip-1")?.track).toBe(1);
expect(moved.get("clip-3")?.track).toBe(2);
expect(moved.get("clip-4")?.track).toBe(3);
expect(moved.get("clip-2")?.track).toBe(4);
},
);
it.each([{ order: [0, 0.5] }, { order: [0, 0.5, 2] }])(
"keeps a collapsed audio group at the zone boundary ($order)",
({ order }) => {
const layout = buildTimelineTrackInsertLayout(order, [
{ anchorKey: 0.5, memberTracks: [1, 3] },
]);
expect(timelineAudioRow(order, new Set([1, 3, 2]), layout.groupTracks)).toBe(1);
},
);
it.each([
{ insertRow: 0, target: 0 },
{ insertRow: null, target: 2 },
])(
"detaches only new-track drops, retaining member-track behavior ($insertRow)",
async ({ insertRow, target }) => {
const hero: TimelineElement = {
id: "hero",
domId: "hero",
tag: "audio",
start: 0,
duration: 1,
track: 1,
audioGroup: "voice",
};
const member: TimelineElement = {
id: "member",
domId: "member",
tag: "audio",
start: 2,
duration: 1,
track: 2,
audioGroup: "voice",
};
const elements = [hero, member];
const order = [0.5, 1, 2];
const layout = buildTimelineTrackInsertLayout(order, [
{ anchorKey: 0.5, memberTracks: [1, 2] },
]);
const moved = new Map(elements.map((element) => [element.id, element]));
const persist = vi.fn();
commitDraggedClipMove(
{
pointerId: null,
element: hero,
insertRow,
started: true,
originClientX: 0,
originClientY: 0,
originScrollLeft: 0,
originScrollTop: 0,
pointerClientX: 0,
pointerClientY: 0,
pointerOffsetX: 0,
pointerOffsetY: 0,
previewStart: 0,
previewTrack: target,
desiredTrack: target,
snapTime: null,
snapType: null,
},
{
elements,
trackOrder: order,
trackInsertLayout: layout,
onMoveElements: persist,
updateElement: (id, updates) => {
moved.set(id, { ...moved.get(id)!, ...updates });
},
},
);
await Promise.resolve();
expect(moved.get("hero")?.audioGroup).toBe(insertRow === null ? "voice" : undefined);
expect(moved.get("hero")?.track).toBe(target);
expect(moved.get("member")?.audioGroup).toBe("voice");
const edit = persist.mock.calls[0][0].find(
(edit: { element: TimelineElement }) => edit.element.id === "hero",
);
expect(edit.updates.audioGroup).toBe(insertRow === null ? undefined : null);
},
);
it("creates the first visual lane above an all-audio timeline", () => {
expect(
resolveZoneDropPlacement({
order: [0, 1],
audioTracks: new Set([0, 1]),
elements: [],
desiredTrack: 0,
deliberateInsertRow: null,
start: 2,
duration: 1,
dragKey: "new-visual",
isAudio: false,
}),
).toEqual({ track: 0, insertRow: 0, start: 2 });
});
it("keeps audible-video groups in the visual zone for header drops and edge inserts", () => {
const order = [0, 0.5, 1, 2];
const layout = buildTimelineTrackInsertLayout(order, [{ anchorKey: 0.5, memberTracks: [1] }]);
const input = {
order,
audioTracks: new Set<number>(),
groupTracks: layout.groupTracks,
allowedInsertRows: layout.allowedRows,
elements: [],
desiredTrack: 0.5,
deliberateInsertRow: null,
start: 2,
duration: 1,
dragKey: "video",
isAudio: false,
origin: { track: 2, start: 2 },
};
expect(timelineAudioRow(order, input.audioTracks, layout.groupTracks)).toBe(-1);
expect(resolveZoneDropPlacement(input)).toEqual({ track: 1, insertRow: null, start: 2 });
expect(resolveZoneDropPlacement({ ...input, deliberateInsertRow: 3 })).toEqual({
track: 0.5,
insertRow: 3,
start: 2,
});
});
it.each([
{ isAudio: false, target: 1 },
{ isAudio: true, target: 3 },
])(
"maps a mixed-kind group header to its first visible member of the clip kind ($isAudio)",
({ isAudio, target }) => {
const order = [0, 0.5, 1, 3, 2];
const layout = buildTimelineTrackInsertLayout(order, [
{ anchorKey: 0.5, memberTracks: [1, 3] },
]);
expect(
resolveZoneDropPlacement({
order,
audioTracks: new Set([3]),
groupTracks: layout.groupTracks,
allowedInsertRows: layout.allowedRows,
elements: [],
desiredTrack: 0.5,
deliberateInsertRow: null,
start: 2,
duration: 1,
dragKey: "clip",
isAudio,
origin: { track: 2, start: 2 },
}),
).toEqual({ track: target, insertRow: null, start: 2 });
},
);
@@ -0,0 +1,71 @@
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
import { timelineAudioRow, isInsertAllowedForZone } from "./timelineCollision";
export function timelineTrackOrderChanged(
previous: readonly number[],
current: readonly number[],
): boolean {
return current.length !== previous.length || current.some((key, row) => key !== previous[row]);
}
export interface TimelineTrackInsertLayout {
allowedRows: ReadonlySet<number>;
trackOrder: number[];
topologyRows: number[];
groupTracks: ReadonlyMap<number, readonly number[]>;
}
export function keyboardPickupInsertRow(
row: number,
allowed: ReadonlySet<number> | undefined,
): number {
while (row > 0 && allowed && !allowed.has(row)) row--;
return row;
}
export function nextKeyboardInsertRow(input: {
current: number;
step: -1 | 1;
order: number[];
layout: TimelineTrackInsertLayout | undefined;
audioTracks: ReadonlySet<number>;
isAudio: boolean;
}): number | null {
const { current, step, order, layout, audioTracks, isAudio } = input;
let row = Math.max(0, Math.min(order.length, current + step));
const allowed = layout?.allowedRows;
while (allowed?.has(row) === false) {
row += step;
if (row < 0 || row > order.length) return null;
}
const audioRow = timelineAudioRow(order, audioTracks, layout?.groupTracks);
return isInsertAllowedForZone(row, audioRow, isAudio, allowed) ? row : null;
}
/** Groups own one contiguous insertion block, including their collapsed members. */
export function buildTimelineTrackInsertLayout(
order: number[],
groups: readonly Pick<TimelineTrackGroupInfo, "anchorKey" | "memberTracks">[],
): TimelineTrackInsertLayout {
const allowedRows = new Set(Array.from({ length: order.length + 1 }, (_, row) => row));
const anchors = new Map(groups.map((group) => [group.anchorKey, group.memberTracks]));
const positions = new Map(order.map((key, row) => [key, row]));
for (const group of groups) {
const anchor = positions.get(group.anchorKey);
if (anchor === undefined) continue;
const end = Math.max(anchor, ...group.memberTracks.map((key) => positions.get(key) ?? anchor));
for (let row = anchor + 1; row <= end; row++) allowedRows.delete(row);
}
const tracks = new Set<number>();
const topologyRows = [0];
for (const key of order) {
for (const track of anchors.get(key) ?? [key]) tracks.add(track);
topologyRows.push(tracks.size);
}
return {
allowedRows,
trackOrder: [...tracks],
topologyRows,
groupTracks: anchors,
};
}
@@ -0,0 +1,163 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, expect, it, vi } from "vitest";
import { useTimelineClipDrag } from "./useTimelineClipDrag";
import { createTimelineRowGeometry } from "./timelineLayout";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { configureTimelineTestViewport } from "./timelineTestViewport";
import { buildTimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
afterEach(() => {
usePlayerStore.getState().reset();
document.body.replaceChildren();
});
function mount(order = [0, 1, 2]) {
const elements: TimelineElement[] = [
{ id: "top", tag: "div", start: 0, duration: 3, track: 0 },
{ id: "hero", domId: "hero", tag: "div", start: 2, duration: 3, track: 1 },
{ id: "tail", tag: "div", start: 6, duration: 3, track: 1 },
{ id: "bottom", tag: "div", start: 0, duration: 3, track: 2 },
];
const groups = order.includes(0.5) ? [{ anchorKey: 0.5, memberTracks: [1, 3] }] : [];
if (groups.length) {
for (const element of elements) element.tag = "audio";
elements[1].audioGroup = "voice";
elements[2].audioGroup = "voice";
elements.push({
id: "member",
tag: "audio",
start: 0,
duration: 2,
track: 3,
audioGroup: "voice",
});
elements.push({ id: "extra", tag: "audio", start: 0, duration: 2, track: 4 });
}
for (const element of elements) element.domId = element.id;
usePlayerStore.getState().setElements(elements);
const scroll = document.createElement("div");
configureTimelineTestViewport(scroll, 400);
const clip = document.createElement("div");
clip.dataset.elId = "hero";
clip.dataset.timelineFocusId = "clip-hero";
clip.tabIndex = 0;
scroll.append(clip);
document.body.append(scroll);
const host = document.createElement("div");
const root = createRoot(host);
const onMoveElements = vi.fn();
const trackOrderRef = { current: order };
const trackInsertLayoutRef = { current: buildTimelineTrackInsertLayout(order, groups) };
let state: ReturnType<typeof useTimelineClipDrag>;
function Probe() {
state = useTimelineClipDrag({
scrollRef: { current: scroll },
ppsRef: { current: 100 },
durationRef: { current: 12 },
trackOrderRef,
trackInsertLayoutRef,
rowGeometryRef: {
current: createTimelineRowGeometry(
trackOrderRef.current,
trackOrderRef.current.map(() => 48),
{ top: 0 },
),
},
onMoveElement: vi.fn(),
onMoveElements,
setShowPopover: () => {},
setRangeSelectionRef: { current: null },
});
return null;
}
act(() => root.render(<Probe />));
clip.focus();
const key = (key: string) =>
act(() => {
clip.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
});
return {
key,
clip,
onMoveElements,
root,
elements,
changeOrder(next: number[]) {
trackOrderRef.current = next;
trackInsertLayoutRef.current = buildTimelineTrackInsertLayout(next, groups);
act(() => root.render(<Probe />));
},
get drag() {
return state.draggedClip;
},
};
}
function expectCanceledPickup(view: ReturnType<typeof mount>) {
expect(view.drag).toBeNull();
expect(view.onMoveElements).not.toHaveBeenCalled();
expect(usePlayerStore.getState().elements).toEqual(view.elements);
}
it("cancels pickup when collapsing a group changes the displayed seam", () => {
const view = mount([0, 0.5, 1, 3, 2, 4]);
view.key(" ");
view.key("ArrowDown");
expect(view.drag?.insertRow).toBe(4);
view.changeOrder([0, 0.5, 2, 4]);
expect(view.drag).toBeNull();
view.key("Enter");
expectCanceledPickup(view);
act(() => view.root.unmount());
});
it("picks up with Space, chooses a seam, then commits at that index once with Enter", async () => {
const view = mount();
view.key(" ");
expect(view.drag).toMatchObject({
pointerId: null,
started: true,
insertRow: 1,
previewStart: 2,
});
view.key("ArrowUp");
expect(view.drag?.insertRow).toBe(0);
await act(async () => view.key("Enter"));
expect(view.drag).toBeNull();
expect(view.onMoveElements).toHaveBeenCalledTimes(1);
const moved = usePlayerStore.getState().elements;
expect(moved.find((e) => e.id === "hero")?.track).toBe(0);
expect(moved.find((e) => e.id === "top")?.track).toBe(1);
view.key("Enter");
expect(view.onMoveElements).toHaveBeenCalledTimes(1);
act(() => view.root.unmount());
});
it("Escape cancels the opened lane without moving a clip or adding a track", () => {
const view = mount();
view.key(" ");
view.key("ArrowDown");
expect(view.drag?.insertRow).toBe(2);
view.key("Escape");
expectCanceledPickup(view);
act(() => view.root.unmount());
});
it("leaves Space on a nested fade slider to the slider", () => {
const view = mount();
const slider = document.createElement("div");
slider.setAttribute("role", "slider");
slider.tabIndex = 0;
view.clip.append(slider);
slider.focus();
const event = new KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true });
act(() => {
slider.dispatchEvent(event);
});
expect(view.drag).toBeNull();
expect(event.defaultPrevented).toBe(false);
act(() => view.root.unmount());
});
@@ -16,6 +16,7 @@ import type { StackingPatch } from "./timelineStackingSync";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import {
computeDragPreview,
createKeyboardClipDrag,
computeResizePreview,
trimPreviewTime,
previewGroupResize,
@@ -29,6 +30,19 @@ import type {
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
import { dropMisalignedTrimPartners, linkedGestureKeys } from "./audioClipLink";
import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
import { useTimelineClipCapabilities } from "./timelineReadOnly";
import { timelineClipFocusId } from "./timelineNavigationIdentity";
import {
handleClipPickupKeyboardEvent,
scrollKeyboardInsertRow,
timelineKeyboardEventTarget,
} from "./useTimelineKeyboardActor";
import {
timelineTrackOrderChanged,
nextKeyboardInsertRow,
keyboardPickupInsertRow,
type TimelineTrackInsertLayout,
} from "./timelineTrackInsertLayout";
import type { TimelineRowGeometry } from "./timelineLayout";
import {
mountTimelineClipDragGestureLifecycle,
@@ -50,17 +64,10 @@ interface UseTimelineClipDragInput {
ppsRef: React.RefObject<number>;
durationRef: React.RefObject<number>;
trackOrderRef: React.RefObject<number[]>;
trackInsertLayoutRef?: React.RefObject<TimelineTrackInsertLayout | undefined>;
rowGeometryRef?: React.RefObject<TimelineRowGeometry>;
onMoveElement?: (
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "track">,
) => Promise<void> | void;
onMoveElements?: (
edits: Array<{
element: TimelineElement;
updates: Pick<TimelineElement, "start" | "track">;
}>,
) => Promise<void> | void;
onMoveElement?: TimelineEditCallbacks["onMoveElement"];
onMoveElements?: TimelineEditCallbacks["onMoveElements"];
onResizeElement?: (
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
@@ -92,6 +99,7 @@ export function useTimelineClipDrag({
ppsRef,
durationRef,
trackOrderRef,
trackInsertLayoutRef,
rowGeometryRef,
onMoveElement,
onMoveElements,
@@ -107,6 +115,8 @@ export function useTimelineClipDrag({
refreshAfterLaneMove,
sessionEpoch = 0,
}: UseTimelineClipDragInput) {
const getClipCapabilities = useTimelineClipCapabilities();
const commitGestureRef = useRef<() => void>(() => {});
const updateElement = usePlayerStore((s) => s.updateElement);
const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
const rawBeatStrengths = usePlayerStore((s) => s.beatAnalysis?.beatStrengths ?? EMPTY_BEAT_TIMES);
@@ -207,7 +217,7 @@ export function useTimelineClipDrag({
const cancelGestureRef = useRef<
(options?: { updateReact?: boolean; suppressClick?: boolean }) => boolean
>(() => false);
const beginGesture = useCallback((kind: TimelineGestureKind, pointerId: number) => {
const beginGesture = useCallback((kind: TimelineGestureKind, pointerId: number | null) => {
if (lifecycleRef.current.phase === "active") cancelGestureRef.current();
lifecycleRef.current = {
kind,
@@ -310,14 +320,25 @@ export function useTimelineClipDrag({
pps: ppsRef.current,
duration: durationRef.current,
trackOrder: trackOrderRef.current,
allowedInsertRows: trackInsertLayoutRef?.current?.allowedRows,
groupTracks: trackInsertLayoutRef?.current?.groupTracks,
rowHeights: rowGeometryRef?.current.rowHeights,
rowGeometry: rowGeometryRef?.current,
elements: elementsRef.current,
selectedKeys: gestureSelectedKeysRef.current,
buildSnapTargets,
audioTracks: dragAudioTracksRef.current,
});
},
[scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, buildSnapTargets],
[
scrollRef,
ppsRef,
durationRef,
trackOrderRef,
trackInsertLayoutRef,
rowGeometryRef,
buildSnapTargets,
],
);
// Recompute the trim preview for a pointer x. Shared by the pointermove resize
@@ -445,6 +466,8 @@ export function useTimelineClipDrag({
useMountEffect(() =>
mountTimelineClipDragGestureLifecycle({
trackInsertLayoutRef,
commitGestureRef,
onStackingPatchesRef,
refreshAfterLaneMoveRef,
readZIndexRef,
@@ -479,10 +502,91 @@ export function useTimelineClipDrag({
}),
);
useEffect(() => {
const movePickup = (drag: DraggedClipState, step: -1 | 1, viewport: HTMLDivElement | null) => {
const audioTracks = getTimelineElementIndexes(elementsRef.current).audioTracks;
const row = nextKeyboardInsertRow({
current: drag.insertRow ?? 0,
step,
order: trackOrderRef.current,
layout: trackInsertLayoutRef?.current,
audioTracks,
isAudio: audioTracks.has(drag.element.track),
});
if (row === null) return;
publishDraggedClip({ ...drag, insertRow: row });
if (viewport && rowGeometryRef)
scrollKeyboardInsertRow(viewport, rowGeometryRef.current, row);
};
const activePickup = (
event: KeyboardEvent,
drag: DraggedClipState,
viewport: HTMLDivElement | null,
) => {
handleClipPickupKeyboardEvent(event, {
move: (step) => movePickup(drag, step, viewport),
commit: commitGestureRef.current,
cancel: cancelGestureRef.current,
focus: () =>
usePlayerStore
.getState()
.requestTimelineFocus(timelineClipFocusId(drag.element.key ?? drag.element.id)),
});
};
const pickup = (event: KeyboardEvent, viewport: HTMLDivElement) => {
const target = timelineKeyboardEventTarget(event.target, viewport);
const key = target?.dataset.elId;
if (key === undefined || !onMoveElementRef.current) return;
const element = getTimelineElementIndexes(elementsRef.current).byKey.get(key);
if (!element || !getClipCapabilities(element).canMove) return;
event.preventDefault();
event.stopImmediatePropagation();
setShowPopover(false);
setRangeSelectionRef.current?.(null);
const row = keyboardPickupInsertRow(
trackOrderRef.current.indexOf(element.track),
trackInsertLayoutRef?.current?.allowedRows,
);
setDraggedClip(createKeyboardClipDrag(element, row, viewport.scrollLeft, viewport.scrollTop));
};
const handleKeyDown = (event: KeyboardEvent) => {
const drag = draggedClipRef.current;
const viewport = scrollRef.current;
if (drag) {
if (drag.pointerId === null) activePickup(event, drag, viewport);
return;
}
if (resizingClipRef.current) return;
if (event.key !== " " || event.repeat) return;
if (viewport) pickup(event, viewport);
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [
getClipCapabilities,
publishDraggedClip,
rowGeometryRef,
scrollRef,
setDraggedClip,
setRangeSelectionRef,
setShowPopover,
trackOrderRef,
trackInsertLayoutRef,
]);
useEffect(() => {
cancelGestureRef.current();
}, [sessionEpoch]);
const previousOrderRef = useRef(trackOrderRef.current);
useEffect(() => {
const order = trackOrderRef.current;
const previous = previousOrderRef.current;
previousOrderRef.current = order;
if (draggedClipRef.current && timelineTrackOrderChanged(previous, order))
cancelGestureRef.current();
});
return {
draggedClip,
setDraggedClip,
@@ -0,0 +1,59 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it } from "vitest";
import { useTimelineDisplayLayout } from "./useTimelineTrackLayout";
import { createTimelineRowGeometry } from "./timelineLayout";
import type { DraggedClipState } from "./timelineClipDragTypes";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
it("opens one full lane at the insertion index without changing committed tracks", () => {
const order = [7, 3, 9];
const geometry = createTimelineRowGeometry(order, [48, 104, 48], { top: 0 });
const host = document.createElement("div");
const root = createRoot(host);
const drag: DraggedClipState = {
pointerId: 1,
element: { id: "clip", tag: "div", start: 1, duration: 2, track: 9 },
originClientX: 0,
originClientY: 0,
originScrollLeft: 0,
originScrollTop: 0,
pointerClientX: 0,
pointerClientY: 0,
pointerOffsetX: 0,
pointerOffsetY: 0,
previewStart: 1,
previewTrack: 3,
insertRow: 1,
snapTime: null,
snapType: null,
started: true,
};
function Probe({ active }: { active: DraggedClipState | null }) {
const layout = useTimelineDisplayLayout(active, order, geometry);
return (
<output>
{JSON.stringify({
order: layout.displayTrackOrder,
pinned: layout.draggedRowKey,
heights: layout.displayRowHeights,
below: layout.rowGeometry.getRowTop(2),
})}
</output>
);
}
act(() => root.render(<Probe active={drag} />));
const seen = JSON.parse(host.textContent!);
expect(seen.order).toHaveLength(4);
expect(seen.pinned).toBe(-1);
expect(seen.order.filter((key: number) => order.includes(key))).toEqual(order);
expect(order.includes(seen.order[1])).toBe(false);
expect(seen.heights).toEqual([48, 48, 104, 48]);
expect(seen.below).toBe(120);
expect(order).toEqual([7, 3, 9]);
act(() => root.render(<Probe active={null} />));
expect(JSON.parse(host.textContent!).order).toEqual(order);
act(() => root.unmount());
});
@@ -1,6 +1,6 @@
import { useCallback, useMemo, type FocusEvent, type KeyboardEvent, type RefObject } from "react";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import type { TimelineRowGeometry } from "./timelineLayout";
import { RULER_H, TRACK_H, type TimelineRowGeometry } from "./timelineLayout";
import {
isTimelineNavigationKey,
locateTimelineLogicalTarget,
@@ -17,14 +17,92 @@ interface TimelineKeyboardActorInput {
onDrillDown?: (element: TimelineElement) => void;
}
function eventTarget(event: FocusEvent | KeyboardEvent): HTMLElement | null {
if (!(event.target instanceof Element)) return null;
type ClipPickupAction =
| { kind: "move"; step: -1 | 1 }
| { kind: "commit" }
| { kind: "cancel" }
| { kind: "focus" }
| { kind: "consume" };
function clipPickupKeyboardAction(event: {
key: string;
repeat: boolean;
}): ClipPickupAction | null {
switch (event.key) {
case "ArrowUp":
return { kind: "move", step: -1 };
case "ArrowDown":
return { kind: "move", step: 1 };
case "Enter":
return { kind: event.repeat ? "focus" : "commit" };
case "Escape":
return { kind: "cancel" };
case " ":
return { kind: "consume" };
default:
return null;
}
}
export function handleClipPickupKeyboardEvent(
event: globalThis.KeyboardEvent,
actions: {
move: (step: -1 | 1) => void;
commit: () => void;
cancel: () => void;
focus: () => void;
},
): void {
const action = clipPickupKeyboardAction(event);
if (!action) return;
event.preventDefault();
event.stopImmediatePropagation();
switch (action.kind) {
case "move":
actions.move(action.step);
break;
case "commit":
actions.commit();
actions.focus();
break;
case "cancel":
actions.cancel();
actions.focus();
break;
case "focus":
actions.focus();
break;
case "consume":
break;
default: {
const unreachable: never = action;
throw new Error(`Unknown pickup action: ${unreachable}`);
}
}
}
export function scrollKeyboardInsertRow(
viewport: HTMLDivElement,
geometry: TimelineRowGeometry,
row: number,
): void {
const top = geometry.getRowTop(row);
if (top < viewport.scrollTop + RULER_H) viewport.scrollTop = Math.max(0, top - RULER_H);
else if (top + TRACK_H > viewport.scrollTop + viewport.clientHeight)
viewport.scrollTop = top + TRACK_H - viewport.clientHeight;
}
export function timelineKeyboardEventTarget(
target: EventTarget | null,
container: HTMLElement,
): HTMLElement | null {
if (!(target instanceof Element)) return null;
// Header actions stay native Tab stops because they have no row-level shortcut.
// The nearest interactive ancestor wins so their events never masquerade as row events.
const target = event.target.closest<HTMLElement>(
const nearest = target.closest<HTMLElement>(
"button, input, select, textarea, a[href], [contenteditable], [role='slider'], [data-timeline-focus-id]",
);
return target?.dataset.timelineFocusId && event.currentTarget.contains(target) ? target : null;
return nearest?.dataset.timelineFocusId && container.contains(nearest) ? nearest : null;
}
function viewportPageSize(
@@ -87,7 +165,8 @@ export function useTimelineKeyboardActor({
const onFocus = useCallback(
(event: FocusEvent<HTMLElement>) => {
const id = eventTarget(event)?.dataset.timelineFocusId;
const id = timelineKeyboardEventTarget(event.target, event.currentTarget)?.dataset
.timelineFocusId;
if (id && id !== focusedTargetId) usePlayerStore.getState().requestTimelineFocus(id);
},
// ponytail: This closure must see the current id or coordinator-driven focus bumps the nonce twice.
@@ -98,7 +177,7 @@ export function useTimelineKeyboardActor({
// One handler owns navigation, context-menu, and disclosure keyboard semantics.
// fallow-ignore-next-line complexity
(event: KeyboardEvent<HTMLElement>) => {
const targetElement = eventTarget(event);
const targetElement = timelineKeyboardEventTarget(event.target, event.currentTarget);
const id = targetElement?.dataset.timelineFocusId;
if (!targetElement || !id) return;
const located = locateTimelineLogicalTarget(logicalRows, id);
@@ -155,6 +155,7 @@ export function useTimelineProviderState({
trackStyles,
trackOrder,
trackOrderRef,
trackInsertLayoutRef,
laneCounts,
rowGeometry,
rowGeometryRef,
@@ -213,6 +214,7 @@ export function useTimelineProviderState({
ppsRef,
durationRef,
trackOrderRef,
trackInsertLayoutRef,
rowGeometryRef,
onMoveElement: pinnedOnMoveElement,
onMoveElements: pinnedOnMoveElements,
@@ -288,7 +290,7 @@ export function useTimelineProviderState({
allowHorizontal: zoomMode === "manual",
viewport,
sessionEpoch,
draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined,
draggedRowKey: displayLayout.draggedRowKey,
resizingElementIds,
clipContextMenuRowKey: clipContextMenu?.element.track,
keyframeContextMenuRowKey: kfContextMenu?.element.track,
@@ -0,0 +1,56 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it } from "vitest";
import { createTimelineRowGeometry, type TimelineRowGeometry } from "./timelineLayout";
import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
it("leaves the viewport still when a live preview lane is removed before the committed row arrives", () => {
const scroll = document.createElement("div");
scroll.scrollTop = 160;
const scrollRef = { current: scroll };
const lastScrollLeftRef = { current: 0 };
const host = document.createElement("div");
const root = createRoot(host);
const normal = createTimelineRowGeometry([0, 1, 2, 3, 4, 5], [48, 48, 48, 48, 48, 48]);
const preview = createTimelineRowGeometry([0, -1, 1, 2, 3, 4, 5], [48, 48, 48, 48, 48, 48, 48]);
const committed = createTimelineRowGeometry([0, 1, 2, 3, 4, 5, 6], [48, 48, 48, 48, 48, 48, 48]);
function Probe({
geometry,
draggedRowKey,
}: {
geometry: TimelineRowGeometry;
draggedRowKey?: number;
}) {
useTimelineRowVirtualization({
scrollRef,
rowGeometry: geometry,
draggedRowKey,
sessionEpoch: 0,
viewport: {
scrollTop: 160,
scrollLeft: 0,
clientWidth: 800,
clientHeight: 200,
scrollWidth: 800,
scrollHeight: 600,
isScrolling: false,
},
elements: [],
selectedElementId: null,
lastScrollLeftRef,
syncScrollViewport: () => {},
});
return null;
}
act(() => root.render(<Probe geometry={normal} />));
act(() => root.render(<Probe geometry={preview} draggedRowKey={-1} />));
expect(scroll.scrollTop).toBe(160);
act(() => root.render(<Probe geometry={normal} />));
expect(scroll.scrollTop).toBe(160);
act(() => root.render(<Probe geometry={committed} />));
expect(scroll.scrollTop).toBe(160);
act(() => root.unmount());
});
@@ -103,6 +103,7 @@ export function useTimelineRowVirtualization({
});
const previousLayoutRef = useRef(rowGeometry);
const previousDraggedRowKeyRef = useRef(draggedRowKey);
const previousSessionEpochRef = useRef(sessionEpoch);
useLayoutEffect(() => {
const scroll = scrollRef.current;
@@ -120,7 +121,7 @@ export function useTimelineRowVirtualization({
previousGeometry,
rowGeometry,
scroll.scrollTop,
draggedRowKey !== undefined,
draggedRowKey !== undefined || previousDraggedRowKeyRef.current !== undefined,
);
if (nextScrollTop !== scroll.scrollTop) {
scroll.scrollTop = nextScrollTop;
@@ -128,6 +129,7 @@ export function useTimelineRowVirtualization({
}
}
previousLayoutRef.current = rowGeometry;
previousDraggedRowKeyRef.current = draggedRowKey;
}, [draggedRowKey, lastScrollLeftRef, rowGeometry, scrollRef, sessionEpoch, syncScrollViewport]);
return {
@@ -6,6 +6,7 @@ import { elementAutomationLanes, groupAutomationLanes } from "./automationLaneDa
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import type { DraggedClipState } from "./timelineClipDragTypes";
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
import { buildTimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
import {
TRACK_H,
createTimelineRowGeometry,
@@ -246,6 +247,12 @@ export function useTimelineTrackLayout(
useTimelineTrackDerivations(expandedElements);
const trackOrderRef = useRef(trackOrder);
trackOrderRef.current = trackOrder;
const trackInsertLayout = useMemo(
() => (groups.length > 0 ? buildTimelineTrackInsertLayout(trackOrder, groups) : undefined),
[trackOrder, groups],
);
const trackInsertLayoutRef = useRef(trackInsertLayout);
trackInsertLayoutRef.current = trackInsertLayout;
const { laneCounts, rowGeometry, rowGeometryRef, rowHeights } = useTimelineRowHeights(
tracks,
gsapAnimations,
@@ -260,6 +267,7 @@ export function useTimelineTrackLayout(
trackStyles,
trackOrder,
trackOrderRef,
trackInsertLayoutRef,
laneCounts,
rowGeometry,
rowGeometryRef,
@@ -283,27 +291,40 @@ function useDisplayRowHeights(
);
}
function insertPreviewTrack(trackOrder: number[], preview: number) {
const result: number[] = [];
let inserted = false;
for (const key of trackOrder) {
if (!inserted && Number.isInteger(key) && key > preview) {
result.push(preview);
inserted = true;
}
result.push(key);
}
if (!inserted) result.push(preview);
return result;
}
function useDisplayTrackOrder(draggedClip: DraggedClipState | null, trackOrder: number[]) {
const started = draggedClip?.started === true;
const insertRow = draggedClip?.insertRow ?? null;
const previewTrack = draggedClip?.previewTrack;
return useMemo(() => {
if (!draggedClip?.started || trackOrder.includes(draggedClip.previewTrack)) return trackOrder;
if (!started || previewTrack === undefined) return trackOrder;
if (insertRow !== null) {
const pendingKey = trackOrder.reduce((lowest, key) => Math.min(lowest, key), 0) - 1;
const displayOrder = [...trackOrder];
displayOrder.splice(insertRow, 0, pendingKey);
return displayOrder;
}
if (trackOrder.includes(previewTrack)) return trackOrder;
// A group's members sit out of raw numeric order (pulled under their
// anchor row), so a plain numeric sort here would undo that grouping the
// moment a clip drags onto a brand-new track. Insert the new preview
// track only relative to other REAL (integer) tracks, leaving any
// fractional group-anchor keys exactly where grouping placed them.
const preview = draggedClip.previewTrack;
const result: number[] = [];
let inserted = false;
for (const key of trackOrder) {
if (!inserted && Number.isInteger(key) && key > preview) {
result.push(preview);
inserted = true;
}
result.push(key);
}
if (!inserted) result.push(preview);
return result;
}, [draggedClip, trackOrder]);
return insertPreviewTrack(trackOrder, previewTrack);
}, [started, insertRow, previewTrack, trackOrder]);
}
export function useTimelineDisplayLayout(
@@ -317,7 +338,15 @@ export function useTimelineDisplayLayout(
() => createTimelineRowGeometry(displayTrackOrder, displayRowHeights, rowGeometry.padding),
[displayTrackOrder, displayRowHeights, rowGeometry.padding],
);
let draggedRowKey: number | undefined;
if (draggedClip?.started) {
draggedRowKey =
draggedClip.insertRow !== null
? displayRowGeometry.rowKeys[draggedClip.insertRow]
: draggedClip.previewTrack;
}
return {
draggedRowKey,
displayTrackOrder,
displayRowHeights: displayRowGeometry.rowHeights,
rowGeometry: displayRowGeometry,
@@ -24,4 +24,11 @@ describe("timelineElementsChanged", () => {
const linked = { ...clip, link: "lk-1" };
expect(timelineElementsChanged([linked], [clip])).toBe(true);
});
it("sees restored group membership with identical timing and lane", () => {
const grouped = { ...clip, audioGroup: "G" };
expect(timelineElementsChanged([clip], [grouped])).toBe(true);
expect(timelineElementsChanged([grouped], [clip])).toBe(true);
expect(timelineElementsChanged([grouped], [{ ...grouped }])).toBe(false);
});
});
@@ -11,6 +11,7 @@ const RENDERED_FIELDS: readonly (keyof TimelineElement)[] = [
"volume",
"playbackRate",
"hidden",
"audioGroup",
"audioGroupVolume",
"audioGroupHidden",
"fadeIn",
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import { bindThumbnailPageLifecycle } from "./thumbnailPageLifecycle";
import {
ThumbnailScheduler,
type ThumbnailRequest,
type ThumbnailLoadedResult,
} from "./thumbnailScheduler";
function mountLifetime() {
const scheduler = new ThumbnailScheduler();
const page = new EventTarget();
const document = Object.assign(new EventTarget(), { hidden: false });
const unbind = bindThumbnailPageLifecycle(page, document, scheduler);
return { scheduler, page, document, unbind };
}
describe("thumbnail document lifetime", () => {
it("cancels every active thumbnail kind on pagehide and resumes retained leases", async () => {
const { scheduler, page, unbind } = mountLifetime();
const signals: AbortSignal[] = [];
const kinds = ["composition", "image", "video", "waveform"] as const;
const requests = kinds.map((kind): ThumbnailRequest => {
let attempts = 0;
return {
key: kind,
projectId: "scene",
sessionEpoch: 0,
kind,
priority: "visible",
load: (signal: AbortSignal) => {
signals.push(signal);
attempts++;
if (attempts > 1)
return Promise.resolve<ThumbnailLoadedResult>({
value: { kind: "image", url: kind, aspect: 1 },
weight: 1,
});
return new Promise<ThumbnailLoadedResult>((_, reject) =>
signal.addEventListener("abort", () =>
reject(new DOMException("Aborted", "AbortError")),
),
);
},
};
});
const leases = requests.map((request) => scheduler.acquire(request, () => {}));
expect(signals).toHaveLength(4);
page.dispatchEvent(new Event("pagehide"));
expect(signals.map((signal) => signal.aborted)).toEqual([true, true, true, true]);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(requests.map((request) => scheduler.getSnapshot(request).status)).toEqual([
"queued",
"queued",
"queued",
"queued",
]);
page.dispatchEvent(new Event("pageshow"));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(signals).toHaveLength(8);
expect(requests.map((request) => scheduler.getSnapshot(request).status)).toEqual([
"ready",
"ready",
"ready",
"ready",
]);
leases.forEach((lease) => lease.release());
unbind();
});
it("holds new jobs while away and preserves a preview reload hold on pageshow", async () => {
const { scheduler, page, document, unbind } = mountLifetime();
const started: string[] = [];
const make = (kind: "composition" | "image"): ThumbnailRequest => ({
key: kind,
projectId: "scene",
kind,
priority: "visible",
load: async () => {
started.push(kind);
return { value: { kind: "image", url: kind, aspect: 1 }, weight: 1 };
},
});
scheduler.setPreviewReloading(true);
page.dispatchEvent(new Event("pagehide"));
const leases = [make("composition"), make("image")].map((request) =>
scheduler.acquire(request, () => {}),
);
expect(started).toEqual([]);
document.hidden = true;
page.dispatchEvent(new Event("pageshow"));
expect(started).toEqual([]);
document.hidden = false;
document.dispatchEvent(new Event("visibilitychange"));
expect(started).toEqual(["image"]);
scheduler.setPreviewReloading(false);
expect(started).toEqual(["image", "composition"]);
await new Promise((resolve) => setTimeout(resolve, 0));
leases.forEach((lease) => lease.release());
unbind();
});
it("removes the lifecycle listeners when unbound", () => {
const { scheduler, page, unbind } = mountLifetime();
unbind();
page.dispatchEvent(new Event("pagehide"));
let started = false;
const lease = scheduler.acquire(
{
key: "poster",
projectId: "scene",
kind: "image",
priority: "visible",
load: async () => {
started = true;
return { value: { kind: "image", url: "poster", aspect: 1 }, weight: 1 };
},
},
() => {},
);
expect(started).toBe(true);
lease.release();
});
});
@@ -0,0 +1,27 @@
import { thumbnailScheduler, type ThumbnailScheduler } from "./thumbnailScheduler";
export function bindThumbnailPageLifecycle(
page: EventTarget,
document: EventTarget & { readonly hidden: boolean },
scheduler: ThumbnailScheduler = thumbnailScheduler,
): () => void {
let left = false;
const sync = () => scheduler.setPageHidden(left || document.hidden);
const hide = () => {
left = true;
sync();
};
const show = () => {
left = false;
sync();
};
page.addEventListener("pagehide", hide);
page.addEventListener("pageshow", show);
document.addEventListener("visibilitychange", sync);
sync();
return () => {
page.removeEventListener("pagehide", hide);
page.removeEventListener("pageshow", show);
document.removeEventListener("visibilitychange", sync);
};
}
@@ -117,6 +117,7 @@ export class ThumbnailScheduler {
private nextSequence = 1;
private scrolling = false;
private previewReloading = false;
private pageHidden = false;
private cacheBytes = 0;
private waveformCacheBytes = 0;
private readonly activeByBucket = { video: 0, composition: 0, general: 0 };
@@ -226,11 +227,27 @@ export class ThumbnailScheduler {
}
for (const entry of this.entries.values()) {
if (entry.state !== "loading" || entry.request.kind !== "composition") continue;
entry.preempted = true;
entry.controller?.abort();
this.preempt(entry);
}
}
setPageHidden(hidden: boolean): void {
if (this.pageHidden === hidden) return;
this.pageHidden = hidden;
if (!hidden) {
this.pump();
return;
}
for (const entry of this.entries.values()) {
if (entry.state === "loading") this.preempt(entry);
}
}
private preempt(entry: ThumbnailEntry): void {
entry.preempted = true;
entry.controller?.abort();
}
/** Evict project cache entries that are no longer owned by mounted consumers. */
invalidateProject(projectId: string): void {
for (const [key, entry] of this.entries) {
@@ -269,6 +286,7 @@ export class ThumbnailScheduler {
}
private pump(): void {
if (this.pageHidden) return;
const queued = Array.from(this.entries.values())
.filter((entry) => entry.state === "queued" && entry.leases.size > 0)
.sort((left, right) => {
@@ -0,0 +1,44 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Grouped track drop fixture</title>
</head>
<body>
<main
id="root"
data-composition-id="root"
data-no-timeline
data-width="1280"
data-height="720"
data-duration="4"
>
<audio
id="group-first"
class="clip"
data-label="First group member"
data-start="0"
data-duration="2"
data-track-index="0"
data-audio-group="Voices"
></audio>
<audio
id="outside"
class="clip"
data-label="Outside group"
data-start="0"
data-duration="3"
data-track-index="1"
></audio>
<audio
id="group-last"
class="clip"
data-label="Last group member"
data-start="3"
data-duration="1"
data-track-index="2"
data-audio-group="Voices"
></audio>
</main>
</body>
</html>
@@ -0,0 +1 @@
{ "$schema": "https://hyperframes.heygen.com/schema/hyperframes.json" }
@@ -0,0 +1,151 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>New track fixture</title>
<style>
html,
body {
margin: 0;
width: 1280px;
height: 720px;
background: #e5e3da;
color: #232623;
font: 20px Arial;
}
main {
position: relative;
width: 1280px;
height: 720px;
}
p {
position: absolute;
left: 64px;
margin: 0;
}
</style>
</head>
<body>
<main
id="ghost-lane"
data-composition-id="ghost-lane"
data-width="1280"
data-height="720"
data-start="0"
data-duration="24"
data-fps="30"
data-no-timeline
>
<p
class="clip"
id="Opening-title"
data-start="0"
data-duration="5"
data-track-index="0"
style="z-index: 20; top: 30px"
>
Opening title
</p>
<p
class="clip"
id="Product-walkthrough-final-approved-cut-with-alternate-opening-and-captions"
data-start="0.4"
data-duration="5"
data-track-index="1"
style="z-index: 19; top: 70px"
>
Product walkthrough final approved cut with alternate opening and captions
</p>
<p
class="clip"
id="Detail-shot"
data-start="0.8"
data-duration="5"
data-track-index="2"
style="z-index: 18; top: 110px"
>
Detail shot
</p>
<p
class="clip"
id="Supporting-graphics"
data-start="1.2"
data-duration="5"
data-track-index="3"
style="z-index: 17; top: 150px"
>
Supporting graphics
</p>
<p
class="clip"
id="Closing-title"
data-start="1.6"
data-duration="5"
data-track-index="4"
style="z-index: 16; top: 190px"
>
Closing title
</p>
<p
class="clip"
id="Music-title"
data-start="2"
data-duration="5"
data-track-index="5"
style="z-index: 15; top: 230px"
>
Music title
</p>
<p
class="clip"
id="Voiceover-title"
data-start="2.4"
data-duration="5"
data-track-index="6"
style="z-index: 14; top: 270px"
>
Voiceover title
</p>
<p
class="clip"
id="Lower-thirds"
data-start="2.8"
data-duration="5"
data-track-index="7"
style="z-index: 13; top: 310px"
>
Lower thirds
</p>
<p
class="clip"
id="Screen-recording"
data-start="3.2"
data-duration="5"
data-track-index="8"
style="z-index: 12; top: 350px"
>
Screen recording
</p>
<p
class="clip"
id="Logo-animation"
data-start="3.6"
data-duration="5"
data-track-index="9"
style="z-index: 11; top: 390px"
>
Logo animation
</p>
<p
class="clip"
id="second-cut"
data-start="10"
data-duration="5"
data-track-index="1"
style="z-index: 19; top: 70px"
>
Second cut
</p>
</main>
</body>
</html>
@@ -0,0 +1,163 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { createServer, request } from "node:http";
import { copyFileSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { launchStudioChrome } from "./chrome-executable.mjs";
const backend = new URL(process.env.STUDIO_URL ?? "http://127.0.0.1:5190");
assert.equal(backend.protocol, "http:", "The local regression proxy requires HTTP");
const studio = fileURLToPath(new URL("../../", import.meta.url));
const fixture = join(dirname(fileURLToPath(import.meta.url)), "fixtures/ghost-lane");
const projects = [];
const held = new Map();
const proxy = createServer((incoming, response) => {
const url = new URL(incoming.url ?? "/", backend);
// Cache-only poster reads never enter the server's render queue.
if (
/\/api\/projects\/[^/]+\/thumbnail\//.test(url.pathname) &&
url.searchParams.get("cached") !== "1"
) {
held.set(response, incoming.url);
response.once("close", () => held.delete(response));
return;
}
forward(incoming, response, url.pathname + url.search);
});
function forward(incoming, response, path) {
const outgoing = request(
backend,
{
path,
method: incoming.method,
headers: incoming.headers,
},
(upstream) => {
response.writeHead(upstream.statusCode ?? 502, upstream.headers);
upstream.pipe(response);
},
);
outgoing.on("error", () => {
if (!response.destroyed) {
response.writeHead(502);
response.end("Upstream unavailable");
}
});
response.once("close", () => {
if (!response.writableEnded) outgoing.destroy();
});
incoming.pipe(outgoing);
}
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitFor(predicate, message, bound = 15_000) {
const deadline = Date.now() + bound;
while (!predicate()) {
assert(Date.now() < deadline, message);
await pause(25);
}
}
const heldFor = (project) =>
[...held.values()].filter((path) => path.includes(`/projects/${project}/`)).length;
const clip =
'[data-clip][data-el-id$="Product-walkthrough-final-approved-cut-with-alternate-opening-and-captions"]';
let browser;
try {
await new Promise((resolve) => proxy.listen(0, "127.0.0.1", resolve));
const address = proxy.address();
assert(address && typeof address !== "string");
const proxyStatus = await new Promise((resolve, reject) => {
const probe = request(
{
hostname: "127.0.0.1",
port: address.port,
path: "http://127.0.0.1:1/",
signal: AbortSignal.timeout(5000),
},
(response) => {
response.resume();
response.once("error", reject);
response.once("end", () => resolve(response.statusCode));
},
);
probe.once("error", reject);
probe.end();
});
assert.equal(proxyStatus, 200, "An absolute request target cannot replace the Studio backend");
({ browser } = await launchStudioChrome());
let previous;
let page;
for (let index = 0; index < 3; index++) {
const id = `thumbnail-lifetime-${process.pid}-${index}`;
const dir = join(studio, "data/projects", id);
mkdirSync(dir, { recursive: true });
projects.push({ id, dir });
for (const name of ["index.html", "hyperframes.json"])
copyFileSync(join(fixture, name), join(dir, name));
page = await browser.newPage();
await page.bringToFront();
if (previous) {
await previous.waitForFunction(() => document.hidden, { timeout: 5000 });
await previous.evaluate(() => new Promise((resolve) => setTimeout(resolve, 0)));
const client = await previous.createCDPSession();
// Background documents can freeze their timers while requests still hold sockets.
await client.send("Page.setWebLifecycleState", { state: "frozen" });
}
await page.setViewport({ width: 1440, height: 900 });
await page.goto(`http://127.0.0.1:${address.port}/#project/${id}`, {
waitUntil: "domcontentloaded",
timeout: 60_000,
});
await page.waitForFunction(
(selector) =>
document.querySelector(selector)?.closest("[data-timeline-row]")?.dataset.timelineRow ===
"1",
{ timeout: 30_000 },
clip,
);
await waitFor(() => heldFor(id) >= 2, "Each project starts real held thumbnail requests");
previous = page;
}
const box = await (await page.$(clip)).boundingBox();
const view = await (await page.$("[data-timeline-scroll-viewport]")).boundingBox();
const row = await (await page.$('[data-timeline-row="2"]')).boundingBox();
assert(box && view && row);
await page.mouse.move(box.x + Math.min(40, box.width / 2), box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(Math.min(view.x + view.width - 120, box.x + 60), row.y, { steps: 8 });
await page.waitForFunction(
() =>
document
.querySelector("[data-timeline-new-track-lane]")
?.getAttribute("data-timeline-new-track-lane") === "2",
{ timeout: 5000 },
);
const dropped = Date.now();
await page.mouse.up();
const saved = join(projects[2].dir, "index.html");
await waitFor(() => {
const tag = readFileSync(saved, "utf8").match(
/<[^>]*\bid="Product-walkthrough-final-approved-cut-with-alternate-opening-and-captions"[^>]*>/,
)?.[0];
return /\bdata-track-index="2"/.test(tag ?? "");
}, "Dropped clip persists within 15 seconds with old-project thumbnails in flight");
assert.equal(heldFor(projects[0].id), 0, "First hidden document releases its thumbnail sockets");
assert.equal(heldFor(projects[1].id), 0, "Second hidden document releases its thumbnail sockets");
assert(
heldFor(projects[2].id) >= 2,
"Current document still has unfinished thumbnails at the save",
);
console.log(
JSON.stringify({
persistedMs: Date.now() - dropped,
oldThumbnailRequests: 0,
currentThumbnailRequests: heldFor(projects[2].id),
saveBoundMs: 15_000,
}),
);
} finally {
await browser?.close();
proxy.closeAllConnections();
await new Promise((resolve) => proxy.close(resolve));
for (const { dir } of projects) rmSync(dir, { recursive: true, force: true });
}
+5
View File
@@ -11,6 +11,7 @@ import {
} from "node:fs";
import { join, resolve } from "node:path";
import { readNodeRequestBody } from "./vite.request-body.js";
import { bindNodeRequestSignal } from "./vite.request-signal.js";
import { watch } from "chokidar";
import { createProjectSignatureCache, createViteAdapter } from "./vite.adapter";
import { previewConfigPayload } from "./vite.preview-config";
@@ -226,6 +227,7 @@ function devProjectApi(): Plugin {
// API middleware
server.middlewares.use(async (req, res, next) => {
if (!req.url?.startsWith("/api/")) return next();
const requestSignal = bindNodeRequestSignal(res);
try {
const api = await getApi();
const url = new URL(req.url, `http://${req.headers.host}`);
@@ -243,6 +245,7 @@ function devProjectApi(): Plugin {
method: req.method,
headers,
body,
signal: requestSignal.signal,
});
const response = await api.fetch(fetchReq);
await bridgeHonoResponse(response, res);
@@ -252,6 +255,8 @@ function devProjectApi(): Plugin {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Internal server error" }));
}
} finally {
requestSignal.dispose();
}
});
@@ -0,0 +1,66 @@
import { createServer, get } from "node:http";
import { describe, expect, it } from "vitest";
import { bindNodeRequestSignal } from "./vite.request-signal";
// A real connection is required: completing a request body is not a disconnect.
describe("Studio dev request cancellation", () => {
it("aborts a held response when its client disconnects", async () => {
let received!: () => void;
let disconnected!: () => void;
const ready = new Promise<void>((resolve) => {
received = resolve;
});
const closed = new Promise<void>((resolve) => {
disconnected = resolve;
});
let signal: AbortSignal | undefined;
const server = createServer((_, response) => {
const request = bindNodeRequestSignal(response);
signal = request.signal;
signal.addEventListener(
"abort",
() => {
request.dispose();
disconnected();
},
{ once: true },
);
received();
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Expected a TCP listener");
const client = get(`http://127.0.0.1:${address.port}/thumbnail`);
client.on("error", (error) => expect(error).toMatchObject({ code: "ECONNRESET" }));
try {
await ready;
expect(signal?.aborted).toBe(false);
client.destroy();
await closed;
expect(signal?.aborted).toBe(true);
} finally {
client.destroy();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("does not abort completed work after its listener is disposed", async () => {
let signal: AbortSignal | undefined;
const server = createServer((_, response) => {
const request = bindNodeRequestSignal(response);
signal = request.signal;
response.end("complete");
request.dispose();
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Expected a TCP listener");
try {
const response = await fetch(`http://127.0.0.1:${address.port}/thumbnail`);
expect(await response.text()).toBe("complete");
expect(signal?.aborted).toBe(false);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});
+11
View File
@@ -0,0 +1,11 @@
import type { ServerResponse } from "node:http";
export function bindNodeRequestSignal(response: ServerResponse): {
signal: AbortSignal;
dispose: () => void;
} {
const controller = new AbortController();
const abort = () => controller.abort();
response.once("close", abort);
return { signal: controller.signal, dispose: () => response.off("close", abort) };
}