@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.
Files changed (41) hide show
  1. package/dist/assets/{hyperframes-player-5Vx2AiUz.js → hyperframes-player-BXSuksFh.js} +3 -3
  2. package/dist/assets/{index-BAnh7cSQ.js → index-BgOVCqjo.js} +1 -1
  3. package/dist/assets/{index-P8fgj_Xa.js → index-FgyO_5Kv.js} +1 -1
  4. package/dist/assets/{index-C0thd4eM.js → index-J7jpL4pj.js} +157 -157
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +148 -175
  7. package/dist/index.js.map +1 -1
  8. package/package.json +8 -8
  9. package/src/components/MediaPreview.tsx +2 -1
  10. package/src/components/TimelineToolbar.test.tsx +1 -1
  11. package/src/components/editor/mediaAudioEdits.test.ts +12 -0
  12. package/src/components/editor/mediaAudioEdits.ts +5 -5
  13. package/src/components/editor/propertyPanelFill.tsx +2 -1
  14. package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -4
  15. package/src/components/editor/propertyPanelMediaSection.tsx +3 -4
  16. package/src/components/editor/useColorGradingController.ts +2 -1
  17. package/src/hooks/useDomEditCommits.ts +2 -1
  18. package/src/hooks/useFileManager.ts +2 -1
  19. package/src/hooks/useFileTree.ts +2 -1
  20. package/src/hooks/useRenderClipContent.ts +15 -3
  21. package/src/player/components/clipMenuNormalize.ts +2 -2
  22. package/src/player/components/clipPeakMap.ts +2 -2
  23. package/src/player/components/thumbnailUtils.test.ts +33 -0
  24. package/src/player/components/thumbnailUtils.ts +5 -0
  25. package/src/player/components/timelineLayout.test.ts +3 -3
  26. package/src/player/components/timelineZoomInput.test.ts +27 -6
  27. package/src/player/components/timelineZoomInput.ts +5 -1
  28. package/src/player/components/useTimelinePlayhead.test.tsx +90 -4
  29. package/src/player/components/useTimelinePlayhead.ts +18 -3
  30. package/src/player/components/useTimelineProviderState.tsx +1 -0
  31. package/src/player/lib/mediaProbe.test.ts +12 -0
  32. package/src/player/lib/mediaProbe.ts +4 -2
  33. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  34. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  35. package/src/utils/sourcePatcher.test.ts +135 -0
  36. package/src/utils/sourcePatcher.ts +102 -186
  37. package/src/utils/studioHelpers.test.ts +23 -0
  38. package/src/utils/studioHelpers.ts +3 -2
  39. package/src/utils/studioSelectionSnapshot.test.ts +31 -0
  40. package/src/utils/timelineAssetDrop.test.ts +14 -0
  41. 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
- }, [pps, userZoomCount, scrollRef, durationRef, contentOrigin]);
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({ scroll, contentOrigin });
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
- }, [handlePinchWheel, scrollRef, timelineReady, elementsLength, contentOrigin]);
297
+ }, [
298
+ handlePinchWheel,
299
+ scrollRef,
300
+ timelineReady,
301
+ elementsLength,
302
+ contentOrigin,
303
+ syncScrollViewport,
304
+ ]);
290
305
 
291
306
  return { seekFromX, autoScrollDuringDrag, dragScrollRaf };
292
307
  }
@@ -336,6 +336,7 @@ export function useTimelineProviderState({
336
336
  const { seekFromX, autoScrollDuringDrag, dragScrollRaf } = useTimelinePlayhead({
337
337
  playheadRef,
338
338
  scrollRef,
339
+ syncScrollViewport,
339
340
  ppsRef,
340
341
  durationRef,
341
342
  isDragging,
@@ -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 ? resolveMediaPreviewUrl(src, projectId, window.location.origin) : src;
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.5,
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.5,
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="$&amp;$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&amp;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&apos;s.mp4'>", "src")).toBe("assets/it's.mp4");
779
+ expect(readTagAttribute("<video src='assets/it&#39;s.mp4'>", "src")).toBe("assets/it's.mp4");
780
+ expect(readTagAttribute('<video src="assets/it&amp;apos;s.mp4">', "src")).toBe(
781
+ "assets/it&apos;s.mp4",
782
+ );
783
+ });