@1agh/maude 0.51.1 → 0.52.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/apps/studio/api.ts +389 -14
  2. package/apps/studio/bin/_video-playwright.mjs +15 -6
  3. package/apps/studio/canvas-edit.ts +141 -20
  4. package/apps/studio/canvas-lib.tsx +61 -0
  5. package/apps/studio/client/app.jsx +824 -121
  6. package/apps/studio/client/panels/ClipInspector.jsx +409 -0
  7. package/apps/studio/client/panels/CloudBar.jsx +12 -2
  8. package/apps/studio/client/panels/TimelinePanel.jsx +1775 -430
  9. package/apps/studio/client/panels/timeline-media-cache.js +274 -0
  10. package/apps/studio/client/panels/timeline-parse.js +46 -1
  11. package/apps/studio/client/panels/timeline-scale.js +64 -0
  12. package/apps/studio/client/report-bug.jsx +15 -15
  13. package/apps/studio/client/styles/3-shell-maude.css +446 -3
  14. package/apps/studio/client/styles/4-components-maude.css +55 -0
  15. package/apps/studio/client/styles/4-components.css +0 -83
  16. package/apps/studio/clip-ops.ts +1980 -0
  17. package/apps/studio/cloud/endpoints.ts +88 -17
  18. package/apps/studio/credential-grammar.ts +100 -0
  19. package/apps/studio/debug-bundle.ts +12 -0
  20. package/apps/studio/dist/client.bundle.js +2048 -2048
  21. package/apps/studio/dist/styles.css +1 -1
  22. package/apps/studio/exporters/video.ts +49 -10
  23. package/apps/studio/http.ts +113 -6
  24. package/apps/studio/ripple.ts +335 -0
  25. package/apps/studio/sync/hub-link.ts +3 -3
  26. package/apps/studio/sync/hubs-config.ts +32 -0
  27. package/apps/studio/sync/workspace-signin.ts +8 -4
  28. package/apps/studio/test/_helpers.ts +5 -3
  29. package/apps/studio/test/canvas-create-api.test.ts +8 -2
  30. package/apps/studio/test/canvas-origin-gate.test.ts +20 -0
  31. package/apps/studio/test/clip-addressing.test.ts +7 -6
  32. package/apps/studio/test/clip-ops.test.ts +836 -0
  33. package/apps/studio/test/cloud-endpoints.test.ts +37 -2
  34. package/apps/studio/test/comments-api.test.ts +49 -0
  35. package/apps/studio/test/credential-grammar.test.ts +107 -0
  36. package/apps/studio/test/read-only-role.test.ts +95 -0
  37. package/apps/studio/test/ripple.test.ts +197 -0
  38. package/apps/studio/test/timeline-scale.test.ts +65 -0
  39. package/apps/studio/test/workspace-signin.test.ts +4 -1
  40. package/apps/studio/whats-new.json +35 -0
  41. package/cli/commands/share.mjs +8 -4
  42. package/cli/lib/ddr-to-kgai.mjs +6 -1
  43. package/cli/lib/ddr-to-kgai.test.mjs +1 -2
  44. package/cli/lib/hubs-config.mjs +15 -1
  45. package/cli/lib/hubs-config.test.mjs +19 -0
  46. package/cli/lib/share-plan.mjs +5 -1
  47. package/cli/lib/share-plan.test.mjs +11 -2
  48. package/package.json +8 -8
@@ -1,18 +1,39 @@
1
- // TimelinePanel — DDR-148 bottom timeline for a video-comp.
1
+ // TimelinePanel — DDR-148 bottom timeline for a video-comp, restructured by
2
+ // feature-enhanced-video-editing into the iMovie model:
2
3
  //
3
- // A horizontal, bottom-docked transport + track: play/pause/loop, a scrub, and
4
- // one ROW PER SEQUENCE (parsed from the comp's raw .tsx by timeline-parse.js),
5
- // each block positioned by frame with its animation (interpolate) windows
6
- // marked as keyframe bars. Clicking a sequence seeks to its start; dragging
7
- // anywhere on the track scrubs. It drives the embedded Player via postMessage
8
- // (`dgn: 'timeline-seek' | -play | -pause`), consumed by video-comp.tsx.
4
+ // • ONE px-per-frame scale (timeline-scale.js) drives every block, handle,
5
+ // tick, and drag — zoom from fit-to-width down to single-frame ticks
6
+ // (⌘+/⌘−, pinch, header slider, `0` = fit), horizontal scroll when the
7
+ // content outgrows the viewport (Task 4);
8
+ // • a three-band layout for series comps: overlay lane above, ONE storyline
9
+ // lane of series beats butted side-by-side (transitions as ⧓ seam chips),
10
+ // audio lane below. Non-series comps keep the stacked row-per-sequence
11
+ // fallback (Task 5);
12
+ // • single-select (stableId) with the accent outline; click selects, the
13
+ // bare track scrubs + deselects; Esc/Delete live in the shell keyboard
14
+ // layer (Task 3).
9
15
  //
10
- // v1 is read-only rows (see + seek). Drag-to-retime a block / move a keyframe
11
- // (source-patch) is the documented next slice.
16
+ // It drives the embedded Player via postMessage (`dgn: 'timeline-seek' |
17
+ // -play | -pause`), consumed by video-comp.tsx.
12
18
 
13
- import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
19
+ import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
20
+ import { createPortal } from 'react-dom';
14
21
 
15
- import { computeSnapTargets, snapFrame, snapThresholdFrames } from './timeline-snap.js';
22
+ import { ImagePlus, MessageSquarePlus, Plus, Scissors, Sparkles, Type } from 'lucide-react';
23
+
24
+ import ClipInspector from './ClipInspector.jsx';
25
+ import { computeSnapTargets, snapFrame } from './timeline-snap.js';
26
+ import { getFilmstrip, getPeaks, peaksToPath, stripBucket } from './timeline-media-cache.js';
27
+ import {
28
+ clampPxPerFrame,
29
+ fitPxPerFrame,
30
+ MAX_PX_PER_FRAME,
31
+ pxToFrame,
32
+ tickLabel,
33
+ tickStepFrames,
34
+ ZOOM_STEP,
35
+ zoomAroundScroll,
36
+ } from './timeline-scale.js';
16
37
  // DDR-150 dogfood — reuse the SAME context-menu component the canvas uses
17
38
  // (viewport-aware positioning, keyboard nav, outside-click/Esc dismiss, shared
18
39
  // .dc-context-menu styling) instead of a bespoke shell menu.
@@ -21,6 +42,7 @@ import { ContextMenuView } from '../../context-menu.tsx';
21
42
  // DDR-150 dogfood #6 — per-row media-kind identity so the timeline reads like
22
43
  // an editor: what's footage, what's a still, what's audio, what's plain JSX.
23
44
  function rowKind(seq) {
45
+ if (seq.placeholder) return 'placeholder';
24
46
  const t = seq.mediaTag;
25
47
  if (t === 'Video' || t === 'OffthreadVideo') return 'video';
26
48
  if (t === 'Img') return 'image';
@@ -29,6 +51,7 @@ function rowKind(seq) {
29
51
  }
30
52
  function rowKindGlyph(seq) {
31
53
  const k = rowKind(seq);
54
+ if (k === 'placeholder') return '✨';
32
55
  if (k === 'video') return '▶';
33
56
  if (k === 'image') return '◫';
34
57
  if (k === 'audio') return '♪';
@@ -37,6 +60,8 @@ function rowKindGlyph(seq) {
37
60
  function rowKindTitle(seq) {
38
61
  const k = rowKind(seq);
39
62
  const src = seq.mediaSrc ? ` · ${String(seq.mediaSrc).split('/').pop()}` : '';
63
+ if (k === 'placeholder')
64
+ return `AI placeholder (${seq.placeholder?.kind || 'veo'}) · ${seq.placeholder?.prompt || ''}`;
40
65
  if (k === 'video') return `Video clip${src}`;
41
66
  if (k === 'image') return `Image${src}`;
42
67
  if (k === 'audio') return `Audio${src}`;
@@ -96,6 +121,12 @@ function TIcon({ name, size = 15 }) {
96
121
  );
97
122
  }
98
123
 
124
+ // lucide wrapper — hairline stroke to match the shell's icon weight (the same
125
+ // convention as inspector-controls' `Ic`).
126
+ const LIc = ({ as: C, size = 14 }) => (
127
+ <C size={size} strokeWidth={1.75} style={{ display: 'block', flex: 'none' }} />
128
+ );
129
+
99
130
  const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
100
131
 
101
132
  function fmtTime(frame, fps) {
@@ -105,10 +136,16 @@ function fmtTime(frame, fps) {
105
136
  return `${whole}.${String(cs).padStart(2, '0')}s`;
106
137
  }
107
138
 
139
+ // The frame axis starts AFTER the fixed label gutter.
140
+ const LABEL_GUTTER = 96;
141
+ // Padding the .tl-scroll container puts around the tracks (see 3-shell-maude.css).
142
+ const SCROLL_PAD_X = 24;
143
+
108
144
  export default function TimelinePanel({
109
145
  comps = [],
110
146
  sequences = [],
111
147
  audio = [],
148
+ transitions = [],
112
149
  total = 0,
113
150
  frame = 0,
114
151
  playing = false,
@@ -130,13 +167,41 @@ export default function TimelinePanel({
130
167
  onReplaceAudio,
131
168
  onReplaceLayer,
132
169
  onReorder,
170
+ // Task 6 — magnetic drag commit: (movedClipRef, targetClipRef, 'before'|'after').
171
+ onReorderMove,
133
172
  onToggleHide,
134
173
  onDropMedia,
135
174
  onClose,
175
+ // Task 3 — single-select model. `selectedClipId` is the selected clip's
176
+ // stableId (survives refetch/reorder, unlike an index); click selects,
177
+ // empty-track click deselects, Esc/Delete are handled by the shell.
178
+ selectedClipId = null,
179
+ onSelect,
180
+ // Task 7 — resolves a comp-relative media src (`assets/x.mp4`) to a URL the
181
+ // shell origin can fetch (`/<designRel>/assets/x.mp4`).
182
+ resolveMediaUrl,
183
+ // Phase 2 — transitions from the enumerator (stableId + contentHash), by
184
+ // seam order, for the ⧓ chip → Transition tab flow.
185
+ transitionClips = [],
186
+ // Phase 2 — parametric verb commit: (clipRef, verb, params).
187
+ onClipVerb,
188
+ // Phase 3 — toolbar entries: scissors + overlay authoring.
189
+ onSplitAtPlayhead,
190
+ onAddTitle,
191
+ onAddImage,
192
+ // Phase 4 — AI placeholder authoring + resolution (DDR-164 spine).
193
+ onAddAiClip,
194
+ onGeneratePlaceholder,
195
+ // Task 23 — timeline comments (frame-anchored 💬 pins + agent context).
196
+ comments = [],
197
+ // WKWebView-safe text ask: (title, initial?) => Promise<string|null>.
198
+ promptText,
199
+ onAddComment,
200
+ onResolveComment,
201
+ onDeleteComment,
136
202
  }) {
137
- // DDR-150 dogfood — clips are expanded into their layers (mp4 background +
138
- // title/…) BY DEFAULT so a ClipShot reads as its parts, not one box. Track the
139
- // rows the user COLLAPSED instead (default-open, per the user's steer).
203
+ // DDR-150 dogfood — layer expansion (stacked-rows fallback mode only; the
204
+ // storyline layout moves layer detail into the clip inspector).
140
205
  const [collapsed, setCollapsed] = useState(() => new Set());
141
206
  const [dropActive, setDropActive] = useState(false);
142
207
  const comp = comps[0] ?? null;
@@ -144,32 +209,174 @@ export default function TimelinePanel({
144
209
  const fps = comp?.fps ?? 30;
145
210
  const clamped = clamp(Math.round(frame), 0, totalFrames - 1);
146
211
  const trackRef = useRef(null);
212
+ const scrollRef = useRef(null);
147
213
  const draggingRef = useRef(false);
148
214
  const rootRef = useRef(null);
149
- // Focus the dock the moment it opens. The transport shortcuts (Space play,
150
- // arrow frame-step) live on the SHELL window, so they only fire when focus is
151
- // in the shell — but opening the Timeline via ⌘⇧T while focus sat in the
152
- // canvas iframe (the common case after any text/element interaction) used to
153
- // leave focus stuck in the iframe, so Space/arrows silently did nothing.
154
- // Pulling focus here on mount (mount === open, the panel is conditionally
155
- // rendered) hands the transport keys a shell focus target immediately.
215
+ // Focus the dock the moment it opens — the transport shortcuts live on the
216
+ // SHELL window and need shell focus (see the pre-restructure history).
156
217
  useEffect(() => {
157
218
  rootRef.current?.focus();
158
219
  }, []);
159
- // Drag-to-retime a sequence's duration: { index, startX, startDur, rowW, curDur }.
220
+
221
+ // -------------------------------------------------------------------------
222
+ // Task 4 — zoom state. `zoomPxf == null` means fit-to-width (the default);
223
+ // any explicit value is clamped to [fit, MAX_PX_PER_FRAME].
224
+ const [zoomPxf, setZoomPxf] = useState(null);
225
+ const [viewW, setViewW] = useState(0);
226
+ // The scroll container mounts LATER than the panel (only once a comp is
227
+ // announced) — capture it via a callback ref so the ResizeObserver attaches
228
+ // whenever it (re)appears, not just on the panel's first render.
229
+ const [scrollEl, setScrollEl] = useState(null);
230
+ const setScrollRef = useCallback((el) => {
231
+ scrollRef.current = el;
232
+ setScrollEl(el);
233
+ }, []);
234
+ useLayoutEffect(() => {
235
+ if (!scrollEl) return undefined;
236
+ const measure = () => setViewW(scrollEl.clientWidth);
237
+ measure();
238
+ // WKWebView occasionally reports 0 on the mount frame (before layout
239
+ // settles) and the ResizeObserver never re-fires — the "zoom stopped
240
+ // working" symptom (fit-scale computed against a 40px axis). Re-measure on
241
+ // rAF until a real width lands.
242
+ let raf = 0;
243
+ const retry = () => {
244
+ if (scrollEl.clientWidth > 0) measure();
245
+ else raf = requestAnimationFrame(retry);
246
+ };
247
+ if (scrollEl.clientWidth <= 0) raf = requestAnimationFrame(retry);
248
+ const ro = new ResizeObserver(measure);
249
+ ro.observe(scrollEl);
250
+ return () => {
251
+ ro.disconnect();
252
+ if (raf) cancelAnimationFrame(raf);
253
+ };
254
+ }, [scrollEl]);
255
+ const axisViewport = Math.max(40, viewW - LABEL_GUTTER - SCROLL_PAD_X);
256
+ const fitPxf = fitPxPerFrame(axisViewport, totalFrames);
257
+ const pxf = zoomPxf == null ? fitPxf : clampPxPerFrame(zoomPxf, axisViewport, totalFrames);
258
+ const axisPx = Math.ceil(totalFrames * pxf);
259
+ const x = (f) => f * pxf; // axis-relative px for a frame
260
+
261
+ // Scroll compensation applied after a zoom (keeps the anchor stationary).
262
+ const pendingScrollRef = useRef(null);
263
+ useLayoutEffect(() => {
264
+ if (pendingScrollRef.current == null) return;
265
+ const el = scrollRef.current;
266
+ if (el) el.scrollLeft = pendingScrollRef.current;
267
+ pendingScrollRef.current = null;
268
+ });
269
+
270
+ /** Zoom to `nextPxf` keeping `anchorFrame` (default: playhead, or selection)
271
+ * at the same viewport x. */
272
+ const zoomTo = useCallback(
273
+ (nextRaw, anchorFrame) => {
274
+ const el = scrollRef.current;
275
+ const next = clampPxPerFrame(nextRaw, axisViewport, totalFrames);
276
+ const anchor =
277
+ anchorFrame != null
278
+ ? anchorFrame
279
+ : (sequences.find((s) => s.stableId && s.stableId === selectedClipId)?.from ?? clamped);
280
+ if (el) pendingScrollRef.current = zoomAroundScroll(pxf, next, anchor, el.scrollLeft);
281
+ setZoomPxf(next <= fitPxf ? null : next);
282
+ },
283
+ [axisViewport, totalFrames, pxf, fitPxf, clamped, sequences, selectedClipId]
284
+ );
285
+
286
+ // ⌘+/⌘− zoom · 0 = fit. Window-level (the dock is a sibling of the canvas),
287
+ // gated on not-typing; ⌘± deliberately overrides browser zoom while the
288
+ // timeline is open (the documented keyboard map).
289
+ useEffect(() => {
290
+ const onKey = (e) => {
291
+ const t = e.target;
292
+ const tag = t?.tagName;
293
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t?.isContentEditable) return;
294
+ if ((e.metaKey || e.ctrlKey) && (e.key === '=' || e.key === '+')) {
295
+ e.preventDefault();
296
+ zoomTo(pxf * ZOOM_STEP);
297
+ } else if ((e.metaKey || e.ctrlKey) && e.key === '-') {
298
+ e.preventDefault();
299
+ zoomTo(pxf / ZOOM_STEP);
300
+ } else if (e.key === '0' && !e.metaKey && !e.ctrlKey && !e.altKey) {
301
+ setZoomPxf(null);
302
+ } else if ((e.key === 'c' || e.key === 'C') && !e.metaKey && !e.ctrlKey && !e.altKey) {
303
+ // The C comment tool (mirrors the artboard's comment shortcut).
304
+ setCommentMode((m) => !m);
305
+ } else if (e.key === 'Escape') {
306
+ setCommentMode(false);
307
+ }
308
+ };
309
+ window.addEventListener('keydown', onKey);
310
+ return () => window.removeEventListener('keydown', onKey);
311
+ }, [pxf, zoomTo]);
312
+
313
+ // Pinch-zoom (trackpad pinch arrives as wheel + ctrlKey) around the cursor.
314
+ // Non-passive listener — preventDefault must actually stop the page zoom.
315
+ useEffect(() => {
316
+ const el = scrollRef.current;
317
+ if (!el) return undefined;
318
+ const onWheel = (e) => {
319
+ if (!e.ctrlKey && !e.metaKey) return; // plain wheel = native scroll
320
+ e.preventDefault();
321
+ const rect = trackRef.current?.getBoundingClientRect();
322
+ const anchor =
323
+ rect != null
324
+ ? clamp(Math.round(pxToFrame(e.clientX - rect.left - LABEL_GUTTER, pxf)), 0, totalFrames - 1)
325
+ : clamped;
326
+ zoomTo(pxf * Math.exp(-e.deltaY * 0.01), anchor);
327
+ };
328
+ el.addEventListener('wheel', onWheel, { passive: false });
329
+ return () => el.removeEventListener('wheel', onWheel);
330
+ }, [pxf, clamped, totalFrames, zoomTo, scrollEl]);
331
+
332
+ // Header slider — log scale from fit (0) to MAX_PX_PER_FRAME (100).
333
+ const zoomSliderValue = (() => {
334
+ const ratio = Math.log(pxf / fitPxf) / Math.log(MAX_PX_PER_FRAME / Math.max(1e-6, fitPxf));
335
+ return clamp(Math.round((Number.isFinite(ratio) ? ratio : 0) * 100), 0, 100);
336
+ })();
337
+ const zoomFromSlider = (v) => {
338
+ const ratio = clamp(v, 0, 100) / 100;
339
+ return fitPxf * Math.pow(MAX_PX_PER_FRAME / Math.max(1e-6, fitPxf), ratio);
340
+ };
341
+
342
+ // -------------------------------------------------------------------------
343
+ // Drag machines (retime / move) — px-per-frame based.
160
344
  const [retimeDrag, setRetimeDrag] = useState(null);
161
- // Drag the block body to move a clip's `from`: { index, startX, startFrom, rowW, curFrom }.
162
345
  const [moveDrag, setMoveDrag] = useState(null);
163
- // DDR-150 dogfood — right-click clip context menu: { index, x, y }. Dismiss +
164
- // repositioning are owned by the shared ContextMenuView.
346
+ // Task 6 — magnetic storyline reorder drag: { index, slot, targetSlot, dx, pxf }.
347
+ const [reorderDrag, setReorderDrag] = useState(null);
348
+ // Debate verdict (feedback): a momentary accent rule at the frame a drag just
349
+ // snapped to — magnetism is otherwise invisible. { frame } | null.
350
+ const [snapFlash, setSnapFlash] = useState(null);
351
+ const snapFlashTimer = useRef(null);
352
+ const flashSnap = useCallback((frame) => {
353
+ setSnapFlash({ frame });
354
+ clearTimeout(snapFlashTimer.current);
355
+ snapFlashTimer.current = setTimeout(() => setSnapFlash(null), 350);
356
+ }, []);
357
+ // Task 6 — file-drop insertion caret: { lane, slot?, frame?, x } (axis px).
358
+ const [dropCaret, setDropCaret] = useState(null);
359
+ // Dogfood (2026-07-30) — the C comment tool: while armed, a click anywhere on
360
+ // the timeline pins a comment to the exact frame (+ clip + lane when the
361
+ // click lands on a block) instead of scrubbing/selecting.
362
+ const [commentMode, setCommentMode] = useState(false);
363
+ // Dogfood (2026-07-30) — vertical layer reorder: dragging an overlay row's
364
+ // lane chip up/down re-stacks the standalone clips (z-order = doc order).
365
+ const [layerDrag, setLayerDrag] = useState(null);
366
+ // Right-click clip context menu: { index, x, y }.
165
367
  const [ctxMenu, setCtxMenu] = useState(null);
166
- // True after a body-drag actually moved — suppresses the click-to-seek that
167
- // would otherwise fire on pointerup.
368
+ // Task 23 — the open comment-thread popover: { id, x, y } | null.
369
+ const [commentPopover, setCommentPopover] = useState(null);
370
+ // Task 9 — the clip inspector popover: { mode: 'clip'|'transition', index?,
371
+ // seam?, x, y }. One instance; opened by double-click / context "Adjust…" /
372
+ // a seam chip click.
373
+ const [inspector, setInspector] = useState(null);
374
+ // Task 11 — left-edge in-point trim drag: { index, ref, startX, pxf, delta }.
375
+ const [trimInDrag, setTrimInDrag] = useState(null);
376
+ // True after a body-drag actually moved — suppresses the click-to-select.
168
377
  const movedRef = useRef(false);
169
- // Drag the top edge to resize the panel taller/shorter. Matches the DS
170
- // resize-panels reference: pointer-capture + window listeners attached
171
- // SYNCHRONOUSLY in pointerdown (a useEffect-on-state attaches a frame late and
172
- // drops the first moves — the "sticks / can't resize" jank).
378
+ // Drag the top edge to resize the panel — pointer-capture + window listeners
379
+ // attached SYNCHRONOUSLY in pointerdown (the "attach a frame late" lesson).
173
380
  const beginResize = useCallback(
174
381
  (e) => {
175
382
  e.preventDefault();
@@ -190,18 +397,15 @@ export default function TimelinePanel({
190
397
  [height, onResize]
191
398
  );
192
399
 
193
- // The row-track's frame axis starts AFTER the fixed label gutter (96px).
194
- const LABEL_GUTTER = 96;
195
400
  const seekAt = useCallback(
196
401
  (clientX) => {
197
402
  const el = trackRef.current;
198
403
  if (!el) return;
199
404
  const r = el.getBoundingClientRect();
200
- const axis = Math.max(1, r.width - LABEL_GUTTER);
201
- const pct = clamp((clientX - r.left - LABEL_GUTTER) / axis, 0, 1);
202
- onSeek?.(Math.round(pct * (totalFrames - 1)));
405
+ const f = pxToFrame(clientX - r.left - LABEL_GUTTER, pxf);
406
+ onSeek?.(clamp(Math.round(f), 0, totalFrames - 1));
203
407
  },
204
- [onSeek, totalFrames]
408
+ [onSeek, totalFrames, pxf]
205
409
  );
206
410
 
207
411
  useEffect(() => {
@@ -218,36 +422,82 @@ export default function TimelinePanel({
218
422
  };
219
423
  });
220
424
 
425
+ // Comment tool — pin a comment at the clicked frame. `seq`/`laneName` are
426
+ // set when the click landed on a clip block (anchor survives reorder via
427
+ // clipStableId + frameOffset); a bare-track click anchors the frame alone.
428
+ // The composer is a small popover ANCHORED at the click (the artboard
429
+ // comment grammar), not a center-screen modal (dogfood 2026-07-30).
430
+ const [commentDraft, setCommentDraft] = useState(null); // { x, y, anchor, at }
431
+ const placeCommentAt = useCallback(
432
+ (e, seq, laneName) => {
433
+ const el = trackRef.current;
434
+ if (!el || !onAddComment) return;
435
+ const r = el.getBoundingClientRect();
436
+ const f = clamp(
437
+ Math.round(pxToFrame(e.clientX - r.left - LABEL_GUTTER, pxf)),
438
+ 0,
439
+ totalFrames - 1
440
+ );
441
+ const anchor =
442
+ seq && seq.stableId
443
+ ? {
444
+ clipStableId: seq.stableId,
445
+ frameOffset: Math.max(0, f - (seq.from || 0)),
446
+ frame: f,
447
+ lane: laneName || undefined,
448
+ }
449
+ : { frame: f, lane: laneName || undefined };
450
+ setCommentDraft({
451
+ x: clamp(e.clientX - 130, 8, window.innerWidth - 280),
452
+ y: Math.max(48, e.clientY - 140),
453
+ anchor,
454
+ at: f,
455
+ });
456
+ setCommentMode(false);
457
+ },
458
+ [pxf, totalFrames, onAddComment]
459
+ );
460
+
221
461
  const onTrackDown = (e) => {
222
- // A click on a sequence block seeks to its start (handled there); a click on
223
- // the bare track scrubs + starts a drag.
462
+ // A click on a sequence block SELECTS it (handled there); a press on the
463
+ // bare track deselects, scrubs, and starts a scrub drag.
224
464
  if (retimeDrag) return; // a resize is in flight — don't scrub
465
+ if (commentMode) {
466
+ e.preventDefault();
467
+ placeCommentAt(e, null, null);
468
+ return;
469
+ }
225
470
  e.preventDefault(); // don't start a text selection while scrubbing
471
+ onSelect?.(null);
226
472
  draggingRef.current = true;
227
473
  seekAt(e.clientX);
228
- // force the effect to (re)attach the window listeners
229
474
  e.currentTarget.setPointerCapture?.(e.pointerId);
230
475
  };
231
476
 
232
- // DDR-148 drag-to-retime — dragging a block's right edge rewrites its
233
- // durationInFrames (const-preferring on the server, so a derived total moves
234
- // in lock-step). The width previews live; the source patch lands on release.
477
+ // Alt = no snap; threshold is ~8 px at the CURRENT zoom.
478
+ const snapThreshold = (altKey) => (altKey ? 0 : 8 / pxf);
479
+
480
+ // Drag-to-retime — the right edge rewrites durationInFrames on release.
235
481
  useEffect(() => {
236
482
  if (!retimeDrag) return undefined;
237
483
  const move = (e) => {
238
- const deltaFrames =
239
- ((e.clientX - retimeDrag.startX) / Math.max(1, retimeDrag.rowW)) * (totalFrames - 1);
240
- let end = retimeDrag.startFrom + Math.round(retimeDrag.startDur + deltaFrames);
241
- // Snap the moving EDGE (clip end) to ticks / neighbor edges / playhead —
242
- // Alt overrides. The clip's own start stays fixed, so duration = end − from.
243
- const thr = e.altKey ? 0 : snapThresholdFrames(retimeDrag.rowW, totalFrames);
244
- end = snapFrame(end, retimeDrag.targets, thr);
245
- const curDur = Math.max(1, end - retimeDrag.startFrom);
246
- setRetimeDrag((d) => (d ? { ...d, curDur } : d));
484
+ const deltaFrames = (e.clientX - retimeDrag.startX) / retimeDrag.pxf;
485
+ const rawEnd = retimeDrag.startFrom + Math.round(retimeDrag.startDur + deltaFrames);
486
+ let end = snapFrame(rawEnd, retimeDrag.targets, e.altKey ? 0 : 8 / retimeDrag.pxf);
487
+ if (end !== rawEnd) flashSnap(end);
488
+ let curDur = Math.max(1, end - retimeDrag.startFrom);
489
+ // Task 8 — clamp to the source's real duration (footage sidecar) so a
490
+ // trim can't ask the clip for frames the file doesn't have.
491
+ let atClamp = curDur <= 1;
492
+ if (retimeDrag.maxDur > 0 && curDur >= retimeDrag.maxDur) {
493
+ curDur = retimeDrag.maxDur;
494
+ atClamp = true;
495
+ }
496
+ setRetimeDrag((d) => (d ? { ...d, curDur, atClamp } : d));
247
497
  };
248
498
  const up = () => {
249
499
  setRetimeDrag((d) => {
250
- if (d && d.curDur !== d.startDur) onRetime?.(d.index, { durationInFrames: d.curDur });
500
+ if (d && d.curDur !== d.startDur) onRetime?.(d.ref ?? d.index, { durationInFrames: d.curDur });
251
501
  return null;
252
502
  });
253
503
  };
@@ -257,26 +507,41 @@ export default function TimelinePanel({
257
507
  window.removeEventListener('pointermove', move);
258
508
  window.removeEventListener('pointerup', up);
259
509
  };
260
- }, [retimeDrag, totalFrames, onRetime]);
510
+ }, [retimeDrag, onRetime, flashSnap]);
261
511
 
262
- // DDR-150 P3 Task 6 — drag the block BODY to move a clip's `from` (the engine
263
- // inserts `from` if the clip was cursor-implicit). Previews live; commits on
264
- // release. Distinct from the right-edge duration drag above.
512
+ // Body-drag to move a standalone clip's `from`. Commits on release.
265
513
  useEffect(() => {
266
514
  if (!moveDrag) return undefined;
267
515
  const move = (e) => {
268
- const deltaFrames =
269
- ((e.clientX - moveDrag.startX) / Math.max(1, moveDrag.rowW)) * (totalFrames - 1);
270
- let curFrom = Math.max(0, Math.round(moveDrag.startFrom + deltaFrames));
271
- // Snap the clip START to ticks / neighbor edges / playhead — Alt overrides.
272
- const thr = e.altKey ? 0 : snapThresholdFrames(moveDrag.rowW, totalFrames);
273
- curFrom = Math.max(0, snapFrame(curFrom, moveDrag.targets, thr));
516
+ const deltaFrames = (e.clientX - moveDrag.startX) / moveDrag.pxf;
517
+ const rawFrom = Math.max(0, Math.round(moveDrag.startFrom + deltaFrames));
518
+ const curFrom = Math.max(0, snapFrame(rawFrom, moveDrag.targets, e.altKey ? 0 : 8 / moveDrag.pxf));
519
+ if (curFrom !== rawFrom) flashSnap(curFrom);
274
520
  if (Math.abs(curFrom - moveDrag.startFrom) >= 1) movedRef.current = true;
275
- setMoveDrag((d) => (d ? { ...d, curFrom } : d));
521
+ // Vertical layer-move affordance: highlight the storyline while the clip
522
+ // hovers over it (the drop would move it between layers).
523
+ const story = document.querySelector('.tl-storyline-track')?.getBoundingClientRect();
524
+ const overStory = !!(story && e.clientY >= story.top && e.clientY <= story.bottom);
525
+ setMoveDrag((d) => (d ? { ...d, curFrom, overStory } : d));
276
526
  };
277
- const up = () => {
527
+ const up = (e) => {
278
528
  setMoveDrag((d) => {
279
- if (d && d.curFrom !== d.startFrom) onRetime?.(d.index, { from: d.curFrom });
529
+ if (d) {
530
+ // Dropping the clip ON the storyline lane moves it between layers
531
+ // (the layers model); otherwise commit the horizontal from-move.
532
+ const story = document.querySelector('.tl-storyline-track')?.getBoundingClientRect();
533
+ if (
534
+ story &&
535
+ onClipVerb &&
536
+ e.clientY >= story.top &&
537
+ e.clientY <= story.bottom &&
538
+ movedRef.current
539
+ ) {
540
+ onClipVerb(d.ref ?? { index: d.index }, 'to-storyline', {});
541
+ } else if (d.curFrom !== d.startFrom) {
542
+ onRetime?.(d.ref ?? d.index, { from: d.curFrom });
543
+ }
544
+ }
280
545
  return null;
281
546
  });
282
547
  };
@@ -286,28 +551,177 @@ export default function TimelinePanel({
286
551
  window.removeEventListener('pointermove', move);
287
552
  window.removeEventListener('pointerup', up);
288
553
  };
289
- }, [moveDrag, totalFrames, onRetime]);
554
+ }, [moveDrag, onRetime, onClipVerb, flashSnap]);
555
+
556
+ // Task 11 — left-edge in-point trim: preview live, commit `trim-in` on
557
+ // release (the server writes trimBefore + duration + from + ripple).
558
+ useEffect(() => {
559
+ if (!trimInDrag) return undefined;
560
+ const move = (e) => {
561
+ const raw = Math.round((e.clientX - trimInDrag.startX) / trimInDrag.pxf);
562
+ // positive = trim INTO the clip; clamp so ≥1 frame stays.
563
+ const delta = Math.min(trimInDrag.maxDelta, raw);
564
+ setTrimInDrag((d) => (d ? { ...d, delta } : d));
565
+ };
566
+ const up = () => {
567
+ setTrimInDrag((d) => {
568
+ if (d && d.delta) onClipVerb?.(d.ref, 'trim-in', { deltaFrames: d.delta });
569
+ return null;
570
+ });
571
+ };
572
+ window.addEventListener('pointermove', move);
573
+ window.addEventListener('pointerup', up, { once: true });
574
+ return () => {
575
+ window.removeEventListener('pointermove', move);
576
+ window.removeEventListener('pointerup', up);
577
+ };
578
+ }, [trimInDrag, onClipVerb]);
579
+
580
+ const startTrimIn = (e, index) => {
581
+ e.stopPropagation();
582
+ e.preventDefault();
583
+ const seq = sequences[index];
584
+ if (!seq) return;
585
+ setTrimInDrag({
586
+ index,
587
+ ref: refFor(seq, index),
588
+ startX: e.clientX,
589
+ pxf,
590
+ delta: 0,
591
+ maxDelta: Math.max(0, (seq.duration ?? 1) - 1),
592
+ });
593
+ };
290
594
 
291
595
  // Snap targets for a drag on clip `index`: ticks + OTHER clips' edges + playhead.
292
596
  const snapTargetsFor = (index) =>
293
597
  computeSnapTargets({ fps, totalFrames, clips: sequences, movingIndex: index, playhead: clamped });
294
598
 
599
+ // Task 3 — every op addresses a clip as { stableId, index }: stableId when the
600
+ // enumerator supplied one (multi-comp-safe, survives reorder), the row index
601
+ // as the legacy fallback (DDR-150: never address by row index when avoidable).
602
+ const refFor = (seq, index) => ({ stableId: seq?.stableId ?? null, index });
603
+
604
+ // Task 6 — magnetic reorder: body-dragging a series beat shuffles the
605
+ // storyline live (a `from` write would be a lie — Remotion computes series
606
+ // offsets), and commits as a real series MOVE on release.
607
+ const startReorder = (e, index) => {
608
+ e.stopPropagation();
609
+ if (!onReorderMove) return;
610
+ const story = sequences
611
+ .map((s, i) => ({ s, i }))
612
+ .filter(({ s }) => s.series);
613
+ const slot = story.findIndex(({ i }) => i === index);
614
+ if (slot < 0) return;
615
+ movedRef.current = false;
616
+ setReorderDrag({ index, slot, targetSlot: slot, dx: 0, startX: e.clientX, pxf });
617
+ };
618
+
619
+ useEffect(() => {
620
+ if (!reorderDrag) return undefined;
621
+ const story = sequences.map((s, i) => ({ s, i })).filter(({ s }) => s.series);
622
+ const move = (e) => {
623
+ const dx = e.clientX - reorderDrag.startX;
624
+ if (Math.abs(dx) > 3) movedRef.current = true;
625
+ const me = story[reorderDrag.slot];
626
+ if (!me) return;
627
+ const draggedCenter = me.s.from + me.s.duration / 2 + dx / reorderDrag.pxf;
628
+ // Slot = how many OTHER beats' centers sit left of the dragged center.
629
+ let target = 0;
630
+ story.forEach(({ s }, k) => {
631
+ if (k === reorderDrag.slot) return;
632
+ if (s.from + s.duration / 2 < draggedCenter) target += 1;
633
+ });
634
+ setReorderDrag((d) => (d ? { ...d, dx, targetSlot: target } : d));
635
+ };
636
+ const up = (e) => {
637
+ setReorderDrag((d) => {
638
+ if (d && movedRef.current && onClipVerb) {
639
+ const story = document.querySelector('.tl-storyline-track')?.getBoundingClientRect();
640
+ if (story && e.clientY < story.top - 6) {
641
+ const beat = sequences[d.index];
642
+ if (beat) {
643
+ onClipVerb(refFor(beat, d.index), 'to-overlay', {});
644
+ return null;
645
+ }
646
+ }
647
+ }
648
+ if (d && d.targetSlot !== d.slot) {
649
+ const moved = story[d.slot];
650
+ // targetSlot counts positions among the OTHER beats — map to a ref
651
+ // beat + before/after in the original order.
652
+ const others = story.filter((_, k) => k !== d.slot);
653
+ if (moved && others.length) {
654
+ if (d.targetSlot <= 0) {
655
+ onReorderMove?.(refFor(moved.s, moved.i), refFor(others[0].s, others[0].i), 'before');
656
+ } else {
657
+ const ref = others[Math.min(d.targetSlot, others.length) - 1];
658
+ onReorderMove?.(refFor(moved.s, moved.i), refFor(ref.s, ref.i), 'after');
659
+ }
660
+ }
661
+ }
662
+ return null;
663
+ });
664
+ };
665
+ window.addEventListener('pointermove', move);
666
+ window.addEventListener('pointerup', up, { once: true });
667
+ return () => {
668
+ window.removeEventListener('pointermove', move);
669
+ window.removeEventListener('pointerup', up);
670
+ };
671
+ }, [reorderDrag, sequences, onReorderMove, onClipVerb]);
672
+
673
+ // Vertical layer reorder — drag an overlay row's V-chip across the rows;
674
+ // the drop commits ONE `layer-order` verb (doc-order index = z-order).
675
+ const startLayerDrag = (e, index, docIdx) => {
676
+ if (!onClipVerb) return;
677
+ e.stopPropagation();
678
+ e.preventDefault();
679
+ setLayerDrag({ index, docIdx, curTarget: docIdx });
680
+ };
681
+ useEffect(() => {
682
+ if (!layerDrag) return undefined;
683
+ const move = (e) => {
684
+ let target = layerDrag.docIdx;
685
+ for (const row of document.querySelectorAll('[data-layer-doc]')) {
686
+ const r = row.getBoundingClientRect();
687
+ if (e.clientY >= r.top && e.clientY <= r.bottom) target = Number(row.dataset.layerDoc);
688
+ }
689
+ setLayerDrag((d) => (d ? { ...d, curTarget: target } : d));
690
+ };
691
+ const up = () => {
692
+ setLayerDrag((d) => {
693
+ if (d && d.curTarget !== d.docIdx) {
694
+ const seq = sequences[d.index];
695
+ if (seq) onClipVerb?.(refFor(seq, d.index), 'layer-order', { toIndex: d.curTarget });
696
+ }
697
+ return null;
698
+ });
699
+ };
700
+ window.addEventListener('pointermove', move);
701
+ window.addEventListener('pointerup', up, { once: true });
702
+ return () => {
703
+ window.removeEventListener('pointermove', move);
704
+ window.removeEventListener('pointerup', up);
705
+ };
706
+ }, [layerDrag, sequences, onClipVerb]);
707
+
295
708
  const startMove = (e, index, startFrom) => {
296
- if (!onRetime) return;
709
+ if (sequences[index]?.loose) return; // loose media row — read-only
297
710
  // A TransitionSeries/Series clip computes its own position — Remotion
298
- // IGNORES a `from` prop on it, so a body-drag would write a no-op lie
299
- // (the "video never changes" dogfood bug). The engine refuses it too;
300
- // don't even start the gesture.
301
- if (sequences[index]?.series) return;
711
+ // IGNORES a `from` prop on it; its body-drag is the magnetic REORDER.
712
+ if (sequences[index]?.series) {
713
+ startReorder(e, index);
714
+ return;
715
+ }
716
+ if (!onRetime) return;
302
717
  e.stopPropagation();
303
- const rowTrack = e.currentTarget.closest('.tl-row-track');
304
- const rowW = rowTrack ? rowTrack.getBoundingClientRect().width : 300;
305
718
  movedRef.current = false;
306
719
  setMoveDrag({
307
720
  index,
721
+ ref: refFor(sequences[index], index),
308
722
  startX: e.clientX,
309
723
  startFrom,
310
- rowW,
724
+ pxf,
311
725
  curFrom: startFrom,
312
726
  targets: snapTargetsFor(index),
313
727
  });
@@ -316,42 +730,625 @@ export default function TimelinePanel({
316
730
  const startResize = (e, index, dur) => {
317
731
  e.stopPropagation();
318
732
  e.preventDefault();
319
- const rowTrack = e.currentTarget.closest('.tl-row-track');
320
- const rowW = rowTrack ? rowTrack.getBoundingClientRect().width : 300;
321
- const startFrom = sequences[index]?.from ?? 0;
733
+ const seq = sequences[index];
734
+ const startFrom = seq?.from ?? 0;
322
735
  setRetimeDrag({
323
736
  index,
737
+ ref: refFor(seq, index),
324
738
  startX: e.clientX,
325
739
  startDur: dur,
326
740
  startFrom,
327
- rowW,
741
+ pxf,
328
742
  curDur: dur,
743
+ atClamp: false,
744
+ // Task 8 — source-bounds clamp (0 = unknown, no clamp).
745
+ maxDur: (seq?.mediaSrc && visuals[`d|${seq.mediaSrc}`]) || 0,
329
746
  targets: snapTargetsFor(index),
330
747
  });
331
748
  };
332
749
 
333
- // Second ticks across the ruler.
334
- const secs = Math.max(1, Math.floor((totalFrames - 1) / fps));
335
- const ticks = Array.from({ length: secs + 1 }, (_, i) => (i * fps) / (totalFrames - 1));
336
- const pct = (f) => `${(clamp(f, 0, totalFrames - 1) / (totalFrames - 1)) * 100}%`;
750
+ /** Task 8 — live trim/move delta: `+12f / +0.40s`. */
751
+ const fmtDelta = (frames) => {
752
+ const sign = frames >= 0 ? '+' : '−';
753
+ const af = Math.abs(Math.round(frames));
754
+ return `${sign}${af}f / ${sign}${(af / (fps || 30)).toFixed(2)}s`;
755
+ };
756
+
757
+ // Ruler ticks at the adaptive density (1 s → 10 f → 1 f as you zoom in).
758
+ const step = tickStepFrames(pxf, fps);
759
+ const ticks = [];
760
+ for (let f = 0; f <= totalFrames - 1; f += step) ticks.push(f);
761
+
762
+ // -------------------------------------------------------------------------
763
+ // Task 5 — the three-band projection. Series beats form THE storyline lane;
764
+ // standalone sequences are the overlay band; loose <Audio> beds the audio
765
+ // band. A comp with no series clips keeps the stacked row-per-sequence
766
+ // fallback (its truthful projection).
767
+ const storyline = [];
768
+ const overlay = [];
769
+ const audioClips = []; // Task 18 — standalone audio clips live in the audio band
770
+ sequences.forEach((seq, i) => {
771
+ if (seq.series) storyline.push({ seq, i });
772
+ else if (rowKind(seq) === 'audio') audioClips.push({ seq, i });
773
+ else overlay.push({ seq, i });
774
+ });
775
+ // Band (iMovie) layout is for MEDIA cuts. A purely digital comp — every
776
+ // sequence a hand-authored JSX scene (Maude Video Intro, Photo Editor
777
+ // Trailer) — keeps the stacked row-per-sequence projection, which is the
778
+ // truthful, layer-expandable view the user asked back (dogfood 2026-07-30).
779
+ const bandMode =
780
+ storyline.length > 0 && sequences.some((s) => rowKind(s) !== 'jsx');
337
781
 
338
- // DDR-150 P4 Task 11 — drop a media file onto the timeline to insert a clip.
782
+ // -------------------------------------------------------------------------
783
+ // Task 7 — real media visuals. Async extraction (never blocks the scrub);
784
+ // today's plain blocks are the instant fallback. Keyed by src + zoom bucket.
785
+ const [visuals, setVisuals] = useState({});
786
+ const resolveUrl = useCallback(
787
+ (src) => {
788
+ if (!src) return null;
789
+ // Security (adversarial review 2026-07-30): mediaSrc comes verbatim from
790
+ // (untrusted, DDR-054) canvas source. Filmstrip/waveform extraction runs
791
+ // on the privileged no-CSP SHELL origin (fetch + <video src>), so a
792
+ // protocol-relative `//host/…` — which passes `startsWith('/')` — would
793
+ // egress to an attacker host zero-click on Timeline open. Reject `//`
794
+ // (and any scheme); only a single-slash-rooted, same-origin path is a
795
+ // real contained asset. Everything else routes through resolveMediaUrl,
796
+ // which prefixes the designRoot (same-origin by construction).
797
+ if (src.startsWith('//')) return null;
798
+ if (/^(https?:|data:|blob:)/i.test(src)) return null;
799
+ if (src.startsWith('/')) return src;
800
+ return resolveMediaUrl ? resolveMediaUrl(src) : null;
801
+ },
802
+ [resolveMediaUrl]
803
+ );
804
+ useEffect(() => {
805
+ let alive = true;
806
+ for (const seq of sequences) {
807
+ if (rowKind(seq) !== 'video' || !seq.mediaSrc) continue;
808
+ const url = resolveUrl(seq.mediaSrc);
809
+ if (!url) continue;
810
+ const bucket = stripBucket(Math.max(2, seq.duration * pxf));
811
+ const key = `s|${seq.mediaSrc}|${bucket}`;
812
+ if (visuals[key] !== undefined) continue;
813
+ setVisuals((v) => (v[key] !== undefined ? v : { ...v, [key]: null }));
814
+ getFilmstrip(url, bucket).then((strip) => {
815
+ if (alive && strip) setVisuals((v) => ({ ...v, [key]: strip }));
816
+ });
817
+ }
818
+ for (const a of audio) {
819
+ if (!a.src) continue;
820
+ const url = resolveUrl(a.src);
821
+ if (!url) continue;
822
+ const key = `p|${a.src}`;
823
+ if (visuals[key] !== undefined) continue;
824
+ setVisuals((v) => (v[key] !== undefined ? v : { ...v, [key]: null }));
825
+ getPeaks(url).then((peaks) => {
826
+ if (alive && peaks) setVisuals((v) => ({ ...v, [key]: peaks }));
827
+ });
828
+ }
829
+ // Task 8 — source duration (frames) from the footage sidecar, for trim
830
+ // clamping. Cached under `d|<src>`; absent sidecar caches `0` (no clamp).
831
+ for (const seq of sequences) {
832
+ if (rowKind(seq) !== 'video' || !seq.mediaSrc) continue;
833
+ const key = `d|${seq.mediaSrc}`;
834
+ if (visuals[key] !== undefined) continue;
835
+ setVisuals((v) => (v[key] !== undefined ? v : { ...v, [key]: null }));
836
+ fetch(`/_api/footage?asset=${encodeURIComponent(seq.mediaSrc)}`)
837
+ .then((r) => (r.ok ? r.json() : null))
838
+ .then((j) => {
839
+ if (!alive) return;
840
+ const sec = Number(j?.durationSec);
841
+ const frames = Number.isFinite(sec) && sec > 0 ? Math.round(sec * (fps || 30)) : 0;
842
+ setVisuals((v) => ({ ...v, [key]: frames }));
843
+ })
844
+ .catch(() => {
845
+ if (alive) setVisuals((v) => ({ ...v, [key]: 0 }));
846
+ });
847
+ }
848
+ return () => {
849
+ alive = false;
850
+ };
851
+ }, [sequences, audio, pxf, resolveUrl, visuals, fps]);
852
+
853
+ // Task 6 — live reorder preview: while a beat drags, the OTHER beats take
854
+ // their preview `from`s (cursor walk over the preview order, seams
855
+ // approximated by the first transition's duration); the dragged beat follows
856
+ // the pointer. Truth re-renders from disk after the commit.
857
+ const previewFroms = (() => {
858
+ if (!reorderDrag) return null;
859
+ const story = sequences.map((s, i) => ({ s, i })).filter(({ s }) => s.series);
860
+ const dragged = story[reorderDrag.slot];
861
+ if (!dragged) return null;
862
+ const seamDur = (transitions && transitions[0]?.dur) || 0;
863
+ const others = story.filter((_, k) => k !== reorderDrag.slot);
864
+ const order = [...others];
865
+ order.splice(Math.max(0, Math.min(reorderDrag.targetSlot, others.length)), 0, dragged);
866
+ const map = new Map();
867
+ let cursor = story[0]?.s.from ?? 0;
868
+ for (const { s, i } of order) {
869
+ map.set(i, cursor);
870
+ cursor += s.duration - seamDur;
871
+ }
872
+ return map;
873
+ })();
874
+
875
+ // DDR-150 P4 / Task 6 — file drops. Lanes set an insertion caret and hand a
876
+ // POSITION to onDropMedia(files, pos); the panel-level drop stays as the
877
+ // append fallback.
339
878
  const hasFiles = (dt) => Array.from(dt?.types ?? []).includes('Files');
340
879
  const onDrop = (e) => {
341
880
  if (!onDropMedia || !hasFiles(e.dataTransfer)) return;
342
881
  e.preventDefault();
343
882
  e.stopPropagation();
344
883
  setDropActive(false);
345
- const file = e.dataTransfer.files?.[0];
346
- if (file) onDropMedia(file);
884
+ setDropCaret(null);
885
+ const files = Array.from(e.dataTransfer.files || []);
886
+ // A drop OUTSIDE any lane = a NEW overlay layer at the playhead (the
887
+ // layers model); dropping ON the storyline lane still makes a beat.
888
+ if (files.length) onDropMedia(files, comp ? { lane: 'overlay', frame: clamped } : null);
889
+ };
890
+
891
+ /** Storyline seam boundaries in axis px (slot 0 … N). */
892
+ const storyBoundaries = () => {
893
+ const story = sequences.filter((s) => s.series);
894
+ const pts = story.map((s) => x(s.from));
895
+ if (story.length) {
896
+ const last = story[story.length - 1];
897
+ pts.push(x(last.from + last.duration));
898
+ }
899
+ return pts;
900
+ };
901
+
902
+ const onStoryDragOver = (e) => {
903
+ if (!onDropMedia || !hasFiles(e.dataTransfer)) return;
904
+ e.preventDefault();
905
+ e.stopPropagation();
906
+ e.dataTransfer.dropEffect = 'copy';
907
+ const rect = e.currentTarget.getBoundingClientRect();
908
+ const px = e.clientX - rect.left;
909
+ const pts = storyBoundaries();
910
+ let slot = 0;
911
+ let best = Infinity;
912
+ pts.forEach((p, k) => {
913
+ const d = Math.abs(p - px);
914
+ if (d < best) {
915
+ best = d;
916
+ slot = k;
917
+ }
918
+ });
919
+ setDropCaret({ lane: 'storyline', slot, x: pts[slot] ?? px });
347
920
  };
348
921
 
922
+ const onLaneDragOver = (lane, row) => (e) => {
923
+ if (!onDropMedia || !hasFiles(e.dataTransfer)) return;
924
+ e.preventDefault();
925
+ e.stopPropagation();
926
+ e.dataTransfer.dropEffect = 'copy';
927
+ const rect = e.currentTarget.getBoundingClientRect();
928
+ const px = e.clientX - rect.left;
929
+ const f = clamp(Math.round(pxToFrame(px, pxf)), 0, totalFrames - 1);
930
+ setDropCaret({ lane, row, frame: f, x: px });
931
+ };
932
+
933
+ const laneCaret = (lane, row) =>
934
+ dropCaret && dropCaret.lane === lane && dropCaret.row === row ? (
935
+ <span
936
+ className="tl-drop-caret"
937
+ data-testid="timeline-drop-caret"
938
+ style={{ left: dropCaret.x - 1 }}
939
+ />
940
+ ) : null;
941
+
942
+ const onLaneDrop = (pos) => (e) => {
943
+ if (!onDropMedia || !hasFiles(e.dataTransfer)) return;
944
+ e.preventDefault();
945
+ e.stopPropagation();
946
+ setDropActive(false);
947
+ const caret = dropCaret;
948
+ setDropCaret(null);
949
+ const files = Array.from(e.dataTransfer.files || []);
950
+ if (!files.length) return;
951
+ if (pos.lane === 'storyline') {
952
+ onDropMedia(files, { lane: 'storyline', index: caret?.slot ?? undefined });
953
+ } else {
954
+ onDropMedia(files, { lane: pos.lane, frame: caret?.frame ?? 0 });
955
+ }
956
+ };
957
+
958
+ /** One clip block + its hover affordances (trim handle, replace, remove),
959
+ * absolutely positioned on the frame axis of its lane. */
960
+ const renderBlock = (seq, i, { lane, laneName }) => {
961
+ const dur = retimeDrag && retimeDrag.index === i ? retimeDrag.curDur : seq.duration;
962
+ const isResizing = !!(retimeDrag && retimeDrag.index === i);
963
+ let blockFrom = moveDrag && moveDrag.index === i ? moveDrag.curFrom : seq.from;
964
+ const moving = !!(moveDrag && moveDrag.index === i);
965
+ // Task 6 — magnetic reorder preview: the dragged beat follows the pointer,
966
+ // its siblings take their preview slots.
967
+ let dragOffset = 0;
968
+ let dragging = false;
969
+ if (lane === 'storyline' && reorderDrag) {
970
+ if (reorderDrag.index === i) {
971
+ dragging = true;
972
+ dragOffset = reorderDrag.dx;
973
+ } else if (previewFroms?.has(i)) {
974
+ blockFrom = previewFroms.get(i);
975
+ }
976
+ }
977
+ const layers = Array.isArray(seq.layers) ? seq.layers : [];
978
+ const decomposable = layers.length >= 2;
979
+ const selected = selectedClipId != null && seq.stableId === selectedClipId;
980
+ // Debate verdict (orientation): never surface a content-hash filename or a
981
+ // bare tag name as the clip's identity — "Clip N" beats "bca08b1e.mov";
982
+ // the real src stays in the tooltip.
983
+ const rawName =
984
+ ['Video', 'OffthreadVideo', 'Audio', 'Img'].includes(seq.label) && seq.mediaSrc
985
+ ? String(seq.mediaSrc).split('/').pop()
986
+ : seq.label;
987
+ const isHashName = /^[a-f0-9]{6,16}\.\w+$/i.test(rawName || '');
988
+ const displayLabel = isHashName ? `Clip ${i + 1}` : rawName;
989
+ // Task 11 — in-point trim preview: left edge follows, width shrinks.
990
+ const trimming = !!(trimInDrag && trimInDrag.index === i);
991
+ const trimDelta = trimming ? trimInDrag.delta : 0;
992
+ const left = x(blockFrom) + dragOffset + (trimming ? x(trimDelta) : 0);
993
+ const width = Math.max(2, x(dur - trimDelta));
994
+ const mp = seq.mediaProps || null;
995
+ return (
996
+ <Fragment key={`b${i}`}>
997
+ <button
998
+ type="button"
999
+ className={`tl-seq-block${isResizing ? ' is-resizing' : ''}${moving ? ' is-moving' : ''}${dragging ? ' is-dragging' : ''}${seq.hidden ? ' is-hidden' : ''}${selected ? ' is-selected' : ''}${lane === 'storyline' ? ' tl-beat' : ''}`}
1000
+ data-testid={`timeline-seq-${i}`}
1001
+ data-clip-id={seq.stableId || undefined}
1002
+ data-kind={rowKind(seq)}
1003
+ data-hidden={seq.hidden ? '1' : undefined}
1004
+ title={
1005
+ seq.series
1006
+ ? `${seq.label} · ${blockFrom}–${blockFrom + dur}f (${dur}f) · series beat — trim the right edge to retime`
1007
+ : `${seq.label} · ${blockFrom}–${blockFrom + dur}f (${dur}f) · drag to move (Alt = no snap)`
1008
+ }
1009
+ aria-label={
1010
+ seq.series
1011
+ ? `${seq.label}, series beat, frames ${blockFrom} to ${blockFrom + dur}. Click to select.`
1012
+ : `${seq.label}, from frame ${blockFrom} to ${blockFrom + dur}, ${dur} frames. Click to select; drag to move.`
1013
+ }
1014
+ style={{
1015
+ left,
1016
+ width,
1017
+ cursor: onRetime && !seq.series ? 'grab' : undefined,
1018
+ }}
1019
+ onContextMenu={(e) => {
1020
+ e.preventDefault();
1021
+ e.stopPropagation();
1022
+ onSelect?.(seq.stableId ?? null); // right-click selects too (iMovie)
1023
+ setCtxMenu({ index: i, x: e.clientX, y: e.clientY });
1024
+ }}
1025
+ onPointerDown={(e) => {
1026
+ if (commentMode) {
1027
+ e.stopPropagation();
1028
+ e.preventDefault();
1029
+ placeCommentAt(e, seq, laneName || lane);
1030
+ return;
1031
+ }
1032
+ startMove(e, i, seq.from);
1033
+ }}
1034
+ onDoubleClick={(e) => {
1035
+ e.stopPropagation();
1036
+ if (seq.loose) return; // read-only row — no clip inspector
1037
+ // Task 9 — double-click opens the clip inspector on the selection.
1038
+ onSelect?.(seq.stableId ?? null);
1039
+ setInspector({ mode: 'clip', index: i, x: e.clientX - 120, y: Math.max(60, e.clientY - 320) });
1040
+ }}
1041
+ onClick={(e) => {
1042
+ e.stopPropagation();
1043
+ if (movedRef.current) {
1044
+ movedRef.current = false;
1045
+ return; // a drag-move just happened — don't also select
1046
+ }
1047
+ // Task 3 — click SELECTS (iMovie's select → act grammar); seeking
1048
+ // stays on the ruler/track scrub.
1049
+ onSelect?.(seq.stableId ?? null);
1050
+ }}
1051
+ >
1052
+ {mp?.playbackRate && mp.playbackRate !== 1 ? (
1053
+ <span className="tl-chip tl-chip-speed" data-testid={`timeline-speed-chip-${i}`}>
1054
+ {mp.playbackRate}×
1055
+ </span>
1056
+ ) : null}
1057
+ {mp?.muted ? (
1058
+ <span className="tl-chip tl-chip-mute" title="Muted" data-testid={`timeline-mute-chip-${i}`}>
1059
+ 🔇
1060
+ </span>
1061
+ ) : null}
1062
+ {(() => {
1063
+ // Task 7 — filmstrip behind the label (async; block is the fallback).
1064
+ // Dogfood 2026-07-30 — an Img overlay shows the IMAGE ITSELF (no
1065
+ // extraction needed; the strip was video-only and image overlays
1066
+ // read as anonymous slabs).
1067
+ if (rowKind(seq) === 'image' && seq.mediaSrc) {
1068
+ const url = resolveUrl(seq.mediaSrc);
1069
+ if (!url) return null;
1070
+ return (
1071
+ <span className="tl-strip tl-strip--img" aria-hidden="true">
1072
+ <img src={url} alt="" draggable={false} />
1073
+ </span>
1074
+ );
1075
+ }
1076
+ if (rowKind(seq) !== 'video' || !seq.mediaSrc) return null;
1077
+ const bucket = stripBucket(Math.max(2, seq.duration * pxf));
1078
+ const strip = visuals[`s|${seq.mediaSrc}|${bucket}`];
1079
+ if (!strip) return null;
1080
+ return (
1081
+ <span className="tl-strip" aria-hidden="true">
1082
+ {strip.map((u, k) => (
1083
+ <img key={k} src={u} alt="" draggable={false} />
1084
+ ))}
1085
+ </span>
1086
+ );
1087
+ })()}
1088
+ <span className="tl-kind" data-kind={rowKind(seq)} title={rowKindTitle(seq)}>
1089
+ {rowKindGlyph(seq)}
1090
+ </span>
1091
+ <span className="tl-seq-name">
1092
+ {displayLabel}
1093
+ {isResizing ? ` · ${dur}f` : ''}
1094
+ {moving ? ` · @${blockFrom}f` : ''}
1095
+ </span>
1096
+ {seq.keyframes.map((kf, k) => {
1097
+ const l = ((kf.from - seq.from) / Math.max(1, seq.duration)) * 100;
1098
+ const w = ((kf.to - kf.from) / Math.max(1, seq.duration)) * 100;
1099
+ return (
1100
+ <span
1101
+ key={k}
1102
+ className="tl-kf"
1103
+ data-testid={`timeline-kf-${i}-${k}`}
1104
+ style={{ left: `${clamp(l, 0, 100)}%`, width: `${Math.max(1.5, w)}%` }}
1105
+ title={`seek to keyframe ${kf.from}f (animates ${kf.from}–${kf.to}f)`}
1106
+ onPointerDown={(e) => e.stopPropagation()}
1107
+ onClick={(e) => {
1108
+ e.stopPropagation();
1109
+ onSeek?.(kf.from);
1110
+ }}
1111
+ />
1112
+ );
1113
+ })}
1114
+ {(comments || [])
1115
+ .filter((c) => c.timeline?.clipStableId && c.timeline.clipStableId === seq.stableId)
1116
+ .map((c) => (
1117
+ // In-place 💬 badge at the comment's exact frame ON the clip —
1118
+ // the top strip stays as the overview; this is the "where".
1119
+ <span
1120
+ key={`cm${c.id}`}
1121
+ role="button"
1122
+ tabIndex={-1}
1123
+ className={`tl-clip-pin${c.status === 'resolved' ? ' is-resolved' : ''}`}
1124
+ data-testid={`timeline-clip-pin-${c.id}`}
1125
+ title={`${c.author || 'comment'}: ${c.text}`}
1126
+ style={{ left: x(clamp(c.timeline.frameOffset || 0, 0, Math.max(0, dur - 1))) }}
1127
+ onPointerDown={(ev) => ev.stopPropagation()}
1128
+ onClick={(ev) => {
1129
+ ev.stopPropagation();
1130
+ setCommentPopover({ id: c.id, x: ev.clientX - 130, y: Math.max(60, ev.clientY - 180) });
1131
+ }}
1132
+ >
1133
+ 💬
1134
+ </span>
1135
+ ))}
1136
+ {lane === 'storyline' && decomposable ? (
1137
+ <span
1138
+ role="button"
1139
+ tabIndex={-1}
1140
+ className="tl-beat-expand"
1141
+ data-testid={`timeline-beat-expand-${i}`}
1142
+ title={collapsed.has(i) ? 'Show this beat’s layers' : 'Collapse layers'}
1143
+ onPointerDown={(ev) => ev.stopPropagation()}
1144
+ onClick={(ev) => {
1145
+ ev.stopPropagation();
1146
+ setCollapsed((prev) => {
1147
+ const next = new Set(prev);
1148
+ if (next.has(i)) next.delete(i);
1149
+ else next.add(i);
1150
+ return next;
1151
+ });
1152
+ }}
1153
+ >
1154
+ {collapsed.has(i) ? '▸' : '▾'}
1155
+ </span>
1156
+ ) : null}
1157
+ </button>
1158
+ {onRetime && !seq.loose ? (
1159
+ <span
1160
+ className="tl-seq-resize"
1161
+ data-testid={`timeline-resize-${i}`}
1162
+ title="Drag to retime this sequence"
1163
+ style={{ left: left + width - 5 }}
1164
+ onPointerDown={(e) => startResize(e, i, seq.duration)}
1165
+ onClick={(e) => e.stopPropagation()}
1166
+ />
1167
+ ) : null}
1168
+ {onClipVerb && !seq.loose && seq.mediaSrc && (rowKind(seq) === 'video' || rowKind(seq) === 'audio') ? (
1169
+ <span
1170
+ className="tl-seq-trim-in"
1171
+ data-testid={`timeline-trim-in-${i}`}
1172
+ title="Drag to trim the in-point (left edge)"
1173
+ style={{ left: left - 1 }}
1174
+ onPointerDown={(e) => startTrimIn(e, i)}
1175
+ onClick={(e) => e.stopPropagation()}
1176
+ />
1177
+ ) : null}
1178
+ {trimming ? (
1179
+ <span
1180
+ className={`tl-trim-tip${trimDelta >= trimInDrag.maxDelta && trimDelta > 0 ? ' is-clamped' : ''}`}
1181
+ data-testid="timeline-trim-in-tip"
1182
+ style={{ left }}
1183
+ >
1184
+ {fmtDelta(trimDelta)}
1185
+ </span>
1186
+ ) : null}
1187
+ {isResizing ? (
1188
+ <span
1189
+ className={`tl-trim-tip${retimeDrag.atClamp ? ' is-clamped' : ''}`}
1190
+ data-testid="timeline-trim-tip"
1191
+ style={{ left: left + width + 8 }}
1192
+ >
1193
+ {fmtDelta(dur - retimeDrag.startDur)}
1194
+ </span>
1195
+ ) : null}
1196
+ {moving ? (
1197
+ <span className="tl-trim-tip" data-testid="timeline-move-tip" style={{ left }}>
1198
+ {fmtDelta(blockFrom - moveDrag.startFrom)}
1199
+ </span>
1200
+ ) : null}
1201
+ {onReplace && seq.replaceable && !decomposable ? (
1202
+ <button
1203
+ type="button"
1204
+ className="tl-seq-replace"
1205
+ data-testid={`timeline-replace-${i}`}
1206
+ title={`Replace this ${rowKind(seq)}`}
1207
+ aria-label={`Replace media in ${seq.label}`}
1208
+ style={{ left: left + width - 34 }}
1209
+ onPointerDown={(e) => e.stopPropagation()}
1210
+ onClick={(e) => {
1211
+ e.stopPropagation();
1212
+ onReplace(refFor(seq, i));
1213
+ }}
1214
+ >
1215
+ ⇄
1216
+ </button>
1217
+ ) : null}
1218
+ </Fragment>
1219
+ );
1220
+ };
1221
+
1222
+ /** Task 17 — hover "+" on hard-cut boundaries (no transition yet). */
1223
+ const renderAddSeams = () => {
1224
+ if (!onClipVerb) return null;
1225
+ const withTrans = new Set((transitions || []).map((t) => t.afterIndex));
1226
+ return storyline.slice(0, -1).map(({ seq, i }, k) => {
1227
+ if (withTrans.has(i)) return null;
1228
+ const next = storyline[k + 1];
1229
+ if (!next) return null;
1230
+ return (
1231
+ <button
1232
+ key={`addseam${k}`}
1233
+ type="button"
1234
+ className="tl-seam tl-seam-add"
1235
+ data-testid={`timeline-seam-add-${k}`}
1236
+ title="Add transition (fade · 15f — click the chip after to change)"
1237
+ style={{ left: x(next.seq.from) - 9 }}
1238
+ onPointerDown={(e) => e.stopPropagation()}
1239
+ onClick={(e) => {
1240
+ e.stopPropagation();
1241
+ onClipVerb(refFor(seq, i), 'insert-transition', {
1242
+ presentation: 'fade',
1243
+ durationInFrames: 15,
1244
+ });
1245
+ }}
1246
+ >
1247
+ <LIc as={Plus} size={13} />
1248
+ </button>
1249
+ );
1250
+ });
1251
+ };
1252
+
1253
+ /** ⧓ seam chips between storyline beats — click opens the Transition tab. */
1254
+ const renderSeams = () =>
1255
+ (transitions || []).map((t, k) => {
1256
+ const next = sequences[t.afterIndex + 1];
1257
+ if (!next) return null;
1258
+ const center = x(next.from + (t.dur || 0) / 2);
1259
+ return (
1260
+ <button
1261
+ key={`seam${k}`}
1262
+ type="button"
1263
+ className="tl-seam"
1264
+ data-testid={`timeline-seam-${k}`}
1265
+ title={`Transition · ${t.dur || 0}f — click to edit`}
1266
+ style={{ left: center - 9 }}
1267
+ onPointerDown={(e) => e.stopPropagation()}
1268
+ onClick={(e) => {
1269
+ e.stopPropagation();
1270
+ setInspector({
1271
+ mode: 'transition',
1272
+ seam: k,
1273
+ x: e.clientX - 120,
1274
+ y: Math.max(60, e.clientY - 260),
1275
+ });
1276
+ }}
1277
+ >
1278
+ ⧓
1279
+ </button>
1280
+ );
1281
+ });
1282
+
1283
+ const renderAudioRow = (a, i) => (
1284
+ <div className="tl-row" key={`a${i}`} data-testid={`timeline-audio-${i}`}>
1285
+ <span className="tl-row-label" title={a.label}>
1286
+ <span className="tl-kind" data-kind="audio" title={`Audio · ${a.label}`} aria-label={`Audio · ${a.label}`}>
1287
+ ♪
1288
+ </span>
1289
+ <span className="tl-row-label-text">{a.label}</span>
1290
+ </span>
1291
+ <div
1292
+ className="tl-row-track"
1293
+ style={{ width: axisPx, flex: 'none' }}
1294
+ onDragOver={onLaneDragOver('audio', `a${i}`)}
1295
+ onDrop={onLaneDrop({ lane: 'audio' })}
1296
+ >
1297
+ {laneCaret('audio', `a${i}`)}
1298
+ <div
1299
+ className="tl-audio-block"
1300
+ title={`${a.label} · ${a.from}–${a.from + a.duration}f`}
1301
+ style={{ left: x(a.from), width: Math.max(2, x(a.duration)) }}
1302
+ >
1303
+ <svg className="tl-wave" viewBox="0 0 100 12" preserveAspectRatio="none" aria-hidden>
1304
+ {a.src && visuals[`p|${a.src}`] ? (
1305
+ // Task 7 — real peaks (WebAudio decode), filled mirrored wave.
1306
+ <path className="tl-wave-real" d={peaksToPath(visuals[`p|${a.src}`])} />
1307
+ ) : (
1308
+ <path d="M0 6 Q2 1 4 6 T8 6 T12 6 T16 6 T20 6 T24 6 T28 6 T32 6 T36 6 T40 6 T44 6 T48 6 T52 6 T56 6 T60 6 T64 6 T68 6 T72 6 T76 6 T80 6 T84 6 T88 6 T92 6 T96 6 T100 6" />
1309
+ )}
1310
+ </svg>
1311
+ <span className="tl-seq-name">{a.label}</span>
1312
+ </div>
1313
+ {onReplaceAudio ? (
1314
+ <button
1315
+ type="button"
1316
+ className="tl-seq-replace"
1317
+ data-testid={`timeline-audio-replace-${i}`}
1318
+ title="Replace this audio"
1319
+ aria-label={`Replace audio ${a.label}`}
1320
+ style={{ left: x(a.from) + Math.max(2, x(a.duration)) - 20 }}
1321
+ onPointerDown={(e) => e.stopPropagation()}
1322
+ onClick={(e) => {
1323
+ e.stopPropagation();
1324
+ onReplaceAudio(i);
1325
+ }}
1326
+ >
1327
+ ⇄
1328
+ </button>
1329
+ ) : null}
1330
+ </div>
1331
+ </div>
1332
+ );
1333
+
349
1334
  return (
350
1335
  <aside
351
1336
  ref={rootRef}
352
1337
  tabIndex={-1}
353
- className={`tl-panel${resizing ? ' is-resizing' : ''}${dropActive ? ' is-drop' : ''}`}
1338
+ className={`tl-panel${resizing ? ' is-resizing' : ''}${dropActive ? ' is-drop' : ''}${commentMode ? ' is-commenting' : ''}`}
354
1339
  style={{ ...(height ? { height } : {}), outline: 'none' }}
1340
+ onPointerEnter={() => {
1341
+ // Keyboard shortcuts (C · Space · ⌘B · ⌘±) live on the SHELL window;
1342
+ // when the canvas iframe holds focus they never arrive. Hovering the
1343
+ // dock is the intent signal — grab shell focus so "press C, click a
1344
+ // clip" works without an extra priming click (dogfood 2026-07-30).
1345
+ if (!document.activeElement || document.activeElement === document.body) {
1346
+ rootRef.current?.focus();
1347
+ } else {
1348
+ const tag = document.activeElement.tagName;
1349
+ if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') rootRef.current?.focus();
1350
+ }
1351
+ }}
355
1352
  onDragOver={(e) => {
356
1353
  if (!onDropMedia || !hasFiles(e.dataTransfer)) return;
357
1354
  e.preventDefault();
@@ -359,7 +1356,10 @@ export default function TimelinePanel({
359
1356
  if (!dropActive) setDropActive(true);
360
1357
  }}
361
1358
  onDragLeave={(e) => {
362
- if (e.relatedTarget == null) setDropActive(false);
1359
+ if (e.relatedTarget == null) {
1360
+ setDropActive(false);
1361
+ setDropCaret(null);
1362
+ }
363
1363
  }}
364
1364
  onDrop={onDrop}
365
1365
  aria-label="Timeline"
@@ -397,7 +1397,7 @@ export default function TimelinePanel({
397
1397
  >
398
1398
  <TIcon name="loop" />
399
1399
  </button>
400
- {audio.length > 0 ? (
1400
+ {comp ? (
401
1401
  <button
402
1402
  type="button"
403
1403
  className={`tl-btn${muted ? '' : ' is-on'}`}
@@ -410,7 +1410,7 @@ export default function TimelinePanel({
410
1410
  <TIcon name={muted ? 'muted' : 'sound'} />
411
1411
  </button>
412
1412
  ) : null}
413
- {audio.length > 0 ? (
1413
+ {comp ? (
414
1414
  <input
415
1415
  type="range"
416
1416
  className="tl-volume"
@@ -429,12 +1429,99 @@ export default function TimelinePanel({
429
1429
  {totalFrames - 1}
430
1430
  <span className="tl-time">{fmtTime(clamped, fps)}</span>
431
1431
  </span>
1432
+ <span className="tl-spacer" />
1433
+ {onSplitAtPlayhead ? (
1434
+ <button
1435
+ type="button"
1436
+ className="tl-btn"
1437
+ aria-label="Split at playhead"
1438
+ title="Split at playhead (⌘B)"
1439
+ data-testid="timeline-split"
1440
+ onClick={() => onSplitAtPlayhead()}
1441
+ >
1442
+ <LIc as={Scissors} />
1443
+ </button>
1444
+ ) : null}
1445
+ {onAddTitle ? (
1446
+ <button
1447
+ type="button"
1448
+ className="tl-btn"
1449
+ aria-label="Add title overlay"
1450
+ title="Add a title overlay at the playhead"
1451
+ data-testid="timeline-add-title"
1452
+ onClick={() => onAddTitle(clamped)}
1453
+ >
1454
+ <LIc as={Type} />
1455
+ </button>
1456
+ ) : null}
1457
+ {onAddImage ? (
1458
+ <button
1459
+ type="button"
1460
+ className="tl-btn"
1461
+ aria-label="Add image overlay"
1462
+ title="Add an image overlay at the playhead"
1463
+ data-testid="timeline-add-image"
1464
+ onClick={() => onAddImage(clamped)}
1465
+ >
1466
+ <LIc as={ImagePlus} />
1467
+ </button>
1468
+ ) : null}
1469
+ {onAddAiClip ? (
1470
+ <button
1471
+ type="button"
1472
+ className="tl-btn"
1473
+ aria-label="Add AI placeholder clip"
1474
+ title="Add an AI placeholder clip at the end of the storyline"
1475
+ data-testid="timeline-placeholder-add"
1476
+ onClick={() => onAddAiClip()}
1477
+ >
1478
+ <LIc as={Sparkles} />
1479
+ </button>
1480
+ ) : null}
1481
+ {onAddComment ? (
1482
+ <button
1483
+ type="button"
1484
+ className={`tl-btn${commentMode ? ' is-on' : ''}`}
1485
+ aria-label="Comment tool"
1486
+ aria-pressed={commentMode}
1487
+ title="Comment tool — click anywhere on the timeline to pin a comment (C)"
1488
+ data-testid="timeline-comment-tool"
1489
+ onClick={() => setCommentMode((m) => !m)}
1490
+ >
1491
+ <LIc as={MessageSquarePlus} />
1492
+ </button>
1493
+ ) : null}
432
1494
  </>
433
1495
  ) : (
434
1496
  <span className="tl-title">Timeline</span>
435
1497
  )}
436
1498
  <span className="tl-spacer" />
1499
+ {comp && totalFrames > 900 ? (
1500
+ // Task 1 — long-comp badge. 900 frames is the safe export tier
1501
+ // (frame-step fallback ~2.5 s/frame); the export dialog shows the
1502
+ // full notice, this is the always-visible hint.
1503
+ <span
1504
+ className="tl-long-badge"
1505
+ data-testid="timeline-long-badge"
1506
+ title={`Long comp: ${totalFrames} frames (≈${Math.round(totalFrames / (fps || 30))}s). Exports over 900 frames can take several minutes on the fallback path — see the export dialog.`}
1507
+ >
1508
+ ⚠ {Math.round(totalFrames / (fps || 30))}s
1509
+ </span>
1510
+ ) : null}
437
1511
  {comp ? <span className="tl-meta">{fps} fps · {totalFrames}f</span> : null}
1512
+ {comp ? (
1513
+ <input
1514
+ type="range"
1515
+ className="tl-zoom"
1516
+ min="0"
1517
+ max="100"
1518
+ value={zoomSliderValue}
1519
+ data-testid="timeline-zoom"
1520
+ aria-label="Timeline zoom"
1521
+ title="Zoom (⌘+/⌘−, 0 = fit)"
1522
+ onChange={(e) => zoomTo(zoomFromSlider(Number(e.target.value)))}
1523
+ />
1524
+ ) : null}
438
1525
  <button type="button" className="tl-x" aria-label="Close timeline" onClick={onClose}>
439
1526
  ×
440
1527
  </button>
@@ -442,383 +1529,559 @@ export default function TimelinePanel({
442
1529
 
443
1530
  {!comp ? (
444
1531
  <div className="tl-empty" data-testid="timeline-empty">
445
- No animation on this canvas. Make an artboard a <b>video-comp</b>
446
- (<code>&lt;VideoComp&gt;</code>) to scrub and export it — or drop clips on
447
- the canvas and <b>File → Assemble dropped clips → video</b>.
1532
+ No video-comp on this canvas. <b>Drop video clips right here</b> to start a
1533
+ new cut, use <b>⌘K → New video…</b> for an empty one, or make an artboard a
1534
+ <code>&lt;VideoComp&gt;</code> yourself.
448
1535
  </div>
449
1536
  ) : (
450
- <div className="tl-scroll">
1537
+ <div className="tl-scroll" ref={setScrollRef}>
451
1538
  <div
452
1539
  className="tl-tracks"
453
1540
  ref={trackRef}
454
1541
  data-testid="timeline-track"
1542
+ style={{ width: LABEL_GUTTER + axisPx }}
455
1543
  onPointerDown={onTrackDown}
1544
+ onDragOver={onLaneDragOver('overlay', 'tracks')}
1545
+ onDrop={onLaneDrop({ lane: 'overlay' })}
456
1546
  >
457
- <div className="tl-ruler">
458
- {ticks.map((t, i) => (
459
- <span key={i} className="tl-tick" style={{ left: `${t * 100}%` }}>
460
- <span className="tl-tick-label">{i}s</span>
461
- </span>
462
- ))}
1547
+ {comments.length ? (
1548
+ // Task 23 — 💬 marker strip. Preferred anchor: clipStableId +
1549
+ // frameOffset (survives reorder/ripple); a comment whose clip is
1550
+ // gone degrades to its absolute frame with a "detached" badge.
1551
+ <div className="tl-comments-strip" data-testid="timeline-comments-strip">
1552
+ {comments.map((c) => {
1553
+ const tl = c.timeline || {};
1554
+ let frame = null;
1555
+ let detached = false;
1556
+ if (tl.clipStableId) {
1557
+ const row = sequences.find((r2) => r2.stableId === tl.clipStableId);
1558
+ if (row) frame = row.from + Math.min(tl.frameOffset || 0, Math.max(0, row.duration - 1));
1559
+ else if (tl.frame != null) {
1560
+ frame = tl.frame;
1561
+ detached = true;
1562
+ }
1563
+ } else if (tl.frame != null) {
1564
+ frame = tl.frame;
1565
+ }
1566
+ if (frame == null) return null;
1567
+ return (
1568
+ <button
1569
+ key={c.id}
1570
+ type="button"
1571
+ className={`tl-comment-pin${c.status === 'resolved' ? ' is-resolved' : ''}${detached ? ' is-detached' : ''}`}
1572
+ data-testid={`timeline-comment-${c.id}`}
1573
+ title={`${c.author || 'comment'}${detached ? ' (detached — clip removed)' : ''}: ${c.text}`}
1574
+ style={{ left: LABEL_GUTTER + x(clamp(frame, 0, totalFrames - 1)) - 8 }}
1575
+ onPointerDown={(e) => e.stopPropagation()}
1576
+ onClick={(e) => {
1577
+ e.stopPropagation();
1578
+ onSeek?.(clamp(frame, 0, totalFrames - 1));
1579
+ setCommentPopover({ id: c.id, x: e.clientX - 130, y: Math.max(60, e.clientY - 180) });
1580
+ }}
1581
+ >
1582
+ 💬
1583
+ </button>
1584
+ );
1585
+ })}
1586
+ </div>
1587
+ ) : null}
1588
+ <div className="tl-ruler" style={{ width: axisPx }}>
1589
+ {ticks.map((f) => {
1590
+ const major = f % (fps || 30) === 0;
1591
+ const showLabel = major || step <= 5;
1592
+ return (
1593
+ <span key={f} className={`tl-tick${major ? ' is-major' : ''}`} style={{ left: x(f) }}>
1594
+ {showLabel ? <span className="tl-tick-label">{tickLabel(f, fps)}</span> : null}
1595
+ </span>
1596
+ );
1597
+ })}
463
1598
  </div>
464
1599
 
1600
+ {laneCaret('overlay', 'tracks')}
465
1601
  {sequences.length === 0 ? (
466
1602
  <div className="tl-row">
467
1603
  <span className="tl-row-label">comp</span>
468
- <div className="tl-row-track">
469
- <div className="tl-seq-block" style={{ left: '0%', width: '100%' }}>
1604
+ <div className="tl-row-track" style={{ width: axisPx, flex: 'none' }}>
1605
+ <div className="tl-seq-block" style={{ left: 0, width: axisPx }}>
470
1606
  <span className="tl-seq-name">whole composition</span>
471
1607
  </div>
472
1608
  </div>
473
1609
  </div>
474
- ) : (
475
- sequences.map((seq, i) => {
476
- const dur =
477
- retimeDrag && retimeDrag.index === i ? retimeDrag.curDur : seq.duration;
478
- const resizing = !!(retimeDrag && retimeDrag.index === i);
479
- const blockFrom =
480
- moveDrag && moveDrag.index === i ? moveDrag.curFrom : seq.from;
481
- const moving = !!(moveDrag && moveDrag.index === i);
482
- const layers = Array.isArray(seq.layers) ? seq.layers : [];
483
- const decomposable = layers.length >= 2;
484
- const isExpanded = decomposable && !collapsed.has(i);
485
- return (
486
- <Fragment key={i}>
487
- <div className="tl-row" data-testid={`timeline-row-${i}`}>
488
- <span className="tl-row-label" title={seq.label}>
489
- {decomposable ? (
490
- <button
491
- type="button"
492
- className="tl-expand"
493
- data-testid={`timeline-expand-${i}`}
494
- title={isExpanded ? 'Collapse layers' : 'Show layers'}
495
- aria-label={isExpanded ? `Collapse ${seq.label} layers` : `Show ${seq.label} layers`}
496
- aria-expanded={isExpanded}
497
- onPointerDown={(e) => e.stopPropagation()}
498
- onClick={(e) => {
499
- e.stopPropagation();
500
- e.preventDefault();
501
- setCollapsed((prev) => {
502
- const next = new Set(prev);
503
- if (next.has(i)) next.delete(i);
504
- else next.add(i);
505
- return next;
506
- });
507
- }}
508
- >
509
- {isExpanded ? '▾' : '▸'}
510
- </button>
511
- ) : (
512
- <span className="tl-expand tl-expand--spacer" aria-hidden="true" />
513
- )}
514
- {onReorder ? (
515
- <span
516
- className="tl-seq-reorder"
517
- role="group"
518
- aria-label={`${seq.series ? 'Play order' : 'Stacking order'} for ${seq.label}`}
519
- >
520
- <button
521
- type="button"
522
- className="tl-reorder-btn"
523
- data-testid={`timeline-raise-${i}`}
524
- title={seq.series ? 'Move earlier (plays sooner)' : 'Bring forward (render on top)'}
525
- aria-label={
526
- seq.series ? `Move ${seq.label} earlier` : `Bring ${seq.label} forward`
527
- }
528
- disabled={seq.series ? i <= 0 : i >= sequences.length - 1}
529
- onClick={(e) => {
530
- e.stopPropagation();
531
- // Series clips play in order — ▲ = earlier (swap with
532
- // the previous beat); standalone → forward in z-stack.
533
- onReorder(i, seq.series ? 'backward' : 'forward');
534
- }}
535
- >
536
- ▲
537
- </button>
538
- <button
539
- type="button"
540
- className="tl-reorder-btn"
541
- data-testid={`timeline-lower-${i}`}
542
- title={seq.series ? 'Move later (plays after)' : 'Send backward (render underneath)'}
543
- aria-label={
544
- seq.series ? `Move ${seq.label} later` : `Send ${seq.label} backward`
545
- }
546
- disabled={seq.series ? i >= sequences.length - 1 : i <= 0}
547
- onClick={(e) => {
548
- e.stopPropagation();
549
- onReorder(i, seq.series ? 'forward' : 'backward');
550
- }}
551
- >
552
- ▼
553
- </button>
1610
+ ) : bandMode ? (
1611
+ <>
1612
+ {/* Overlay band — standalone Sequences above the storyline.
1613
+ Rendered TOP = topmost paint (last in the document), so the
1614
+ stack reads like FCP; drag the V-chip to reorder layers. */}
1615
+ {(() => {
1616
+ // z-ladder = the overlay clips the SERVER can reorder
1617
+ // (standalone <Sequence>s; `loose` bare media has no clip
1618
+ // identity and stays out of the index space).
1619
+ const zLadder = overlay.filter(({ seq }) => !seq.loose);
1620
+ return [...overlay].reverse().map(({ seq, i }) => {
1621
+ const docIdx = seq.loose ? null : zLadder.findIndex((o) => o.i === i);
1622
+ const vNum = docIdx == null ? null : docIdx + 2; // V1 = the storyline
1623
+ const rowCls =
1624
+ `tl-row tl-row--overlay` +
1625
+ (layerDrag && layerDrag.index === i ? ' is-layer-dragging' : '') +
1626
+ (layerDrag && docIdx != null && layerDrag.index !== i && layerDrag.curTarget === docIdx
1627
+ ? ' is-layer-target'
1628
+ : '');
1629
+ return (
1630
+ <div
1631
+ className={rowCls}
1632
+ key={`o${i}`}
1633
+ data-testid={`timeline-row-${i}`}
1634
+ {...(docIdx != null ? { 'data-layer-doc': docIdx } : {})}
1635
+ >
1636
+ <span className="tl-row-label" title={seq.mediaSrc || seq.label}>
1637
+ {docIdx != null ? (
1638
+ <span
1639
+ className="tl-lane-id tl-lane-id--grab"
1640
+ data-testid={`timeline-lane-chip-${i}`}
1641
+ title="Drag up/down to reorder layers"
1642
+ onPointerDown={(e) => startLayerDrag(e, i, docIdx)}
1643
+ >
1644
+ V{vNum}
1645
+ </span>
1646
+ ) : (
1647
+ <span className="tl-lane-id" title="Loose media — no layer order">◌</span>
1648
+ )}
1649
+ <span className="tl-kind" data-kind={rowKind(seq)}>{rowKindGlyph(seq)}</span>
554
1650
  </span>
555
- ) : null}
1651
+ <div
1652
+ className="tl-row-track"
1653
+ style={{ width: axisPx, flex: 'none' }}
1654
+ onDragOver={onLaneDragOver('overlay', `o${i}`)}
1655
+ onDrop={onLaneDrop({ lane: 'overlay' })}
1656
+ >
1657
+ {renderBlock(seq, i, { lane: 'overlay', laneName: vNum != null ? `V${vNum}` : 'loose' })}
1658
+ {laneCaret('overlay', `o${i}`)}
1659
+ </div>
1660
+ </div>
1661
+ );
1662
+ });
1663
+ })()}
1664
+ {/* THE storyline — series beats butted side-by-side. */}
1665
+ <div className="tl-row tl-row--storyline" data-testid="timeline-storyline">
1666
+ <span className="tl-row-label">
1667
+ <span className="tl-lane-id tl-lane-id--story">V1</span>
1668
+ <span className="tl-band-tag tl-band-tag--story">storyline</span>
1669
+ </span>
1670
+ <div
1671
+ className={`tl-row-track tl-storyline-track${reorderDrag ? ' is-reordering' : ''}${moveDrag?.overStory ? ' is-drop-target' : ''}`}
1672
+ style={{ width: axisPx, flex: 'none' }}
1673
+ onDragOver={onStoryDragOver}
1674
+ onDrop={onLaneDrop({ lane: 'storyline' })}
1675
+ >
1676
+ {storyline.map(({ seq, i }) => renderBlock(seq, i, { lane: 'storyline', laneName: 'V1' }))}
1677
+ {reorderDrag ? null : renderSeams()}
1678
+ {reorderDrag ? null : renderAddSeams()}
1679
+ {dropCaret?.lane === 'storyline' ? (
556
1680
  <span
557
- className="tl-kind"
558
- data-kind={rowKind(seq)}
559
- title={rowKindTitle(seq)}
560
- aria-label={rowKindTitle(seq)}
561
- >
562
- {rowKindGlyph(seq)}
563
- </span>
564
- <span className="tl-row-label-text">{seq.label}</span>
1681
+ className="tl-drop-caret"
1682
+ data-testid="timeline-drop-caret"
1683
+ style={{ left: dropCaret.x - 1 }}
1684
+ />
1685
+ ) : null}
1686
+ </div>
1687
+ </div>
1688
+ {/* Dogfood 2026-07-30 (image #22) — a decomposable beat (an
1689
+ old hand-authored scene component) expands into its layer
1690
+ sub-rows right under the storyline, exactly like the
1691
+ stacked fallback used to show; default-expanded. */}
1692
+ {storyline
1693
+ .filter(
1694
+ ({ seq, i }) =>
1695
+ Array.isArray(seq.layers) && seq.layers.length >= 2 && !collapsed.has(i)
1696
+ )
1697
+ .map(({ seq, i }) =>
1698
+ seq.layers.map((ly, li) => {
1699
+ const lyKind =
1700
+ ly.kind === 'video' || ly.kind === 'image' || ly.kind === 'audio'
1701
+ ? ly.kind
1702
+ : 'jsx';
1703
+ const lyGlyph =
1704
+ ly.kind === 'video' ? '▶' : ly.kind === 'image' ? '◫' : ly.kind === 'audio' ? '♪' : 'ƒ';
1705
+ return (
1706
+ <div
1707
+ className="tl-row tl-row--layer"
1708
+ key={`bl${i}-${li}`}
1709
+ data-testid={`timeline-layer-${i}-${li}`}
1710
+ >
1711
+ <span className="tl-row-label" title={`${seq.label} · ${ly.label}`}>
1712
+ <span className="tl-layer-indent" aria-hidden="true" />
1713
+ <span className="tl-kind" data-kind={lyKind}>{lyGlyph}</span>
1714
+ <span className="tl-row-label-text">{ly.label}</span>
1715
+ </span>
1716
+ <div className="tl-row-track" style={{ width: axisPx, flex: 'none' }}>
1717
+ <div
1718
+ className="tl-seq-block tl-layer-block"
1719
+ data-kind={lyKind}
1720
+ data-testid={`timeline-layer-block-${i}-${li}`}
1721
+ title={`${seq.label} · ${ly.label} (${ly.kind} layer)`}
1722
+ style={{ left: x(seq.from), width: Math.max(2, x(seq.duration)) }}
1723
+ onContextMenu={(e) => {
1724
+ e.preventDefault();
1725
+ e.stopPropagation();
1726
+ setCtxMenu({ index: i, layerIndex: li, x: e.clientX, y: e.clientY });
1727
+ }}
1728
+ >
1729
+ <span className="tl-seq-name">{ly.label}</span>
1730
+ </div>
1731
+ {onReplaceLayer && (ly.mediaCdId || ly.mediaArrayRef) ? (
1732
+ <button
1733
+ type="button"
1734
+ className="tl-seq-replace"
1735
+ data-testid={`timeline-layer-replace-${i}-${li}`}
1736
+ title={`Replace this ${ly.kind}`}
1737
+ aria-label={`Replace ${ly.label}`}
1738
+ style={{ left: x(seq.from) + Math.max(2, x(seq.duration)) - 20 }}
1739
+ onPointerDown={(e) => e.stopPropagation()}
1740
+ onClick={(e) => {
1741
+ e.stopPropagation();
1742
+ onReplaceLayer(refFor(seq, i), li);
1743
+ }}
1744
+ >
1745
+ ⇄
1746
+ </button>
1747
+ ) : null}
1748
+ </div>
1749
+ </div>
1750
+ );
1751
+ })
1752
+ )}
1753
+ {/* Audio band — standalone audio clips (movable/trimmable) +
1754
+ loose <Audio> beds. */}
1755
+ {audioClips.map(({ seq, i }, k) => (
1756
+ <div className="tl-row tl-row--overlay tl-row--audio" key={`ac${i}`} data-testid={`timeline-row-${i}`}>
1757
+ <span className="tl-row-label" title={seq.mediaSrc || seq.label}>
1758
+ <span className="tl-lane-id tl-lane-id--audio">A{k + 1}</span>
1759
+ <span className="tl-kind" data-kind="audio">♪</span>
565
1760
  </span>
566
- <div className="tl-row-track">
567
- <button
568
- type="button"
569
- className={`tl-seq-block${resizing ? ' is-resizing' : ''}${moving ? ' is-moving' : ''}${seq.hidden ? ' is-hidden' : ''}`}
570
- data-testid={`timeline-seq-${i}`}
571
- data-kind={rowKind(seq)}
572
- data-hidden={seq.hidden ? '1' : undefined}
573
- title={
574
- seq.series
575
- ? `${seq.label} · ${blockFrom}–${blockFrom + dur}f (${dur}f) · position computed by the series — trim the right edge to retime`
576
- : `${seq.label} · ${blockFrom}–${blockFrom + dur}f (${dur}f) · drag to move (Alt = no snap)`
577
- }
578
- aria-label={
579
- seq.series
580
- ? `${seq.label}, series beat, frames ${blockFrom} to ${blockFrom + dur}. Click to seek; trim its edge to retime.`
581
- : `${seq.label}, from frame ${blockFrom} to ${blockFrom + dur}, ${dur} frames. Click to seek; drag to move.`
582
- }
583
- style={{
584
- left: pct(blockFrom),
585
- width: `${(dur / (totalFrames - 1)) * 100}%`,
586
- cursor: onRetime && !seq.series ? 'grab' : undefined,
587
- }}
588
- onContextMenu={(e) => {
589
- e.preventDefault();
590
- e.stopPropagation();
591
- setCtxMenu({ index: i, x: e.clientX, y: e.clientY });
592
- }}
593
- onPointerDown={(e) => startMove(e, i, seq.from)}
594
- onClick={(e) => {
595
- e.stopPropagation();
596
- if (movedRef.current) {
597
- movedRef.current = false;
598
- return; // a drag-move just happened — don't also seek
599
- }
600
- onSeek?.(seq.from);
601
- }}
602
- >
603
- <span className="tl-seq-name">
604
- {seq.label}
605
- {resizing ? ` · ${dur}f` : ''}
606
- {moving ? ` · @${blockFrom}f` : ''}
607
- </span>
608
- {seq.keyframes.map((kf, k) => {
609
- const l = ((kf.from - seq.from) / Math.max(1, seq.duration)) * 100;
610
- const w = ((kf.to - kf.from) / Math.max(1, seq.duration)) * 100;
611
- return (
612
- <span
613
- key={k}
614
- className="tl-kf"
615
- data-testid={`timeline-kf-${i}-${k}`}
616
- style={{ left: `${clamp(l, 0, 100)}%`, width: `${Math.max(1.5, w)}%` }}
617
- title={`seek to keyframe ${kf.from}f (animates ${kf.from}–${kf.to}f)`}
1761
+ <div
1762
+ className="tl-row-track"
1763
+ style={{ width: axisPx, flex: 'none' }}
1764
+ onDragOver={onLaneDragOver('audio', `ac${i}`)}
1765
+ onDrop={onLaneDrop({ lane: 'audio' })}
1766
+ >
1767
+ {renderBlock(seq, i, { lane: 'audio', laneName: `A${k + 1}` })}
1768
+ {laneCaret('audio', `ac${i}`)}
1769
+ </div>
1770
+ </div>
1771
+ ))}
1772
+ {audio.map((a, i) => renderAudioRow(a, i))}
1773
+ {/* Debate verdict: the dead space below the lanes gets a JOB —
1774
+ it IS the new-layer drop target, and says so. */}
1775
+ <div
1776
+ className={`tl-newlayer-zone${dropCaret?.row === 'newlayer' ? ' is-active' : ''}`}
1777
+ data-testid="timeline-newlayer-zone"
1778
+ onDragOver={onLaneDragOver('overlay', 'newlayer')}
1779
+ onDrop={onLaneDrop({ lane: 'overlay' })}
1780
+ >
1781
+ <span style={{ marginLeft: LABEL_GUTTER }}>Drop clips here — new layer</span>
1782
+ </div>
1783
+ </>
1784
+ ) : (
1785
+ <>
1786
+ {sequences.map((seq, i) => {
1787
+ const layers = Array.isArray(seq.layers) ? seq.layers : [];
1788
+ const decomposable = layers.length >= 2;
1789
+ const isExpanded = decomposable && !collapsed.has(i);
1790
+ const dur = retimeDrag && retimeDrag.index === i ? retimeDrag.curDur : seq.duration;
1791
+ return (
1792
+ <Fragment key={i}>
1793
+ <div className="tl-row" data-testid={`timeline-row-${i}`}>
1794
+ <span className="tl-row-label" title={seq.label}>
1795
+ {decomposable ? (
1796
+ <button
1797
+ type="button"
1798
+ className="tl-expand"
1799
+ data-testid={`timeline-expand-${i}`}
1800
+ title={isExpanded ? 'Collapse layers' : 'Show layers'}
1801
+ aria-label={isExpanded ? `Collapse ${seq.label} layers` : `Show ${seq.label} layers`}
1802
+ aria-expanded={isExpanded}
618
1803
  onPointerDown={(e) => e.stopPropagation()}
619
1804
  onClick={(e) => {
620
1805
  e.stopPropagation();
621
- onSeek?.(kf.from);
1806
+ e.preventDefault();
1807
+ setCollapsed((prev) => {
1808
+ const next = new Set(prev);
1809
+ if (next.has(i)) next.delete(i);
1810
+ else next.add(i);
1811
+ return next;
1812
+ });
622
1813
  }}
623
- />
624
- );
625
- })}
626
- </button>
627
- {onRetime ? (
628
- <span
629
- className="tl-seq-resize"
630
- data-testid={`timeline-resize-${i}`}
631
- title="Drag to retime this sequence"
632
- style={{
633
- left: `calc(${pct(seq.from)} + ${(dur / (totalFrames - 1)) * 100}% - 5px)`,
634
- }}
635
- onPointerDown={(e) => startResize(e, i, seq.duration)}
636
- onClick={(e) => e.stopPropagation()}
637
- />
638
- ) : null}
639
- {onReplace && seq.replaceable && !decomposable ? (
640
- <button
641
- type="button"
642
- className="tl-seq-replace"
643
- data-testid={`timeline-replace-${i}`}
644
- title={`Replace this ${rowKind(seq)}`}
645
- aria-label={`Replace media in ${seq.label}`}
646
- style={{
647
- left: `calc(${pct(seq.from)} + ${(dur / (totalFrames - 1)) * 100}% - 34px)`,
648
- }}
649
- onPointerDown={(e) => e.stopPropagation()}
650
- onClick={(e) => {
651
- e.stopPropagation();
652
- onReplace(i);
653
- }}
654
- >
655
- ⇄
656
- </button>
657
- ) : null}
658
- {onRemove ? (
659
- <button
660
- type="button"
661
- className="tl-seq-remove"
662
- data-testid={`timeline-remove-${i}`}
663
- title="Remove this clip"
664
- aria-label={`Remove ${seq.label}`}
665
- style={{
666
- left: `calc(${pct(seq.from)} + ${(dur / (totalFrames - 1)) * 100}% - 17px)`,
667
- }}
668
- onPointerDown={(e) => e.stopPropagation()}
669
- onClick={(e) => {
670
- e.stopPropagation();
671
- onRemove(i);
672
- }}
673
- >
674
- ×
675
- </button>
676
- ) : null}
677
- </div>
678
- </div>
679
- {isExpanded
680
- ? layers.map((ly, li) => {
681
- const lyKind =
682
- ly.kind === 'video'
683
- ? 'video'
684
- : ly.kind === 'image'
685
- ? 'image'
686
- : ly.kind === 'audio'
687
- ? 'audio'
688
- : 'jsx';
689
- const lyGlyph =
690
- ly.kind === 'video'
691
- ? '▶'
692
- : ly.kind === 'image'
693
- ? '◫'
694
- : ly.kind === 'audio'
695
- ? '♪'
696
- : 'ƒ';
697
- const lyReplaceable = !!(ly.mediaCdId || ly.mediaArrayRef);
698
- return (
699
- <div
700
- className="tl-row tl-row--layer"
701
- key={`l${li}`}
702
- data-testid={`timeline-layer-${i}-${li}`}
1814
+ >
1815
+ {isExpanded ? '▾' : '▸'}
1816
+ </button>
1817
+ ) : (
1818
+ <span className="tl-expand tl-expand--spacer" aria-hidden="true" />
1819
+ )}
1820
+ <span
1821
+ className="tl-kind"
1822
+ data-kind={rowKind(seq)}
1823
+ title={rowKindTitle(seq)}
1824
+ aria-label={rowKindTitle(seq)}
703
1825
  >
704
- <span className="tl-row-label" title={ly.label}>
705
- <span className="tl-layer-indent" aria-hidden="true" />
706
- <span className="tl-kind" data-kind={lyKind}>
707
- {lyGlyph}
708
- </span>
709
- <span className="tl-row-label-text">{ly.label}</span>
710
- </span>
711
- <div className="tl-row-track">
1826
+ {rowKindGlyph(seq)}
1827
+ </span>
1828
+ <span className="tl-row-label-text">{seq.label}</span>
1829
+ </span>
1830
+ <div className="tl-row-track" style={{ width: axisPx, flex: 'none' }}>
1831
+ {renderBlock(seq, i, { lane: 'row' })}
1832
+ </div>
1833
+ </div>
1834
+ {isExpanded
1835
+ ? layers.map((ly, li) => {
1836
+ const lyKind =
1837
+ ly.kind === 'video'
1838
+ ? 'video'
1839
+ : ly.kind === 'image'
1840
+ ? 'image'
1841
+ : ly.kind === 'audio'
1842
+ ? 'audio'
1843
+ : 'jsx';
1844
+ const lyGlyph =
1845
+ ly.kind === 'video'
1846
+ ? '▶'
1847
+ : ly.kind === 'image'
1848
+ ? '◫'
1849
+ : ly.kind === 'audio'
1850
+ ? '♪'
1851
+ : 'ƒ';
1852
+ const lyReplaceable = !!(ly.mediaCdId || ly.mediaArrayRef);
1853
+ return (
712
1854
  <div
713
- className="tl-seq-block tl-layer-block"
714
- data-kind={lyKind}
715
- data-testid={`timeline-layer-block-${i}-${li}`}
716
- title={`${ly.label} (${ly.kind} layer)`}
717
- style={{ left: pct(seq.from), width: `${(dur / (totalFrames - 1)) * 100}%` }}
718
- onContextMenu={(e) => {
719
- e.preventDefault();
720
- e.stopPropagation();
721
- setCtxMenu({ index: i, layerIndex: li, x: e.clientX, y: e.clientY });
722
- }}
1855
+ className="tl-row tl-row--layer"
1856
+ key={`l${li}`}
1857
+ data-testid={`timeline-layer-${i}-${li}`}
723
1858
  >
724
- <span className="tl-seq-name">{ly.label}</span>
1859
+ <span className="tl-row-label" title={ly.label}>
1860
+ <span className="tl-layer-indent" aria-hidden="true" />
1861
+ <span className="tl-kind" data-kind={lyKind}>
1862
+ {lyGlyph}
1863
+ </span>
1864
+ <span className="tl-row-label-text">{ly.label}</span>
1865
+ </span>
1866
+ <div className="tl-row-track" style={{ width: axisPx, flex: 'none' }}>
1867
+ <div
1868
+ className="tl-seq-block tl-layer-block"
1869
+ data-kind={lyKind}
1870
+ data-testid={`timeline-layer-block-${i}-${li}`}
1871
+ title={`${ly.label} (${ly.kind} layer)`}
1872
+ style={{ left: x(seq.from), width: Math.max(2, x(dur)) }}
1873
+ onContextMenu={(e) => {
1874
+ e.preventDefault();
1875
+ e.stopPropagation();
1876
+ setCtxMenu({ index: i, layerIndex: li, x: e.clientX, y: e.clientY });
1877
+ }}
1878
+ >
1879
+ <span className="tl-seq-name">{ly.label}</span>
1880
+ </div>
1881
+ {onReplaceLayer && lyReplaceable ? (
1882
+ <button
1883
+ type="button"
1884
+ className="tl-seq-replace"
1885
+ data-testid={`timeline-layer-replace-${i}-${li}`}
1886
+ title={`Replace this ${ly.kind}`}
1887
+ aria-label={`Replace ${ly.label}`}
1888
+ style={{ left: x(seq.from) + Math.max(2, x(dur)) - 20 }}
1889
+ onPointerDown={(e) => e.stopPropagation()}
1890
+ onClick={(e) => {
1891
+ e.stopPropagation();
1892
+ onReplaceLayer(refFor(seq, i), li);
1893
+ }}
1894
+ >
1895
+ ⇄
1896
+ </button>
1897
+ ) : null}
1898
+ </div>
725
1899
  </div>
726
- {onReplaceLayer && lyReplaceable ? (
727
- <button
728
- type="button"
729
- className="tl-seq-replace"
730
- data-testid={`timeline-layer-replace-${i}-${li}`}
731
- title={`Replace this ${ly.kind}`}
732
- aria-label={`Replace ${ly.label}`}
733
- style={{
734
- left: `calc(${pct(seq.from)} + ${(dur / (totalFrames - 1)) * 100}% - 20px)`,
735
- }}
736
- onPointerDown={(e) => e.stopPropagation()}
737
- onClick={(e) => {
738
- e.stopPropagation();
739
- onReplaceLayer(i, li);
740
- }}
741
- >
742
- ⇄
743
- </button>
744
- ) : null}
745
- </div>
746
- </div>
747
- );
748
- })
749
- : null}
750
- </Fragment>
751
- );
752
- })
1900
+ );
1901
+ })
1902
+ : null}
1903
+ </Fragment>
1904
+ );
1905
+ })}
1906
+ {audio.map((a, i) => renderAudioRow(a, i))}
1907
+ </>
753
1908
  )}
754
1909
 
755
- {audio.map((a, i) => (
756
- <div className="tl-row" key={`a${i}`} data-testid={`timeline-audio-${i}`}>
757
- <span className="tl-row-label" title={a.label}>
758
- <span className="tl-kind" data-kind="audio" title={`Audio · ${a.label}`} aria-label={`Audio · ${a.label}`}>
759
- ♪
760
- </span>
761
- <span className="tl-row-label-text">{a.label}</span>
762
- </span>
763
- <div className="tl-row-track">
764
- <div
765
- className="tl-audio-block"
766
- title={`${a.label} · ${a.from}–${a.from + a.duration}f`}
767
- style={{
768
- left: pct(a.from),
769
- width: `${(a.duration / (totalFrames - 1)) * 100}%`,
770
- }}
771
- >
772
- <svg className="tl-wave" viewBox="0 0 100 12" preserveAspectRatio="none" aria-hidden>
773
- <path d="M0 6 Q2 1 4 6 T8 6 T12 6 T16 6 T20 6 T24 6 T28 6 T32 6 T36 6 T40 6 T44 6 T48 6 T52 6 T56 6 T60 6 T64 6 T68 6 T72 6 T76 6 T80 6 T84 6 T88 6 T92 6 T96 6 T100 6" />
774
- </svg>
775
- <span className="tl-seq-name">{a.label}</span>
776
- </div>
777
- {onReplaceAudio ? (
778
- <button
779
- type="button"
780
- className="tl-seq-replace"
781
- data-testid={`timeline-audio-replace-${i}`}
782
- title="Replace this audio"
783
- aria-label={`Replace audio ${a.label}`}
784
- style={{
785
- left: `calc(${pct(a.from)} + ${(a.duration / (totalFrames - 1)) * 100}% - 20px)`,
786
- }}
787
- onPointerDown={(e) => e.stopPropagation()}
788
- onClick={(e) => {
789
- e.stopPropagation();
790
- onReplaceAudio(i);
791
- }}
792
- >
793
- ⇄
794
- </button>
795
- ) : null}
796
- </div>
797
- </div>
798
- ))}
799
-
1910
+ {snapFlash ? (
1911
+ <span className="tl-snapline" style={{ left: LABEL_GUTTER + x(snapFlash.frame) }} />
1912
+ ) : null}
800
1913
  <span
801
1914
  className="tl-playhead"
802
1915
  data-testid="timeline-playhead"
803
- style={{ left: `calc(96px + (100% - 96px) * ${(clamped / (totalFrames - 1)) || 0})` }}
804
- />
1916
+ style={{ left: LABEL_GUTTER + x(clamped) }}
1917
+ >
1918
+ <span className="tl-playhead-time">{fmtTime(clamped, fps)}</span>
1919
+ </span>
805
1920
  </div>
806
1921
  </div>
807
1922
  )}
1923
+ {commentDraft
1924
+ ? createPortal(
1925
+ <div className="tlci tlci--comment" style={{ left: commentDraft.x, top: commentDraft.y }}>
1926
+ <div className="tlci-head" style={{ cursor: 'default' }}>
1927
+ <span className="tlci-title">
1928
+ Comment @ {fmtTime(commentDraft.at, fps)}
1929
+ {commentDraft.anchor.lane ? ` · ${commentDraft.anchor.lane}` : ''}
1930
+ </span>
1931
+ <button type="button" className="tlci-x" aria-label="Close" onClick={() => setCommentDraft(null)}>
1932
+ ×
1933
+ </button>
1934
+ </div>
1935
+ <div className="tlci-body">
1936
+ <textarea
1937
+ className="tlci-textarea"
1938
+ rows={3}
1939
+ autoFocus
1940
+ placeholder="Comment… (Enter to add)"
1941
+ data-testid="timeline-comment-composer"
1942
+ onKeyDown={(e) => {
1943
+ e.stopPropagation();
1944
+ if (e.key === 'Enter' && !e.shiftKey) {
1945
+ e.preventDefault();
1946
+ const text = e.currentTarget.value.trim();
1947
+ if (text) onAddComment?.(commentDraft.anchor, text);
1948
+ setCommentDraft(null);
1949
+ } else if (e.key === 'Escape') {
1950
+ setCommentDraft(null);
1951
+ }
1952
+ }}
1953
+ />
1954
+ <div className="tlci-row">
1955
+ <button
1956
+ type="button"
1957
+ className="tlci-btn"
1958
+ data-testid="timeline-comment-submit"
1959
+ onClick={(e) => {
1960
+ const ta = e.currentTarget.closest('.tlci')?.querySelector('textarea');
1961
+ const text = ta?.value?.trim();
1962
+ if (text) onAddComment?.(commentDraft.anchor, text);
1963
+ setCommentDraft(null);
1964
+ }}
1965
+ >
1966
+ Add comment
1967
+ </button>
1968
+ </div>
1969
+ </div>
1970
+ </div>,
1971
+ document.body
1972
+ )
1973
+ : null}
1974
+ {commentPopover
1975
+ ? createPortal((() => {
1976
+ const c = comments.find((k) => k.id === commentPopover.id);
1977
+ if (!c) return null;
1978
+ return (
1979
+ <div
1980
+ className="tlci"
1981
+ style={{ left: commentPopover.x, top: commentPopover.y }}
1982
+ role="dialog"
1983
+ aria-label="Timeline comment"
1984
+ data-testid="timeline-comment-popover"
1985
+ onPointerDown={(e) => e.stopPropagation()}
1986
+ >
1987
+ <div className="tlci-head">
1988
+ <span className="tlci-title">💬 {c.author || 'comment'}</span>
1989
+ <button type="button" className="tlci-x" aria-label="Close" onClick={() => setCommentPopover(null)}>
1990
+ ×
1991
+ </button>
1992
+ </div>
1993
+ <div className="tlci-body">
1994
+ <p className="tlci-comment-text">{c.text}</p>
1995
+ {(c.thread || []).map((r2, k2) => (
1996
+ <p key={k2} className="tlci-note">
1997
+ ↳ {r2.author ? `${r2.author}: ` : ''}{r2.body}
1998
+ </p>
1999
+ ))}
2000
+ <div className="tlci-presets">
2001
+ {c.status !== 'resolved' && onResolveComment ? (
2002
+ <button
2003
+ type="button"
2004
+ className="tlci-chip"
2005
+ onClick={() => {
2006
+ onResolveComment(c.id);
2007
+ setCommentPopover(null);
2008
+ }}
2009
+ >
2010
+ Resolve
2011
+ </button>
2012
+ ) : null}
2013
+ {onDeleteComment ? (
2014
+ <button
2015
+ type="button"
2016
+ className="tlci-chip"
2017
+ onClick={() => {
2018
+ onDeleteComment(c.id);
2019
+ setCommentPopover(null);
2020
+ }}
2021
+ >
2022
+ Delete
2023
+ </button>
2024
+ ) : null}
2025
+ </div>
2026
+ </div>
2027
+ </div>
2028
+ );
2029
+ })(), document.body)
2030
+ : null}
2031
+ {inspector
2032
+ ? createPortal((() => {
2033
+ if (inspector.mode === 'transition') {
2034
+ const tClip = transitionClips[inspector.seam];
2035
+ const tParse = (transitions || [])[inspector.seam];
2036
+ if (!tClip) return null;
2037
+ return (
2038
+ <ClipInspector
2039
+ mode="transition"
2040
+ clipLabel="Transition"
2041
+ transition={{ dur: tParse?.dur ?? 15 }}
2042
+ x={inspector.x}
2043
+ y={inspector.y}
2044
+ onVerb={(verb, params) =>
2045
+ onClipVerb?.({ stableId: tClip.stableId, transition: true }, verb, params)
2046
+ }
2047
+ onClose={() => setInspector(null)}
2048
+ />
2049
+ );
2050
+ }
2051
+ const seq = sequences[inspector.index];
2052
+ if (!seq) return null;
2053
+ return (
2054
+ <ClipInspector
2055
+ mode="clip"
2056
+ clipLabel={seq.label}
2057
+ kind={rowKind(seq)}
2058
+ textValue={seq.placeholder ? seq.placeholder.prompt || '' : rowKind(seq) === 'jsx' && !seq.series ? '' : null}
2059
+ mediaProps={seq.mediaProps || null}
2060
+ x={inspector.x}
2061
+ y={inspector.y}
2062
+ onVerb={(verb, params) => onClipVerb?.(refFor(seq, inspector.index), verb, params)}
2063
+ onClose={() => setInspector(null)}
2064
+ />
2065
+ );
2066
+ })(), document.body)
2067
+ : null}
808
2068
  {ctxMenu && sequences[ctxMenu.index]
809
- ? (() => {
2069
+ ? createPortal((() => {
810
2070
  const seq = sequences[ctxMenu.index];
811
2071
  const i = ctxMenu.index;
812
2072
  const li = ctxMenu.layerIndex;
813
2073
  let sections;
814
- if (li != null) {
2074
+ if (seq.loose) {
2075
+ // A bare media sibling outside every <Sequence> — visible for
2076
+ // orientation, but it has no clip identity for server ops.
2077
+ sections = [[{ id: 'noop', label: 'Loose media (no clip wrapper) — edit via /design:edit', disabled: true, onSelect: () => {} }]];
2078
+ } else if (li != null) {
815
2079
  // A LAYER (sub-clip) menu — a stacked layer shares the clip's
816
- // timing, so only media-replace applies here; trim/move/cut are
817
- // clip-level ops on the parent row.
2080
+ // timing, so only media-replace applies here.
818
2081
  const ly = (Array.isArray(seq.layers) ? seq.layers : [])[li];
819
2082
  const items = [];
820
2083
  if (onReplaceLayer && ly && (ly.mediaCdId || ly.mediaArrayRef)) {
821
- items.push({ id: 'replace-layer', label: `Replace ${ly.kind}…`, onSelect: () => onReplaceLayer(i, li) });
2084
+ items.push({ id: 'replace-layer', label: `Replace ${ly.kind}…`, onSelect: () => onReplaceLayer(refFor(seq, i), li) });
822
2085
  }
823
2086
  if (items.length === 0) {
824
2087
  items.push({ id: 'noop', label: 'No replaceable media in this layer', disabled: true, onSelect: () => {} });
@@ -826,23 +2089,105 @@ export default function TimelinePanel({
826
2089
  sections = [items];
827
2090
  } else {
828
2091
  const primary = [];
829
- // Replace only when the clip's media is directly on it (not split
830
- // into layers — those replace from their own sub-rows).
2092
+ if (onAddComment) {
2093
+ primary.push({
2094
+ id: 'comment',
2095
+ label: 'Comment on clip…',
2096
+ onSelect: () => {
2097
+ // Same anchored composer as the C tool — no modal.
2098
+ setCommentDraft({
2099
+ x: clamp(ctxMenu.x - 130, 8, window.innerWidth - 280),
2100
+ y: Math.max(48, ctxMenu.y - 140),
2101
+ anchor: {
2102
+ clipStableId: seq.stableId || undefined,
2103
+ frameOffset: Math.max(0, clamped - (seq.from || 0)),
2104
+ frame: clamped,
2105
+ },
2106
+ at: clamped,
2107
+ });
2108
+ },
2109
+ });
2110
+ }
2111
+ if (seq.placeholder && onGeneratePlaceholder) {
2112
+ primary.push({
2113
+ id: 'generate',
2114
+ label: `Generate ✨ (${seq.placeholder.kind || 'veo'})`,
2115
+ onSelect: () => onGeneratePlaceholder(refFor(seq, i)),
2116
+ });
2117
+ }
2118
+ if (onClipVerb && seq.mediaSrc) {
2119
+ if (seq.series) {
2120
+ primary.push({
2121
+ id: 'to-overlay',
2122
+ label: 'Move to overlay layer',
2123
+ onSelect: () => onClipVerb(refFor(seq, i), 'to-overlay', {}),
2124
+ });
2125
+ } else {
2126
+ primary.push({
2127
+ id: 'to-storyline',
2128
+ label: 'Move to storyline',
2129
+ onSelect: () => onClipVerb(refFor(seq, i), 'to-storyline', {}),
2130
+ });
2131
+ }
2132
+ }
2133
+ if (onClipVerb) {
2134
+ primary.push({
2135
+ id: 'adjust',
2136
+ label: 'Adjust… (Speed · Audio · Crop · Grade)',
2137
+ onSelect: () => {
2138
+ onSelect?.(seq.stableId ?? null);
2139
+ setInspector({ mode: 'clip', index: i, x: ctxMenu.x - 120, y: Math.max(60, ctxMenu.y - 320) });
2140
+ },
2141
+ });
2142
+ if (seq.mediaProps) {
2143
+ primary.push({
2144
+ id: 'mute',
2145
+ label: seq.mediaProps.muted ? 'Unmute clip' : 'Mute clip',
2146
+ onSelect: () => onClipVerb(refFor(seq, i), 'audio', { muted: !seq.mediaProps.muted }),
2147
+ });
2148
+ if (rowKind(seq) === 'video' && seq.mediaSrc) {
2149
+ primary.push({
2150
+ id: 'detach-audio',
2151
+ label: 'Detach audio',
2152
+ onSelect: () => onClipVerb(refFor(seq, i), 'detach-audio', {}),
2153
+ });
2154
+ }
2155
+ }
2156
+ }
831
2157
  if (onReplace && seq.replaceable && !(Array.isArray(seq.layers) && seq.layers.length >= 2)) {
832
- primary.push({ id: 'replace', label: `Replace ${rowKind(seq)}…`, onSelect: () => onReplace(i) });
2158
+ primary.push({ id: 'replace', label: `Replace ${rowKind(seq)}…`, onSelect: () => onReplace(refFor(seq, i)) });
2159
+ }
2160
+ {
2161
+ // Vertical z-order (overlay clips only) — mirrors the V-chip drag.
2162
+ const zLadder = overlay.filter((o) => !o.seq.loose);
2163
+ const docIdx = zLadder.findIndex((o) => o.i === i);
2164
+ if (onClipVerb && docIdx >= 0) {
2165
+ primary.push({
2166
+ id: 'layer-up',
2167
+ label: 'Move layer up',
2168
+ disabled: docIdx >= zLadder.length - 1,
2169
+ onSelect: () => onClipVerb(refFor(seq, i), 'layer-order', { toIndex: docIdx + 1 }),
2170
+ });
2171
+ primary.push({
2172
+ id: 'layer-down',
2173
+ label: 'Move layer down',
2174
+ disabled: docIdx <= 0,
2175
+ onSelect: () => onClipVerb(refFor(seq, i), 'layer-order', { toIndex: docIdx - 1 }),
2176
+ });
2177
+ }
833
2178
  }
834
2179
  if (onReorder) {
835
2180
  primary.push({
836
2181
  id: 'earlier',
837
2182
  label: seq.series ? 'Move earlier' : 'Bring forward',
838
2183
  disabled: seq.series ? i <= 0 : i >= sequences.length - 1,
839
- onSelect: () => onReorder(i, seq.series ? 'backward' : 'forward'),
2184
+ onSelect: () => onReorder(refFor(seq, i), seq.series ? 'backward' : 'forward'),
840
2185
  });
841
2186
  primary.push({
842
2187
  id: 'later',
843
2188
  label: seq.series ? 'Move later' : 'Send backward',
844
2189
  disabled: seq.series ? i >= sequences.length - 1 : i <= 0,
845
- onSelect: () => onReorder(i, seq.series ? 'forward' : 'backward'),
2190
+ onSelect: () => onReorder(refFor(seq, i), seq.series ? 'forward' : 'backward'),
846
2191
  });
847
2192
  }
848
2193
  sections = [primary];
@@ -851,11 +2196,11 @@ export default function TimelinePanel({
851
2196
  tail.push({
852
2197
  id: 'hide',
853
2198
  label: seq.hidden ? 'Show clip' : 'Hide clip',
854
- onSelect: () => onToggleHide(i),
2199
+ onSelect: () => onToggleHide(refFor(seq, i)),
855
2200
  });
856
2201
  }
857
2202
  if (onRemove) {
858
- tail.push({ id: 'remove', label: 'Remove clip', destructive: true, onSelect: () => onRemove(i) });
2203
+ tail.push({ id: 'remove', label: 'Remove clip', destructive: true, onSelect: () => onRemove(refFor(seq, i)) });
859
2204
  }
860
2205
  if (tail.length) sections.push(tail);
861
2206
  }
@@ -866,7 +2211,7 @@ export default function TimelinePanel({
866
2211
  onClose={() => setCtxMenu(null)}
867
2212
  />
868
2213
  );
869
- })()
2214
+ })(), document.body)
870
2215
  : null}
871
2216
  </aside>
872
2217
  );