@1agh/maude 0.39.0 → 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.
- package/README.md +1 -1
- package/apps/studio/acp/bridge.ts +144 -5
- package/apps/studio/acp/index.ts +11 -1
- package/apps/studio/annotations-context-toolbar.tsx +44 -1
- package/apps/studio/annotations-layer.tsx +253 -3
- package/apps/studio/annotations-model.ts +107 -6
- package/apps/studio/api.ts +812 -64
- package/apps/studio/bin/_html-playwright.mjs +9 -1
- package/apps/studio/bin/_pdf-playwright.mjs +8 -1
- package/apps/studio/bin/_png-playwright.mjs +8 -1
- package/apps/studio/bin/_pw-launch.mjs +54 -0
- package/apps/studio/bin/_svg-playwright.mjs +8 -1
- package/apps/studio/bin/_video-playwright.mjs +452 -0
- package/apps/studio/bin/prep.sh +8 -1
- package/apps/studio/canvas-edit.ts +1885 -104
- package/apps/studio/canvas-lib.tsx +19 -0
- package/apps/studio/canvas-list-watch.ts +6 -2
- package/apps/studio/canvas-shell.tsx +27 -0
- package/apps/studio/client/app.jsx +1029 -30
- package/apps/studio/client/github.js +7 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
- package/apps/studio/client/panels/timeline-parse.js +229 -0
- package/apps/studio/client/panels/timeline-snap.js +55 -0
- package/apps/studio/client/styles/3-shell-maude.css +107 -0
- package/apps/studio/config.schema.json +14 -0
- package/apps/studio/context-menu.tsx +1 -1
- package/apps/studio/context.ts +113 -1
- package/apps/studio/dist/client.bundle.js +88 -16
- package/apps/studio/dist/comment-mount.js +1 -1
- package/apps/studio/dist/runtime/.min-sizes.json +11 -1
- package/apps/studio/dist/runtime/@remotion_media.js +491 -0
- package/apps/studio/dist/runtime/@remotion_player.js +56 -0
- package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
- package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
- package/apps/studio/dist/runtime/remotion.js +42 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +117 -0
- package/apps/studio/exporters/_runtime.ts +69 -0
- package/apps/studio/exporters/html.ts +4 -3
- package/apps/studio/exporters/index.ts +28 -2
- package/apps/studio/exporters/pdf.ts +4 -3
- package/apps/studio/exporters/png.ts +5 -3
- package/apps/studio/exporters/pptx.ts +6 -4
- package/apps/studio/exporters/svg.ts +9 -5
- package/apps/studio/exporters/video-encode-lib.ts +200 -0
- package/apps/studio/exporters/video-render-lib.ts +108 -0
- package/apps/studio/exporters/video.ts +184 -0
- package/apps/studio/http.ts +535 -27
- package/apps/studio/input-router.tsx +7 -1
- package/apps/studio/runtime-bundle.ts +30 -0
- package/apps/studio/server.ts +34 -9
- package/apps/studio/test/acp-bridge.test.ts +132 -0
- package/apps/studio/test/acp-commands.test.ts +8 -3
- package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
- package/apps/studio/test/canvas-create-api.test.ts +76 -0
- package/apps/studio/test/canvas-edit.test.ts +90 -0
- package/apps/studio/test/canvas-list-watch.test.ts +49 -0
- package/apps/studio/test/canvas-media-drop.test.ts +30 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +36 -0
- package/apps/studio/test/clip-addressing.test.ts +732 -0
- package/apps/studio/test/config-reload.test.ts +230 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
- package/apps/studio/test/edit-persistence.test.ts +91 -0
- package/apps/studio/test/exporters/runtime.test.ts +59 -0
- package/apps/studio/test/file-lock.test.ts +84 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +24 -1
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
- package/apps/studio/test/timeline-parse.test.ts +127 -0
- package/apps/studio/test/timeline-snap.test.ts +85 -0
- package/apps/studio/test/video-asset.test.ts +163 -0
- package/apps/studio/test/video-comp-fixture.test.ts +50 -0
- package/apps/studio/test/video-comp.test.ts +168 -0
- package/apps/studio/test/video-render-bridge.test.ts +149 -0
- package/apps/studio/use-annotation-resize.tsx +6 -3
- package/apps/studio/use-canvas-media-drop.tsx +66 -4
- package/apps/studio/video-comp.tsx +444 -0
- package/apps/studio/whats-new.json +36 -0
- package/apps/studio/ws.ts +5 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +25 -2
- 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><VideoComp></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
|
+
}
|