@hyperframes/studio 0.7.54 → 0.7.55
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/{index-pRhCpGPz.js → index-BRwkMj0w.js} +108 -108
- package/dist/assets/{index-uBY329wb.js → index-BXaqaVKt.js} +1 -1
- package/dist/assets/{index-CMHYjEZ5.js → index-CPetwHFV.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +48 -3
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
- package/src/components/editor/CanvasContextMenu.tsx +198 -0
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +112 -0
- package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
- package/src/hooks/useRazorSplit.history.test.tsx +173 -0
- package/src/player/components/timelineCollision.test.ts +437 -0
- package/src/player/components/timelineCollision.ts +263 -0
- package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
- package/src/player/components/timelineMultiDragPreview.ts +106 -0
- package/src/player/components/timelineSnapping.test.ts +134 -0
- package/src/player/components/timelineSnapping.ts +110 -0
- package/src/player/components/timelineStackingSync.test.ts +244 -0
- package/src/player/components/timelineStackingSync.ts +331 -0
- package/src/player/components/timelineZones.test.ts +425 -0
- package/src/player/components/timelineZones.ts +198 -0
- package/src/player/components/timelineZoom.test.ts +67 -0
- package/src/player/components/timelineZoom.ts +51 -0
- package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
- package/src/utils/assetClickBehavior.test.ts +104 -0
- package/src/utils/assetClickBehavior.ts +75 -0
- package/src/utils/canvasNudgeGate.test.ts +31 -0
- package/src/utils/canvasNudgeGate.ts +32 -0
- package/src/utils/studioUiPreferences.test.ts +38 -0
- package/src/utils/studioUiPreferences.ts +27 -0
- package/src/utils/timelineInspector.test.ts +121 -0
- package/src/utils/timelineInspector.ts +32 -1
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
clampTimelineZoomPercent,
|
|
4
|
+
computePinnedZoomPercent,
|
|
4
5
|
getNextTimelineZoomPercent,
|
|
5
6
|
getPinchTimelineZoomPercent,
|
|
6
7
|
getTimelinePixelsPerSecond,
|
|
7
8
|
getTimelineZoomPercent,
|
|
8
9
|
MAX_TIMELINE_ZOOM_PERCENT,
|
|
9
10
|
MIN_TIMELINE_ZOOM_PERCENT,
|
|
11
|
+
timelineZoomPercentToSlider,
|
|
12
|
+
timelineSliderToZoomPercent,
|
|
10
13
|
} from "./timelineZoom";
|
|
11
14
|
|
|
12
15
|
describe("clampTimelineZoomPercent", () => {
|
|
@@ -81,3 +84,67 @@ describe("getPinchTimelineZoomPercent", () => {
|
|
|
81
84
|
expect(getPinchTimelineZoomPercent(-10000, "manual", 100)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
|
|
82
85
|
});
|
|
83
86
|
});
|
|
87
|
+
|
|
88
|
+
describe("timelineZoomPercentToSlider", () => {
|
|
89
|
+
it("maps min zoom to slider position 0", () => {
|
|
90
|
+
expect(timelineZoomPercentToSlider(MIN_TIMELINE_ZOOM_PERCENT)).toBeCloseTo(0, 5);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("maps max zoom to slider position 100", () => {
|
|
94
|
+
expect(timelineZoomPercentToSlider(MAX_TIMELINE_ZOOM_PERCENT)).toBeCloseTo(100, 5);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("maps 100% to the log midpoint between 10 and 2000", () => {
|
|
98
|
+
const expected = ((Math.log(100) - Math.log(10)) / (Math.log(2000) - Math.log(10))) * 100;
|
|
99
|
+
expect(timelineZoomPercentToSlider(100)).toBeCloseTo(expected, 3);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("timelineSliderToZoomPercent", () => {
|
|
104
|
+
it("maps slider 0 to min zoom", () => {
|
|
105
|
+
expect(timelineSliderToZoomPercent(0)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("maps slider 100 to max zoom", () => {
|
|
109
|
+
expect(timelineSliderToZoomPercent(100)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("computePinnedZoomPercent", () => {
|
|
114
|
+
it("returns 100 when current pps equals the fit pps (a no-op pin at the current fit)", () => {
|
|
115
|
+
expect(computePinnedZoomPercent(42, 42)).toBe(100);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("reproduces the current pps: percent × fitPps / 100 === currentPps", () => {
|
|
119
|
+
const fitPps = 20;
|
|
120
|
+
const currentPps = 50; // user zoomed in 2.5×
|
|
121
|
+
const percent = computePinnedZoomPercent(currentPps, fitPps);
|
|
122
|
+
expect(percent).toBe(250);
|
|
123
|
+
// Round-trips through getTimelinePixelsPerSecond back to the on-screen pps.
|
|
124
|
+
expect(getTimelinePixelsPerSecond(fitPps, "manual", percent)).toBeCloseTo(currentPps, 5);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("clamps a pin that would exceed the manual-zoom bounds", () => {
|
|
128
|
+
// currentPps 10000 / fitPps 1 = 1_000_000% → clamped to MAX.
|
|
129
|
+
expect(computePinnedZoomPercent(10000, 1)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
|
|
130
|
+
// Tiny ratio → clamped up to MIN.
|
|
131
|
+
expect(computePinnedZoomPercent(0.001, 1000)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("falls back to 100 for unusable inputs (a safe no-op pin)", () => {
|
|
135
|
+
expect(computePinnedZoomPercent(Number.NaN, 20)).toBe(100);
|
|
136
|
+
expect(computePinnedZoomPercent(50, 0)).toBe(100);
|
|
137
|
+
expect(computePinnedZoomPercent(-5, 20)).toBe(100);
|
|
138
|
+
expect(computePinnedZoomPercent(50, Number.POSITIVE_INFINITY)).toBe(100);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("timelineZoomPercentToSlider / timelineSliderToZoomPercent round-trip", () => {
|
|
143
|
+
for (const percent of [10, 100, 500, 2000]) {
|
|
144
|
+
it(`round-trips ${percent}% within ±1%`, () => {
|
|
145
|
+
const slider = timelineZoomPercentToSlider(percent);
|
|
146
|
+
const back = timelineSliderToZoomPercent(slider);
|
|
147
|
+
expect(Math.abs(back - percent) / percent).toBeLessThan(0.01);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
});
|
|
@@ -18,6 +18,34 @@ export function getTimelineZoomPercent(zoomMode: ZoomMode, manualZoomPercent: nu
|
|
|
18
18
|
return zoomMode === "fit" ? 100 : clampTimelineZoomPercent(manualZoomPercent);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The manual-zoom percent that, applied to `fitPixelsPerSecond`, reproduces the
|
|
23
|
+
* CURRENT on-screen pixels-per-second exactly. Used to PIN the timeline zoom on
|
|
24
|
+
* the first edit so a duration change (which recomputes fit-pps) no longer
|
|
25
|
+
* rescales every clip: we switch `zoomMode` to "manual" with this percent, so
|
|
26
|
+
* `getTimelinePixelsPerSecond` keeps returning today's pps regardless of the new
|
|
27
|
+
* fit basis.
|
|
28
|
+
*
|
|
29
|
+
* Since `pps = fitPps * (percent / 100)` in manual mode, and while fitting
|
|
30
|
+
* `pps === fitPps`, the pinned percent is `currentPps / fitPps * 100`. Clamped to
|
|
31
|
+
* the manual-zoom range so the pin can't land outside the slider's bounds; falls
|
|
32
|
+
* back to 100 (a no-op pin at the current fit) when either input is unusable.
|
|
33
|
+
*/
|
|
34
|
+
export function computePinnedZoomPercent(
|
|
35
|
+
currentPixelsPerSecond: number,
|
|
36
|
+
fitPixelsPerSecond: number,
|
|
37
|
+
): number {
|
|
38
|
+
if (
|
|
39
|
+
!Number.isFinite(currentPixelsPerSecond) ||
|
|
40
|
+
currentPixelsPerSecond <= 0 ||
|
|
41
|
+
!Number.isFinite(fitPixelsPerSecond) ||
|
|
42
|
+
fitPixelsPerSecond <= 0
|
|
43
|
+
) {
|
|
44
|
+
return 100;
|
|
45
|
+
}
|
|
46
|
+
return clampTimelineZoomPercent((currentPixelsPerSecond / fitPixelsPerSecond) * 100);
|
|
47
|
+
}
|
|
48
|
+
|
|
21
49
|
export function getTimelinePixelsPerSecond(
|
|
22
50
|
fitPixelsPerSecond: number,
|
|
23
51
|
zoomMode: ZoomMode,
|
|
@@ -47,3 +75,26 @@ export function getPinchTimelineZoomPercent(
|
|
|
47
75
|
if (!Number.isFinite(deltaY) || deltaY === 0) return current;
|
|
48
76
|
return clampTimelineZoomPercent(current * Math.exp(-deltaY * PINCH_ZOOM_SENSITIVITY));
|
|
49
77
|
}
|
|
78
|
+
|
|
79
|
+
const LOG_MIN = Math.log(MIN_TIMELINE_ZOOM_PERCENT);
|
|
80
|
+
const LOG_MAX = Math.log(MAX_TIMELINE_ZOOM_PERCENT);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Maps a zoom percent (10–2000) to a slider position (0–100) using a log scale.
|
|
84
|
+
* Log scale is used because the range spans 200×; linear would compress the
|
|
85
|
+
* low end (10–100%) into a tiny sliver of the slider.
|
|
86
|
+
*/
|
|
87
|
+
export function timelineZoomPercentToSlider(percent: number): number {
|
|
88
|
+
const clamped = clampTimelineZoomPercent(percent);
|
|
89
|
+
return ((Math.log(clamped) - LOG_MIN) / (LOG_MAX - LOG_MIN)) * 100;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Maps a slider position (0–100) to a zoom percent (10–2000) using a log scale.
|
|
94
|
+
* Inverse of `timelineZoomPercentToSlider`.
|
|
95
|
+
*/
|
|
96
|
+
export function timelineSliderToZoomPercent(slider: number): number {
|
|
97
|
+
const clampedSlider = Math.max(0, Math.min(100, slider));
|
|
98
|
+
const logValue = LOG_MIN + (clampedSlider / 100) * (LOG_MAX - LOG_MIN);
|
|
99
|
+
return clampTimelineZoomPercent(Math.exp(logValue));
|
|
100
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
resolveReloadSeekTime,
|
|
5
|
+
resolveTimelineTotalDuration,
|
|
6
|
+
revealIframe,
|
|
7
|
+
} from "./useTimelineSyncCallbacks";
|
|
8
|
+
import { readTimelineDurationFromDocument } from "../lib/timelineDOM";
|
|
9
|
+
|
|
10
|
+
// Minimal stand-in — revealIframe only touches `style.visibility`. Avoids
|
|
11
|
+
// depending on a DOM environment (this test file runs under node).
|
|
12
|
+
function fakeIframe(visibility: string): HTMLIFrameElement {
|
|
13
|
+
return { style: { visibility } } as unknown as HTMLIFrameElement;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("revealIframe", () => {
|
|
17
|
+
it("clears a hidden iframe's visibility (undoes refreshPlayer's hide)", () => {
|
|
18
|
+
const iframe = fakeIframe("hidden");
|
|
19
|
+
revealIframe(iframe);
|
|
20
|
+
expect(iframe.style.visibility).toBe("");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("leaves an already-visible iframe untouched (idempotent)", () => {
|
|
24
|
+
const iframe = fakeIframe("");
|
|
25
|
+
revealIframe(iframe);
|
|
26
|
+
expect(iframe.style.visibility).toBe("");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("no-ops on a null iframe", () => {
|
|
30
|
+
expect(() => revealIframe(null)).not.toThrow();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("resolveReloadSeekTime", () => {
|
|
35
|
+
it("restores the pending seek saved by refreshPlayer (the primary reload path)", () => {
|
|
36
|
+
expect(
|
|
37
|
+
resolveReloadSeekTime({
|
|
38
|
+
pendingSeek: 7.2,
|
|
39
|
+
requestedSeek: null,
|
|
40
|
+
storeCurrentTime: 7.2,
|
|
41
|
+
duration: 20,
|
|
42
|
+
}),
|
|
43
|
+
).toBe(7.2);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("honors a deep-link seek request when no pending seek exists", () => {
|
|
47
|
+
expect(
|
|
48
|
+
resolveReloadSeekTime({
|
|
49
|
+
pendingSeek: null,
|
|
50
|
+
requestedSeek: 12.5,
|
|
51
|
+
storeCurrentTime: 0,
|
|
52
|
+
duration: 20,
|
|
53
|
+
}),
|
|
54
|
+
).toBe(12.5);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("THE BUG: a second overlapping reload (pending seek already consumed) restores the store playhead, not 0", () => {
|
|
58
|
+
// Drop → reload #1 consumes pendingSeek and seeks/syncs to 7.2. A staggered
|
|
59
|
+
// second reload (refreshPreviewDocumentVersion 80/300ms bumps) then finds the
|
|
60
|
+
// slot empty — the old code reset the playhead to 0 here.
|
|
61
|
+
expect(
|
|
62
|
+
resolveReloadSeekTime({
|
|
63
|
+
pendingSeek: null,
|
|
64
|
+
requestedSeek: null,
|
|
65
|
+
storeCurrentTime: 7.2,
|
|
66
|
+
duration: 20,
|
|
67
|
+
}),
|
|
68
|
+
).toBe(7.2);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("fresh project load starts at 0 (store resets currentTime on project switch)", () => {
|
|
72
|
+
expect(
|
|
73
|
+
resolveReloadSeekTime({
|
|
74
|
+
pendingSeek: null,
|
|
75
|
+
requestedSeek: null,
|
|
76
|
+
storeCurrentTime: 0,
|
|
77
|
+
duration: 20,
|
|
78
|
+
}),
|
|
79
|
+
).toBe(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("clamps to duration when content shrank past the playhead (the one sanctioned move)", () => {
|
|
83
|
+
expect(
|
|
84
|
+
resolveReloadSeekTime({
|
|
85
|
+
pendingSeek: 18,
|
|
86
|
+
requestedSeek: null,
|
|
87
|
+
storeCurrentTime: 18,
|
|
88
|
+
duration: 9,
|
|
89
|
+
}),
|
|
90
|
+
).toBe(9);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("a pending seek of 0 is an explicit position, not a missing value", () => {
|
|
94
|
+
expect(
|
|
95
|
+
resolveReloadSeekTime({
|
|
96
|
+
pendingSeek: 0,
|
|
97
|
+
requestedSeek: 12,
|
|
98
|
+
storeCurrentTime: 5,
|
|
99
|
+
duration: 20,
|
|
100
|
+
}),
|
|
101
|
+
).toBe(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns the guarded target unclamped when the duration is non-finite (no seek(NaN))", () => {
|
|
105
|
+
// Mid-reload the adapter can report a NaN duration; Math.min(target, NaN) would
|
|
106
|
+
// be NaN and seek(NaN). The guarded target must pass through unclamped instead.
|
|
107
|
+
expect(
|
|
108
|
+
resolveReloadSeekTime({
|
|
109
|
+
pendingSeek: 7.2,
|
|
110
|
+
requestedSeek: null,
|
|
111
|
+
storeCurrentTime: 7.2,
|
|
112
|
+
duration: Number.NaN,
|
|
113
|
+
}),
|
|
114
|
+
).toBe(7.2);
|
|
115
|
+
// A zero/negative duration is equally unusable — pass the target through.
|
|
116
|
+
expect(
|
|
117
|
+
resolveReloadSeekTime({
|
|
118
|
+
pendingSeek: 7.2,
|
|
119
|
+
requestedSeek: null,
|
|
120
|
+
storeCurrentTime: 7.2,
|
|
121
|
+
duration: 0,
|
|
122
|
+
}),
|
|
123
|
+
).toBe(7.2);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("guards against non-finite and negative targets", () => {
|
|
127
|
+
expect(
|
|
128
|
+
resolveReloadSeekTime({
|
|
129
|
+
pendingSeek: Number.NaN,
|
|
130
|
+
requestedSeek: null,
|
|
131
|
+
storeCurrentTime: 5,
|
|
132
|
+
duration: 20,
|
|
133
|
+
}),
|
|
134
|
+
).toBe(0);
|
|
135
|
+
expect(
|
|
136
|
+
resolveReloadSeekTime({
|
|
137
|
+
pendingSeek: -3,
|
|
138
|
+
requestedSeek: null,
|
|
139
|
+
storeCurrentTime: 5,
|
|
140
|
+
duration: 20,
|
|
141
|
+
}),
|
|
142
|
+
).toBe(0);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("resolveTimelineTotalDuration", () => {
|
|
147
|
+
it("THE BUG: a clip-manifest total shorter than the authored root duration never wins (stale '0:44/0:40')", () => {
|
|
148
|
+
// Fixture shape: root data-duration=44.5, furthest clip ends at 40. A runtime
|
|
149
|
+
// that measures the manifest from the furthest clip end reports 40s; playback
|
|
150
|
+
// still runs the full 44.5s, so the transport total must stay 44.5, not 40.
|
|
151
|
+
expect(
|
|
152
|
+
resolveTimelineTotalDuration({
|
|
153
|
+
manifestDurationSeconds: 40,
|
|
154
|
+
authoredRootDurationSeconds: 44.5,
|
|
155
|
+
}),
|
|
156
|
+
).toBe(44.5);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("lets clips extend the total PAST the authored root (content can grow the timeline)", () => {
|
|
160
|
+
expect(
|
|
161
|
+
resolveTimelineTotalDuration({
|
|
162
|
+
manifestDurationSeconds: 50,
|
|
163
|
+
authoredRootDurationSeconds: 44.5,
|
|
164
|
+
}),
|
|
165
|
+
).toBe(50);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("falls back to the manifest total when the root authors no duration", () => {
|
|
169
|
+
expect(
|
|
170
|
+
resolveTimelineTotalDuration({
|
|
171
|
+
manifestDurationSeconds: 40,
|
|
172
|
+
authoredRootDurationSeconds: 0,
|
|
173
|
+
}),
|
|
174
|
+
).toBe(40);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("uses the authored root when the manifest is loop-inflated / non-finite", () => {
|
|
178
|
+
expect(
|
|
179
|
+
resolveTimelineTotalDuration({
|
|
180
|
+
manifestDurationSeconds: Number.POSITIVE_INFINITY,
|
|
181
|
+
authoredRootDurationSeconds: 44.5,
|
|
182
|
+
}),
|
|
183
|
+
).toBe(44.5);
|
|
184
|
+
expect(
|
|
185
|
+
resolveTimelineTotalDuration({
|
|
186
|
+
manifestDurationSeconds: 9000, // beyond the 7200s sanity cap
|
|
187
|
+
authoredRootDurationSeconds: 44.5,
|
|
188
|
+
}),
|
|
189
|
+
).toBe(44.5);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("returns 0 when neither source yields a usable duration", () => {
|
|
193
|
+
expect(
|
|
194
|
+
resolveTimelineTotalDuration({
|
|
195
|
+
manifestDurationSeconds: Number.NaN,
|
|
196
|
+
authoredRootDurationSeconds: -1,
|
|
197
|
+
}),
|
|
198
|
+
).toBe(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("floors the manifest at the authored root read from the fixture's DOM shape", () => {
|
|
202
|
+
// Reconstruct the fixture: root authored at 44.5s, last clip (v-letters)
|
|
203
|
+
// ends at 34 + 6 = 40s. readTimelineDurationFromDocument must report the
|
|
204
|
+
// authored 44.5, and flooring the 40s manifest total against it yields 44.5.
|
|
205
|
+
const doc = document.implementation.createHTMLDocument("fixture");
|
|
206
|
+
doc.body.innerHTML =
|
|
207
|
+
'<div id="main" data-composition-id="main" data-duration="44.5">' +
|
|
208
|
+
'<video class="clip" data-start="34" data-duration="6"></video>' +
|
|
209
|
+
"</div>";
|
|
210
|
+
const authoredRootDurationSeconds = readTimelineDurationFromDocument(doc);
|
|
211
|
+
expect(authoredRootDurationSeconds).toBe(44.5);
|
|
212
|
+
expect(
|
|
213
|
+
resolveTimelineTotalDuration({
|
|
214
|
+
manifestDurationSeconds: 1200 / 30, // durationInFrames measured from clip end
|
|
215
|
+
authoredRootDurationSeconds,
|
|
216
|
+
}),
|
|
217
|
+
).toBe(44.5);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
createImplicitTimelineLayersFromDOM,
|
|
20
20
|
buildStandaloneRootTimelineElement,
|
|
21
21
|
getTimelineElementSelector,
|
|
22
|
+
readTimelineDurationFromDocument,
|
|
22
23
|
} from "../lib/timelineDOM";
|
|
23
24
|
import {
|
|
24
25
|
normalizePreviewViewport,
|
|
@@ -41,6 +42,76 @@ interface UseTimelineSyncCallbacksParams {
|
|
|
41
42
|
applyPreviewAudioState: () => void;
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Where should the player seek when the preview (re)loads?
|
|
47
|
+
* Priority: explicit pending seek (saved by refreshPlayer right before a
|
|
48
|
+
* reload) → store-level seek request (deep-link `?t=` hydration) → the store's
|
|
49
|
+
* last known playhead. The last fallback makes the playhead RELOAD-INVARIANT:
|
|
50
|
+
* edits persist + reload the preview, sometimes more than once (App's
|
|
51
|
+
* refreshPreviewDocumentVersion staggers extra bumps at 80/300ms), and the
|
|
52
|
+
* consume-once pendingSeekRef meant any reload after the first found the slot
|
|
53
|
+
* empty and reset the playhead to 0 — the "dropped a file and the playhead
|
|
54
|
+
* jumped to 0" bug. Falling back to the store's playhead means every reload
|
|
55
|
+
* restores position; a fresh project load still starts at 0 because the store
|
|
56
|
+
* resets currentTime on project switch. Invariant: an edit NEVER moves the
|
|
57
|
+
* playhead (the clamp below is the one sanctioned move — content shrank past it).
|
|
58
|
+
*/
|
|
59
|
+
/**
|
|
60
|
+
* Undo the `visibility: hidden` that refreshPlayer sets across a full reload.
|
|
61
|
+
* Safe to call when the iframe was never hidden (idempotent no-op). Every reload
|
|
62
|
+
* completion + failure path funnels through here so the preview can never get
|
|
63
|
+
* stuck invisible.
|
|
64
|
+
*/
|
|
65
|
+
export function revealIframe(iframe: HTMLIFrameElement | null): void {
|
|
66
|
+
if (iframe && iframe.style.visibility === "hidden") {
|
|
67
|
+
iframe.style.visibility = "";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function resolveReloadSeekTime(input: {
|
|
72
|
+
pendingSeek: number | null;
|
|
73
|
+
requestedSeek: number | null;
|
|
74
|
+
storeCurrentTime: number;
|
|
75
|
+
duration: number;
|
|
76
|
+
}): number {
|
|
77
|
+
const target = input.pendingSeek ?? input.requestedSeek ?? input.storeCurrentTime;
|
|
78
|
+
if (!Number.isFinite(target) || target <= 0) return 0;
|
|
79
|
+
// Only clamp to duration when it's a usable positive number. A non-finite or
|
|
80
|
+
// non-positive duration (e.g. the adapter reports NaN mid-reload) would turn
|
|
81
|
+
// Math.min(target, NaN) into NaN and seek(NaN); return the guarded target
|
|
82
|
+
// unclamped instead so the playhead lands at the intended position.
|
|
83
|
+
if (!Number.isFinite(input.duration) || input.duration <= 0) return target;
|
|
84
|
+
return Math.min(target, input.duration);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Reject non-finite, non-positive, and absurdly large (loop-inflated) values. */
|
|
88
|
+
function sanitizeDurationSeconds(value: number): number {
|
|
89
|
+
return Number.isFinite(value) && value > 0 && value < 7200 ? value : 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The transport TOTAL a clip-manifest message should write to the store.
|
|
94
|
+
*
|
|
95
|
+
* The manifest's `durationInFrames` measures the runtime timeline; some runtimes
|
|
96
|
+
* report only the furthest clip end and ignore the root composition's authored
|
|
97
|
+
* `data-duration`. When that manifest total is SHORTER than the authored root
|
|
98
|
+
* duration, writing it makes the readout stale (playback still runs the full
|
|
99
|
+
* authored window — the user saw "0:44/0:40" on a root authored at 44.5s whose
|
|
100
|
+
* last clip ends at 40s). The authored root duration is the floor for the total,
|
|
101
|
+
* so the readout can never sit below what the file declares. A manifest total
|
|
102
|
+
* that is LONGER (clips extend past the root) still wins — content can only grow
|
|
103
|
+
* the timeline, never shrink it below the authored window.
|
|
104
|
+
*/
|
|
105
|
+
export function resolveTimelineTotalDuration(input: {
|
|
106
|
+
manifestDurationSeconds: number;
|
|
107
|
+
authoredRootDurationSeconds: number;
|
|
108
|
+
}): number {
|
|
109
|
+
return Math.max(
|
|
110
|
+
sanitizeDurationSeconds(input.manifestDurationSeconds),
|
|
111
|
+
sanitizeDurationSeconds(input.authoredRootDurationSeconds),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
44
115
|
export function useTimelineSyncCallbacks({
|
|
45
116
|
iframeRef,
|
|
46
117
|
probeIntervalRef,
|
|
@@ -154,8 +225,14 @@ export function useTimelineSyncCallbacks({
|
|
|
154
225
|
const rawDuration = data.durationInFrames / 30;
|
|
155
226
|
// Clamp non-finite or absurdly large durations — the runtime can emit
|
|
156
227
|
// Infinity when it detects a loop-inflated GSAP timeline without an
|
|
157
|
-
// explicit data-duration on the root composition.
|
|
158
|
-
|
|
228
|
+
// explicit data-duration on the root composition. Floor the manifest total
|
|
229
|
+
// at the authored root `data-duration` so a runtime that measures only the
|
|
230
|
+
// furthest clip end (shorter than the authored window) can't leave a stale,
|
|
231
|
+
// too-short total in the transport (the "0:44/0:40" bug).
|
|
232
|
+
const newDuration = resolveTimelineTotalDuration({
|
|
233
|
+
manifestDurationSeconds: rawDuration,
|
|
234
|
+
authoredRootDurationSeconds: readTimelineDurationFromDocument(iframeDoc),
|
|
235
|
+
});
|
|
159
236
|
const effectiveDuration = newDuration > 0 ? newDuration : usePlayerStore.getState().duration;
|
|
160
237
|
const clampedEls =
|
|
161
238
|
effectiveDuration > 0
|
|
@@ -218,12 +295,32 @@ export function useTimelineSyncCallbacks({
|
|
|
218
295
|
// never reaches pendingSeekRef). Reconciling with the store here is what makes a
|
|
219
296
|
// deep-linked `?t=` land instead of starting at 0.
|
|
220
297
|
const storeSeek = usePlayerStore.getState().requestedSeekTime;
|
|
221
|
-
const
|
|
298
|
+
const startTime = resolveReloadSeekTime({
|
|
299
|
+
pendingSeek: pendingSeekRef.current,
|
|
300
|
+
requestedSeek: storeSeek,
|
|
301
|
+
storeCurrentTime: usePlayerStore.getState().currentTime,
|
|
302
|
+
duration: adapter.getDuration(),
|
|
303
|
+
});
|
|
222
304
|
pendingSeekRef.current = null;
|
|
223
305
|
if (storeSeek != null) usePlayerStore.getState().clearSeekRequest();
|
|
224
|
-
const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
|
|
225
306
|
|
|
307
|
+
// Force a REAL render at startTime, not a no-op. After a post-edit reload the
|
|
308
|
+
// freshly rebuilt GSAP timeline can already report being at `startTime`
|
|
309
|
+
// internally (the reload restores the same playhead), so a single
|
|
310
|
+
// `adapter.seek(startTime)` is a GSAP no-op — `tl.seek(t)` at the current time
|
|
311
|
+
// doesn't re-evaluate. That's why a just-dropped clip stayed invisible until
|
|
312
|
+
// the user nudged the playhead: its element's state was never applied at the
|
|
313
|
+
// restore position. Seeking to a DIFFERENT guard value first (a hair off, or 0
|
|
314
|
+
// when startTime is already ~0) guarantees the follow-up seek to `startTime`
|
|
315
|
+
// crosses a time boundary and re-renders every clip — including the new one.
|
|
316
|
+
const guardTime = startTime > 0.001 ? Math.max(0, startTime - 0.001) : 0.001;
|
|
317
|
+
adapter.seek(guardTime);
|
|
226
318
|
adapter.seek(startTime);
|
|
319
|
+
// The correct frame is now rendered — reveal the iframe that refreshPlayer hid
|
|
320
|
+
// for the reload, so the user sees the restored frame directly (never the raw
|
|
321
|
+
// all-clips DOM). Cleared unconditionally: any later failure path must not leave
|
|
322
|
+
// the preview stuck invisible.
|
|
323
|
+
revealIframe(iframeRef.current);
|
|
227
324
|
// Keep non-React listeners such as the capture link and time display in sync
|
|
228
325
|
// with the initial adapter seek on iframe load.
|
|
229
326
|
liveTime.notify(startTime);
|
|
@@ -332,6 +429,9 @@ export function useTimelineSyncCallbacks({
|
|
|
332
429
|
trySettle();
|
|
333
430
|
}
|
|
334
431
|
window.removeEventListener("message", onMessage);
|
|
432
|
+
// Never leave the preview stuck invisible if the runtime never settled
|
|
433
|
+
// (initializeAdapter reveals on success; this covers the give-up case).
|
|
434
|
+
revealIframe(iframeRef.current);
|
|
335
435
|
}, 5000) as unknown as ReturnType<typeof setInterval>;
|
|
336
436
|
}, [initializeAdapter, iframeRef, probeIntervalRef, applyPreviewAudioState]);
|
|
337
437
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { findClipForAsset, isPointerClick, DRAG_THRESHOLD_PX } from "./assetClickBehavior";
|
|
3
|
+
import type { TimelineElement } from "../player/store/playerStore";
|
|
4
|
+
|
|
5
|
+
// Minimal TimelineElement factory — only the fields the function inspects.
|
|
6
|
+
function makeEl(overrides: Partial<TimelineElement> & { id: string }): TimelineElement {
|
|
7
|
+
return {
|
|
8
|
+
tag: "div",
|
|
9
|
+
start: 0,
|
|
10
|
+
duration: 5,
|
|
11
|
+
track: 0,
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("findClipForAsset", () => {
|
|
17
|
+
it("returns null when elements array is empty", () => {
|
|
18
|
+
expect(findClipForAsset([], "assets/foo.mp4")).toBeNull();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns null when no element matches", () => {
|
|
22
|
+
const el = makeEl({ id: "el1", src: "assets/other.mp4" });
|
|
23
|
+
expect(findClipForAsset([el], "assets/foo.mp4")).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("matches a bare relative src against the project-relative asset path", () => {
|
|
27
|
+
const el = makeEl({ id: "el1", src: "assets/clip.mp4", start: 2 });
|
|
28
|
+
expect(findClipForAsset([el], "assets/clip.mp4")).toBe(el);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("matches a src with a ./ prefix", () => {
|
|
32
|
+
const el = makeEl({ id: "el1", src: "./assets/logo.png" });
|
|
33
|
+
expect(findClipForAsset([el], "assets/logo.png")).toBe(el);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("matches a server-relative /api/projects/…/preview/ src", () => {
|
|
37
|
+
const el = makeEl({ id: "el1", src: "/api/projects/demo/preview/assets/bgm.mp3" });
|
|
38
|
+
expect(findClipForAsset([el], "assets/bgm.mp3")).toBe(el);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("matches a fully-absolute URL (as produced by the core runtime)", () => {
|
|
42
|
+
const el = makeEl({
|
|
43
|
+
id: "el1",
|
|
44
|
+
src: "http://localhost:3012/api/projects/demo/preview/assets/clip.mp4",
|
|
45
|
+
});
|
|
46
|
+
expect(findClipForAsset([el], "assets/clip.mp4")).toBe(el);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("decodes percent-encoded filenames when matching", () => {
|
|
50
|
+
const el = makeEl({
|
|
51
|
+
id: "el1",
|
|
52
|
+
src: "http://localhost:3012/api/projects/p/preview/assets/my%20file%20(1).mp4",
|
|
53
|
+
});
|
|
54
|
+
expect(findClipForAsset([el], "assets/my file (1).mp4")).toBe(el);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("strips query strings from src before matching", () => {
|
|
58
|
+
const el = makeEl({ id: "el1", src: "assets/clip.mp4?v=2" });
|
|
59
|
+
expect(findClipForAsset([el], "assets/clip.mp4")).toBe(el);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("returns the element with the earliest start when multiple clips match", () => {
|
|
63
|
+
const later = makeEl({ id: "late", src: "assets/clip.mp4", start: 10 });
|
|
64
|
+
const earlier = makeEl({ id: "early", src: "assets/clip.mp4", start: 2 });
|
|
65
|
+
const first = makeEl({ id: "first", src: "assets/clip.mp4", start: 0 });
|
|
66
|
+
expect(findClipForAsset([later, earlier, first], "assets/clip.mp4")).toBe(first);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("prefers the key over id when the element has both", () => {
|
|
70
|
+
// findClipForAsset returns the element object itself; callers do `clip.key ?? clip.id`
|
|
71
|
+
const el = makeEl({ id: "el1", key: "clip-key", src: "assets/img.png" });
|
|
72
|
+
const found = findClipForAsset([el], "assets/img.png");
|
|
73
|
+
expect(found?.key).toBe("clip-key");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("skips elements with no src", () => {
|
|
77
|
+
const noSrc = makeEl({ id: "nosrc" });
|
|
78
|
+
const withSrc = makeEl({ id: "withsrc", src: "assets/img.png" });
|
|
79
|
+
expect(findClipForAsset([noSrc, withSrc], "assets/img.png")).toBe(withSrc);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("isPointerClick", () => {
|
|
84
|
+
it("returns true for zero movement", () => {
|
|
85
|
+
expect(isPointerClick(0, 0)).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns true for movement within the threshold", () => {
|
|
89
|
+
expect(isPointerClick(DRAG_THRESHOLD_PX - 1, 0)).toBe(true);
|
|
90
|
+
expect(isPointerClick(0, DRAG_THRESHOLD_PX - 1)).toBe(true);
|
|
91
|
+
expect(isPointerClick(DRAG_THRESHOLD_PX - 1, DRAG_THRESHOLD_PX - 1)).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("returns false at or beyond the threshold", () => {
|
|
95
|
+
expect(isPointerClick(DRAG_THRESHOLD_PX, 0)).toBe(false);
|
|
96
|
+
expect(isPointerClick(0, DRAG_THRESHOLD_PX)).toBe(false);
|
|
97
|
+
expect(isPointerClick(DRAG_THRESHOLD_PX + 10, 0)).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("handles negative movement (pointer moved left/up)", () => {
|
|
101
|
+
expect(isPointerClick(-(DRAG_THRESHOLD_PX - 1), 0)).toBe(true);
|
|
102
|
+
expect(isPointerClick(-DRAG_THRESHOLD_PX, 0)).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for CapCut-style asset card click behavior.
|
|
3
|
+
*
|
|
4
|
+
* Clicking an asset card that is ALREADY ADDED to the timeline selects the
|
|
5
|
+
* corresponding clip. Clicking one NOT yet in the timeline opens a lightweight
|
|
6
|
+
* preview overlay. Both behaviors are gated on "this was a click, not a drag".
|
|
7
|
+
*
|
|
8
|
+
* Pure — unit-tested.
|
|
9
|
+
*/
|
|
10
|
+
import type { TimelineElement } from "../player/store/playerStore";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Find the TimelineElement that references `assetPath`, returning the one with
|
|
14
|
+
* the earliest start time when multiple clips share the same source.
|
|
15
|
+
*
|
|
16
|
+
* Matching mirrors `deriveUsedPaths` in AssetsTab: an element's `src` may be a
|
|
17
|
+
* fully-absolute URL, a server-relative `/api/projects/…/preview/…` path, a
|
|
18
|
+
* `./`-prefixed relative path, or a bare relative path — all normalised to the
|
|
19
|
+
* project-relative form that `assetPath` carries.
|
|
20
|
+
*
|
|
21
|
+
* Returns `null` when no element matches.
|
|
22
|
+
*/
|
|
23
|
+
export function findClipForAsset(
|
|
24
|
+
elements: TimelineElement[],
|
|
25
|
+
assetPath: string,
|
|
26
|
+
): TimelineElement | null {
|
|
27
|
+
let best: TimelineElement | null = null;
|
|
28
|
+
for (const el of elements) {
|
|
29
|
+
if (!el.src) continue;
|
|
30
|
+
if (normalizeSrc(el.src) !== assetPath) continue;
|
|
31
|
+
if (best === null || el.start < best.start) best = el;
|
|
32
|
+
}
|
|
33
|
+
return best;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Normalise a raw element `src` to the bare project-relative path so it can be
|
|
38
|
+
* compared against the asset-list strings (which have no leading slash, no
|
|
39
|
+
* origin, no query string).
|
|
40
|
+
*
|
|
41
|
+
* Mirrors the logic in `deriveUsedPaths` (AssetsTab.tsx) — keep in sync.
|
|
42
|
+
*/
|
|
43
|
+
function normalizeSrc(src: string): string {
|
|
44
|
+
let s = src;
|
|
45
|
+
try {
|
|
46
|
+
const u = new URL(s);
|
|
47
|
+
s = u.pathname;
|
|
48
|
+
} catch {
|
|
49
|
+
// Not an absolute URL — leave as-is
|
|
50
|
+
}
|
|
51
|
+
s = s
|
|
52
|
+
.replace(/^\/api\/projects\/[^/]+\/preview\//, "")
|
|
53
|
+
.replace(/^\.?\//, "")
|
|
54
|
+
.split(/[?#]/)[0];
|
|
55
|
+
try {
|
|
56
|
+
s = decodeURIComponent(s);
|
|
57
|
+
} catch {
|
|
58
|
+
// Malformed encoding — use as-is
|
|
59
|
+
}
|
|
60
|
+
return s;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Drag-detection threshold in pixels — movements within this are treated as clicks. */
|
|
64
|
+
export const DRAG_THRESHOLD_PX = 4;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Determine whether a pointer-up event should be treated as a click given the
|
|
68
|
+
* total pointer displacement since pointer-down.
|
|
69
|
+
*
|
|
70
|
+
* @param dx Horizontal distance moved in pixels.
|
|
71
|
+
* @param dy Vertical distance moved in pixels.
|
|
72
|
+
*/
|
|
73
|
+
export function isPointerClick(dx: number, dy: number): boolean {
|
|
74
|
+
return Math.abs(dx) < DRAG_THRESHOLD_PX && Math.abs(dy) < DRAG_THRESHOLD_PX;
|
|
75
|
+
}
|