@hyperframes/studio 0.8.142 → 0.8.144

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 (76) hide show
  1. package/dist/assets/hyperframes-player-Bw_jIaSX.js +473 -0
  2. package/dist/assets/{index-B-wffzRH.js → index-BVM9NuWl.js} +153 -153
  3. package/dist/assets/{index-D3Vij9t1.js → index-Cj7W_c7p.js} +1 -1
  4. package/dist/assets/{index-D5q2_7Gw.js → index-DyH9dT7b.js} +1 -1
  5. package/dist/{chunk-TFLU2MGI.js → chunk-3GHTHPZD.js} +8 -1
  6. package/dist/chunk-3GHTHPZD.js.map +1 -0
  7. package/dist/{chunk-H2DUOXTF.js → chunk-G3EDKRZH.js} +3 -3
  8. package/dist/{chunk-UYWON5AX.js → chunk-PEAKCOUW.js} +7 -3
  9. package/dist/chunk-PEAKCOUW.js.map +1 -0
  10. package/dist/{chunk-LOVBLSMR.js → chunk-Y2OHHNM2.js} +35 -19
  11. package/dist/chunk-Y2OHHNM2.js.map +1 -0
  12. package/dist/{domEditingLayers-KBTPQKGA.js → domEditingLayers-PYST4G45.js} +3 -3
  13. package/dist/index.html +1 -1
  14. package/dist/index.js +261 -230
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +3 -3
  17. package/dist/ui.d.ts +4 -5
  18. package/dist/ui.js +1 -1
  19. package/package.json +8 -8
  20. package/src/components/MediaPreview.tsx +2 -1
  21. package/src/components/editor/DomEditSelectionChrome.test.tsx +103 -0
  22. package/src/components/editor/DomEditSelectionChrome.tsx +42 -16
  23. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +36 -0
  24. package/src/components/editor/domEditOverlayGestures.ts +1 -0
  25. package/src/components/editor/domEditOverlayStartGesture.ts +5 -0
  26. package/src/components/editor/domEditResizeLocal.test.ts +81 -1
  27. package/src/components/editor/domEditResizeLocal.ts +27 -3
  28. package/src/components/editor/domEditingLayers.ts +4 -1
  29. package/src/components/editor/domEditingTextFields.test.ts +18 -0
  30. package/src/components/editor/manualEditsDom.ts +12 -12
  31. package/src/components/editor/mediaAudioEdits.test.ts +12 -0
  32. package/src/components/editor/mediaAudioEdits.ts +5 -5
  33. package/src/components/editor/previewGestureStarts.ts +14 -5
  34. package/src/components/editor/propertyPanelFill.tsx +2 -1
  35. package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -4
  36. package/src/components/editor/propertyPanelMediaSection.tsx +3 -4
  37. package/src/components/editor/resizeDraft.ts +13 -4
  38. package/src/components/editor/useColorGradingController.ts +2 -1
  39. package/src/components/editor/useDomEditOverlayGestures.ts +5 -3
  40. package/src/components/ui/Menu.test.tsx +67 -5
  41. package/src/components/ui/Menu.tsx +21 -4
  42. package/src/components/ui/Popover.tsx +11 -5
  43. package/src/hooks/gsapResizeIntercept.test.ts +46 -11
  44. package/src/hooks/gsapResizeIntercept.ts +25 -20
  45. package/src/hooks/useDomEditCommits.ts +2 -1
  46. package/src/hooks/useFileManager.ts +2 -1
  47. package/src/hooks/useFileTree.ts +2 -1
  48. package/src/hooks/useRenderClipContent.ts +15 -3
  49. package/src/hooks/useThumbnailStripSize.test.tsx +123 -0
  50. package/src/hooks/useThumbnailStripSize.ts +19 -8
  51. package/src/player/components/CompositionThumbnail.test.ts +3 -0
  52. package/src/player/components/ImageThumbnail.test.tsx +4 -1
  53. package/src/player/components/VideoThumbnail.test.tsx +13 -2
  54. package/src/player/components/clipMenuNormalize.ts +2 -2
  55. package/src/player/components/clipPeakMap.ts +2 -2
  56. package/src/player/components/thumbnailUtils.test.ts +33 -0
  57. package/src/player/components/thumbnailUtils.ts +5 -0
  58. package/src/player/hooks/timelineSyncHydration.ts +2 -1
  59. package/src/player/lib/mediaProbe.test.ts +12 -0
  60. package/src/player/lib/mediaProbe.ts +4 -2
  61. package/src/utils/authoredSource.test.ts +25 -0
  62. package/src/utils/authoredSource.ts +3 -5
  63. package/src/utils/rounding.ts +7 -0
  64. package/src/utils/sourcePatcher.test.ts +135 -0
  65. package/src/utils/sourcePatcher.ts +102 -186
  66. package/src/utils/studioHelpers.test.ts +23 -0
  67. package/src/utils/studioHelpers.ts +3 -2
  68. package/src/utils/studioSelectionSnapshot.test.ts +31 -0
  69. package/src/utils/timelineAssetDrop.test.ts +14 -0
  70. package/src/utils/timelineAssetDrop.ts +2 -1
  71. package/dist/assets/hyperframes-player-sAsRuV73.js +0 -473
  72. package/dist/chunk-LOVBLSMR.js.map +0 -1
  73. package/dist/chunk-TFLU2MGI.js.map +0 -1
  74. package/dist/chunk-UYWON5AX.js.map +0 -1
  75. /package/dist/{chunk-H2DUOXTF.js.map → chunk-G3EDKRZH.js.map} +0 -0
  76. /package/dist/{domEditingLayers-KBTPQKGA.js.map → domEditingLayers-PYST4G45.js.map} +0 -0
@@ -170,6 +170,11 @@ function tweenUsesScaleLonghands(anim: GsapAnimation | null): boolean {
170
170
 
171
171
  // ── Resize intercept ──────────────────────────────────────────────────────
172
172
 
173
+ // The resize is centre-anchored, so a scale that cannot reproduce the dragged size splits the gap evenly.
174
+ function rectCentre(rect: DOMRect): { x: number; y: number } {
175
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
176
+ }
177
+
173
178
  // fallow-ignore-next-line complexity
174
179
  export async function tryGsapResizeIntercept(
175
180
  selection: DomEditSelection,
@@ -371,15 +376,10 @@ export async function tryGsapResizeIntercept(
371
376
  committedScale = useScaleLonghands
372
377
  ? { x: newScaleX, y: newScaleY }
373
378
  : { x: newScaleX, y: newScaleX };
374
- // Where the user DROPPED the box: the draft (anchor-pinned to the
375
- // gesture-start top-left) is still applied here, so this rect is exactly
376
- // what the preview showed at release. The committed scale renders around
377
- // the element CENTER instead — the finalize step below measures that
378
- // difference and compensates, so release matches the drop pixel-for-pixel
379
- // regardless of live scale or repeat resizes.
379
+ // Where the user DROPPED the box: the draft is still applied, so this is what the preview showed
380
+ // at release. The finalize step below moves the committed scale's box onto it.
380
381
  if (el) {
381
- const dropRect = draw(() => el.getBoundingClientRect());
382
- scaleDraftDropPoint = { x: dropRect.x, y: dropRect.y };
382
+ scaleDraftDropPoint = draw(() => rectCentre(el.getBoundingClientRect()));
383
383
  }
384
384
  } else {
385
385
  resizeProps = {
@@ -396,8 +396,8 @@ export async function tryGsapResizeIntercept(
396
396
  // position (a `tl.set` hold or none) — a keyframed position path has no
397
397
  // single anchor to preserve, so it keeps the plain center-scale behavior.
398
398
  // The size route hands its draft to GSAP itself (handOverDraftSize).
399
- // ponytail: for a 3D-rotated element the rects are AABBs, so the anchor is
400
- // approximate rather than corner-exact.
399
+ // ponytail: the centre of an AABB is exact for any 2D transform; under
400
+ // perspective it is approximate.
401
401
  // fallow-ignore-next-line complexity
402
402
  const finalizeScaleResizeCommit = async (): Promise<boolean> => {
403
403
  // Only the scale route captures the element, so a null draft means this
@@ -428,15 +428,9 @@ export async function tryGsapResizeIntercept(
428
428
  );
429
429
  const base = { x: baseGsapX, y: baseGsapY };
430
430
  setElementGsapPosition(draftEl, base.x, base.y);
431
- const post = draftEl.getBoundingClientRect();
431
+ const post = rectCentre(draftEl.getBoundingClientRect());
432
432
  const residual = { x: dropPoint.x - post.x, y: dropPoint.y - post.y };
433
433
  if (!Number.isFinite(residual.x) || !Number.isFinite(residual.y)) return null;
434
- if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) {
435
- logResize("scale-finalize", { skipped: "already-on-drop-point", residual, base });
436
- // Settled, with nothing to write. Still ours: forwarding the drag offset
437
- // on top would move the box off the point it is already sitting on.
438
- return "settled" as const;
439
- }
440
434
  // The ONE corrected position — rounded once so the live runtime and the
441
435
  // persisted file agree exactly (commitStaticGsapPosition composes the same
442
436
  // rounded value from this delta).
@@ -444,9 +438,15 @@ export async function tryGsapResizeIntercept(
444
438
  x: roundTo3(base.x + residual.x),
445
439
  y: roundTo3(base.y + residual.y),
446
440
  };
441
+ if (corrected.x === roundTo3(base.x) && corrected.y === roundTo3(base.y)) {
442
+ logResize("scale-finalize", { skipped: "already-on-drop-point", residual, base });
443
+ // Settled, with nothing to write. Still ours: forwarding the drag offset
444
+ // on top would move the box off the point it is already sitting on.
445
+ return "settled" as const;
446
+ }
447
447
  logResize("scale-finalize", {
448
448
  dropPoint,
449
- post: { x: post.x, y: post.y },
449
+ post,
450
450
  residual,
451
451
  gsapPos,
452
452
  base,
@@ -455,11 +455,11 @@ export async function tryGsapResizeIntercept(
455
455
  // Correct the live box in the same task as the measurement, so no frame shows it off the drop
456
456
  // point while the position write is in flight.
457
457
  setElementGsapPosition(draftEl, corrected.x, corrected.y);
458
- return { base, corrected };
458
+ return { base, corrected, residual };
459
459
  });
460
460
  if (measured === null) return false;
461
461
  if (measured === "settled") return true;
462
- const { base, corrected } = measured;
462
+ const { base, corrected, residual } = measured;
463
463
  // Re-fetch: the scale commit above just rewrote the script, so the caller's
464
464
  // animation list (and its ids) may be stale for the position lookup.
465
465
  const currentAnimations = fetchFallbackAnimations
@@ -482,6 +482,11 @@ export async function tryGsapResizeIntercept(
482
482
  moment.time,
483
483
  );
484
484
  if (positionTween) {
485
+ // A sub-pixel correction is not worth a keyframe in an authored tween.
486
+ if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) {
487
+ draw(() => setElementGsapPosition(draftEl, base.x, base.y));
488
+ return true;
489
+ }
485
490
  logResize("scale-finalize", { route: "position-keyframe", tweenId: positionTween.id });
486
491
  assertGsapEditPersisted(
487
492
  await commitGsapPositionFromDrag(selection, positionTween, delta, base, iframe, {
@@ -23,6 +23,7 @@ import {
23
23
  } from "./useDomEditCommitsHelpers";
24
24
  import type { CutoverResult } from "../utils/sdkCutover";
25
25
  import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
26
+ import { encodeUrlPath } from "@hyperframes/parsers";
26
27
 
27
28
  export interface UseDomEditCommitsParams {
28
29
  activeCompPath: string | null;
@@ -112,7 +113,7 @@ export function useDomEditCommits({
112
113
  return {
113
114
  family: fontFamilyFromAssetPath(asset),
114
115
  path: asset,
115
- url: buildProjectApiPath(projectId, `/preview/${asset}`),
116
+ url: buildProjectApiPath(projectId, `/preview/${encodeUrlPath(asset)}`),
116
117
  };
117
118
  },
118
119
  [fileTree, projectId, importedFontAssetsRef],
@@ -9,6 +9,7 @@ import { useFileTree } from "./useFileTree";
9
9
  import { useEditorSave } from "./useEditorSave";
10
10
  import { useProjectFileWriter } from "./useProjectFileWriter";
11
11
  import { studioApiFetch } from "../utils/studioApiFetch";
12
+ import { encodeUrlPath } from "@hyperframes/parsers";
12
13
 
13
14
  // ── Types ──
14
15
 
@@ -386,7 +387,7 @@ export function useFileManager({
386
387
  .map((asset) => ({
387
388
  family: fontFamilyFromAssetPath(asset),
388
389
  path: asset,
389
- url: `/api/projects/${encodeURIComponent(pid)}/preview/${asset}`,
390
+ url: `/api/projects/${encodeURIComponent(pid)}/preview/${encodeUrlPath(asset)}`,
390
391
  }));
391
392
  importedFontAssetsRef.current = [
392
393
  ...imported,
@@ -4,6 +4,7 @@ import { FONT_EXT } from "../utils/mediaTypes";
4
4
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
5
5
  import { captureProjectProvenance } from "../components/feedback/projectProvenance";
6
6
  import { studioApiFetch } from "../utils/studioApiFetch";
7
+ import { encodeUrlPath } from "@hyperframes/parsers";
7
8
 
8
9
  interface UseFileTreeOptions {
9
10
  projectId: string | null;
@@ -119,7 +120,7 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
119
120
  .map((asset) => ({
120
121
  family: fontFamilyFromAssetPath(asset),
121
122
  path: asset,
122
- url: projectId ? buildProjectApiPath(projectId, `/preview/${asset}`) : "",
123
+ url: projectId ? buildProjectApiPath(projectId, `/preview/${encodeUrlPath(asset)}`) : "",
123
124
  })),
124
125
  [assets, projectId],
125
126
  );
@@ -17,7 +17,11 @@ import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipCo
17
17
  import { ClipPeakMarks } from "../player/components/ClipPeakMarks";
18
18
  import { clipPeaksUrl, clipSourceWindow } from "../player/components/clipPeakMap";
19
19
  import { clipHasSound } from "../player/components/clipMenuNormalize";
20
- import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
20
+ import {
21
+ authoredSrcPath,
22
+ encodePreviewPath,
23
+ resolveMediaPreviewUrl,
24
+ } from "../player/components/thumbnailUtils";
21
25
  import { usePlayerStore } from "../player/store/playerStore";
22
26
  import { thumbnailRevisionOf } from "../player/store/thumbnailSlice";
23
27
  import { effectiveThumbnailMode } from "../player/lib/thumbnailPolicy";
@@ -67,7 +71,11 @@ function renderAudioClip(
67
71
  elements: readonly TimelineElement[],
68
72
  labelInset?: number,
69
73
  ): ReactNode {
70
- const audioUrl = resolveMediaPreviewUrl(el.src ?? "", pid, window.location.origin);
74
+ const audioUrl = resolveMediaPreviewUrl(
75
+ authoredSrcPath(el.src ?? ""),
76
+ pid,
77
+ window.location.origin,
78
+ );
71
79
  const srcRelative = resolvePreviewRelative(audioUrl, pid, window.location.origin);
72
80
  // Encode each path segment (spaces, parens, U+202F, unicode) so the URL matches
73
81
  // what the assets panel loads — a raw segment 404s. resolvePreviewRelative
@@ -213,7 +221,11 @@ export function useRenderClipContent({
213
221
  const htmlPreviewEligible = el.duration > 0 && effectiveTimelineDuration > 0;
214
222
 
215
223
  if ((el.tag === "video" || el.tag === "img") && el.src) {
216
- const mediaSrc = resolveMediaPreviewUrl(el.src, pid, window.location.origin);
224
+ const mediaSrc = resolveMediaPreviewUrl(
225
+ authoredSrcPath(el.src),
226
+ pid,
227
+ window.location.origin,
228
+ );
217
229
  // Still images can't be decoded by VideoThumbnail's <video> extractor
218
230
  // (the error event fires and the shimmer never resolves) — render the
219
231
  // image itself as the strip.
@@ -18,9 +18,125 @@ vi.mock("../player/components/timelineZoomInput", () => ({
18
18
 
19
19
  Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
20
20
 
21
+ /** Runs the frames the hook asked for, as the browser does after the observer's delivery. */
22
+ function nextFrames() {
23
+ vi.useFakeTimers({ toFake: ["requestAnimationFrame", "cancelAnimationFrame"] });
24
+ return () => act(() => vi.advanceTimersToNextFrame());
25
+ }
26
+
27
+ // A strip that re-rendered inside the observer's delivery resized the timeline Chromium had already measured that
28
+ // frame, which it reported as a ResizeObserver loop on every frame of a trim.
29
+ it("applies a reported size on the next frame, never inside the observer's delivery", () => {
30
+ const originalResizeObserver = globalThis.ResizeObserver;
31
+ globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
32
+ const runFrames = nextFrames();
33
+ const host = document.createElement("div");
34
+ document.body.append(host);
35
+ Object.defineProperty(host, "clientWidth", { configurable: true, value: 300 });
36
+ Object.defineProperty(host, "clientHeight", { configurable: true, value: 40 });
37
+ const root = createRoot(host);
38
+ function Harness() {
39
+ const [size, ref] = useThumbnailStripSize();
40
+ return <div ref={ref}>{`${size.width}x${size.height}`}</div>;
41
+ }
42
+ try {
43
+ act(() => root.render(<Harness />));
44
+ reportResize(310, 40);
45
+ reportResize(320, 40);
46
+ expect(vi.getTimerCount()).toBe(1);
47
+ expect(host.textContent).toBe("300x40");
48
+ runFrames();
49
+ expect(host.textContent).toBe("320x40");
50
+ } finally {
51
+ act(() => root.unmount());
52
+ host.remove();
53
+ vi.useRealTimers();
54
+ vi.restoreAllMocks();
55
+ globalThis.ResizeObserver = originalResizeObserver;
56
+ }
57
+ });
58
+
59
+ it("cancels a pending resize frame when the last strip unmounts", () => {
60
+ const originalResizeObserver = globalThis.ResizeObserver;
61
+ globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
62
+ const runFrames = nextFrames();
63
+ const host = document.createElement("div");
64
+ document.body.append(host);
65
+ const root = createRoot(host);
66
+ function Harness() {
67
+ const [, ref] = useThumbnailStripSize();
68
+ return <div ref={ref} />;
69
+ }
70
+ try {
71
+ act(() => root.render(<Harness />));
72
+ reportResize(320, 40);
73
+ expect(vi.getTimerCount()).toBe(1);
74
+ act(() => root.unmount());
75
+ expect(vi.getTimerCount()).toBe(0);
76
+ runFrames();
77
+ } finally {
78
+ act(() => root.unmount());
79
+ host.remove();
80
+ vi.useRealTimers();
81
+ vi.restoreAllMocks();
82
+ globalThis.ResizeObserver = originalResizeObserver;
83
+ }
84
+ });
85
+
86
+ it("drops a pending size when its strip unmounts, so a strip remounted on that box keeps its own", () => {
87
+ const originalResizeObserver = globalThis.ResizeObserver;
88
+ globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
89
+ const runFrames = nextFrames();
90
+ const host = document.createElement("div");
91
+ document.body.append(host);
92
+ const root = createRoot(host);
93
+ function Strip() {
94
+ const [size, ref] = useThumbnailStripSize();
95
+ return <div ref={ref}>{`${size.width}x${size.height}`}</div>;
96
+ }
97
+ function Box({ id }: { id: string }) {
98
+ const box = (element: HTMLDivElement | null) => {
99
+ if (!element) return;
100
+ Object.defineProperty(element, "clientWidth", { configurable: true, value: 300 });
101
+ Object.defineProperty(element, "clientHeight", { configurable: true, value: 40 });
102
+ };
103
+ return (
104
+ <div ref={box} data-testid="box">
105
+ <Strip key={id} />
106
+ </div>
107
+ );
108
+ }
109
+ // The other strip keeps the shared observer alive across the remount.
110
+ const render = (id: string) =>
111
+ act(() =>
112
+ root.render(
113
+ <>
114
+ <div>
115
+ <Strip />
116
+ </div>
117
+ <Box id={id} />
118
+ </>,
119
+ ),
120
+ );
121
+ try {
122
+ render("a");
123
+ reportResize(320, 40);
124
+ render("b");
125
+ runFrames();
126
+ expect(host.querySelector('[data-testid="box"]')?.textContent).toBe("300x40");
127
+ } finally {
128
+ act(() => root.unmount());
129
+ host.remove();
130
+ vi.useRealTimers();
131
+ vi.restoreAllMocks();
132
+ globalThis.ResizeObserver = originalResizeObserver;
133
+ }
134
+ });
135
+
21
136
  it("does not re-render the strip when the observer reports the size it already holds", () => {
22
137
  const originalResizeObserver = globalThis.ResizeObserver;
23
138
  globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
139
+ const runFrames = nextFrames();
24
140
  const host = document.createElement("div");
25
141
  document.body.append(host);
26
142
  Object.defineProperty(host, "clientWidth", { configurable: true, value: 300 });
@@ -45,14 +161,18 @@ it("does not re-render the strip when the observer reports the size it already h
45
161
  const settled = stripRenders;
46
162
 
47
163
  act(() => reportResize(300, 40));
164
+ runFrames();
48
165
  expect(stripRenders).toBe(settled);
49
166
 
50
167
  act(() => reportResize(320, 40));
168
+ runFrames();
51
169
  expect(stripRenders).toBe(settled + 1);
52
170
  expect(host.textContent).toBe("320x40");
53
171
  } finally {
54
172
  act(() => root.unmount());
55
173
  host.remove();
174
+ vi.useRealTimers();
175
+ vi.restoreAllMocks();
56
176
  globalThis.ResizeObserver = originalResizeObserver;
57
177
  }
58
178
  });
@@ -60,6 +180,7 @@ it("does not re-render the strip when the observer reports the size it already h
60
180
  it("measures a strip once a zoom preview ends, not while the preview scales it", () => {
61
181
  const originalResizeObserver = globalThis.ResizeObserver;
62
182
  globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
183
+ const runFrames = nextFrames();
63
184
  const host = document.createElement("div");
64
185
  document.body.append(host);
65
186
  let width = 300;
@@ -75,6 +196,7 @@ it("measures a strip once a zoom preview ends, not while the preview scales it",
75
196
  zoom.previewing = true;
76
197
  width = 600;
77
198
  act(() => reportResize(600, 40));
199
+ runFrames();
78
200
  expect(host.textContent).toBe("300x40");
79
201
  zoom.previewing = false;
80
202
  act(() => zoom.listeners.forEach((listener) => listener()));
@@ -83,6 +205,7 @@ it("measures a strip once a zoom preview ends, not while the preview scales it",
83
205
  zoom.previewing = false;
84
206
  act(() => root.unmount());
85
207
  host.remove();
208
+ vi.useRealTimers();
86
209
  globalThis.ResizeObserver = originalResizeObserver;
87
210
  }
88
211
  });
@@ -152,13 +152,20 @@ const measure = (entries: { target: Element; size?: { width: number; height: num
152
152
  const onPresence = (entries: IntersectionObserverEntry[]) =>
153
153
  measure(entries.filter((entry) => entry.isIntersecting));
154
154
 
155
- const onResize = (entries: ResizeObserverEntry[]) =>
156
- measure(
157
- entries.map(({ target, contentRect: { width, height } }) => ({
158
- target,
159
- size: { width, height },
160
- })),
161
- );
155
+ const pendingSizes = new Map<Element, { width: number; height: number }>();
156
+ let pendingSizesFrame = 0;
157
+ const applyPendingSizes = () => {
158
+ pendingSizesFrame = 0;
159
+ const entries = [...pendingSizes].map(([target, size]) => ({ target, size }));
160
+ pendingSizes.clear();
161
+ measure(entries);
162
+ };
163
+
164
+ const applyResizesNextFrame = (entries: ResizeObserverEntry[]) => {
165
+ for (const { target, contentRect } of entries)
166
+ pendingSizes.set(target, { width: contentRect.width, height: contentRect.height });
167
+ pendingSizesFrame ||= requestAnimationFrame(applyPendingSizes);
168
+ };
162
169
 
163
170
  const observeIntersections = (callback: IntersectionObserverCallback) =>
164
171
  typeof IntersectionObserver === "undefined"
@@ -168,7 +175,7 @@ const observeIntersections = (callback: IntersectionObserverCallback) =>
168
175
  function acquire() {
169
176
  if (users++ === 0) {
170
177
  shared = {
171
- resize: new ResizeObserver(onResize),
178
+ resize: new ResizeObserver(applyResizesNextFrame),
172
179
  presence: observeIntersections(onPresence),
173
180
  gaps: observeIntersections(
174
181
  (entries) => entries.some((entry) => entry.isIntersecting) && scheduleRefresh(),
@@ -190,6 +197,9 @@ function release() {
190
197
  window.removeEventListener("scroll", scheduleRefresh, { capture: true });
191
198
  cancelAnimationFrame(frame);
192
199
  frame = 0;
200
+ cancelAnimationFrame(pendingSizesFrame);
201
+ pendingSizesFrame = 0;
202
+ pendingSizes.clear();
193
203
  }
194
204
 
195
205
  const watchGap = (gap: HTMLDivElement | null) => {
@@ -234,6 +244,7 @@ export function useThumbnailStripSize() {
234
244
  resize.unobserve(target);
235
245
  presence?.unobserve(target);
236
246
  strips.delete(target);
247
+ pendingSizes.delete(target);
237
248
  release();
238
249
  };
239
250
  }, []);
@@ -47,6 +47,7 @@ beforeEach(() => {
47
47
  afterEach(() => {
48
48
  act(() => root?.unmount());
49
49
  root = null;
50
+ vi.useRealTimers();
50
51
  thumbnailScheduler.invalidateProject("/api/projects/demo/preview");
51
52
  globalThis.ResizeObserver = originalResizeObserver;
52
53
  globalThis.Image = originalImage;
@@ -175,7 +176,9 @@ describe("CompositionThumbnail", () => {
175
176
  await new Promise((resolve) => setTimeout(resolve, 0));
176
177
  });
177
178
 
179
+ vi.useFakeTimers({ toFake: ["requestAnimationFrame", "cancelAnimationFrame"] });
178
180
  act(() => reportResize(500, 40));
181
+ act(() => vi.advanceTimersToNextFrame());
179
182
 
180
183
  expect(host.querySelector("img")?.parentElement?.style.width).toBe("108px");
181
184
  });
@@ -2,7 +2,7 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import { MockResizeObserver, reportResize } from "../../hooks/resizeObserverTestUtils";
7
7
  import { thumbnailScheduler } from "../lib/thumbnailScheduler";
8
8
  import { ImageThumbnail } from "./ImageThumbnail";
@@ -64,6 +64,7 @@ beforeEach(() => {
64
64
  afterEach(() => {
65
65
  act(() => root?.unmount());
66
66
  root = null;
67
+ vi.useRealTimers();
67
68
  thumbnailScheduler.invalidateProject("p");
68
69
  globalThis.IntersectionObserver = originalIO;
69
70
  globalThis.ResizeObserver = originalRO;
@@ -187,7 +188,9 @@ describe("ImageThumbnail", () => {
187
188
  probe.onload?.();
188
189
  });
189
190
 
191
+ vi.useFakeTimers({ toFake: ["requestAnimationFrame", "cancelAnimationFrame"] });
190
192
  act(() => reportResize(500, 40));
193
+ act(() => vi.advanceTimersToNextFrame());
191
194
 
192
195
  expect(host.querySelector("img")?.parentElement?.style.width).toBe("108px");
193
196
  });
@@ -23,6 +23,7 @@ beforeEach(() => {
23
23
  });
24
24
 
25
25
  afterEach(() => {
26
+ vi.useRealTimers();
26
27
  thumbnailScheduler.invalidateProject("p");
27
28
  vi.clearAllMocks();
28
29
  });
@@ -243,8 +244,10 @@ describe("VideoThumbnail", () => {
243
244
  });
244
245
  await render(0, 0);
245
246
 
247
+ vi.useFakeTimers({ toFake: ["requestAnimationFrame", "cancelAnimationFrame"] });
248
+ act(() => reportResize(500, 40));
246
249
  await act(async () => {
247
- reportResize(500, 40);
250
+ vi.advanceTimersToNextFrame();
248
251
  await Promise.resolve();
249
252
  });
250
253
 
@@ -264,7 +267,9 @@ describe("VideoThumbnail", () => {
264
267
 
265
268
  describe("VideoThumbnail during a zoom", () => {
266
269
  beforeEach(() => {
267
- vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
270
+ vi.useFakeTimers({
271
+ toFake: ["setTimeout", "clearTimeout", "requestAnimationFrame", "cancelAnimationFrame"],
272
+ });
268
273
  vi.mocked(decodeVideoThumbnail).mockResolvedValue({
269
274
  value: { kind: "filmstrip", urls: ["blob:a", "blob:b"], aspect: 16 / 9 },
270
275
  weight: 256,
@@ -308,6 +313,7 @@ describe("VideoThumbnail during a zoom", () => {
308
313
  vi.mocked(decodeVideoThumbnail).mockImplementation(() => new Promise(() => {}));
309
314
  await act(async () => {
310
315
  reportResize(880, 40);
316
+ vi.advanceTimersToNextFrame();
311
317
  await Promise.resolve();
312
318
  });
313
319
  await rest();
@@ -326,6 +332,7 @@ describe("VideoThumbnail during a zoom", () => {
326
332
  vi.mocked(decodeVideoThumbnail).mockImplementation(() => new Promise(() => {}));
327
333
  await act(async () => {
328
334
  reportResize(880, 40);
335
+ vi.advanceTimersToNextFrame();
329
336
  root.render(clip(undefined, "overscan"));
330
337
  await Promise.resolve();
331
338
  });
@@ -371,6 +378,7 @@ describe("VideoThumbnail during a zoom", () => {
371
378
  vi.mocked(decodeVideoThumbnail).mockImplementation(() => new Promise(() => {}));
372
379
  await act(async () => {
373
380
  reportResize(880, 40);
381
+ vi.advanceTimersToNextFrame();
374
382
  await Promise.resolve();
375
383
  });
376
384
  await rest();
@@ -411,6 +419,7 @@ describe("VideoThumbnail during a zoom", () => {
411
419
  await render(440);
412
420
  await act(async () => {
413
421
  reportResize(50, 40);
422
+ vi.advanceTimersToNextFrame();
414
423
  await Promise.resolve();
415
424
  });
416
425
  await rest();
@@ -419,6 +428,7 @@ describe("VideoThumbnail during a zoom", () => {
419
428
  vi.mocked(decodeVideoThumbnail).mockImplementation(() => new Promise(() => {}));
420
429
  await act(async () => {
421
430
  reportResize(880, 40);
431
+ vi.advanceTimersToNextFrame();
422
432
  await Promise.resolve();
423
433
  });
424
434
  await rest();
@@ -431,6 +441,7 @@ describe("VideoThumbnail during a zoom", () => {
431
441
  await act(async () => {
432
442
  markTimelineMotion();
433
443
  reportResize(880, 40);
444
+ vi.advanceTimersToNextFrame();
434
445
  await Promise.resolve();
435
446
  });
436
447
  expect(decodeVideoThumbnail).not.toHaveBeenCalled();
@@ -1,6 +1,6 @@
1
1
  import { buildProjectApiPath } from "../../utils/projectRouting";
2
2
  import { resolvePreviewRelative } from "../../utils/previewRelativePath";
3
- import { resolveMediaPreviewUrl } from "./thumbnailUtils";
3
+ import { authoredSrcPath, resolveMediaPreviewUrl } from "./thumbnailUtils";
4
4
  import type { TimelineElement } from "../store/timelineElement";
5
5
  import { parseAutomation, VOLUME_TARGET } from "@hyperframes/core/audio-automation";
6
6
  import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
@@ -79,7 +79,7 @@ export async function requestNormalizePlan(
79
79
  ): Promise<NormalizePlan> {
80
80
  const origin = window.location.origin;
81
81
  const relative = resolvePreviewRelative(
82
- resolveMediaPreviewUrl(el.src ?? "", projectId, origin),
82
+ resolveMediaPreviewUrl(authoredSrcPath(el.src ?? ""), projectId, origin),
83
83
  projectId,
84
84
  origin,
85
85
  );
@@ -3,7 +3,7 @@ import { studioApiFetch } from "../../utils/studioApiFetch";
3
3
  import { resolvePreviewRelative } from "../../utils/previewRelativePath";
4
4
  import type { TimelineElement } from "../store/timelineElement";
5
5
  import { clipSourcePeak, isPeakMap, type ClipSourceWindow, type PeakMap } from "./clipPeakRuns";
6
- import { encodePreviewPath, resolveMediaPreviewUrl } from "./thumbnailUtils";
6
+ import { authoredSrcPath, encodePreviewPath, resolveMediaPreviewUrl } from "./thumbnailUtils";
7
7
 
8
8
  const peakMapRequests = new Map<string, Promise<PeakMap | null>>();
9
9
 
@@ -23,7 +23,7 @@ export function loadPeakMap(url: string): Promise<PeakMap | null> {
23
23
  export function clipPeaksUrl(src: string | undefined, projectId: string): string | undefined {
24
24
  const origin = window.location.origin;
25
25
  const relative = resolvePreviewRelative(
26
- resolveMediaPreviewUrl(src ?? "", projectId, origin),
26
+ resolveMediaPreviewUrl(authoredSrcPath(src ?? ""), projectId, origin),
27
27
  projectId,
28
28
  origin,
29
29
  );
@@ -1,5 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { buildTimelineAssetInsertHtml } from "../../utils/timelineAssetDrop";
2
3
  import {
4
+ authoredSrcPath,
3
5
  computeThumbnailStrip,
4
6
  encodePreviewPath,
5
7
  resolveMediaPreviewUrl,
@@ -187,3 +189,34 @@ describe("encodePreviewPath", () => {
187
189
  expect(encodePreviewPath("assets/music.mp3")).toBe("assets/music.mp3");
188
190
  });
189
191
  });
192
+
193
+ describe("authoredSrcPath", () => {
194
+ const droppedSrc = (assetPath: string) =>
195
+ /src="([^"]*)"/.exec(
196
+ buildTimelineAssetInsertHtml({
197
+ id: "clip",
198
+ hfId: "hf-clip",
199
+ assetPath,
200
+ kind: "video",
201
+ start: 0,
202
+ duration: 2,
203
+ track: 1,
204
+ zIndex: 1,
205
+ }),
206
+ )![1]!;
207
+
208
+ it.each([
209
+ ["assets/My clip.mp4", "/api/projects/p/preview/assets/My%20clip.mp4"],
210
+ ["assets/café.mp4", "/api/projects/p/preview/assets/caf%C3%A9.mp4"],
211
+ ["assets/50% off #1?.mp4", "/api/projects/p/preview/assets/50%25%20off%20%231%3F.mp4"],
212
+ ])("previews a dropped %j at its own file", (assetPath, url) => {
213
+ expect(resolveMediaPreviewUrl(authoredSrcPath(droppedSrc(assetPath)), "p")).toBe(url);
214
+ });
215
+
216
+ it("drops the query and fragment, and leaves URLs with a scheme alone", () => {
217
+ expect(authoredSrcPath("assets/a%20b.mp4?v=2#t=1")).toBe("assets/a b.mp4");
218
+ expect(authoredSrcPath("assets/100%.png")).toBe("assets/100%.png");
219
+ expect(authoredSrcPath("https://cdn.example/a%20b.mp4")).toBe("https://cdn.example/a%20b.mp4");
220
+ expect(authoredSrcPath("//cdn.example/a%20b.mp4")).toBe("//cdn.example/a%20b.mp4");
221
+ });
222
+ });
@@ -1,3 +1,4 @@
1
+ import { decodedUrlPath } from "@hyperframes/parsers";
1
2
  import { buildProjectApiPath } from "../../utils/projectRouting";
2
3
  import { MAX_VISIBLE_THUMBNAIL_FRAMES } from "../lib/timelineViewportBudgets";
3
4
 
@@ -94,6 +95,10 @@ export function computeThumbnailStrip(
94
95
  return { frameW, frameCount };
95
96
  }
96
97
 
98
+ export function authoredSrcPath(src: string): string {
99
+ return /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(src) ? src : decodedUrlPath(src);
100
+ }
101
+
97
102
  /**
98
103
  * Percent-encode each segment of a composition-relative media path so filenames
99
104
  * containing spaces, parentheses, a U+202F narrow no-break space (the macOS
@@ -13,6 +13,7 @@ import { createTimelineDomNodeResolver, findClipElementById } from "../lib/timel
13
13
  import { usePlayerStore } from "../store/playerStore";
14
14
  import type { TimelineElement, DomClipChild, SubCompositionHostState } from "../store/playerStore";
15
15
  import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
16
+ import { LOOP_INFLATED_TIMELINE_SECONDS } from "@hyperframes/core/runtime/composition-length";
16
17
  import type { ClipTree } from "@hyperframes/core/runtime/clipTree";
17
18
  import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups";
18
19
  import { groupInfoFor } from "../lib/timelineGroupInfo";
@@ -31,7 +32,7 @@ import { inspectStudioRuntimeMessage } from "../lib/runtimeProtocol";
31
32
 
32
33
  /** Reject non-finite, non-positive, and absurdly large (loop-inflated) values. */
33
34
  export function sanitizeDurationSeconds(value: number): number {
34
- return Number.isFinite(value) && value > 0 && value < 7200 ? value : 0;
35
+ return Number.isFinite(value) && value > 0 && value < LOOP_INFLATED_TIMELINE_SECONDS ? value : 0;
35
36
  }
36
37
 
37
38
  /**