@1agh/maude 0.51.1 → 0.52.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/apps/studio/api.ts +389 -14
- package/apps/studio/bin/_video-playwright.mjs +15 -6
- package/apps/studio/canvas-edit.ts +141 -20
- package/apps/studio/canvas-lib.tsx +61 -0
- package/apps/studio/client/app.jsx +824 -121
- package/apps/studio/client/panels/ClipInspector.jsx +409 -0
- package/apps/studio/client/panels/CloudBar.jsx +12 -2
- package/apps/studio/client/panels/TimelinePanel.jsx +1775 -430
- package/apps/studio/client/panels/timeline-media-cache.js +274 -0
- package/apps/studio/client/panels/timeline-parse.js +46 -1
- package/apps/studio/client/panels/timeline-scale.js +64 -0
- package/apps/studio/client/report-bug.jsx +15 -15
- package/apps/studio/client/styles/3-shell-maude.css +446 -3
- package/apps/studio/client/styles/4-components-maude.css +55 -0
- package/apps/studio/client/styles/4-components.css +0 -83
- package/apps/studio/clip-ops.ts +1980 -0
- package/apps/studio/cloud/endpoints.ts +88 -17
- package/apps/studio/credential-grammar.ts +100 -0
- package/apps/studio/debug-bundle.ts +12 -0
- package/apps/studio/dist/client.bundle.js +2048 -2048
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/video.ts +49 -10
- package/apps/studio/http.ts +113 -6
- package/apps/studio/ripple.ts +335 -0
- package/apps/studio/sync/hub-link.ts +3 -3
- package/apps/studio/sync/hubs-config.ts +32 -0
- package/apps/studio/sync/workspace-signin.ts +8 -4
- package/apps/studio/test/_helpers.ts +5 -3
- package/apps/studio/test/canvas-create-api.test.ts +8 -2
- package/apps/studio/test/canvas-origin-gate.test.ts +20 -0
- package/apps/studio/test/clip-addressing.test.ts +7 -6
- package/apps/studio/test/clip-ops.test.ts +836 -0
- package/apps/studio/test/cloud-endpoints.test.ts +37 -2
- package/apps/studio/test/comments-api.test.ts +49 -0
- package/apps/studio/test/credential-grammar.test.ts +107 -0
- package/apps/studio/test/read-only-role.test.ts +95 -0
- package/apps/studio/test/ripple.test.ts +197 -0
- package/apps/studio/test/timeline-scale.test.ts +65 -0
- package/apps/studio/test/workspace-signin.test.ts +4 -1
- package/apps/studio/whats-new.json +35 -0
- package/cli/commands/share.mjs +8 -4
- package/cli/lib/ddr-to-kgai.mjs +6 -1
- package/cli/lib/ddr-to-kgai.test.mjs +1 -2
- package/cli/lib/hubs-config.mjs +15 -1
- package/cli/lib/hubs-config.test.mjs +19 -0
- package/cli/lib/share-plan.mjs +5 -1
- package/cli/lib/share-plan.test.mjs +11 -2
- 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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
//
|
|
11
|
-
//
|
|
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 {
|
|
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 —
|
|
138
|
-
//
|
|
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
|
|
150
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
164
|
-
|
|
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
|
-
//
|
|
167
|
-
|
|
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
|
|
170
|
-
//
|
|
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
|
|
201
|
-
|
|
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
|
|
223
|
-
//
|
|
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
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
240
|
-
let end = retimeDrag.
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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,
|
|
510
|
+
}, [retimeDrag, onRetime, flashSnap]);
|
|
261
511
|
|
|
262
|
-
//
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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 (
|
|
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
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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
|
|
320
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
346
|
-
|
|
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)
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
|
446
|
-
|
|
447
|
-
|
|
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><VideoComp></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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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:
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
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-
|
|
558
|
-
data-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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-
|
|
714
|
-
|
|
715
|
-
data-testid={`timeline-layer
|
|
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-
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
{
|
|
756
|
-
<
|
|
757
|
-
|
|
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:
|
|
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 (
|
|
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
|
|
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
|
-
|
|
830
|
-
|
|
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
|
);
|