@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.
Files changed (35) hide show
  1. package/dist/assets/{index-pRhCpGPz.js → index-BRwkMj0w.js} +108 -108
  2. package/dist/assets/{index-uBY329wb.js → index-BXaqaVKt.js} +1 -1
  3. package/dist/assets/{index-CMHYjEZ5.js → index-CPetwHFV.js} +1 -1
  4. package/dist/index.html +1 -1
  5. package/dist/index.js +48 -3
  6. package/dist/index.js.map +1 -1
  7. package/package.json +7 -7
  8. package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
  9. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  10. package/src/components/editor/anchoredResizeReleaseShift.test.ts +112 -0
  11. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  12. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  13. package/src/hooks/useRazorSplit.history.test.tsx +173 -0
  14. package/src/player/components/timelineCollision.test.ts +437 -0
  15. package/src/player/components/timelineCollision.ts +263 -0
  16. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  17. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  18. package/src/player/components/timelineSnapping.test.ts +134 -0
  19. package/src/player/components/timelineSnapping.ts +110 -0
  20. package/src/player/components/timelineStackingSync.test.ts +244 -0
  21. package/src/player/components/timelineStackingSync.ts +331 -0
  22. package/src/player/components/timelineZones.test.ts +425 -0
  23. package/src/player/components/timelineZones.ts +198 -0
  24. package/src/player/components/timelineZoom.test.ts +67 -0
  25. package/src/player/components/timelineZoom.ts +51 -0
  26. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  27. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  28. package/src/utils/assetClickBehavior.test.ts +104 -0
  29. package/src/utils/assetClickBehavior.ts +75 -0
  30. package/src/utils/canvasNudgeGate.test.ts +31 -0
  31. package/src/utils/canvasNudgeGate.ts +32 -0
  32. package/src/utils/studioUiPreferences.test.ts +38 -0
  33. package/src/utils/studioUiPreferences.ts +27 -0
  34. package/src/utils/timelineInspector.test.ts +121 -0
  35. 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
- const newDuration = Number.isFinite(rawDuration) && rawDuration < 7200 ? rawDuration : 0;
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 seekTo = pendingSeekRef.current ?? storeSeek;
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
+ }