@1agh/maude 0.39.1 → 0.40.0

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 (82) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  3. package/apps/studio/annotations-layer.tsx +253 -3
  4. package/apps/studio/annotations-model.ts +107 -6
  5. package/apps/studio/api.ts +812 -64
  6. package/apps/studio/bin/_html-playwright.mjs +9 -1
  7. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  8. package/apps/studio/bin/_png-playwright.mjs +8 -1
  9. package/apps/studio/bin/_pw-launch.mjs +54 -0
  10. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  11. package/apps/studio/bin/_video-playwright.mjs +452 -0
  12. package/apps/studio/bin/prep.sh +8 -1
  13. package/apps/studio/canvas-edit.ts +1885 -104
  14. package/apps/studio/canvas-lib.tsx +19 -0
  15. package/apps/studio/canvas-list-watch.ts +6 -2
  16. package/apps/studio/canvas-shell.tsx +27 -0
  17. package/apps/studio/client/app.jsx +1029 -30
  18. package/apps/studio/client/github.js +7 -0
  19. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  20. package/apps/studio/client/panels/timeline-parse.js +229 -0
  21. package/apps/studio/client/panels/timeline-snap.js +55 -0
  22. package/apps/studio/client/styles/3-shell-maude.css +107 -0
  23. package/apps/studio/config.schema.json +14 -0
  24. package/apps/studio/context-menu.tsx +1 -1
  25. package/apps/studio/context.ts +113 -1
  26. package/apps/studio/dist/client.bundle.js +88 -16
  27. package/apps/studio/dist/comment-mount.js +1 -1
  28. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  29. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  30. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  31. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  32. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  33. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  34. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  35. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  36. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  37. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  38. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  39. package/apps/studio/dist/runtime/remotion.js +42 -0
  40. package/apps/studio/dist/styles.css +1 -1
  41. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  42. package/apps/studio/exporters/_runtime.ts +69 -0
  43. package/apps/studio/exporters/html.ts +4 -3
  44. package/apps/studio/exporters/index.ts +28 -2
  45. package/apps/studio/exporters/pdf.ts +4 -3
  46. package/apps/studio/exporters/png.ts +5 -3
  47. package/apps/studio/exporters/pptx.ts +6 -4
  48. package/apps/studio/exporters/svg.ts +9 -5
  49. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  50. package/apps/studio/exporters/video-render-lib.ts +108 -0
  51. package/apps/studio/exporters/video.ts +184 -0
  52. package/apps/studio/http.ts +535 -27
  53. package/apps/studio/input-router.tsx +7 -1
  54. package/apps/studio/runtime-bundle.ts +30 -0
  55. package/apps/studio/server.ts +34 -9
  56. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  57. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  58. package/apps/studio/test/canvas-edit.test.ts +90 -0
  59. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  60. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  61. package/apps/studio/test/canvas-origin-gate.test.ts +36 -0
  62. package/apps/studio/test/clip-addressing.test.ts +732 -0
  63. package/apps/studio/test/config-reload.test.ts +230 -0
  64. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  65. package/apps/studio/test/edit-persistence.test.ts +91 -0
  66. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  67. package/apps/studio/test/file-lock.test.ts +84 -0
  68. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  69. package/apps/studio/test/timeline-parse.test.ts +127 -0
  70. package/apps/studio/test/timeline-snap.test.ts +85 -0
  71. package/apps/studio/test/video-asset.test.ts +163 -0
  72. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  73. package/apps/studio/test/video-comp.test.ts +168 -0
  74. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  75. package/apps/studio/use-annotation-resize.tsx +6 -3
  76. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  77. package/apps/studio/video-comp.tsx +444 -0
  78. package/apps/studio/whats-new.json +27 -0
  79. package/apps/studio/ws.ts +5 -0
  80. package/package.json +8 -8
  81. package/plugins/design/templates/_shell.html +25 -2
  82. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
@@ -0,0 +1,860 @@
1
+ // TimelinePanel — DDR-148 bottom timeline for a video-comp.
2
+ //
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.
9
+ //
10
+ // v1 is read-only rows (see + seek). Drag-to-retime a block / move a keyframe
11
+ // (source-patch) is the documented next slice.
12
+
13
+ import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
14
+
15
+ import { computeSnapTargets, snapFrame, snapThresholdFrames } from './timeline-snap.js';
16
+ // DDR-150 dogfood — reuse the SAME context-menu component the canvas uses
17
+ // (viewport-aware positioning, keyboard nav, outside-click/Esc dismiss, shared
18
+ // .dc-context-menu styling) instead of a bespoke shell menu.
19
+ import { ContextMenuView } from '../../context-menu.tsx';
20
+
21
+ // DDR-150 dogfood #6 — per-row media-kind identity so the timeline reads like
22
+ // an editor: what's footage, what's a still, what's audio, what's plain JSX.
23
+ function rowKind(seq) {
24
+ const t = seq.mediaTag;
25
+ if (t === 'Video' || t === 'OffthreadVideo') return 'video';
26
+ if (t === 'Img') return 'image';
27
+ if (t === 'Audio') return 'audio';
28
+ return 'jsx';
29
+ }
30
+ function rowKindGlyph(seq) {
31
+ const k = rowKind(seq);
32
+ if (k === 'video') return '▶';
33
+ if (k === 'image') return '◫';
34
+ if (k === 'audio') return '♪';
35
+ return 'ƒ';
36
+ }
37
+ function rowKindTitle(seq) {
38
+ const k = rowKind(seq);
39
+ const src = seq.mediaSrc ? ` · ${String(seq.mediaSrc).split('/').pop()}` : '';
40
+ if (k === 'video') return `Video clip${src}`;
41
+ if (k === 'image') return `Image${src}`;
42
+ if (k === 'audio') return `Audio${src}`;
43
+ return 'Animated JSX (code-driven)';
44
+ }
45
+
46
+ function TIcon({ name, size = 15 }) {
47
+ const paths = {
48
+ play: <path d="M4 3l9 5-9 5z" fill="currentColor" stroke="none" />,
49
+ pause: (
50
+ <>
51
+ <rect x="4" y="3.5" width="3" height="9" fill="currentColor" stroke="none" />
52
+ <rect x="9" y="3.5" width="3" height="9" fill="currentColor" stroke="none" />
53
+ </>
54
+ ),
55
+ loop: (
56
+ <>
57
+ <polyline points="4 4 4 7 7 7" />
58
+ <path d="M4 7a4.5 4.5 0 1 1 1.3 4.6" />
59
+ </>
60
+ ),
61
+ start: (
62
+ <>
63
+ <line x1="4.5" y1="3.5" x2="4.5" y2="12.5" />
64
+ <path d="M12 3.5l-6 4.5 6 4.5z" fill="currentColor" stroke="none" />
65
+ </>
66
+ ),
67
+ sound: (
68
+ <>
69
+ <path d="M3 6h2.2L8 3.3v9.4L5.2 10H3z" fill="currentColor" stroke="none" />
70
+ <path d="M10.5 5.5a3.2 3.2 0 0 1 0 5" />
71
+ <path d="M12.3 3.7a5.6 5.6 0 0 1 0 8.6" />
72
+ </>
73
+ ),
74
+ muted: (
75
+ <>
76
+ <path d="M3 6h2.2L8 3.3v9.4L5.2 10H3z" fill="currentColor" stroke="none" />
77
+ <line x1="10.7" y1="6" x2="13.7" y2="10" />
78
+ <line x1="13.7" y1="6" x2="10.7" y2="10" />
79
+ </>
80
+ ),
81
+ };
82
+ return (
83
+ <svg
84
+ width={size}
85
+ height={size}
86
+ viewBox="0 0 16 16"
87
+ fill="none"
88
+ stroke="currentColor"
89
+ strokeWidth="1.5"
90
+ strokeLinecap="round"
91
+ strokeLinejoin="round"
92
+ aria-hidden="true"
93
+ >
94
+ {paths[name]}
95
+ </svg>
96
+ );
97
+ }
98
+
99
+ const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
100
+
101
+ function fmtTime(frame, fps) {
102
+ const s = frame / (fps || 30);
103
+ const whole = Math.floor(s);
104
+ const cs = Math.round((s - whole) * 100);
105
+ return `${whole}.${String(cs).padStart(2, '0')}s`;
106
+ }
107
+
108
+ export default function TimelinePanel({
109
+ comps = [],
110
+ sequences = [],
111
+ audio = [],
112
+ total = 0,
113
+ frame = 0,
114
+ playing = false,
115
+ loop = true,
116
+ onSeek,
117
+ onPlay,
118
+ onPause,
119
+ onToggleLoop,
120
+ muted = false,
121
+ onToggleMute,
122
+ volume = 1,
123
+ onVolume,
124
+ height,
125
+ resizing,
126
+ onResize,
127
+ onRetime,
128
+ onRemove,
129
+ onReplace,
130
+ onReplaceAudio,
131
+ onReplaceLayer,
132
+ onReorder,
133
+ onToggleHide,
134
+ onDropMedia,
135
+ onClose,
136
+ }) {
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).
140
+ const [collapsed, setCollapsed] = useState(() => new Set());
141
+ const [dropActive, setDropActive] = useState(false);
142
+ const comp = comps[0] ?? null;
143
+ const totalFrames = Math.max(1, total || comp?.durationInFrames || 1);
144
+ const fps = comp?.fps ?? 30;
145
+ const clamped = clamp(Math.round(frame), 0, totalFrames - 1);
146
+ const trackRef = useRef(null);
147
+ const draggingRef = useRef(false);
148
+ // Drag-to-retime a sequence's duration: { index, startX, startDur, rowW, curDur }.
149
+ const [retimeDrag, setRetimeDrag] = useState(null);
150
+ // Drag the block body to move a clip's `from`: { index, startX, startFrom, rowW, curFrom }.
151
+ const [moveDrag, setMoveDrag] = useState(null);
152
+ // DDR-150 dogfood — right-click clip context menu: { index, x, y }. Dismiss +
153
+ // repositioning are owned by the shared ContextMenuView.
154
+ const [ctxMenu, setCtxMenu] = useState(null);
155
+ // True after a body-drag actually moved — suppresses the click-to-seek that
156
+ // would otherwise fire on pointerup.
157
+ const movedRef = useRef(false);
158
+ // Drag the top edge to resize the panel taller/shorter. Matches the DS
159
+ // resize-panels reference: pointer-capture + window listeners attached
160
+ // SYNCHRONOUSLY in pointerdown (a useEffect-on-state attaches a frame late and
161
+ // drops the first moves — the "sticks / can't resize" jank).
162
+ const beginResize = useCallback(
163
+ (e) => {
164
+ e.preventDefault();
165
+ e.stopPropagation();
166
+ e.currentTarget.setPointerCapture?.(e.pointerId);
167
+ const startY = e.clientY;
168
+ const startH = height || 216;
169
+ const onMove = (ev) => {
170
+ onResize?.(clamp(startH + (startY - ev.clientY), 140, 640));
171
+ };
172
+ const onUp = () => {
173
+ window.removeEventListener('pointermove', onMove);
174
+ window.removeEventListener('pointerup', onUp);
175
+ };
176
+ window.addEventListener('pointermove', onMove);
177
+ window.addEventListener('pointerup', onUp);
178
+ },
179
+ [height, onResize]
180
+ );
181
+
182
+ // The row-track's frame axis starts AFTER the fixed label gutter (96px).
183
+ const LABEL_GUTTER = 96;
184
+ const seekAt = useCallback(
185
+ (clientX) => {
186
+ const el = trackRef.current;
187
+ if (!el) return;
188
+ const r = el.getBoundingClientRect();
189
+ const axis = Math.max(1, r.width - LABEL_GUTTER);
190
+ const pct = clamp((clientX - r.left - LABEL_GUTTER) / axis, 0, 1);
191
+ onSeek?.(Math.round(pct * (totalFrames - 1)));
192
+ },
193
+ [onSeek, totalFrames]
194
+ );
195
+
196
+ useEffect(() => {
197
+ if (!draggingRef.current) return undefined;
198
+ const move = (e) => seekAt(e.clientX);
199
+ const up = () => {
200
+ draggingRef.current = false;
201
+ };
202
+ window.addEventListener('pointermove', move);
203
+ window.addEventListener('pointerup', up, { once: true });
204
+ return () => {
205
+ window.removeEventListener('pointermove', move);
206
+ window.removeEventListener('pointerup', up);
207
+ };
208
+ });
209
+
210
+ const onTrackDown = (e) => {
211
+ // A click on a sequence block seeks to its start (handled there); a click on
212
+ // the bare track scrubs + starts a drag.
213
+ if (retimeDrag) return; // a resize is in flight — don't scrub
214
+ e.preventDefault(); // don't start a text selection while scrubbing
215
+ draggingRef.current = true;
216
+ seekAt(e.clientX);
217
+ // force the effect to (re)attach the window listeners
218
+ e.currentTarget.setPointerCapture?.(e.pointerId);
219
+ };
220
+
221
+ // DDR-148 drag-to-retime — dragging a block's right edge rewrites its
222
+ // durationInFrames (const-preferring on the server, so a derived total moves
223
+ // in lock-step). The width previews live; the source patch lands on release.
224
+ useEffect(() => {
225
+ if (!retimeDrag) return undefined;
226
+ const move = (e) => {
227
+ const deltaFrames =
228
+ ((e.clientX - retimeDrag.startX) / Math.max(1, retimeDrag.rowW)) * (totalFrames - 1);
229
+ let end = retimeDrag.startFrom + Math.round(retimeDrag.startDur + deltaFrames);
230
+ // Snap the moving EDGE (clip end) to ticks / neighbor edges / playhead —
231
+ // Alt overrides. The clip's own start stays fixed, so duration = end − from.
232
+ const thr = e.altKey ? 0 : snapThresholdFrames(retimeDrag.rowW, totalFrames);
233
+ end = snapFrame(end, retimeDrag.targets, thr);
234
+ const curDur = Math.max(1, end - retimeDrag.startFrom);
235
+ setRetimeDrag((d) => (d ? { ...d, curDur } : d));
236
+ };
237
+ const up = () => {
238
+ setRetimeDrag((d) => {
239
+ if (d && d.curDur !== d.startDur) onRetime?.(d.index, { durationInFrames: d.curDur });
240
+ return null;
241
+ });
242
+ };
243
+ window.addEventListener('pointermove', move);
244
+ window.addEventListener('pointerup', up, { once: true });
245
+ return () => {
246
+ window.removeEventListener('pointermove', move);
247
+ window.removeEventListener('pointerup', up);
248
+ };
249
+ }, [retimeDrag, totalFrames, onRetime]);
250
+
251
+ // DDR-150 P3 Task 6 — drag the block BODY to move a clip's `from` (the engine
252
+ // inserts `from` if the clip was cursor-implicit). Previews live; commits on
253
+ // release. Distinct from the right-edge duration drag above.
254
+ useEffect(() => {
255
+ if (!moveDrag) return undefined;
256
+ const move = (e) => {
257
+ const deltaFrames =
258
+ ((e.clientX - moveDrag.startX) / Math.max(1, moveDrag.rowW)) * (totalFrames - 1);
259
+ let curFrom = Math.max(0, Math.round(moveDrag.startFrom + deltaFrames));
260
+ // Snap the clip START to ticks / neighbor edges / playhead — Alt overrides.
261
+ const thr = e.altKey ? 0 : snapThresholdFrames(moveDrag.rowW, totalFrames);
262
+ curFrom = Math.max(0, snapFrame(curFrom, moveDrag.targets, thr));
263
+ if (Math.abs(curFrom - moveDrag.startFrom) >= 1) movedRef.current = true;
264
+ setMoveDrag((d) => (d ? { ...d, curFrom } : d));
265
+ };
266
+ const up = () => {
267
+ setMoveDrag((d) => {
268
+ if (d && d.curFrom !== d.startFrom) onRetime?.(d.index, { from: d.curFrom });
269
+ return null;
270
+ });
271
+ };
272
+ window.addEventListener('pointermove', move);
273
+ window.addEventListener('pointerup', up, { once: true });
274
+ return () => {
275
+ window.removeEventListener('pointermove', move);
276
+ window.removeEventListener('pointerup', up);
277
+ };
278
+ }, [moveDrag, totalFrames, onRetime]);
279
+
280
+ // Snap targets for a drag on clip `index`: ticks + OTHER clips' edges + playhead.
281
+ const snapTargetsFor = (index) =>
282
+ computeSnapTargets({ fps, totalFrames, clips: sequences, movingIndex: index, playhead: clamped });
283
+
284
+ const startMove = (e, index, startFrom) => {
285
+ if (!onRetime) return;
286
+ // A TransitionSeries/Series clip computes its own position — Remotion
287
+ // IGNORES a `from` prop on it, so a body-drag would write a no-op lie
288
+ // (the "video never changes" dogfood bug). The engine refuses it too;
289
+ // don't even start the gesture.
290
+ if (sequences[index]?.series) return;
291
+ e.stopPropagation();
292
+ const rowTrack = e.currentTarget.closest('.tl-row-track');
293
+ const rowW = rowTrack ? rowTrack.getBoundingClientRect().width : 300;
294
+ movedRef.current = false;
295
+ setMoveDrag({
296
+ index,
297
+ startX: e.clientX,
298
+ startFrom,
299
+ rowW,
300
+ curFrom: startFrom,
301
+ targets: snapTargetsFor(index),
302
+ });
303
+ };
304
+
305
+ const startResize = (e, index, dur) => {
306
+ e.stopPropagation();
307
+ e.preventDefault();
308
+ const rowTrack = e.currentTarget.closest('.tl-row-track');
309
+ const rowW = rowTrack ? rowTrack.getBoundingClientRect().width : 300;
310
+ const startFrom = sequences[index]?.from ?? 0;
311
+ setRetimeDrag({
312
+ index,
313
+ startX: e.clientX,
314
+ startDur: dur,
315
+ startFrom,
316
+ rowW,
317
+ curDur: dur,
318
+ targets: snapTargetsFor(index),
319
+ });
320
+ };
321
+
322
+ // Second ticks across the ruler.
323
+ const secs = Math.max(1, Math.floor((totalFrames - 1) / fps));
324
+ const ticks = Array.from({ length: secs + 1 }, (_, i) => (i * fps) / (totalFrames - 1));
325
+ const pct = (f) => `${(clamp(f, 0, totalFrames - 1) / (totalFrames - 1)) * 100}%`;
326
+
327
+ // DDR-150 P4 Task 11 — drop a media file onto the timeline to insert a clip.
328
+ const hasFiles = (dt) => Array.from(dt?.types ?? []).includes('Files');
329
+ const onDrop = (e) => {
330
+ if (!onDropMedia || !hasFiles(e.dataTransfer)) return;
331
+ e.preventDefault();
332
+ e.stopPropagation();
333
+ setDropActive(false);
334
+ const file = e.dataTransfer.files?.[0];
335
+ if (file) onDropMedia(file);
336
+ };
337
+
338
+ return (
339
+ <aside
340
+ className={`tl-panel${resizing ? ' is-resizing' : ''}${dropActive ? ' is-drop' : ''}`}
341
+ style={height ? { height } : undefined}
342
+ onDragOver={(e) => {
343
+ if (!onDropMedia || !hasFiles(e.dataTransfer)) return;
344
+ e.preventDefault();
345
+ e.dataTransfer.dropEffect = 'copy';
346
+ if (!dropActive) setDropActive(true);
347
+ }}
348
+ onDragLeave={(e) => {
349
+ if (e.relatedTarget == null) setDropActive(false);
350
+ }}
351
+ onDrop={onDrop}
352
+ aria-label="Timeline"
353
+ data-testid="timeline-panel"
354
+ >
355
+ <div
356
+ className="tl-resize-handle"
357
+ data-testid="timeline-resize-handle"
358
+ title="Drag to resize"
359
+ onPointerDown={beginResize}
360
+ />
361
+ <div className="tl-head">
362
+ {comp ? (
363
+ <>
364
+ <button
365
+ type="button"
366
+ className="tl-btn tl-btn-primary"
367
+ aria-label={playing ? 'Pause' : 'Play'}
368
+ aria-pressed={playing}
369
+ data-testid="timeline-playpause"
370
+ onClick={() => (playing ? onPause?.() : onPlay?.())}
371
+ >
372
+ <TIcon name={playing ? 'pause' : 'play'} />
373
+ </button>
374
+ <button type="button" className="tl-btn" aria-label="Jump to start" onClick={() => onSeek?.(0)}>
375
+ <TIcon name="start" />
376
+ </button>
377
+ <button
378
+ type="button"
379
+ className={`tl-btn${loop ? ' is-on' : ''}`}
380
+ aria-label="Loop"
381
+ aria-pressed={loop}
382
+ data-testid="timeline-loop"
383
+ onClick={() => onToggleLoop?.()}
384
+ >
385
+ <TIcon name="loop" />
386
+ </button>
387
+ {audio.length > 0 ? (
388
+ <button
389
+ type="button"
390
+ className={`tl-btn${muted ? '' : ' is-on'}`}
391
+ aria-label={muted ? 'Unmute' : 'Mute'}
392
+ aria-pressed={!muted}
393
+ data-testid="timeline-mute"
394
+ title={muted ? 'Unmute audio' : 'Mute audio'}
395
+ onClick={() => onToggleMute?.()}
396
+ >
397
+ <TIcon name={muted ? 'muted' : 'sound'} />
398
+ </button>
399
+ ) : null}
400
+ {audio.length > 0 ? (
401
+ <input
402
+ type="range"
403
+ className="tl-volume"
404
+ min="0"
405
+ max="100"
406
+ value={Math.round((muted ? 0 : volume) * 100)}
407
+ data-testid="timeline-volume"
408
+ aria-label="Volume"
409
+ title="Volume"
410
+ onChange={(e) => onVolume?.(Number(e.target.value) / 100)}
411
+ />
412
+ ) : null}
413
+ <span className="tl-readout" data-testid="timeline-readout">
414
+ <b>{clamped}</b>
415
+ <span className="tl-sep">/</span>
416
+ {totalFrames - 1}
417
+ <span className="tl-time">{fmtTime(clamped, fps)}</span>
418
+ </span>
419
+ </>
420
+ ) : (
421
+ <span className="tl-title">Timeline</span>
422
+ )}
423
+ <span className="tl-spacer" />
424
+ {comp ? <span className="tl-meta">{fps} fps · {totalFrames}f</span> : null}
425
+ <button type="button" className="tl-x" aria-label="Close timeline" onClick={onClose}>
426
+ ×
427
+ </button>
428
+ </div>
429
+
430
+ {!comp ? (
431
+ <div className="tl-empty" data-testid="timeline-empty">
432
+ No animation on this canvas. Make an artboard a <b>video-comp</b>
433
+ (<code>&lt;VideoComp&gt;</code>) to scrub and export it — or drop clips on
434
+ the canvas and <b>File → Assemble dropped clips → video</b>.
435
+ </div>
436
+ ) : (
437
+ <div className="tl-scroll">
438
+ <div
439
+ className="tl-tracks"
440
+ ref={trackRef}
441
+ data-testid="timeline-track"
442
+ onPointerDown={onTrackDown}
443
+ >
444
+ <div className="tl-ruler">
445
+ {ticks.map((t, i) => (
446
+ <span key={i} className="tl-tick" style={{ left: `${t * 100}%` }}>
447
+ <span className="tl-tick-label">{i}s</span>
448
+ </span>
449
+ ))}
450
+ </div>
451
+
452
+ {sequences.length === 0 ? (
453
+ <div className="tl-row">
454
+ <span className="tl-row-label">comp</span>
455
+ <div className="tl-row-track">
456
+ <div className="tl-seq-block" style={{ left: '0%', width: '100%' }}>
457
+ <span className="tl-seq-name">whole composition</span>
458
+ </div>
459
+ </div>
460
+ </div>
461
+ ) : (
462
+ sequences.map((seq, i) => {
463
+ const dur =
464
+ retimeDrag && retimeDrag.index === i ? retimeDrag.curDur : seq.duration;
465
+ const resizing = !!(retimeDrag && retimeDrag.index === i);
466
+ const blockFrom =
467
+ moveDrag && moveDrag.index === i ? moveDrag.curFrom : seq.from;
468
+ const moving = !!(moveDrag && moveDrag.index === i);
469
+ const layers = Array.isArray(seq.layers) ? seq.layers : [];
470
+ const decomposable = layers.length >= 2;
471
+ const isExpanded = decomposable && !collapsed.has(i);
472
+ return (
473
+ <Fragment key={i}>
474
+ <div className="tl-row" data-testid={`timeline-row-${i}`}>
475
+ <span className="tl-row-label" title={seq.label}>
476
+ {decomposable ? (
477
+ <button
478
+ type="button"
479
+ className="tl-expand"
480
+ data-testid={`timeline-expand-${i}`}
481
+ title={isExpanded ? 'Collapse layers' : 'Show layers'}
482
+ aria-label={isExpanded ? `Collapse ${seq.label} layers` : `Show ${seq.label} layers`}
483
+ aria-expanded={isExpanded}
484
+ onPointerDown={(e) => e.stopPropagation()}
485
+ onClick={(e) => {
486
+ e.stopPropagation();
487
+ e.preventDefault();
488
+ setCollapsed((prev) => {
489
+ const next = new Set(prev);
490
+ if (next.has(i)) next.delete(i);
491
+ else next.add(i);
492
+ return next;
493
+ });
494
+ }}
495
+ >
496
+ {isExpanded ? '▾' : '▸'}
497
+ </button>
498
+ ) : (
499
+ <span className="tl-expand tl-expand--spacer" aria-hidden="true" />
500
+ )}
501
+ {onReorder ? (
502
+ <span
503
+ className="tl-seq-reorder"
504
+ role="group"
505
+ aria-label={`${seq.series ? 'Play order' : 'Stacking order'} for ${seq.label}`}
506
+ >
507
+ <button
508
+ type="button"
509
+ className="tl-reorder-btn"
510
+ data-testid={`timeline-raise-${i}`}
511
+ title={seq.series ? 'Move earlier (plays sooner)' : 'Bring forward (render on top)'}
512
+ aria-label={
513
+ seq.series ? `Move ${seq.label} earlier` : `Bring ${seq.label} forward`
514
+ }
515
+ disabled={seq.series ? i <= 0 : i >= sequences.length - 1}
516
+ onClick={(e) => {
517
+ e.stopPropagation();
518
+ // Series clips play in order — ▲ = earlier (swap with
519
+ // the previous beat); standalone → forward in z-stack.
520
+ onReorder(i, seq.series ? 'backward' : 'forward');
521
+ }}
522
+ >
523
+
524
+ </button>
525
+ <button
526
+ type="button"
527
+ className="tl-reorder-btn"
528
+ data-testid={`timeline-lower-${i}`}
529
+ title={seq.series ? 'Move later (plays after)' : 'Send backward (render underneath)'}
530
+ aria-label={
531
+ seq.series ? `Move ${seq.label} later` : `Send ${seq.label} backward`
532
+ }
533
+ disabled={seq.series ? i >= sequences.length - 1 : i <= 0}
534
+ onClick={(e) => {
535
+ e.stopPropagation();
536
+ onReorder(i, seq.series ? 'forward' : 'backward');
537
+ }}
538
+ >
539
+
540
+ </button>
541
+ </span>
542
+ ) : null}
543
+ <span
544
+ className="tl-kind"
545
+ data-kind={rowKind(seq)}
546
+ title={rowKindTitle(seq)}
547
+ aria-label={rowKindTitle(seq)}
548
+ >
549
+ {rowKindGlyph(seq)}
550
+ </span>
551
+ <span className="tl-row-label-text">{seq.label}</span>
552
+ </span>
553
+ <div className="tl-row-track">
554
+ <button
555
+ type="button"
556
+ className={`tl-seq-block${resizing ? ' is-resizing' : ''}${moving ? ' is-moving' : ''}${seq.hidden ? ' is-hidden' : ''}`}
557
+ data-testid={`timeline-seq-${i}`}
558
+ data-kind={rowKind(seq)}
559
+ data-hidden={seq.hidden ? '1' : undefined}
560
+ title={
561
+ seq.series
562
+ ? `${seq.label} · ${blockFrom}–${blockFrom + dur}f (${dur}f) · position computed by the series — trim the right edge to retime`
563
+ : `${seq.label} · ${blockFrom}–${blockFrom + dur}f (${dur}f) · drag to move (Alt = no snap)`
564
+ }
565
+ aria-label={
566
+ seq.series
567
+ ? `${seq.label}, series beat, frames ${blockFrom} to ${blockFrom + dur}. Click to seek; trim its edge to retime.`
568
+ : `${seq.label}, from frame ${blockFrom} to ${blockFrom + dur}, ${dur} frames. Click to seek; drag to move.`
569
+ }
570
+ style={{
571
+ left: pct(blockFrom),
572
+ width: `${(dur / (totalFrames - 1)) * 100}%`,
573
+ cursor: onRetime && !seq.series ? 'grab' : undefined,
574
+ }}
575
+ onContextMenu={(e) => {
576
+ e.preventDefault();
577
+ e.stopPropagation();
578
+ setCtxMenu({ index: i, x: e.clientX, y: e.clientY });
579
+ }}
580
+ onPointerDown={(e) => startMove(e, i, seq.from)}
581
+ onClick={(e) => {
582
+ e.stopPropagation();
583
+ if (movedRef.current) {
584
+ movedRef.current = false;
585
+ return; // a drag-move just happened — don't also seek
586
+ }
587
+ onSeek?.(seq.from);
588
+ }}
589
+ >
590
+ <span className="tl-seq-name">
591
+ {seq.label}
592
+ {resizing ? ` · ${dur}f` : ''}
593
+ {moving ? ` · @${blockFrom}f` : ''}
594
+ </span>
595
+ {seq.keyframes.map((kf, k) => {
596
+ const l = ((kf.from - seq.from) / Math.max(1, seq.duration)) * 100;
597
+ const w = ((kf.to - kf.from) / Math.max(1, seq.duration)) * 100;
598
+ return (
599
+ <span
600
+ key={k}
601
+ className="tl-kf"
602
+ data-testid={`timeline-kf-${i}-${k}`}
603
+ style={{ left: `${clamp(l, 0, 100)}%`, width: `${Math.max(1.5, w)}%` }}
604
+ title={`seek to keyframe ${kf.from}f (animates ${kf.from}–${kf.to}f)`}
605
+ onPointerDown={(e) => e.stopPropagation()}
606
+ onClick={(e) => {
607
+ e.stopPropagation();
608
+ onSeek?.(kf.from);
609
+ }}
610
+ />
611
+ );
612
+ })}
613
+ </button>
614
+ {onRetime ? (
615
+ <span
616
+ className="tl-seq-resize"
617
+ data-testid={`timeline-resize-${i}`}
618
+ title="Drag to retime this sequence"
619
+ style={{
620
+ left: `calc(${pct(seq.from)} + ${(dur / (totalFrames - 1)) * 100}% - 5px)`,
621
+ }}
622
+ onPointerDown={(e) => startResize(e, i, seq.duration)}
623
+ onClick={(e) => e.stopPropagation()}
624
+ />
625
+ ) : null}
626
+ {onReplace && seq.replaceable && !decomposable ? (
627
+ <button
628
+ type="button"
629
+ className="tl-seq-replace"
630
+ data-testid={`timeline-replace-${i}`}
631
+ title={`Replace this ${rowKind(seq)}`}
632
+ aria-label={`Replace media in ${seq.label}`}
633
+ style={{
634
+ left: `calc(${pct(seq.from)} + ${(dur / (totalFrames - 1)) * 100}% - 34px)`,
635
+ }}
636
+ onPointerDown={(e) => e.stopPropagation()}
637
+ onClick={(e) => {
638
+ e.stopPropagation();
639
+ onReplace(i);
640
+ }}
641
+ >
642
+
643
+ </button>
644
+ ) : null}
645
+ {onRemove ? (
646
+ <button
647
+ type="button"
648
+ className="tl-seq-remove"
649
+ data-testid={`timeline-remove-${i}`}
650
+ title="Remove this clip"
651
+ aria-label={`Remove ${seq.label}`}
652
+ style={{
653
+ left: `calc(${pct(seq.from)} + ${(dur / (totalFrames - 1)) * 100}% - 17px)`,
654
+ }}
655
+ onPointerDown={(e) => e.stopPropagation()}
656
+ onClick={(e) => {
657
+ e.stopPropagation();
658
+ onRemove(i);
659
+ }}
660
+ >
661
+ ×
662
+ </button>
663
+ ) : null}
664
+ </div>
665
+ </div>
666
+ {isExpanded
667
+ ? layers.map((ly, li) => {
668
+ const lyKind =
669
+ ly.kind === 'video'
670
+ ? 'video'
671
+ : ly.kind === 'image'
672
+ ? 'image'
673
+ : ly.kind === 'audio'
674
+ ? 'audio'
675
+ : 'jsx';
676
+ const lyGlyph =
677
+ ly.kind === 'video'
678
+ ? '▶'
679
+ : ly.kind === 'image'
680
+ ? '◫'
681
+ : ly.kind === 'audio'
682
+ ? '♪'
683
+ : 'ƒ';
684
+ const lyReplaceable = !!(ly.mediaCdId || ly.mediaArrayRef);
685
+ return (
686
+ <div
687
+ className="tl-row tl-row--layer"
688
+ key={`l${li}`}
689
+ data-testid={`timeline-layer-${i}-${li}`}
690
+ >
691
+ <span className="tl-row-label" title={ly.label}>
692
+ <span className="tl-layer-indent" aria-hidden="true" />
693
+ <span className="tl-kind" data-kind={lyKind}>
694
+ {lyGlyph}
695
+ </span>
696
+ <span className="tl-row-label-text">{ly.label}</span>
697
+ </span>
698
+ <div className="tl-row-track">
699
+ <div
700
+ className="tl-seq-block tl-layer-block"
701
+ data-kind={lyKind}
702
+ data-testid={`timeline-layer-block-${i}-${li}`}
703
+ title={`${ly.label} (${ly.kind} layer)`}
704
+ style={{ left: pct(seq.from), width: `${(dur / (totalFrames - 1)) * 100}%` }}
705
+ onContextMenu={(e) => {
706
+ e.preventDefault();
707
+ e.stopPropagation();
708
+ setCtxMenu({ index: i, layerIndex: li, x: e.clientX, y: e.clientY });
709
+ }}
710
+ >
711
+ <span className="tl-seq-name">{ly.label}</span>
712
+ </div>
713
+ {onReplaceLayer && lyReplaceable ? (
714
+ <button
715
+ type="button"
716
+ className="tl-seq-replace"
717
+ data-testid={`timeline-layer-replace-${i}-${li}`}
718
+ title={`Replace this ${ly.kind}`}
719
+ aria-label={`Replace ${ly.label}`}
720
+ style={{
721
+ left: `calc(${pct(seq.from)} + ${(dur / (totalFrames - 1)) * 100}% - 20px)`,
722
+ }}
723
+ onPointerDown={(e) => e.stopPropagation()}
724
+ onClick={(e) => {
725
+ e.stopPropagation();
726
+ onReplaceLayer(i, li);
727
+ }}
728
+ >
729
+
730
+ </button>
731
+ ) : null}
732
+ </div>
733
+ </div>
734
+ );
735
+ })
736
+ : null}
737
+ </Fragment>
738
+ );
739
+ })
740
+ )}
741
+
742
+ {audio.map((a, i) => (
743
+ <div className="tl-row" key={`a${i}`} data-testid={`timeline-audio-${i}`}>
744
+ <span className="tl-row-label" title={a.label}>
745
+ <span className="tl-kind" data-kind="audio" title={`Audio · ${a.label}`} aria-label={`Audio · ${a.label}`}>
746
+
747
+ </span>
748
+ <span className="tl-row-label-text">{a.label}</span>
749
+ </span>
750
+ <div className="tl-row-track">
751
+ <div
752
+ className="tl-audio-block"
753
+ title={`${a.label} · ${a.from}–${a.from + a.duration}f`}
754
+ style={{
755
+ left: pct(a.from),
756
+ width: `${(a.duration / (totalFrames - 1)) * 100}%`,
757
+ }}
758
+ >
759
+ <svg className="tl-wave" viewBox="0 0 100 12" preserveAspectRatio="none" aria-hidden>
760
+ <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" />
761
+ </svg>
762
+ <span className="tl-seq-name">{a.label}</span>
763
+ </div>
764
+ {onReplaceAudio ? (
765
+ <button
766
+ type="button"
767
+ className="tl-seq-replace"
768
+ data-testid={`timeline-audio-replace-${i}`}
769
+ title="Replace this audio"
770
+ aria-label={`Replace audio ${a.label}`}
771
+ style={{
772
+ left: `calc(${pct(a.from)} + ${(a.duration / (totalFrames - 1)) * 100}% - 20px)`,
773
+ }}
774
+ onPointerDown={(e) => e.stopPropagation()}
775
+ onClick={(e) => {
776
+ e.stopPropagation();
777
+ onReplaceAudio(i);
778
+ }}
779
+ >
780
+
781
+ </button>
782
+ ) : null}
783
+ </div>
784
+ </div>
785
+ ))}
786
+
787
+ <span
788
+ className="tl-playhead"
789
+ data-testid="timeline-playhead"
790
+ style={{ left: `calc(96px + (100% - 96px) * ${(clamped / (totalFrames - 1)) || 0})` }}
791
+ />
792
+ </div>
793
+ </div>
794
+ )}
795
+ {ctxMenu && sequences[ctxMenu.index]
796
+ ? (() => {
797
+ const seq = sequences[ctxMenu.index];
798
+ const i = ctxMenu.index;
799
+ const li = ctxMenu.layerIndex;
800
+ let sections;
801
+ if (li != null) {
802
+ // A LAYER (sub-clip) menu — a stacked layer shares the clip's
803
+ // timing, so only media-replace applies here; trim/move/cut are
804
+ // clip-level ops on the parent row.
805
+ const ly = (Array.isArray(seq.layers) ? seq.layers : [])[li];
806
+ const items = [];
807
+ if (onReplaceLayer && ly && (ly.mediaCdId || ly.mediaArrayRef)) {
808
+ items.push({ id: 'replace-layer', label: `Replace ${ly.kind}…`, onSelect: () => onReplaceLayer(i, li) });
809
+ }
810
+ if (items.length === 0) {
811
+ items.push({ id: 'noop', label: 'No replaceable media in this layer', disabled: true, onSelect: () => {} });
812
+ }
813
+ sections = [items];
814
+ } else {
815
+ const primary = [];
816
+ // Replace only when the clip's media is directly on it (not split
817
+ // into layers — those replace from their own sub-rows).
818
+ if (onReplace && seq.replaceable && !(Array.isArray(seq.layers) && seq.layers.length >= 2)) {
819
+ primary.push({ id: 'replace', label: `Replace ${rowKind(seq)}…`, onSelect: () => onReplace(i) });
820
+ }
821
+ if (onReorder) {
822
+ primary.push({
823
+ id: 'earlier',
824
+ label: seq.series ? 'Move earlier' : 'Bring forward',
825
+ disabled: seq.series ? i <= 0 : i >= sequences.length - 1,
826
+ onSelect: () => onReorder(i, seq.series ? 'backward' : 'forward'),
827
+ });
828
+ primary.push({
829
+ id: 'later',
830
+ label: seq.series ? 'Move later' : 'Send backward',
831
+ disabled: seq.series ? i >= sequences.length - 1 : i <= 0,
832
+ onSelect: () => onReorder(i, seq.series ? 'forward' : 'backward'),
833
+ });
834
+ }
835
+ sections = [primary];
836
+ const tail = [];
837
+ if (onToggleHide) {
838
+ tail.push({
839
+ id: 'hide',
840
+ label: seq.hidden ? 'Show clip' : 'Hide clip',
841
+ onSelect: () => onToggleHide(i),
842
+ });
843
+ }
844
+ if (onRemove) {
845
+ tail.push({ id: 'remove', label: 'Remove clip', destructive: true, onSelect: () => onRemove(i) });
846
+ }
847
+ if (tail.length) sections.push(tail);
848
+ }
849
+ return (
850
+ <ContextMenuView
851
+ target={{ kind: 'element', el: null, cdId: null, artboardId: null, clientX: ctxMenu.x, clientY: ctxMenu.y }}
852
+ sections={sections}
853
+ onClose={() => setCtxMenu(null)}
854
+ />
855
+ );
856
+ })()
857
+ : null}
858
+ </aside>
859
+ );
860
+ }