@hyperframes/studio 0.8.80 → 0.8.82

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 (110) hide show
  1. package/dist/assets/{hyperframes-player-CgnoxDoD.js → hyperframes-player-zGIbZhLJ.js} +1 -1
  2. package/dist/assets/{index-J9HXe7mU.js → index-BiLCTunu.js} +1 -1
  3. package/dist/assets/{index-CsG24Jub.js → index-DSazIt7R.js} +198 -198
  4. package/dist/assets/{index-DZGVvqDT.js → index-DStmfnpZ.js} +1 -1
  5. package/dist/index.d.ts +172 -7
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +2567 -2314
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/App.tsx +8 -9
  11. package/src/components/StudioRightPanels.tsx +4 -48
  12. package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
  13. package/src/components/TimelineHistoryButtons.tsx +26 -14
  14. package/src/components/TimelineToolbar.test.tsx +9 -1
  15. package/src/components/TimelineToolbar.tsx +13 -5
  16. package/src/components/editor/BlockParamsPanel.tsx +14 -10
  17. package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
  18. package/src/components/editor/manualEdits.test.ts +24 -0
  19. package/src/components/editor/manualEdits.ts +6 -2
  20. package/src/components/editor/manualEditsDom.ts +0 -65
  21. package/src/components/editor/manualEditsSeekReapply.ts +136 -0
  22. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +71 -32
  23. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
  24. package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
  25. package/src/components/editor/propertyPanelHelpers.ts +15 -0
  26. package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
  27. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
  28. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
  29. package/src/components/editor/useFxLevelling.ts +35 -10
  30. package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
  31. package/src/components/nle/AudioMeterStrip.tsx +23 -12
  32. package/src/components/panels/VariablesOtherCompositions.tsx +4 -4
  33. package/src/components/panels/VariablesPanel.test.tsx +86 -0
  34. package/src/components/panels/VariablesPanel.tsx +3 -3
  35. package/src/components/pointerTargetSize.test.tsx +1 -1
  36. package/src/components/sidebar/SidebarLintButton.test.tsx +29 -0
  37. package/src/components/sidebar/SidebarLintButton.tsx +4 -0
  38. package/src/components/studioColorGradingScope.ts +16 -42
  39. package/src/contexts/TimelineEditContext.test.tsx +49 -0
  40. package/src/contexts/TimelineEditContext.tsx +27 -23
  41. package/src/hooks/canvasQuickSaves.test.tsx +195 -0
  42. package/src/hooks/gsapMutationClient.ts +116 -0
  43. package/src/hooks/resizeObserverTestUtils.ts +14 -0
  44. package/src/hooks/timelineAudioGroupCreate.ts +18 -40
  45. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  46. package/src/hooks/timelineEditingHelpers.ts +66 -61
  47. package/src/hooks/timelineQuickSaves.test.ts +195 -0
  48. package/src/hooks/timelineTimingSync.test.ts +12 -0
  49. package/src/hooks/timelineTimingSync.ts +31 -123
  50. package/src/hooks/timelineTrackVisibility.ts +15 -29
  51. package/src/hooks/useApplyColorGradingScope.test.tsx +53 -0
  52. package/src/hooks/useApplyColorGradingScope.ts +54 -0
  53. package/src/hooks/useAskAgentModal.test.tsx +78 -0
  54. package/src/hooks/useAskAgentModal.ts +3 -1
  55. package/src/hooks/useClipboard.source.test.ts +32 -0
  56. package/src/hooks/useClipboard.ts +21 -20
  57. package/src/hooks/useDomEditCommits.test.tsx +13 -9
  58. package/src/hooks/useDomEditCommits.ts +88 -97
  59. package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
  60. package/src/hooks/useEditorSave.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.ts +36 -37
  62. package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
  63. package/src/hooks/useFileManager.ts +21 -12
  64. package/src/hooks/useGroupCommits.ts +23 -24
  65. package/src/hooks/useGsapAnimationOps.ts +26 -8
  66. package/src/hooks/useGsapScriptCommits.ts +1 -0
  67. package/src/hooks/useLintModal.test.tsx +15 -0
  68. package/src/hooks/useLintModal.ts +5 -2
  69. package/src/hooks/useProjectCompositionVariables.ts +3 -3
  70. package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
  71. package/src/hooks/useThumbnailStripSize.ts +31 -0
  72. package/src/hooks/useTimelineAssetDropOps.ts +38 -35
  73. package/src/hooks/useTimelineDeleteOps.ts +53 -56
  74. package/src/hooks/useTimelineEditing.ts +3 -1
  75. package/src/hooks/useTimelineGroupEditing.ts +5 -1
  76. package/src/index.ts +8 -0
  77. package/src/player/components/CompositionThumbnail.test.ts +40 -6
  78. package/src/player/components/CompositionThumbnail.tsx +6 -21
  79. package/src/player/components/ImageThumbnail.test.tsx +27 -6
  80. package/src/player/components/ImageThumbnail.tsx +4 -18
  81. package/src/player/components/PlayerControls.test.tsx +52 -0
  82. package/src/player/components/PlayerControls.tsx +2 -1
  83. package/src/player/components/Timeline.test.ts +28 -3
  84. package/src/player/components/TimelineCanvas.tsx +5 -3
  85. package/src/player/components/VideoThumbnail.test.tsx +33 -11
  86. package/src/player/components/VideoThumbnail.tsx +7 -29
  87. package/src/player/components/thumbnailUtils.test.ts +16 -20
  88. package/src/player/components/thumbnailUtils.ts +6 -7
  89. package/src/player/components/timelineEditing.test.ts +55 -0
  90. package/src/player/components/timelineGroupEditing.ts +13 -6
  91. package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
  92. package/src/player/components/timelinePlayheadTransform.ts +17 -0
  93. package/src/player/components/useTimelinePlayhead.ts +26 -8
  94. package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
  95. package/src/player/hooks/timelinePlayerSync.ts +18 -8
  96. package/src/player/hooks/useShadowPreviewReload.ts +15 -4
  97. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
  98. package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
  99. package/src/player/lib/playbackShortcuts.ts +18 -0
  100. package/src/player/lib/playbackTypes.ts +1 -0
  101. package/src/player/lib/timelineDOM.test.ts +92 -0
  102. package/src/player/lib/timelineDOM.ts +0 -2
  103. package/src/player/lib/timelineElementHelpers.ts +47 -10
  104. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  105. package/src/player/store/timelineElement.ts +2 -1
  106. package/src/timelineChromeExports.test.tsx +79 -0
  107. package/src/utils/authoredSource.ts +15 -0
  108. package/src/utils/blockInstaller.ts +32 -27
  109. package/src/utils/studioFileHistory.test.ts +14 -14
  110. package/src/utils/studioFileHistory.ts +31 -2
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { Window } from "happy-dom";
3
3
  import {
4
4
  buildStandaloneRootTimelineElement,
@@ -597,3 +597,95 @@ describe("shouldIgnorePlaybackShortcutEvent", () => {
597
597
  ).toBe(false);
598
598
  });
599
599
  });
600
+
601
+ describe("shouldIgnorePlaybackShortcutEvent while a modal dialog is open", () => {
602
+ afterEach(() => {
603
+ vi.unstubAllGlobals();
604
+ });
605
+
606
+ // happy-dom has no layout, so the `hidden` attribute stands in for checkVisibility.
607
+ function stubStudioDocument(markup: string): Document {
608
+ const document = createDocument(markup);
609
+ const elementProto = document.defaultView!.Element.prototype as {
610
+ checkVisibility?: (this: Element) => boolean;
611
+ };
612
+ elementProto.checkVisibility = function () {
613
+ return this.closest("[hidden]") === null;
614
+ };
615
+ vi.stubGlobal("document", document);
616
+ return document;
617
+ }
618
+
619
+ it("ignores playback keys with focus still on the page behind the dialog", () => {
620
+ const document = stubStudioDocument(
621
+ `<div role="dialog" aria-modal="true"><p>Lint results</p></div>`,
622
+ );
623
+ for (const code of ["Space", "ArrowRight", "KeyL"]) {
624
+ expect(
625
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent(code, { target: document.body })),
626
+ ).toBe(true);
627
+ }
628
+ });
629
+
630
+ it("ignores playback keys with focus inside the dialog", () => {
631
+ const document = stubStudioDocument(
632
+ `<div role="dialog" aria-modal="true" tabindex="-1"><p>Lint results</p></div>`,
633
+ );
634
+ const dialog = document.querySelector("[role='dialog']");
635
+ const text = document.querySelector("p");
636
+ expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("Space", { target: dialog }))).toBe(
637
+ true,
638
+ );
639
+ expect(
640
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft", { target: text })),
641
+ ).toBe(true);
642
+ });
643
+
644
+ it("keeps playback keys for dialogs that are hidden, inert or not modal", () => {
645
+ for (const markup of [
646
+ `<div role="dialog" aria-modal="true" hidden></div>`,
647
+ `<div inert><div role="dialog" aria-modal="true"></div></div>`,
648
+ `<div role="dialog"></div>`,
649
+ ]) {
650
+ const document = stubStudioDocument(markup);
651
+ expect(
652
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("Space", { target: document.body })),
653
+ ).toBe(false);
654
+ }
655
+ });
656
+
657
+ it("counts a dialog under fullscreen only when the fullscreen element holds it", () => {
658
+ const document = stubStudioDocument(
659
+ `<div id="stage"><div id="inner" role="dialog" aria-modal="true"></div></div>` +
660
+ `<div id="outer" role="dialog" aria-modal="true"></div>`,
661
+ );
662
+ const space = () =>
663
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("Space", { target: document.body }));
664
+ const fullscreenOn = (id: string) =>
665
+ Object.defineProperty(document, "fullscreenElement", {
666
+ configurable: true,
667
+ value: document.getElementById(id),
668
+ });
669
+ fullscreenOn("stage");
670
+ expect(space()).toBe(true);
671
+ document.getElementById("inner")!.remove();
672
+ expect(space()).toBe(false);
673
+ });
674
+
675
+ it("counts an aria-modal dialog where the browser has no checkVisibility", () => {
676
+ const document = createDocument(`<div role="dialog" aria-modal="true"></div>`);
677
+ delete (document.defaultView!.Element.prototype as { checkVisibility?: unknown })
678
+ .checkVisibility;
679
+ vi.stubGlobal("document", document);
680
+ expect(
681
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("Space", { target: document.body })),
682
+ ).toBe(true);
683
+ });
684
+
685
+ it("keeps playback keys when no dialog is open", () => {
686
+ const document = stubStudioDocument(`<main><p>Preview</p></main>`);
687
+ expect(
688
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("Space", { target: document.body })),
689
+ ).toBe(false);
690
+ });
691
+ });
@@ -41,6 +41,23 @@ export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null):
41
41
  );
42
42
  }
43
43
 
44
+ const MODAL_DIALOG_SELECTOR = "[role=dialog][aria-modal=true]";
45
+
46
+ // An open modal owns the keyboard wherever focus sits, preview iframe included. It counts only
47
+ // when shown: visible, not inert, and not behind a fullscreen element that leaves it out.
48
+ function isModalDialogOpen(): boolean {
49
+ const doc = globalThis.document;
50
+ if (!doc) return false;
51
+ const fullscreen = doc.fullscreenElement;
52
+ return Array.from(doc.querySelectorAll(MODAL_DIALOG_SELECTOR)).some(
53
+ (dialog) =>
54
+ (!fullscreen || fullscreen.contains(dialog)) &&
55
+ !dialog.closest("[inert]") &&
56
+ (typeof dialog.checkVisibility !== "function" ||
57
+ dialog.checkVisibility({ visibilityProperty: true })),
58
+ );
59
+ }
60
+
44
61
  interface PlaybackShortcutCaptionState {
45
62
  isCaptionEditMode: boolean;
46
63
  selectedCaptionSegmentCount: number;
@@ -60,6 +77,7 @@ export function shouldIgnorePlaybackShortcutEvent(
60
77
  ): boolean {
61
78
  if (event.metaKey || event.ctrlKey || event.altKey) return true;
62
79
  if (shouldIgnorePlaybackShortcutTarget(event.target)) return true;
80
+ if (isModalDialogOpen()) return true;
63
81
  return (
64
82
  PLAYBACK_FRAME_STEP_CODES.has(event.code) &&
65
83
  captionState.isCaptionEditMode &&
@@ -62,6 +62,7 @@ export type IframeWindow = Window & {
62
62
  __timeline?: TimelineLike;
63
63
  __timelines?: Record<string, TimelineLike>;
64
64
  __clipManifest?: ClipManifest;
65
+ __hfWaitForSeekCompletion?: () => Promise<void>;
65
66
  /** Declared runtime-side in core's window.d.ts, which this package cannot see.
66
67
  * Every member stays optional and is optional-called, so a runtime that
67
68
  * predates the hook degrades to a no-op instead of throwing. */
@@ -9,6 +9,7 @@ import {
9
9
  import { isTimelineIgnoredElement } from "./timelineElementHelpers";
10
10
  import { computeResizePreview } from "../components/timelineClipDragPreview";
11
11
  import type { TimelineElement } from "../store/playerStore";
12
+ import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
12
13
 
13
14
  function el(id: string, extra: Partial<TimelineElement> = {}): TimelineElement {
14
15
  return { id, tag: "img", start: 0, duration: 5, track: 0, ...extra };
@@ -46,6 +47,26 @@ describe("parseTimelineFromDOM — media in-point", () => {
46
47
  });
47
48
  });
48
49
 
50
+ describe("parseTimelineFromDOM — in-point read as playback reads it", () => {
51
+ it.each([
52
+ ['data-playback-start="-1" data-media-start="2"', 2, "playback-start"],
53
+ ['data-playback-start="abc" data-media-start="2"', 2, "playback-start"],
54
+ ['data-media-start="junk"', 0, "media-start"],
55
+ ['data-media-start="1.5s"', 0, "media-start"],
56
+ ])("%s", (inPoint, playbackStart, playbackStartAttr) => {
57
+ const doc = makeDoc(
58
+ `<div data-composition-id="root"><video id="v" class="clip" data-start="0" data-duration="4" ${inPoint}></video></div>`,
59
+ );
60
+ const element = parseTimelineFromDOM(doc, 10).find((e) => e.domId === "v")!;
61
+ const video = doc.getElementById("v")!;
62
+ expect(element.playbackStart).toBe(readMediaOffsetSeconds((n) => video.getAttribute(n)));
63
+ expect([element.playbackStart, element.playbackStartAttr]).toEqual([
64
+ playbackStart,
65
+ playbackStartAttr,
66
+ ]);
67
+ });
68
+ });
69
+
49
70
  describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
50
71
  it("bridges a real GSAP transition marker onto both named clips", () => {
51
72
  document.body.innerHTML = `
@@ -526,3 +547,74 @@ describe("a composition clip's source length", () => {
526
547
  expect(card?.sourceDuration).toBeUndefined();
527
548
  });
528
549
  });
550
+
551
+ describe("what the live clip list says a clip plays", () => {
552
+ function manifestVideo(attrs: string): TimelineElement {
553
+ const doc = makeDoc(
554
+ `<div data-composition-id="root"><video id="v" src="a.mp4" data-start="0" data-duration="4" ${attrs}></video></div>`,
555
+ );
556
+ return createTimelineElementFromManifestClip({
557
+ clip: {
558
+ id: "v",
559
+ label: "v",
560
+ kind: "video",
561
+ tagName: "video",
562
+ start: 0,
563
+ duration: 4,
564
+ track: 0,
565
+ assetUrl: null,
566
+ },
567
+ fallbackIndex: 0,
568
+ doc,
569
+ hostEl: doc.getElementById("v"),
570
+ });
571
+ }
572
+
573
+ it("carries data-volume and muted from the element", () => {
574
+ expect(manifestVideo('data-volume="0" muted')).toMatchObject({ volume: 0, muted: true });
575
+ const plain = manifestVideo("");
576
+ expect(plain.volume).toBeUndefined();
577
+ expect(plain.muted).toBeUndefined();
578
+ });
579
+
580
+ it("carries the clip-edge fades, and drops a zero one", () => {
581
+ const faded = manifestVideo('data-fade-in="1.5" data-fade-out="0"');
582
+ expect(faded.fadeIn).toBe(1.5);
583
+ expect(faded.fadeOut).toBeUndefined();
584
+ });
585
+
586
+ it("gives a video with neither muted nor data-has-audio sound, as the compiler does", () => {
587
+ expect(manifestVideo("").hasAudio).toBe(true);
588
+ expect(manifestVideo("muted").hasAudio).toBeUndefined();
589
+ expect(manifestVideo('data-has-audio="false"').hasAudio).toBeUndefined();
590
+ expect(manifestVideo('muted data-has-audio="true"')).toMatchObject({
591
+ hasAudio: true,
592
+ muted: true,
593
+ });
594
+ });
595
+
596
+ it.each(["", ' data-has-audio="true"'])(
597
+ "does not give a timed wrapper the sound of an untimed video%s inside it",
598
+ (videoAttrs) => {
599
+ const doc = makeDoc(
600
+ `<div data-composition-id="root"><div id="w" data-start="0" data-duration="4"><video src="a.mp4"${videoAttrs}></video></div></div>`,
601
+ );
602
+ const wrapper = createTimelineElementFromManifestClip({
603
+ clip: {
604
+ id: "w",
605
+ label: "w",
606
+ kind: "element",
607
+ tagName: "div",
608
+ start: 0,
609
+ duration: 4,
610
+ track: 0,
611
+ assetUrl: null,
612
+ },
613
+ fallbackIndex: 0,
614
+ doc,
615
+ hostEl: doc.getElementById("w"),
616
+ });
617
+ expect(wrapper.hasAudio).toBeUndefined();
618
+ },
619
+ );
620
+ });
@@ -304,8 +304,6 @@ export function parseTimelineFromDOM(
304
304
  if (mediaEl.tagName === "IMG") {
305
305
  entry.tag = "img";
306
306
  }
307
- const vol = el.getAttribute("data-volume") ?? mediaEl.getAttribute("data-volume");
308
- if (vol) entry.volume = parseFloat(vol);
309
307
  // Override AFTER the helper (which sets the raw relative attribute) so the
310
308
  // resolved absolute URL wins — the Studio can then fetch the asset
311
309
  // regardless of whether the attribute value was relative or absolute.
@@ -13,7 +13,7 @@ import { isFinitePositive } from "./playbackAdapter";
13
13
  import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
14
14
  import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
15
15
  import { readElementFades } from "@hyperframes/core/audio-fade";
16
- import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
16
+ import { type AttrReader, readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
17
17
 
18
18
  // ---------------------------------------------------------------------------
19
19
  // Layer-reveal lift transparency
@@ -168,14 +168,34 @@ export function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageEl
168
168
  : null;
169
169
  }
170
170
 
171
+ /** The in-point as playback reads it, and the attribute holding it; empty when neither is authored. */
172
+ export function readPlaybackStartAttributes(
173
+ getAttr: AttrReader,
174
+ ): Pick<TimelineElement, "playbackStart" | "playbackStartAttr"> {
175
+ const playbackStartAttr =
176
+ getAttr("data-playback-start") != null
177
+ ? "playback-start"
178
+ : getAttr("data-media-start") != null
179
+ ? "media-start"
180
+ : undefined;
181
+ return playbackStartAttr
182
+ ? { playbackStart: readMediaOffsetSeconds(getAttr), playbackStartAttr }
183
+ : {};
184
+ }
185
+
186
+ export function playbackStartAttributeForElement(
187
+ element: Pick<TimelineElement, "kind" | "playbackStartAttr">,
188
+ ): "data-media-start" | "data-playback-start" {
189
+ return element.playbackStartAttr === "playback-start" || element.kind === "composition"
190
+ ? "data-playback-start"
191
+ : "data-media-start";
192
+ }
193
+
171
194
  function applyPlaybackMetadataFromElement(entry: TimelineElement, el: Element): void {
172
- const playbackStartValue = el.getAttribute("data-playback-start");
173
- const legacyMediaStartValue = el.getAttribute("data-media-start");
174
- const mediaStartValue = playbackStartValue ?? legacyMediaStartValue;
175
- if (mediaStartValue != null)
176
- entry.playbackStart = readMediaOffsetSeconds((n) => el.getAttribute(n));
177
- if (playbackStartValue != null) entry.playbackStartAttr = "playback-start";
178
- else if (legacyMediaStartValue != null) entry.playbackStartAttr = "media-start";
195
+ Object.assign(
196
+ entry,
197
+ readPlaybackStartAttributes((n) => el.getAttribute(n)),
198
+ );
179
199
 
180
200
  const authoredPlaybackRate = Number.parseFloat(el.getAttribute("data-playback-rate") ?? "");
181
201
  if (Number.isFinite(authoredPlaybackRate) && authoredPlaybackRate > 0) {
@@ -193,9 +213,25 @@ function setOptional<K extends keyof TimelineElement>(
193
213
  else entry[key] = value;
194
214
  }
195
215
 
196
- /** `data-has-audio` and the clip-edge fades: what the mixer hears and how it enters and leaves. */
216
+ function readVolume(el: Element, media: Element): number | undefined {
217
+ const volume = Number.parseFloat(
218
+ el.getAttribute("data-volume") ?? media.getAttribute("data-volume") ?? "",
219
+ );
220
+ return Number.isFinite(volume) ? volume : undefined;
221
+ }
222
+
223
+ /** What the mixer gets: the compiler's `data-has-audio` rule, muted and volume. */
197
224
  function applyAudioMetadataFromElement(entry: TimelineElement, el: Element): void {
198
- setOptional(entry, "hasAudio", el.getAttribute("data-has-audio") === "true" ? true : undefined);
225
+ const media = resolveMediaElement(el) ?? el;
226
+ const muted = el.hasAttribute("muted") || media.hasAttribute("muted");
227
+ const hasAudio = el.getAttribute("data-has-audio");
228
+ const sound = hasAudio === null ? el.tagName === "VIDEO" && !muted : hasAudio === "true";
229
+ setOptional(entry, "hasAudio", sound ? true : undefined);
230
+ setOptional(entry, "muted", muted ? true : undefined);
231
+ setOptional(entry, "volume", readVolume(el, media));
232
+ }
233
+
234
+ function applyFadeMetadataFromElement(entry: TimelineElement, el: Element): void {
199
235
  const fades = readElementFades(el);
200
236
  setOptional(entry, "fadeIn", fades.fadeIn > 0 ? fades.fadeIn : undefined);
201
237
  setOptional(entry, "fadeOut", fades.fadeOut > 0 ? fades.fadeOut : undefined);
@@ -204,6 +240,7 @@ function applyAudioMetadataFromElement(entry: TimelineElement, el: Element): voi
204
240
  export function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
205
241
  applyPlaybackMetadataFromElement(entry, el);
206
242
  applyAudioMetadataFromElement(entry, el);
243
+ applyFadeMetadataFromElement(entry, el);
207
244
 
208
245
  const mediaEl = resolveMediaElement(el);
209
246
  if (!mediaEl) return;
@@ -49,7 +49,7 @@ export interface TimelineViewportBudgets {
49
49
 
50
50
  const MEBIBYTE = 1024 * 1024;
51
51
  const DAY_MS = 24 * 60 * 60 * 1000;
52
- export const MAX_VISIBLE_THUMBNAIL_FRAMES = Math.ceil(3840 / (66 * (16 / 9))); // 4K timeline width / tallest 16:9 tile.
52
+ export const MAX_VISIBLE_THUMBNAIL_FRAMES = Math.ceil(3840 / (66 * (16 / 9))); // Decode-memory cap, 4K width / a 66 px 16:9 tile; 40 px clips reuse frames past it.
53
53
 
54
54
  /**
55
55
  * The sole default budget owner for timeline viewport and media virtualization.
@@ -55,8 +55,9 @@ export interface TimelineElement {
55
55
  playbackRate?: number;
56
56
  sourceDuration?: number;
57
57
  volume?: number;
58
- /** `data-has-audio="true"` on a video: its sound is mixed, so it takes audio affordances (fades). */
58
+ /** A video with sound to mix (`data-has-audio`, or unmuted without it); `muted` silences it. */
59
59
  hasAudio?: boolean;
60
+ muted?: boolean;
60
61
  /** Clip-edge fades from `data-fade-in` / `data-fade-out`, seconds; absent means none. */
61
62
  fadeIn?: number;
62
63
  fadeOut?: number;
@@ -0,0 +1,79 @@
1
+ // @vitest-environment happy-dom
2
+ // Mounts the timeline chrome by package name with no StudioShellProvider, as a host app does.
3
+ import { act } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ AudioMeterStrip,
7
+ TimelineHistoryButtons,
8
+ TimelineToolbar,
9
+ useClipboard,
10
+ usePlayerStore,
11
+ type UseClipboardOptions,
12
+ } from "@hyperframes/studio";
13
+ import { installReactActEnvironment, mountReactHarness } from "./hooks/domSelectionTestHarness";
14
+ import { useAudioMetersVisible } from "./utils/audioMeterVisibility";
15
+
16
+ installReactActEnvironment();
17
+
18
+ afterEach(() => {
19
+ document.body.innerHTML = "";
20
+ usePlayerStore.setState({ elements: [], selectedElementId: null });
21
+ useAudioMetersVisible.setState(useAudioMetersVisible.getInitialState());
22
+ });
23
+
24
+ describe("timeline chrome package exports, outside Studio's shell", () => {
25
+ it("mounts the toolbar with the host's history and no Add beat", async () => {
26
+ const onUndo = vi.fn();
27
+ const root = mountReactHarness(
28
+ <TimelineToolbar history={{ canUndo: true, onUndo }} showAddBeat={false} />,
29
+ );
30
+ const undo = document.querySelector<HTMLButtonElement>('button[aria-label="Undo"]');
31
+ act(() => undo?.click());
32
+ expect(onUndo).toHaveBeenCalledTimes(1);
33
+ expect(document.querySelector('button[aria-label="Add beat at playhead"]')).toBeNull();
34
+ await act(async () => root.unmount());
35
+ });
36
+
37
+ it("mounts the history buttons disabled when given nothing", async () => {
38
+ const root = mountReactHarness(<TimelineHistoryButtons />);
39
+ const buttons = Array.from(document.querySelectorAll("button"));
40
+ expect(buttons.map((b) => b.disabled)).toEqual([true, true]);
41
+ await act(async () => root.unmount());
42
+ });
43
+
44
+ it("mounts the meter strip on the host's preview iframe", async () => {
45
+ usePlayerStore.setState({
46
+ elements: [{ id: "music", tag: "audio", start: 0, duration: 4, track: 0 }],
47
+ });
48
+ useAudioMetersVisible.setState({ visible: true });
49
+ const root = mountReactHarness(<AudioMeterStrip previewIframeRef={{ current: null }} />);
50
+ expect(document.querySelector('[data-testid="audio-meter-strip"]')).not.toBeNull();
51
+ await act(async () => root.unmount());
52
+ });
53
+
54
+ it("runs the clipboard with no DOM edit session", async () => {
55
+ const toasts: string[] = [];
56
+ const options: UseClipboardOptions = {
57
+ projectId: "p",
58
+ activeCompPath: "index.html",
59
+ domEditSelectionRef: { current: null },
60
+ showToast: (message) => toasts.push(message),
61
+ writeProjectFile: async () => {},
62
+ recordEdit: async () => {},
63
+ reloadPreview: () => {},
64
+ handleTimelineElementsDelete: async () => {},
65
+ handleDomEditElementDelete: async () => {},
66
+ previewIframeRef: { current: null },
67
+ waitForPendingDomEditSaves: async () => {},
68
+ };
69
+ const api: { current: ReturnType<typeof useClipboard> | null } = { current: null };
70
+ function Harness() {
71
+ api.current = useClipboard(options);
72
+ return null;
73
+ }
74
+ const root = mountReactHarness(<Harness />);
75
+ expect(api.current?.handleCopy()).toBe(false);
76
+ expect(toasts).toEqual(["Nothing selected to copy."]);
77
+ await act(async () => root.unmount());
78
+ });
79
+ });
@@ -1,4 +1,8 @@
1
1
  import { ensureHfIds, isCompositionTemplate } from "@hyperframes/parsers/hf-ids";
2
+ import {
3
+ STUDIO_PREVIEW_LAZY_ATTR,
4
+ STUDIO_PREVIEW_UPCOMING_ATTR,
5
+ } from "@hyperframes/core/studio-preview-mark";
2
6
 
3
7
  // Stamped as the preview stamps the files it serves, so a live element's hf-id finds its source.
4
8
  export function parseSavedSource(html: string): Document {
@@ -90,3 +94,14 @@ export function authoredMarkup(authored: Element, live: Element, sourceFile: str
90
94
  }
91
95
  return copy.outerHTML;
92
96
  }
97
+
98
+ export function liveMarkupWithoutPreviewMarks(live: Element): string {
99
+ const copy = live.cloneNode(true) as Element;
100
+ for (const el of [copy, ...Array.from(copy.querySelectorAll("*"))]) {
101
+ el.removeAttribute(STUDIO_PREVIEW_UPCOMING_ATTR);
102
+ if (!el.hasAttribute(STUDIO_PREVIEW_LAZY_ATTR)) continue;
103
+ el.removeAttribute("loading");
104
+ el.removeAttribute(STUDIO_PREVIEW_LAZY_ATTR);
105
+ }
106
+ return copy.outerHTML;
107
+ }
@@ -9,6 +9,7 @@ import { collectHtmlIds } from "./studioHelpers";
9
9
  import { generateId } from "./generateId";
10
10
  import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
11
11
  import { saveProjectFilesWithHistory } from "./studioFileHistory";
12
+ import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
12
13
  import { extendRootDurationInSource } from "./rootDuration";
13
14
  import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
14
15
 
@@ -93,12 +94,14 @@ async function makeComponentBackgroundTransparent(
93
94
  writeProjectFile: AddBlockOptions["writeProjectFile"],
94
95
  ): Promise<void> {
95
96
  if (block.type !== "hyperframes:component") return;
96
- const content = await readProjectFile(compositionFile);
97
- const transparentContent = content.replace(
98
- /background:\s*(?:#(?:0a0a0a|000000|000|0a0805)|rgba?\([^)]*\))\s*;/g,
99
- "background: transparent;",
100
- );
101
- if (transparentContent !== content) await writeProjectFile(compositionFile, transparentContent);
97
+ await serializeStudioFileMutation(writeProjectFile, compositionFile, async () => {
98
+ const content = await readProjectFile(compositionFile);
99
+ const transparentContent = content.replace(
100
+ /background:\s*(?:#(?:0a0a0a|000000|000|0a0805)|rgba?\([^)]*\))\s*;/g,
101
+ "background: transparent;",
102
+ );
103
+ if (transparentContent !== content) await writeProjectFile(compositionFile, transparentContent);
104
+ });
102
105
  }
103
106
 
104
107
  function resolveBlockPlacement({
@@ -201,7 +204,6 @@ export async function addBlockToProject(
201
204
  );
202
205
 
203
206
  const targetPath = activeCompPath || "index.html";
204
- const originalContent = await readProjectFile(targetPath);
205
207
  const relevantElements = timelineElements.filter(
206
208
  (element) => (element.sourceFile || targetPath) === targetPath,
207
209
  );
@@ -211,29 +213,32 @@ export async function addBlockToProject(
211
213
  timelineElements: relevantElements,
212
214
  currentTime: opts.currentTime ?? 0,
213
215
  });
214
- const { width, height } = resolveTimelineAssetCompositionSize(originalContent);
215
- const hostId = buildUniqueCompositionId(block.name, collectHtmlIds(originalContent));
216
- const subComposition = buildSubCompositionHtml({
217
- id: hostId,
218
- compositionFile,
219
- start,
220
- duration,
221
- track,
222
- width,
223
- height,
224
- left: visualPosition ? Math.round(visualPosition.left) : 0,
225
- top: visualPosition ? Math.round(visualPosition.top) : 0,
226
- zIndex: getMaxZIndexFromIframe(opts.previewIframe ?? null) + 1,
227
- });
228
- const patchedContent = extendRootDurationInSource(
229
- insertTimelineAssetIntoSource(originalContent, subComposition),
230
- start + duration,
231
- );
216
+ let hostId = "";
217
+ const insertHost = (originalContent: string) => {
218
+ const { width, height } = resolveTimelineAssetCompositionSize(originalContent);
219
+ hostId = buildUniqueCompositionId(block.name, collectHtmlIds(originalContent));
220
+ const subComposition = buildSubCompositionHtml({
221
+ id: hostId,
222
+ compositionFile,
223
+ start,
224
+ duration,
225
+ track,
226
+ width,
227
+ height,
228
+ left: visualPosition ? Math.round(visualPosition.left) : 0,
229
+ top: visualPosition ? Math.round(visualPosition.top) : 0,
230
+ zIndex: getMaxZIndexFromIframe(opts.previewIframe ?? null) + 1,
231
+ });
232
+ return extendRootDurationInSource(
233
+ insertTimelineAssetIntoSource(originalContent, subComposition),
234
+ start + duration,
235
+ );
236
+ };
232
237
  await saveProjectFilesWithHistory({
233
238
  projectId,
234
239
  label: `Add ${isBlock ? "block" : "component"}: ${block.title}`,
235
- files: { [targetPath]: patchedContent },
236
- readFile: async () => originalContent,
240
+ files: { [targetPath]: insertHost },
241
+ readFile: readProjectFile,
237
242
  writeFile: writeProjectFile,
238
243
  recordEdit,
239
244
  });
@@ -11,7 +11,7 @@ describe("saveProjectFilesWithHistory", () => {
11
11
  await saveProjectFilesWithHistory({
12
12
  projectId: "project-1",
13
13
  label: "Move layer",
14
- files: { "index.html": "after" },
14
+ files: { "index.html": () => "after" },
15
15
  readFile: async (path) => reads[path],
16
16
  writeFile: async (path, content) => {
17
17
  writes[path] = content;
@@ -40,7 +40,7 @@ describe("saveProjectFilesWithHistory", () => {
40
40
  await saveProjectFilesWithHistory({
41
41
  projectId: "project-1",
42
42
  label: "Delete timeline clip",
43
- files: { "index.html": "removed+shrunk" },
43
+ files: { "index.html": () => "removed+shrunk" },
44
44
  readFile: async () => "original",
45
45
  diskContent: { "index.html": "removed" },
46
46
  writeFile: async (path, _content, expectedContent) => {
@@ -64,7 +64,7 @@ describe("saveProjectFilesWithHistory", () => {
64
64
  await saveProjectFilesWithHistory({
65
65
  projectId: "project-1",
66
66
  label: "Move layer",
67
- files: { "index.html": "after" },
67
+ files: { "index.html": () => "after" },
68
68
  readFile: async () => "before",
69
69
  writeFile: async (path, _content, expectedContent) => {
70
70
  expectations[path] = expectedContent;
@@ -81,7 +81,7 @@ describe("saveProjectFilesWithHistory", () => {
81
81
  const changedPaths = await saveProjectFilesWithHistory({
82
82
  projectId: "project-1",
83
83
  label: "Edit layer",
84
- files: { "index.html": "same" },
84
+ files: { "index.html": () => "same" },
85
85
  readFile: async () => "same",
86
86
  writeFile,
87
87
  recordEdit,
@@ -105,8 +105,8 @@ describe("saveProjectFilesWithHistory", () => {
105
105
  projectId: "project-1",
106
106
  label: "Move layer",
107
107
  files: {
108
- "index.html": "index-after",
109
- "scene.html": "scene-after",
108
+ "index.html": () => "index-after",
109
+ "scene.html": () => "scene-after",
110
110
  },
111
111
  readFile: async (path) => reads[path],
112
112
  writeFile: async (path, content) => {
@@ -139,8 +139,8 @@ describe("saveProjectFilesWithHistory", () => {
139
139
  projectId: "project-1",
140
140
  label: "Move layer",
141
141
  files: {
142
- "index.html": "index-after",
143
- "scene.html": "scene-after",
142
+ "index.html": () => "index-after",
143
+ "scene.html": () => "scene-after",
144
144
  },
145
145
  readFile: async (path) => reads[path],
146
146
  writeFile: async (path, content) => {
@@ -172,8 +172,8 @@ describe("saveProjectFilesWithHistory", () => {
172
172
  projectId: "project-1",
173
173
  label: "Move layer",
174
174
  files: {
175
- "index.html": "index-after",
176
- "scene.html": "scene-after",
175
+ "index.html": () => "index-after",
176
+ "scene.html": () => "scene-after",
177
177
  },
178
178
  readFile: async (path) => reads[path],
179
179
  writeFile: async (path, content) => {
@@ -196,7 +196,7 @@ describe("saveProjectFilesWithHistory", () => {
196
196
  ]);
197
197
  });
198
198
 
199
- it("reads and writes after an earlier same-file mutation completes", async () => {
199
+ it("builds from what the file holds after an earlier same-file mutation completes", async () => {
200
200
  let disk = "before";
201
201
  let release!: () => void;
202
202
  const blocked = new Promise<void>((resolve) => {
@@ -215,7 +215,7 @@ describe("saveProjectFilesWithHistory", () => {
215
215
  const save = saveProjectFilesWithHistory({
216
216
  projectId: "project-1",
217
217
  label: "Edit source",
218
- files: { "index.html": "editor-after" },
218
+ files: { "index.html": (current) => `${current}+editor` },
219
219
  readFile,
220
220
  writeFile,
221
221
  recordEdit,
@@ -227,10 +227,10 @@ describe("saveProjectFilesWithHistory", () => {
227
227
  await priorMutation;
228
228
  await save;
229
229
 
230
- expect(disk).toBe("editor-after");
230
+ expect(disk).toBe("sdk-after+editor");
231
231
  expect(recordEdit).toHaveBeenCalledWith(
232
232
  expect.objectContaining({
233
- files: { "index.html": { before: "sdk-after", after: "editor-after" } },
233
+ files: { "index.html": { before: "sdk-after", after: "sdk-after+editor" } },
234
234
  }),
235
235
  );
236
236
  });