@hyperframes/studio 0.8.141 → 0.8.143
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-5Vx2AiUz.js → hyperframes-player-BXSuksFh.js} +3 -3
- package/dist/assets/{index-BAnh7cSQ.js → index-BgOVCqjo.js} +1 -1
- package/dist/assets/{index-P8fgj_Xa.js → index-FgyO_5Kv.js} +1 -1
- package/dist/assets/{index-C0thd4eM.js → index-J7jpL4pj.js} +157 -157
- package/dist/index.html +1 -1
- package/dist/index.js +148 -175
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/MediaPreview.tsx +2 -1
- package/src/components/TimelineToolbar.test.tsx +1 -1
- package/src/components/editor/mediaAudioEdits.test.ts +12 -0
- package/src/components/editor/mediaAudioEdits.ts +5 -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/useColorGradingController.ts +2 -1
- 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/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/components/timelineLayout.test.ts +3 -3
- package/src/player/components/timelineZoomInput.test.ts +27 -6
- package/src/player/components/timelineZoomInput.ts +5 -1
- package/src/player/components/useTimelinePlayhead.test.tsx +90 -4
- package/src/player/components/useTimelinePlayhead.ts +18 -3
- package/src/player/components/useTimelineProviderState.tsx +1 -0
- package/src/player/lib/mediaProbe.test.ts +12 -0
- package/src/player/lib/mediaProbe.ts +4 -2
- package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- 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 +3 -16
|
@@ -42,6 +42,7 @@ function revealPlayheadScrollLeft(
|
|
|
42
42
|
interface UseTimelinePlayheadInput {
|
|
43
43
|
playheadRef: React.RefObject<HTMLDivElement | null>;
|
|
44
44
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
45
|
+
syncScrollViewport: (scroll: HTMLDivElement) => void;
|
|
45
46
|
ppsRef: React.RefObject<number>;
|
|
46
47
|
durationRef: React.RefObject<number>;
|
|
47
48
|
isDragging: React.RefObject<boolean>;
|
|
@@ -61,6 +62,7 @@ interface UseTimelinePlayheadInput {
|
|
|
61
62
|
export function useTimelinePlayhead({
|
|
62
63
|
playheadRef,
|
|
63
64
|
scrollRef,
|
|
65
|
+
syncScrollViewport,
|
|
64
66
|
ppsRef,
|
|
65
67
|
durationRef,
|
|
66
68
|
isDragging,
|
|
@@ -100,6 +102,7 @@ export function useTimelinePlayhead({
|
|
|
100
102
|
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
|
|
101
103
|
const left = anchor.time * pps + contentOrigin - anchor.x;
|
|
102
104
|
scroll.scrollLeft = Math.max(0, Math.min(maxScrollLeft, left));
|
|
105
|
+
syncScrollViewport(scroll);
|
|
103
106
|
return;
|
|
104
107
|
}
|
|
105
108
|
const zoomed = userZoomCount !== prevZoomCount;
|
|
@@ -121,7 +124,8 @@ export function useTimelinePlayhead({
|
|
|
121
124
|
scroll.scrollLeft = zoomed
|
|
122
125
|
? revealPlayheadScrollLeft(scroll, contentOrigin + time * pps, contentOrigin, anchored)
|
|
123
126
|
: anchored;
|
|
124
|
-
|
|
127
|
+
syncScrollViewport(scroll);
|
|
128
|
+
}, [pps, userZoomCount, scrollRef, durationRef, contentOrigin, syncScrollViewport]);
|
|
125
129
|
|
|
126
130
|
const syncPlayheadPosition = useCallback(
|
|
127
131
|
(time: number) => {
|
|
@@ -279,14 +283,25 @@ export function useTimelinePlayhead({
|
|
|
279
283
|
// A press meets the zoom it sees, not the one still waiting to be laid out.
|
|
280
284
|
scroll.addEventListener("pointerdown", settleTimelineZoom, { capture: true });
|
|
281
285
|
scroll.addEventListener("scroll", redrawTimelineZoomPreview, { passive: true });
|
|
282
|
-
const unregisterZoomViewport = registerTimelineZoomViewport({
|
|
286
|
+
const unregisterZoomViewport = registerTimelineZoomViewport({
|
|
287
|
+
scroll,
|
|
288
|
+
contentOrigin,
|
|
289
|
+
publishScroll: syncScrollViewport,
|
|
290
|
+
});
|
|
283
291
|
return () => {
|
|
284
292
|
scroll.removeEventListener("wheel", handlePinchWheel, { capture: true });
|
|
285
293
|
scroll.removeEventListener("pointerdown", settleTimelineZoom, { capture: true });
|
|
286
294
|
scroll.removeEventListener("scroll", redrawTimelineZoomPreview);
|
|
287
295
|
unregisterZoomViewport();
|
|
288
296
|
};
|
|
289
|
-
}, [
|
|
297
|
+
}, [
|
|
298
|
+
handlePinchWheel,
|
|
299
|
+
scrollRef,
|
|
300
|
+
timelineReady,
|
|
301
|
+
elementsLength,
|
|
302
|
+
contentOrigin,
|
|
303
|
+
syncScrollViewport,
|
|
304
|
+
]);
|
|
290
305
|
|
|
291
306
|
return { seekFromX, autoScrollDuringDrag, dragScrollRaf };
|
|
292
307
|
}
|
|
@@ -89,6 +89,18 @@ describe("media probe registry", () => {
|
|
|
89
89
|
expect(getDurationFromMetadata).toHaveBeenCalledTimes(2);
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
+
it("probes a dropped clip's URL-spelled src at its own file", async () => {
|
|
93
|
+
await probeMissingSourceDurations(
|
|
94
|
+
[{ id: "clip", tag: "video", src: "assets/My%20clip%20%231.mp4" }],
|
|
95
|
+
"project-a",
|
|
96
|
+
vi.fn(),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
expect(requestedSources).toEqual([
|
|
100
|
+
`${window.location.origin}/api/projects/project-a/preview/assets/My%20clip%20%231.mp4`,
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
92
104
|
it("probes same-origin rooted media through the active project preview", async () => {
|
|
93
105
|
const apply = vi.fn();
|
|
94
106
|
await probeMissingSourceDurations(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolveMediaPreviewUrl } from "../components/thumbnailUtils";
|
|
1
|
+
import { authoredSrcPath, resolveMediaPreviewUrl } from "../components/thumbnailUtils";
|
|
2
2
|
import { TIMELINE_VIEWPORT_BUDGETS } from "./timelineViewportBudgets";
|
|
3
3
|
|
|
4
4
|
export interface MediaProbeResult {
|
|
@@ -87,7 +87,9 @@ function getCachedProbe(url: string): MediaProbeResult | undefined {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
function resolveProbeSource(src: string, projectId: string | null): string {
|
|
90
|
-
return projectId
|
|
90
|
+
return projectId
|
|
91
|
+
? resolveMediaPreviewUrl(authoredSrcPath(src), projectId, window.location.origin)
|
|
92
|
+
: src;
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
function evictMetadataOverflow(): void {
|
|
@@ -9,7 +9,7 @@ describe("timeline viewport budgets", () => {
|
|
|
9
9
|
expect(TIMELINE_VIEWPORT_BUDGETS).toMatchObject({
|
|
10
10
|
directScrollSafetyPx: 8_000_000,
|
|
11
11
|
rowOverscanPerSide: 2,
|
|
12
|
-
timeOverscanViewportRatio: 0.
|
|
12
|
+
timeOverscanViewportRatio: 0.25,
|
|
13
13
|
maxMountedRows: 64,
|
|
14
14
|
maxMountedClipRoots: 512,
|
|
15
15
|
maxMountedClipRootsPerRow: 128,
|
|
@@ -61,7 +61,7 @@ export const MAX_VISIBLE_THUMBNAIL_FRAMES = Math.ceil(3840 / (66 * (16 / 9))); /
|
|
|
61
61
|
export const TIMELINE_VIEWPORT_BUDGETS: Readonly<TimelineViewportBudgets> = Object.freeze({
|
|
62
62
|
directScrollSafetyPx: 8_000_000,
|
|
63
63
|
rowOverscanPerSide: 2,
|
|
64
|
-
timeOverscanViewportRatio: 0.
|
|
64
|
+
timeOverscanViewportRatio: 0.25,
|
|
65
65
|
maxMountedRows: 64,
|
|
66
66
|
maxMountedClipRoots: 512,
|
|
67
67
|
maxMountedClipRootsPerRow: 128,
|
|
@@ -3,11 +3,43 @@ import {
|
|
|
3
3
|
applyPatch,
|
|
4
4
|
applyPatchByTarget,
|
|
5
5
|
readAttributeByTarget,
|
|
6
|
+
readTagAttribute,
|
|
6
7
|
readTagSnippetByTarget,
|
|
7
8
|
type PatchOperation,
|
|
8
9
|
} from "./sourcePatcher";
|
|
9
10
|
|
|
10
11
|
describe("applyPatchByTarget", () => {
|
|
12
|
+
it("removes all duplicate boolean attributes so unmuting cannot reveal another", () => {
|
|
13
|
+
expect(
|
|
14
|
+
applyPatch(`<video id="clip" muted muted></video>`, "clip", {
|
|
15
|
+
type: "html-attribute",
|
|
16
|
+
property: "muted",
|
|
17
|
+
value: null,
|
|
18
|
+
}),
|
|
19
|
+
).toBe(`<video id="clip"></video>`);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("locates Unicode custom tags and respects the first duplicate attribute", () => {
|
|
23
|
+
const html = `<my-élement id="real" id="other" data-duration="1"></my-élement>`;
|
|
24
|
+
const op: PatchOperation = { type: "attribute", property: "duration", value: "2" };
|
|
25
|
+
expect(applyPatch(html, "real", op)).toContain('data-duration="2"');
|
|
26
|
+
expect(applyPatch(html, "other", op)).toBe(html);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("reads and patches only the real source outside quoted attribute content", () => {
|
|
30
|
+
const html = `<video id="hero" title="example src='other.mp4'" data-src="lazy.mp4" src="assets/it's.mp4"></video>`;
|
|
31
|
+
expect(readTagAttribute(html, "src")).toBe("assets/it's.mp4");
|
|
32
|
+
const op: PatchOperation = { type: "html-attribute", property: "src", value: "new.mp4" };
|
|
33
|
+
const expected = `<video id="hero" title="example src='other.mp4'" data-src="lazy.mp4" src="new.mp4"></video>`;
|
|
34
|
+
expect(applyPatch(html, "hero", op)).toBe(expected);
|
|
35
|
+
expect(applyPatchByTarget(html, { id: "hero" }, op)).toBe(expected);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("does not locate a target through a prefixed or quoted id", () => {
|
|
39
|
+
const html = `<video data-id="hero" title="id='hero'" src="other.mp4"></video><video id="hero" src="real.mp4"></video>`;
|
|
40
|
+
expect(readTagSnippetByTarget(html, { id: "hero" })).toContain('src="real.mp4"');
|
|
41
|
+
});
|
|
42
|
+
|
|
11
43
|
it.each(['"', "'"])("patches long and multiline styles quoted with %s", (quote) => {
|
|
12
44
|
const otherQuote = quote === '"' ? "'" : '"';
|
|
13
45
|
const op: PatchOperation = { type: "inline-style", property: "opacity", value: "0.5" };
|
|
@@ -646,3 +678,106 @@ describe("T3 — hfId targeting (spec for R1)", () => {
|
|
|
646
678
|
expect(result).toContain('<h2 class="b">B</h2>');
|
|
647
679
|
});
|
|
648
680
|
});
|
|
681
|
+
|
|
682
|
+
describe("file names with special characters", () => {
|
|
683
|
+
const src = (value: string): PatchOperation => ({
|
|
684
|
+
type: "html-attribute",
|
|
685
|
+
property: "src",
|
|
686
|
+
value,
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("replaces a double-quoted value that holds an apostrophe instead of adding a second one", () => {
|
|
690
|
+
expect(applyPatch(`<img id="a" src="assets/it's.png">`, "a", src("assets/b.png"))).toBe(
|
|
691
|
+
`<img id="a" src="assets/b.png">`,
|
|
692
|
+
);
|
|
693
|
+
expect(applyPatch(`<img id="a" src='assets/say "hi".png'>`, "a", src("b.png"))).toBe(
|
|
694
|
+
`<img id="a" src="b.png">`,
|
|
695
|
+
);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("writes $ sequences in a value literally", () => {
|
|
699
|
+
expect(applyPatch(`<img id="a" src="old.png">`, "a", src("assets/$100 bill.png"))).toBe(
|
|
700
|
+
`<img id="a" src="assets/$100 bill.png">`,
|
|
701
|
+
);
|
|
702
|
+
expect(applyPatch(`<img id="a">`, "a", src("$&$1.png"))).toBe(
|
|
703
|
+
`<img id="a" src="$&$1.png">`,
|
|
704
|
+
);
|
|
705
|
+
expect(
|
|
706
|
+
applyPatch(`<div id="a" data-src="old">`, "a", {
|
|
707
|
+
type: "attribute",
|
|
708
|
+
property: "src",
|
|
709
|
+
value: "$' and $`",
|
|
710
|
+
}),
|
|
711
|
+
).toBe(`<div id="a" data-src="$' and $\`">`);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it("reads a data attribute whose value holds the other quote kind", () => {
|
|
715
|
+
expect(readAttributeByTarget(`<div id="a" data-title="it's here">`, { id: "a" }, "title")).toBe(
|
|
716
|
+
"it's here",
|
|
717
|
+
);
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
describe("readTagAttribute", () => {
|
|
722
|
+
it("reads both quote kinds and decodes entities without losing the other quote", () => {
|
|
723
|
+
expect(readTagAttribute(`<video src="assets/it's&ours.mp4">`, "src")).toBe(
|
|
724
|
+
"assets/it's&ours.mp4",
|
|
725
|
+
);
|
|
726
|
+
expect(readTagAttribute(`<video src='assets/say "hi".mp4'>`, "src")).toBe(
|
|
727
|
+
'assets/say "hi".mp4',
|
|
728
|
+
);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it("distinguishes an empty attribute from a missing attribute", () => {
|
|
732
|
+
expect(readTagAttribute('<video src="">', "src")).toBe("");
|
|
733
|
+
expect(readTagAttribute("<video>", "src")).toBeUndefined();
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
describe("whole attribute names", () => {
|
|
738
|
+
it.each(["data-src", "x-src", "xml:src", "srcset"])(
|
|
739
|
+
"reads and edits src without matching %s",
|
|
740
|
+
(other) => {
|
|
741
|
+
const tag = `<img id="a" ${other}="assets/other.png" src="assets/original.png">`;
|
|
742
|
+
expect(readTagAttribute(tag, "src")).toBe("assets/original.png");
|
|
743
|
+
for (const patch of [
|
|
744
|
+
(op: PatchOperation) => applyPatch(tag, "a", op),
|
|
745
|
+
(op: PatchOperation) => applyPatchByTarget(tag, { id: "a" }, op),
|
|
746
|
+
]) {
|
|
747
|
+
expect(patch({ type: "html-attribute", property: "src", value: "assets/new.png" })).toBe(
|
|
748
|
+
`<img id="a" ${other}="assets/other.png" src="assets/new.png">`,
|
|
749
|
+
);
|
|
750
|
+
expect(patch({ type: "html-attribute", property: "src", value: null })).toBe(
|
|
751
|
+
`<img id="a" ${other}="assets/other.png">`,
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
expect(readTagAttribute(`<img ${other}="assets/other.png">`, "src")).toBeUndefined();
|
|
755
|
+
},
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
it("does not treat a longer data attribute as a boolean attribute to remove", () => {
|
|
759
|
+
const tag = '<div id="a" data-title-long="keep">';
|
|
760
|
+
expect(
|
|
761
|
+
applyPatchByTarget(tag, { id: "a" }, { type: "attribute", property: "title", value: null }),
|
|
762
|
+
).toBe(tag);
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it("removes a boolean attribute without truncating a longer attribute name", () => {
|
|
767
|
+
const tag = '<video id="a" muted-long="keep" muted>';
|
|
768
|
+
expect(
|
|
769
|
+
applyPatchByTarget(
|
|
770
|
+
tag,
|
|
771
|
+
{ id: "a" },
|
|
772
|
+
{ type: "html-attribute", property: "muted", value: null },
|
|
773
|
+
),
|
|
774
|
+
).toBe('<video id="a" muted-long="keep">');
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it("decodes an authored apostrophe entity once", () => {
|
|
778
|
+
expect(readTagAttribute("<video src='assets/it's.mp4'>", "src")).toBe("assets/it's.mp4");
|
|
779
|
+
expect(readTagAttribute("<video src='assets/it's.mp4'>", "src")).toBe("assets/it's.mp4");
|
|
780
|
+
expect(readTagAttribute('<video src="assets/it&apos;s.mp4">', "src")).toBe(
|
|
781
|
+
"assets/it's.mp4",
|
|
782
|
+
);
|
|
783
|
+
});
|