@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.
- package/dist/assets/hyperframes-player-Bw_jIaSX.js +473 -0
- package/dist/assets/{index-B-wffzRH.js → index-BVM9NuWl.js} +153 -153
- package/dist/assets/{index-D3Vij9t1.js → index-Cj7W_c7p.js} +1 -1
- package/dist/assets/{index-D5q2_7Gw.js → index-DyH9dT7b.js} +1 -1
- package/dist/{chunk-TFLU2MGI.js → chunk-3GHTHPZD.js} +8 -1
- package/dist/chunk-3GHTHPZD.js.map +1 -0
- package/dist/{chunk-H2DUOXTF.js → chunk-G3EDKRZH.js} +3 -3
- package/dist/{chunk-UYWON5AX.js → chunk-PEAKCOUW.js} +7 -3
- package/dist/chunk-PEAKCOUW.js.map +1 -0
- package/dist/{chunk-LOVBLSMR.js → chunk-Y2OHHNM2.js} +35 -19
- package/dist/chunk-Y2OHHNM2.js.map +1 -0
- package/dist/{domEditingLayers-KBTPQKGA.js → domEditingLayers-PYST4G45.js} +3 -3
- package/dist/index.html +1 -1
- package/dist/index.js +261 -230
- package/dist/index.js.map +1 -1
- package/dist/player.js +3 -3
- package/dist/ui.d.ts +4 -5
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/components/MediaPreview.tsx +2 -1
- package/src/components/editor/DomEditSelectionChrome.test.tsx +103 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +42 -16
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +36 -0
- package/src/components/editor/domEditOverlayGestures.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -0
- package/src/components/editor/domEditResizeLocal.test.ts +81 -1
- package/src/components/editor/domEditResizeLocal.ts +27 -3
- package/src/components/editor/domEditingLayers.ts +4 -1
- package/src/components/editor/domEditingTextFields.test.ts +18 -0
- package/src/components/editor/manualEditsDom.ts +12 -12
- package/src/components/editor/mediaAudioEdits.test.ts +12 -0
- package/src/components/editor/mediaAudioEdits.ts +5 -5
- package/src/components/editor/previewGestureStarts.ts +14 -5
- package/src/components/editor/propertyPanelFill.tsx +2 -1
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -4
- package/src/components/editor/propertyPanelMediaSection.tsx +3 -4
- package/src/components/editor/resizeDraft.ts +13 -4
- package/src/components/editor/useColorGradingController.ts +2 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +5 -3
- package/src/components/ui/Menu.test.tsx +67 -5
- package/src/components/ui/Menu.tsx +21 -4
- package/src/components/ui/Popover.tsx +11 -5
- package/src/hooks/gsapResizeIntercept.test.ts +46 -11
- package/src/hooks/gsapResizeIntercept.ts +25 -20
- package/src/hooks/useDomEditCommits.ts +2 -1
- package/src/hooks/useFileManager.ts +2 -1
- package/src/hooks/useFileTree.ts +2 -1
- package/src/hooks/useRenderClipContent.ts +15 -3
- package/src/hooks/useThumbnailStripSize.test.tsx +123 -0
- package/src/hooks/useThumbnailStripSize.ts +19 -8
- package/src/player/components/CompositionThumbnail.test.ts +3 -0
- package/src/player/components/ImageThumbnail.test.tsx +4 -1
- package/src/player/components/VideoThumbnail.test.tsx +13 -2
- package/src/player/components/clipMenuNormalize.ts +2 -2
- package/src/player/components/clipPeakMap.ts +2 -2
- package/src/player/components/thumbnailUtils.test.ts +33 -0
- package/src/player/components/thumbnailUtils.ts +5 -0
- package/src/player/hooks/timelineSyncHydration.ts +2 -1
- package/src/player/lib/mediaProbe.test.ts +12 -0
- package/src/player/lib/mediaProbe.ts +4 -2
- package/src/utils/authoredSource.test.ts +25 -0
- package/src/utils/authoredSource.ts +3 -5
- package/src/utils/rounding.ts +7 -0
- package/src/utils/sourcePatcher.test.ts +135 -0
- package/src/utils/sourcePatcher.ts +102 -186
- package/src/utils/studioHelpers.test.ts +23 -0
- package/src/utils/studioHelpers.ts +3 -2
- package/src/utils/studioSelectionSnapshot.test.ts +31 -0
- package/src/utils/timelineAssetDrop.test.ts +14 -0
- package/src/utils/timelineAssetDrop.ts +2 -1
- package/dist/assets/hyperframes-player-sAsRuV73.js +0 -473
- package/dist/chunk-LOVBLSMR.js.map +0 -1
- package/dist/chunk-TFLU2MGI.js.map +0 -1
- package/dist/chunk-UYWON5AX.js.map +0 -1
- /package/dist/{chunk-H2DUOXTF.js.map → chunk-G3EDKRZH.js.map} +0 -0
- /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
|
|
375
|
-
//
|
|
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
|
-
|
|
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:
|
|
400
|
-
//
|
|
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
|
|
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,
|
package/src/hooks/useFileTree.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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(
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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(
|
|
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
|
-
|
|
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({
|
|
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 <
|
|
35
|
+
return Number.isFinite(value) && value > 0 && value < LOOP_INFLATED_TIMELINE_SECONDS ? value : 0;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
/**
|