@hyperframes/studio 0.7.56 → 0.7.57
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/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
- package/dist/assets/{index-C4csZims.js → index-Bf1x1y8H.js} +1 -1
- package/dist/assets/{index-CmVCjZjp.js → index-DfmYkU44.js} +196 -196
- package/dist/assets/{index-BWnOxAiH.js → index-KsfE1bUu.js} +1 -1
- package/dist/assets/index-_pqzyxB1.css +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.html +2 -2
- package/dist/index.js +4345 -3812
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
- package/src/components/editor/CanvasContextMenu.tsx +33 -18
- package/src/components/editor/DomEditOverlay.tsx +31 -62
- package/src/components/editor/LayersPanel.tsx +10 -1
- package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
- package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
- package/src/components/editor/useCanvasContextMenuState.ts +92 -0
- package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
- package/src/components/nle/PreviewOverlays.tsx +36 -16
- package/src/components/sidebar/AssetCard.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +19 -2
- package/src/components/sidebar/AudioRow.tsx +19 -2
- package/src/hooks/domEditCommitTypes.ts +12 -1
- package/src/hooks/timelineEditingHelpers.test.ts +91 -0
- package/src/hooks/timelineEditingHelpers.ts +31 -244
- package/src/hooks/timelineMoveAdapter.test.ts +44 -40
- package/src/hooks/timelineMoveAdapter.ts +5 -3
- package/src/hooks/timelineTimingSync.test.ts +150 -0
- package/src/hooks/timelineTimingSync.ts +412 -0
- package/src/hooks/useCaptionDetection.ts +2 -0
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useDomEditCommits.test.tsx +92 -1
- package/src/hooks/useDomEditCommits.ts +63 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +165 -0
- package/src/hooks/useElementLifecycleOps.ts +20 -7
- package/src/hooks/useElementPicker.ts +7 -8
- package/src/hooks/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +378 -212
- package/src/hooks/useTimelineEditing.ts +181 -265
- package/src/hooks/useTimelineGroupEditing.ts +153 -132
- package/src/player/components/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +7 -2
- package/src/player/components/Timeline.test.ts +85 -8
- package/src/player/components/Timeline.tsx +9 -9
- package/src/player/components/TimelineCanvas.tsx +11 -2
- package/src/player/components/TimelineRuler.tsx +58 -48
- package/src/player/components/timelineClipDragCommit.test.ts +91 -8
- package/src/player/components/timelineClipDragCommit.ts +106 -9
- package/src/player/components/timelineLayout.ts +99 -26
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +60 -0
- package/src/player/components/timelineStackingSync.ts +27 -2
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- package/src/player/components/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +11 -1
- package/src/player/components/useTimelineStackingSync.ts +13 -2
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +4 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/index.ts +3 -1
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- package/src/player/lib/timelineDOM.ts +9 -0
- package/src/player/lib/timelineElementHelpers.ts +19 -0
- package/src/player/lib/timelineIframeHelpers.ts +2 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +37 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +62 -86
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-D-GyYi2d.css +0 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
2
|
import type { TimelineTheme } from "./timelineTheme";
|
|
3
3
|
import { GUTTER, RULER_H, formatTimelineTickLabel } from "./timelineLayout";
|
|
4
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
5
|
+
import { secondsToFrame } from "../lib/time";
|
|
4
6
|
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
|
5
7
|
|
|
6
8
|
interface TimelineRulerProps {
|
|
@@ -26,6 +28,7 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
26
28
|
theme,
|
|
27
29
|
beatAnalysis,
|
|
28
30
|
}: TimelineRulerProps) {
|
|
31
|
+
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
|
|
29
32
|
const beatTimes = beatAnalysis?.beatTimes ?? [];
|
|
30
33
|
const beatStrengths = beatAnalysis?.beatStrengths ?? [];
|
|
31
34
|
|
|
@@ -38,27 +41,13 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
38
41
|
|
|
39
42
|
return (
|
|
40
43
|
<>
|
|
41
|
-
{/*
|
|
42
|
-
|
|
44
|
+
{/* Background SVG — beat lines only; major-tick gridlines removed so only
|
|
45
|
+
the ruler's own small ticks mark intervals (no full-height lines). */}
|
|
43
46
|
<svg
|
|
44
47
|
className="absolute pointer-events-none"
|
|
45
48
|
style={{ left: GUTTER, width: trackContentWidth, zIndex: 0 }}
|
|
46
49
|
height={totalH}
|
|
47
50
|
>
|
|
48
|
-
{major.map((t) => {
|
|
49
|
-
const x = t * pps;
|
|
50
|
-
return (
|
|
51
|
-
<line
|
|
52
|
-
key={`g-${t}`}
|
|
53
|
-
x1={x}
|
|
54
|
-
y1={RULER_H}
|
|
55
|
-
x2={x}
|
|
56
|
-
y2={totalH}
|
|
57
|
-
stroke={theme.tickMinor}
|
|
58
|
-
strokeWidth="1"
|
|
59
|
-
/>
|
|
60
|
-
);
|
|
61
|
-
})}
|
|
62
51
|
{showBeats &&
|
|
63
52
|
beatTimes.map((t, i) => {
|
|
64
53
|
const x = t * pps;
|
|
@@ -79,41 +68,62 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
79
68
|
})}
|
|
80
69
|
</svg>
|
|
81
70
|
|
|
82
|
-
{/* Ruler
|
|
83
|
-
|
|
84
|
-
|
|
71
|
+
{/* Ruler — sticky so the timestamps stay visible while the tracks scroll
|
|
72
|
+
vertically. Opaque background (plus the gutter corner block) so clips
|
|
73
|
+
scrolling underneath don't bleed through; z-index sits above the track
|
|
74
|
+
rows and drag overlays but below the playhead (z 100). */}
|
|
85
75
|
<div
|
|
86
|
-
className="
|
|
87
|
-
style={{
|
|
88
|
-
height: RULER_H,
|
|
89
|
-
marginLeft: GUTTER,
|
|
90
|
-
width: `calc(100% - ${GUTTER}px)`,
|
|
91
|
-
background: theme.gutterBackground,
|
|
92
|
-
borderBottom: `1px solid ${theme.rulerBorder}`,
|
|
93
|
-
}}
|
|
76
|
+
className="sticky top-0 flex"
|
|
77
|
+
style={{ height: RULER_H, width: GUTTER + trackContentWidth, zIndex: 70 }}
|
|
94
78
|
>
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
79
|
+
<div
|
|
80
|
+
className="sticky left-0 z-[12] flex-shrink-0"
|
|
81
|
+
style={{
|
|
82
|
+
width: GUTTER,
|
|
83
|
+
// Ruler corner uses the panel surface — same as the ruler strip itself.
|
|
84
|
+
background: theme.shellBackground,
|
|
85
|
+
borderRight: `1px solid ${theme.gutterBorder}`,
|
|
86
|
+
}}
|
|
87
|
+
/>
|
|
88
|
+
<div
|
|
89
|
+
className="relative overflow-hidden"
|
|
90
|
+
style={{
|
|
91
|
+
height: RULER_H,
|
|
92
|
+
width: trackContentWidth,
|
|
93
|
+
// Ruler background = panel surface (#0A0A0B) — no bottom border,
|
|
94
|
+
// no tick lines (CapCut-style clean ruler, labels only).
|
|
95
|
+
background: theme.shellBackground,
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
{/* Each 1px tick line is shifted -0.5px so its CENTER sits exactly on
|
|
99
|
+
t * pps — matching the playhead line, which is also centered on
|
|
100
|
+
GUTTER + t * pps (see getTimelinePlayheadLeft). Without the shift
|
|
101
|
+
a tick spans [x, x+1) and its center is half a pixel right. */}
|
|
102
|
+
{minor.map((t) => (
|
|
103
|
+
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps - 0.5 }}>
|
|
104
|
+
<div className="w-px h-2" style={{ background: theme.tickMinor }} />
|
|
105
|
+
</div>
|
|
106
|
+
))}
|
|
100
107
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
108
|
+
{major.map((t) => (
|
|
109
|
+
<div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps - 0.5 }}>
|
|
110
|
+
<span
|
|
111
|
+
className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
|
|
112
|
+
style={{
|
|
113
|
+
color: theme.tickText,
|
|
114
|
+
left: 5,
|
|
115
|
+
top: 5,
|
|
116
|
+
fontSize: 10,
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
{timeDisplayMode === "frame"
|
|
120
|
+
? secondsToFrame(t)
|
|
121
|
+
: formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
|
|
122
|
+
</span>
|
|
123
|
+
<div className="w-px" style={{ height: RULER_H, background: theme.tickMajor }} />
|
|
124
|
+
</div>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
117
127
|
</div>
|
|
118
128
|
</>
|
|
119
129
|
);
|
|
@@ -219,6 +219,88 @@ describe("commitDraggedClipMove", () => {
|
|
|
219
219
|
expect(map.b).toBeUndefined(); // the other clip is NOT rewritten
|
|
220
220
|
});
|
|
221
221
|
|
|
222
|
+
it("persists a lane change in AUTHORED track space when the file is sparse", () => {
|
|
223
|
+
// Discovery normalized authored tracks {1, 2} to display lanes {0, 1}
|
|
224
|
+
// (authoredTrack records the file value). Moving 'a' onto b's lane must
|
|
225
|
+
// write b's AUTHORED track (2) — writing the display lane (1) would target
|
|
226
|
+
// a's own authored row and silently no-op in the file.
|
|
227
|
+
const elements = [
|
|
228
|
+
{ ...el("a", 0, 0, 3), authoredTrack: 1 },
|
|
229
|
+
{ ...el("b", 1, 10, 3), authoredTrack: 2 },
|
|
230
|
+
];
|
|
231
|
+
const { updateElement, onMoveElements } = runClipMove(
|
|
232
|
+
drag(elements[0], { previewStart: 20, previewTrack: 1, desiredTrack: 1 }),
|
|
233
|
+
{ elements, trackOrder: [0, 1] },
|
|
234
|
+
);
|
|
235
|
+
// Store stays in display-lane space, but authoredTrack is refreshed to the
|
|
236
|
+
// value just written to the file so a SECOND drag before any reload resolves
|
|
237
|
+
// authored tracks from current data, not the stale pre-edit value.
|
|
238
|
+
expect(updateElement).toHaveBeenCalledWith("a", { start: 20, track: 1, authoredTrack: 2 });
|
|
239
|
+
// ...while the persist is translated to the target lane's authored track.
|
|
240
|
+
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
241
|
+
expect(map.a).toEqual({ start: 20, track: 2 });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("resolves the authored track from occupants of the dragged clip's OWN source file", () => {
|
|
245
|
+
// Expanded sub-comp children live on synthetic display lanes next to host
|
|
246
|
+
// clips. Their authoredTrack is in THEIR file's coordinate space, so a lane
|
|
247
|
+
// occupied by a clip from a DIFFERENT file must never lend its authored
|
|
248
|
+
// value. Here lane 1 holds both a host-file clip (authored 12) and a
|
|
249
|
+
// same-file sibling (authored 7): the sibling answers.
|
|
250
|
+
const child3 = { ...el("c3", 0, 0, 3), authoredTrack: 3, sourceFile: "scene.html" };
|
|
251
|
+
const child7 = { ...el("c7", 1, 10, 3), authoredTrack: 7, sourceFile: "scene.html" };
|
|
252
|
+
const hostClip = { ...el("h", 1, 20, 3), authoredTrack: 12, sourceFile: "index.html" };
|
|
253
|
+
const elements = [child3, child7, hostClip];
|
|
254
|
+
const { onMoveElements } = runClipMove(
|
|
255
|
+
drag(child3, { previewStart: 0, previewTrack: 1, desiredTrack: 1 }),
|
|
256
|
+
{ elements, trackOrder: [0, 1] },
|
|
257
|
+
);
|
|
258
|
+
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
259
|
+
expect(map.c3).toEqual({ start: 0, track: 7 });
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("never persists the display-lane integer when the target lane has no same-file occupant", () => {
|
|
263
|
+
// Reviewer scenario: an expanded child of a SPARSE file (authored tracks 3
|
|
264
|
+
// and 7 → display lanes 4 and 5) dragged onto display lane 6, which holds
|
|
265
|
+
// only another file's clip. Persisting 6 (the display integer) or 12 (the
|
|
266
|
+
// foreign authored value) would corrupt the sparse file; the fallback
|
|
267
|
+
// offsets from the NEAREST same-file lane instead: authored 7 at lane 5,
|
|
268
|
+
// one lane further down → 8.
|
|
269
|
+
const child3 = { ...el("c3", 4, 0, 3), authoredTrack: 3, sourceFile: "scene.html" };
|
|
270
|
+
const child7 = { ...el("c7", 5, 10, 3), authoredTrack: 7, sourceFile: "scene.html" };
|
|
271
|
+
const foreign = { ...el("f", 6, 20, 3), authoredTrack: 12, sourceFile: "index.html" };
|
|
272
|
+
const elements = [child3, child7, foreign];
|
|
273
|
+
const { onMoveElements } = runClipMove(
|
|
274
|
+
drag(child3, { previewStart: 0, previewTrack: 6, desiredTrack: 6 }),
|
|
275
|
+
{ elements, trackOrder: [4, 5, 6] },
|
|
276
|
+
);
|
|
277
|
+
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
278
|
+
expect(map.c3.track).not.toBe(6); // not the display-lane integer
|
|
279
|
+
expect(map.c3.track).not.toBe(12); // not the foreign file's authored value
|
|
280
|
+
expect(map.c3).toEqual({ start: 0, track: 8 });
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("dropping onto an overlap spill sub-lane persists the base lane's shared authored track", () => {
|
|
284
|
+
// Authored track 2 holds two time-overlapping clips, which packTrackLanes
|
|
285
|
+
// spills onto display sub-lanes 1 and 2 (both authoredTrack 2). Dropping
|
|
286
|
+
// 'a' onto the spill sub-lane (2) is a legitimate same-track join: the
|
|
287
|
+
// persisted value is the shared authored track (2), even though the clip
|
|
288
|
+
// may re-pack onto a different sub-lane on the next normalize.
|
|
289
|
+
const elements = normalizeToZones([
|
|
290
|
+
{ ...el("a", 0, 30, 3), authoredTrack: 1 },
|
|
291
|
+
{ ...el("b1", 2, 0, 5), authoredTrack: 2 },
|
|
292
|
+
{ ...el("b2", 2, 3, 5), authoredTrack: 2 }, // overlaps b1 → spills
|
|
293
|
+
]);
|
|
294
|
+
expect(elements.map((e) => e.track)).toEqual([0, 1, 2]); // spill happened
|
|
295
|
+
const spillLane = elements.find((e) => e.id === "b2")!.track;
|
|
296
|
+
const { onMoveElements } = runClipMove(
|
|
297
|
+
drag(elements[0], { previewStart: 30, previewTrack: spillLane, desiredTrack: spillLane }),
|
|
298
|
+
{ elements, trackOrder: [0, 1, 2] },
|
|
299
|
+
);
|
|
300
|
+
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
301
|
+
expect(map.a).toEqual({ start: 30, track: 2 });
|
|
302
|
+
});
|
|
303
|
+
|
|
222
304
|
it("multi-selection time-move shifts EVERY selected clip by the drag delta (atomic)", () => {
|
|
223
305
|
const elements = [el("a", 0, 2, 3), el("b", 1, 10, 3), el("c", 2, 20, 3)];
|
|
224
306
|
// Drag 'a' +5s on its own lane while {a, b} are marquee-selected.
|
|
@@ -283,8 +365,9 @@ describe("commitDraggedClipMove", () => {
|
|
|
283
365
|
const map = editMap(onMoveElements.mock.calls[0][0]);
|
|
284
366
|
// Lanes are contiguous and distinct (no two overlapping clips share a lane).
|
|
285
367
|
expect(new Set([map.a.track, map.b.track, map.c.track])).toEqual(new Set([0, 1, 2]));
|
|
286
|
-
//
|
|
287
|
-
//
|
|
368
|
+
expect(map.a.track).toBe(0); // above the insert → unchanged
|
|
369
|
+
expect(map.c.track).toBe(1); // dragged clip lands on the new lane
|
|
370
|
+
expect(map.b.track).toBe(2); // at/below the insert → +1 shift
|
|
288
371
|
});
|
|
289
372
|
|
|
290
373
|
describe("lane ↔ stacking sync", () => {
|
|
@@ -440,9 +523,9 @@ describe("commitDraggedClipMove", () => {
|
|
|
440
523
|
drag(elements[2], { previewStart: 30, previewTrack: 1, insertRow: 0 }),
|
|
441
524
|
[0, 1],
|
|
442
525
|
);
|
|
443
|
-
//
|
|
444
|
-
|
|
445
|
-
expect(lane.
|
|
526
|
+
expect(lane.dragged).toBe(0); // aimed at the very top
|
|
527
|
+
expect(lane.top).toBe(1);
|
|
528
|
+
expect(lane.mid).toBe(2);
|
|
446
529
|
});
|
|
447
530
|
|
|
448
531
|
it("BETWEEN-insert of a non-overlapping clip lands it between its neighbours", async () => {
|
|
@@ -453,8 +536,8 @@ describe("commitDraggedClipMove", () => {
|
|
|
453
536
|
[0, 1, 2],
|
|
454
537
|
);
|
|
455
538
|
expect(lane.a).toBe(0);
|
|
456
|
-
expect(lane.
|
|
457
|
-
expect(lane.
|
|
539
|
+
expect(lane.x).toBe(1); // between a and b, as aimed
|
|
540
|
+
expect(lane.b).toBe(2);
|
|
458
541
|
});
|
|
459
542
|
|
|
460
543
|
it("TOP-insert clears a NON-overlapping clip that currently tops the timeline", async () => {
|
|
@@ -466,7 +549,7 @@ describe("commitDraggedClipMove", () => {
|
|
|
466
549
|
drag(elements[2], { previewStart: 10, previewTrack: 2, insertRow: 0 }),
|
|
467
550
|
[0, 1, 2],
|
|
468
551
|
);
|
|
469
|
-
expect(lane.X).toBe(
|
|
552
|
+
expect(lane.X).toBe(0); // aimed top, cleared the non-overlapping T
|
|
470
553
|
});
|
|
471
554
|
|
|
472
555
|
it("dragging X among overlapping neighbours preserves the RELATIVE order of the others (symptom 2)", async () => {
|
|
@@ -13,6 +13,13 @@ type StartTrack = Pick<TimelineElement, "start" | "track">;
|
|
|
13
13
|
export interface TimelineMoveEdit {
|
|
14
14
|
element: TimelineElement;
|
|
15
15
|
updates: StartTrack;
|
|
16
|
+
/**
|
|
17
|
+
* File-space track override for the persist. The store's `updates.track` is a
|
|
18
|
+
* DISPLAY lane; when the source file's numbering is sparse (authored tracks
|
|
19
|
+
* 1,2,... or gaps), the file write must target the lane's AUTHORED track or it
|
|
20
|
+
* silently re-targets the wrong row. Omitted → persist `updates.track` as-is.
|
|
21
|
+
*/
|
|
22
|
+
persistTrack?: number;
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
export interface DragCommitDeps {
|
|
@@ -114,21 +121,41 @@ function persistMoveEdits(
|
|
|
114
121
|
key: keyOf(e.element),
|
|
115
122
|
start: e.element.start,
|
|
116
123
|
track: e.element.track,
|
|
124
|
+
authoredTrack: e.element.authoredTrack,
|
|
117
125
|
}));
|
|
118
126
|
const revision = beginTimelineOptimisticGesture(
|
|
119
127
|
updateElement,
|
|
120
128
|
edits.map((edit) => keyOf(edit.element)),
|
|
121
129
|
);
|
|
122
|
-
|
|
130
|
+
// The file write below targets `persistTrack` (authored space) when supplied,
|
|
131
|
+
// or `updates.track` on a genuine lane write (track insert renumber). Mirror
|
|
132
|
+
// that written value into the store's `authoredTrack` so a SECOND drag before
|
|
133
|
+
// any reload resolves authored tracks from what the file now says, not stale
|
|
134
|
+
// pre-edit data. Pure time-moves leave authoredTrack untouched.
|
|
135
|
+
for (const e of edits) {
|
|
136
|
+
const writtenTrack =
|
|
137
|
+
e.persistTrack ?? (e.updates.track !== e.element.track ? e.updates.track : undefined);
|
|
138
|
+
updateElement(
|
|
139
|
+
keyOf(e.element),
|
|
140
|
+
writtenTrack == null ? e.updates : { ...e.updates, authoredTrack: writtenTrack },
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
// The store above gets DISPLAY lanes; the file below gets the authored-space
|
|
144
|
+
// track when one was resolved (see TimelineMoveEdit.persistTrack).
|
|
145
|
+
const persistEdits = edits.map((e) =>
|
|
146
|
+
e.persistTrack == null || e.persistTrack === e.updates.track
|
|
147
|
+
? e
|
|
148
|
+
: { element: e.element, updates: { ...e.updates, track: e.persistTrack } },
|
|
149
|
+
);
|
|
123
150
|
const persisted = onMoveElements
|
|
124
|
-
? onMoveElements(
|
|
125
|
-
: Promise.all(
|
|
151
|
+
? onMoveElements(persistEdits, coalesceKey, operation)
|
|
152
|
+
: Promise.all(persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
|
|
126
153
|
return Promise.resolve(persisted).then(
|
|
127
154
|
() => true,
|
|
128
155
|
(error) => {
|
|
129
156
|
for (const p of prev) {
|
|
130
157
|
if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
|
|
131
|
-
updateElement(p.key, { start: p.start, track: p.track });
|
|
158
|
+
updateElement(p.key, { start: p.start, track: p.track, authoredTrack: p.authoredTrack });
|
|
132
159
|
}
|
|
133
160
|
}
|
|
134
161
|
console.error("[Timeline] Failed to persist clip edits", error);
|
|
@@ -143,6 +170,56 @@ function persistMoveEdits(
|
|
|
143
170
|
* then compacts it to a distinct integer lane between its neighbours, and the
|
|
144
171
|
* clips at/below the insert shift down by one — the sanctioned index-renumber.
|
|
145
172
|
*/
|
|
173
|
+
/** Same-source-file predicate: authored track numbers only compare within ONE
|
|
174
|
+
* file's coordinate space (an expanded sub-comp child's authoredTrack is in ITS
|
|
175
|
+
* file, not the host timeline's). `undefined` means the active composition. */
|
|
176
|
+
const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
|
|
177
|
+
(a.sourceFile ?? null) === (b.sourceFile ?? null);
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Translate a DISPLAY lane into the AUTHORED (source-file) track to persist for
|
|
181
|
+
* `dragged`. Occupants are consulted ONLY from the dragged clip's own source
|
|
182
|
+
* file — an occupant from a different file (e.g. an expanded sub-comp child, or
|
|
183
|
+
* a host clip next to expanded rows) carries authored values in a different
|
|
184
|
+
* coordinate space, and borrowing them would write a foreign file's numbering.
|
|
185
|
+
*
|
|
186
|
+
* Lane semantics after normalizeToZones: each distinct authored track owns one
|
|
187
|
+
* base lane, and time-overlapping same-track clips spill onto adjacent display
|
|
188
|
+
* sub-lanes (packTrackLanes). A spill sub-lane IS a legal drop target (Timeline's
|
|
189
|
+
* trackOrder lists it): its occupants share the base lane's authored track by
|
|
190
|
+
* construction, so the same-file occupant lookup returns that authored track and
|
|
191
|
+
* the drop persists as a same-track join. The clip may then DISPLAY on a
|
|
192
|
+
* different sub-lane than it was dropped on — the spill re-packs
|
|
193
|
+
* deterministically by stable id, first-fit — but the persisted track is
|
|
194
|
+
* correct.
|
|
195
|
+
*
|
|
196
|
+
* Fallbacks when the lane has no same-file occupant (e.g. an expanded child
|
|
197
|
+
* dropped on a lane holding only other files' clips — the display-lane integer
|
|
198
|
+
* must NOT be persisted into a sparse file):
|
|
199
|
+
* 1. Offset from the NEAREST same-file lane: authored(nearest) + lane distance,
|
|
200
|
+
* preserving "one lane up = one authored track up" in the clip's own file.
|
|
201
|
+
* 2. No same-file peers at all → the lane value itself (single-clip files:
|
|
202
|
+
* display and authored spaces coincide for want of any other anchor).
|
|
203
|
+
* Edge-created lanes (min-1 / max+1 inserts) route through the insert path,
|
|
204
|
+
* never here.
|
|
205
|
+
*/
|
|
206
|
+
function authoredTrackForLane(
|
|
207
|
+
lane: number,
|
|
208
|
+
elements: TimelineElement[],
|
|
209
|
+
dragged: TimelineElement,
|
|
210
|
+
): number {
|
|
211
|
+
const dragKey = keyOf(dragged);
|
|
212
|
+
const peers = elements.filter((e) => keyOf(e) !== dragKey && sameSourceFile(e, dragged));
|
|
213
|
+
const occupant = peers.find((e) => e.track === lane);
|
|
214
|
+
if (occupant) return occupant.authoredTrack ?? occupant.track;
|
|
215
|
+
let nearest: TimelineElement | null = null;
|
|
216
|
+
for (const p of peers) {
|
|
217
|
+
if (!nearest || Math.abs(p.track - lane) < Math.abs(nearest.track - lane)) nearest = p;
|
|
218
|
+
}
|
|
219
|
+
if (!nearest) return lane;
|
|
220
|
+
return (nearest.authoredTrack ?? nearest.track) + (lane - nearest.track);
|
|
221
|
+
}
|
|
222
|
+
|
|
146
223
|
function insertTrackValue(trackOrder: number[], insertRow: number): number {
|
|
147
224
|
if (trackOrder.length === 0) return 0;
|
|
148
225
|
if (insertRow <= 0) return trackOrder[0] - 0.5;
|
|
@@ -251,6 +328,7 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
|
|
|
251
328
|
const dragEdit: TimelineMoveEdit = {
|
|
252
329
|
element: drag.element,
|
|
253
330
|
updates: { start: drag.previewStart, track: drag.previewTrack },
|
|
331
|
+
persistTrack: authoredTrackForLane(drag.previewTrack, elements, drag.element),
|
|
254
332
|
};
|
|
255
333
|
const coalesceKey = isVertical ? `clip-lane-move:${laneChangeGestureSeq++}` : undefined;
|
|
256
334
|
|
|
@@ -265,9 +343,13 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
|
|
|
265
343
|
// The drop-intent set for the z-sync: the dragged clip at its new lane, others
|
|
266
344
|
// as-is. Reasoning on this (not a re-normalize) keeps the sync seeing the user's
|
|
267
345
|
// move; computeStackingPatches only compares lanes relatively.
|
|
268
|
-
const candidate = elements.map((e) =>
|
|
269
|
-
keyOf(e) === dragKey
|
|
270
|
-
|
|
346
|
+
const candidate = elements.map((e) => {
|
|
347
|
+
if (keyOf(e) === dragKey) return { ...e, start: drag.previewStart, track: drag.previewTrack };
|
|
348
|
+
// Selection members shift in time with the drag — the z-sync must reason on
|
|
349
|
+
// their POST-move overlap sets, same as the insert branch's candidate.
|
|
350
|
+
if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
|
|
351
|
+
return e;
|
|
352
|
+
});
|
|
271
353
|
const multiKeys = multi ? multi.keys : null;
|
|
272
354
|
void persistMoveEdits(edits, deps, coalesceKey, "lane-reorder").then((moved) => {
|
|
273
355
|
if (moved && isVertical) {
|
|
@@ -292,6 +374,7 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
|
|
|
292
374
|
* The whole affected set is persisted atomically (single undo), and the deliberate
|
|
293
375
|
* vertical move syncs the dragged clip's stacking afterwards.
|
|
294
376
|
*/
|
|
377
|
+
// fallow-ignore-next-line complexity
|
|
295
378
|
function commitTrackInsert(
|
|
296
379
|
drag: DraggedClipState,
|
|
297
380
|
deps: DragCommitDeps,
|
|
@@ -314,12 +397,25 @@ function commitTrackInsert(
|
|
|
314
397
|
// shifts the at/below clips down by one — the sanctioned +1 index renumber.
|
|
315
398
|
const normalized = normalizeToZones(candidate);
|
|
316
399
|
const bySrc = new Map(elements.map((e) => [keyOf(e), e]));
|
|
400
|
+
// The renumber is only correct as a WHOLE-SET write: skipping an unwritable
|
|
401
|
+
// clip whose lane shifts leaves its track colliding with a renumbered
|
|
402
|
+
// neighbour, and the next normalize merges the two lanes. If any shifted clip
|
|
403
|
+
// can't be written, refuse the insert instead of persisting a broken layout.
|
|
404
|
+
for (const norm of normalized) {
|
|
405
|
+
const src = bySrc.get(keyOf(norm));
|
|
406
|
+
if (src && !canMoveElement(src) && norm.track !== src.track) {
|
|
407
|
+
console.warn(
|
|
408
|
+
`[Timeline] Track insert refused: locked clip ${keyOf(src)} would need renumbering`,
|
|
409
|
+
);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
317
413
|
const edits: TimelineMoveEdit[] = [];
|
|
318
414
|
for (const norm of normalized) {
|
|
319
415
|
const src = bySrc.get(keyOf(norm));
|
|
320
416
|
if (!src) continue;
|
|
321
|
-
// Capabilities gate
|
|
322
|
-
//
|
|
417
|
+
// Capabilities gate (unchanged-lane clips only reach here now): never write
|
|
418
|
+
// a locked/implicit clip.
|
|
323
419
|
if (!canMoveElement(src)) continue;
|
|
324
420
|
const start =
|
|
325
421
|
keyOf(norm) === dragKey || multi?.keys.has(keyOf(norm))
|
|
@@ -391,6 +487,7 @@ function syncStackingForEdit(
|
|
|
391
487
|
zIndex: readZIndex(el),
|
|
392
488
|
isAudio: classifyZone(el) === "audio",
|
|
393
489
|
domIndex,
|
|
490
|
+
stackingContextId: el.stackingContextId ?? null,
|
|
394
491
|
}));
|
|
395
492
|
|
|
396
493
|
const editedKeys = [dragKey];
|
|
@@ -67,51 +67,105 @@ export const DRAG_EXTEND_MARGIN_PX = 160;
|
|
|
67
67
|
* with 60s of ruler after it.
|
|
68
68
|
*/
|
|
69
69
|
export const MIN_TIMELINE_EXTENT_S = 60;
|
|
70
|
+
/**
|
|
71
|
+
* Fit-mode headroom (CapCut-style): "fit" maps `duration * 1.2` — not the bare
|
|
72
|
+
* duration — onto the viewport, so the composition ends at ~83% of the width
|
|
73
|
+
* and the trailing ~17% stays empty ruler + droppable lane surface (room to
|
|
74
|
+
* drag clips past the current end without first zooming out). Applied ONLY
|
|
75
|
+
* inside {@link getTimelineFitPps}, the single fit-pps source, so the ruler,
|
|
76
|
+
* lanes, playhead, marquee, and drag math all inherit it consistently. Manual
|
|
77
|
+
* zoom percentages stay defined relative to this fit basis (100% == fit).
|
|
78
|
+
*/
|
|
79
|
+
export const FIT_ZOOM_HEADROOM = 1.2;
|
|
70
80
|
|
|
71
81
|
/* ── Tick generation ──────────────────────────────────────────────── */
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
// fallow-ignore-next-line complexity
|
|
83
|
+
function getMajorTickInterval(
|
|
84
|
+
duration: number,
|
|
85
|
+
pixelsPerSecond?: number,
|
|
86
|
+
frameRate?: number,
|
|
87
|
+
): number {
|
|
88
|
+
// "Nice" NLE steps: 1-2-5 sub-second decades, then 1s/2s/5s/10s/15s/30s,
|
|
89
|
+
// minute multiples, and 15m/30m/1h so ultra-zoomed-out long comps still get
|
|
90
|
+
// readable (non-colliding) labels instead of the old 10m fallback everywhere.
|
|
91
|
+
const zoomIntervals = [
|
|
92
|
+
0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600,
|
|
93
|
+
];
|
|
94
|
+
let interval: number;
|
|
74
95
|
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
|
|
75
96
|
const targetMajorPx = 88;
|
|
76
|
-
|
|
77
|
-
zoomIntervals.find((
|
|
78
|
-
|
|
97
|
+
interval =
|
|
98
|
+
zoomIntervals.find((candidate) => candidate * (pixelsPerSecond ?? 0) >= targetMajorPx) ??
|
|
99
|
+
3600;
|
|
100
|
+
} else {
|
|
101
|
+
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
|
|
102
|
+
const target = duration / 6;
|
|
103
|
+
interval = durationIntervals.find((candidate) => candidate >= target) ?? 60;
|
|
79
104
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
105
|
+
// Frame display mode: labels are frame numbers, so a major step must be a
|
|
106
|
+
// WHOLE number of frames — sub-frame steps produce duplicate/uneven labels
|
|
107
|
+
// (e.g. 0.02s at 30fps is 0.6 frames → "0, 1, 1, 2, 2…"). Snap UP (ceil) so
|
|
108
|
+
// the label spacing never drops below the readability target.
|
|
109
|
+
if (Number.isFinite(frameRate) && (frameRate ?? 0) > 0) {
|
|
110
|
+
const fps = frameRate ?? 0;
|
|
111
|
+
return Math.max(1, Math.ceil(interval * fps - 1e-6)) / fps;
|
|
112
|
+
}
|
|
113
|
+
return interval;
|
|
83
114
|
}
|
|
84
115
|
|
|
85
116
|
// How many equal parts to split each major interval into for minor ticks. Prefer
|
|
86
117
|
// quarters (4) so the midpoint stays a minor tick; fall back to halves (2) then
|
|
87
|
-
// none (0) as ticks get too dense to read (< ~8px apart).
|
|
88
|
-
|
|
118
|
+
// none (0) as ticks get too dense to read (< ~8px apart). In frame display mode
|
|
119
|
+
// the subdivision must also keep minor ticks on WHOLE frames (a minor tick at a
|
|
120
|
+
// sub-frame time is not a seekable position), so only divisors of the major
|
|
121
|
+
// step's frame count qualify — quarters, then fifths (15/30-frame majors),
|
|
122
|
+
// thirds, halves.
|
|
123
|
+
// fallow-ignore-next-line complexity
|
|
124
|
+
function getMinorSubdivisions(
|
|
125
|
+
majorInterval: number,
|
|
126
|
+
pixelsPerSecond?: number,
|
|
127
|
+
frameRate?: number,
|
|
128
|
+
): number {
|
|
89
129
|
const pps = Number.isFinite(pixelsPerSecond) ? (pixelsPerSecond ?? 0) : 0;
|
|
90
130
|
if (pps <= 0) return 4; // no zoom info (duration-fit mode): quarter ticks
|
|
91
|
-
|
|
92
|
-
|
|
131
|
+
const fps = Number.isFinite(frameRate) ? (frameRate ?? 0) : 0;
|
|
132
|
+
const majorFrames = fps > 0 ? Math.round(majorInterval * fps) : 0;
|
|
133
|
+
const candidates = fps > 0 ? [4, 5, 3, 2] : [4, 2];
|
|
134
|
+
for (const parts of candidates) {
|
|
135
|
+
if (fps > 0 && majorFrames % parts !== 0) continue;
|
|
136
|
+
if ((majorInterval / parts) * pps >= 8) return parts;
|
|
137
|
+
}
|
|
93
138
|
return 0;
|
|
94
139
|
}
|
|
95
140
|
|
|
141
|
+
// Ticks are exact multiples of the interval (multiplied per index, never
|
|
142
|
+
// accumulated with `+=`, so long rulers don't drift), then rounded to 1µs to
|
|
143
|
+
// keep values/keys clean without disturbing frame-exact positions like 2/30s.
|
|
144
|
+
function roundTickValue(t: number): number {
|
|
145
|
+
return Math.round(t * 1e6) / 1e6;
|
|
146
|
+
}
|
|
147
|
+
|
|
96
148
|
export function generateTicks(
|
|
97
149
|
duration: number,
|
|
98
150
|
pixelsPerSecond?: number,
|
|
151
|
+
frameRate?: number,
|
|
99
152
|
): { major: number[]; minor: number[] } {
|
|
100
|
-
if (duration <= 0 || !Number.isFinite(duration) || duration >
|
|
153
|
+
if (duration <= 0 || !Number.isFinite(duration) || duration > 14400)
|
|
101
154
|
return { major: [], minor: [] };
|
|
102
|
-
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
|
|
103
|
-
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond);
|
|
155
|
+
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond, frameRate);
|
|
156
|
+
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
|
|
104
157
|
const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0;
|
|
105
158
|
const major: number[] = [];
|
|
106
159
|
const minor: number[] = [];
|
|
107
160
|
const maxTicks = 2000; // Safety cap to prevent runaway tick generation
|
|
108
|
-
for (let
|
|
109
|
-
const
|
|
110
|
-
|
|
161
|
+
for (let i = 0; major.length < maxTicks; i++) {
|
|
162
|
+
const t = i * majorInterval;
|
|
163
|
+
if (t > duration + 0.001) break;
|
|
164
|
+
major.push(roundTickValue(t));
|
|
111
165
|
// Emit the (subdivisions - 1) minor ticks between this major and the next.
|
|
112
166
|
for (let k = 1; k < subdivisions && major.length + minor.length < maxTicks; k++) {
|
|
113
|
-
const m =
|
|
114
|
-
if (m <= duration + 0.001) minor.push(m);
|
|
167
|
+
const m = t + k * minorInterval;
|
|
168
|
+
if (m <= duration + 0.001) minor.push(roundTickValue(m));
|
|
115
169
|
}
|
|
116
170
|
}
|
|
117
171
|
return { major, minor };
|
|
@@ -144,15 +198,17 @@ export function formatTimelineTickLabel(time: number, duration: number, majorInt
|
|
|
144
198
|
|
|
145
199
|
/* ── Width / duration derivation ──────────────────────────────────── */
|
|
146
200
|
/**
|
|
147
|
-
* Fit-mode pixels-per-second: fill the viewport with the composition
|
|
148
|
-
*
|
|
149
|
-
*
|
|
201
|
+
* Fit-mode pixels-per-second: fill the viewport with the composition plus
|
|
202
|
+
* FIT_ZOOM_HEADROOM trailing headroom (CapCut-style — the comp never slams
|
|
203
|
+
* into the right edge), and never map fewer than MIN_TIMELINE_EXTENT_S
|
|
204
|
+
* seconds onto it — a short comp takes a fraction of the width and the
|
|
205
|
+
* remaining ruler runs to 1:00.
|
|
150
206
|
* Manual zoom multiplies this base, so the floor only anchors the default.
|
|
151
207
|
*/
|
|
152
208
|
export function getTimelineFitPps(viewportWidth: number, effectiveDuration: number): number {
|
|
153
209
|
const safeDuration =
|
|
154
210
|
Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
|
|
155
|
-
const span = Math.max(safeDuration, MIN_TIMELINE_EXTENT_S);
|
|
211
|
+
const span = Math.max(safeDuration * FIT_ZOOM_HEADROOM, MIN_TIMELINE_EXTENT_S);
|
|
156
212
|
if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER) return 100;
|
|
157
213
|
return (viewportWidth - GUTTER - 2) / span;
|
|
158
214
|
}
|
|
@@ -227,9 +283,26 @@ export function getTimelineScrollLeftForZoomAnchor(input: {
|
|
|
227
283
|
}
|
|
228
284
|
|
|
229
285
|
/* ── Playhead / canvas ────────────────────────────────────────────── */
|
|
286
|
+
/**
|
|
287
|
+
* Width of the playhead wrapper element (== the diamond head chip's layout
|
|
288
|
+
* width, which the wrapper shrink-wraps to). The 1px vertical line inside
|
|
289
|
+
* PlayheadIndicator is centered at 50% of this wrapper, so the wrapper must be
|
|
290
|
+
* shifted LEFT by half this width for the line's center to land exactly on
|
|
291
|
+
* `GUTTER + time * pps` — see {@link getTimelinePlayheadLeft}.
|
|
292
|
+
*/
|
|
293
|
+
export const PLAYHEAD_HEAD_W = 9;
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The `left` for the playhead WRAPPER such that the vertical line's CENTER
|
|
297
|
+
* sits exactly on `GUTTER + time * pps` (the same x the ruler ticks center
|
|
298
|
+
* on) at every zoom level. Without the half-head offset the line sat
|
|
299
|
+
* `PLAYHEAD_HEAD_W / 2` px to the right of its ruler tick.
|
|
300
|
+
*/
|
|
230
301
|
export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
|
|
231
|
-
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond))
|
|
232
|
-
|
|
302
|
+
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) {
|
|
303
|
+
return GUTTER - PLAYHEAD_HEAD_W / 2;
|
|
304
|
+
}
|
|
305
|
+
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond) - PLAYHEAD_HEAD_W / 2;
|
|
233
306
|
}
|
|
234
307
|
|
|
235
308
|
export function getTimelineCanvasHeight(trackCount: number): number {
|