@hyperframes/studio 0.8.115 → 0.8.117

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 (163) hide show
  1. package/dist/assets/{hyperframes-player-C-9N8nk0.js → hyperframes-player-5fcgSstJ.js} +1 -1
  2. package/dist/assets/{index-CAvZ0AA-.js → index-C5qKdpgl.js} +205 -203
  3. package/dist/assets/{index-BoUM6Xqb.js → index-XJSawwYe.js} +1 -1
  4. package/dist/assets/{index-CVuorIMv.js → index-tg2LXUeu.js} +1 -1
  5. package/dist/{chunk-TXZMMOMA.js → chunk-3F47EFCT.js} +100 -2
  6. package/dist/chunk-3F47EFCT.js.map +1 -0
  7. package/dist/{domEditingLayers-D7VYFJJD.js → domEditingLayers-OL7GLSS4.js} +2 -2
  8. package/dist/index.d.ts +132 -41
  9. package/dist/index.html +1 -1
  10. package/dist/index.js +1184 -1084
  11. package/dist/index.js.map +1 -1
  12. package/package.json +8 -8
  13. package/src/captions/hooks/useCaptionSync.ts +10 -6
  14. package/src/components/ProjectUnreachableBanner.tsx +3 -3
  15. package/src/components/editor/DomEditOverlay.tsx +1 -0
  16. package/src/components/editor/domEditOverlayGestures.ts +1 -0
  17. package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
  18. package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
  19. package/src/components/editor/domGroupMoveBy.ts +95 -0
  20. package/src/components/editor/dragUndoPaint.test.ts +5 -5
  21. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
  22. package/src/components/editor/manualEditingAvailability.ts +8 -0
  23. package/src/components/editor/manualOffsetDrag.ts +8 -9
  24. package/src/components/editor/motionPathSelection.test.ts +6 -31
  25. package/src/components/editor/plainTranslate.ts +5 -3
  26. package/src/components/editor/probeSourceElement.ts +2 -1
  27. package/src/components/editor/propertyPanelFont.tsx +3 -2
  28. package/src/components/editor/propertyPanelHelpers.ts +2 -2
  29. package/src/components/editor/propertyPanelTransformCommit.ts +2 -2
  30. package/src/components/editor/rotationDraft.ts +2 -1
  31. package/src/components/editor/useColorGradingController.ts +2 -1
  32. package/src/components/editor/useDomEditOverlayGestures.ts +2 -2
  33. package/src/components/editor/useFxCarveNodes.ts +2 -1
  34. package/src/components/editor/useFxLevelling.ts +2 -1
  35. package/src/components/feedback/projectProvenance.ts +2 -1
  36. package/src/components/nle/NLEContext.test.ts +1 -0
  37. package/src/components/nle/NLEContext.tsx +2 -1
  38. package/src/components/nle/NLEPreview.tsx +12 -10
  39. package/src/components/nle/PreviewPoster.tsx +2 -1
  40. package/src/components/renders/useFfmpegStatus.ts +2 -1
  41. package/src/components/renders/useRenderQueue.ts +5 -4
  42. package/src/components/sidebar/AssetsTab.tsx +2 -1
  43. package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
  44. package/src/contexts/DomEditContext.tsx +4 -0
  45. package/src/hooks/domSelectionTestHarness.ts +23 -0
  46. package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
  47. package/src/hooks/domStyleCommit.ts +8 -1
  48. package/src/hooks/draggedGsapPosition.ts +10 -0
  49. package/src/hooks/gsapDragCommit.test.ts +75 -164
  50. package/src/hooks/gsapDragCommit.ts +2 -105
  51. package/src/hooks/gsapDragPositionCommit.ts +30 -393
  52. package/src/hooks/gsapEditOutcome.ts +22 -2
  53. package/src/hooks/gsapGroupPreflight.ts +34 -0
  54. package/src/hooks/gsapMutationClient.ts +4 -3
  55. package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
  56. package/src/hooks/gsapParsedTween.test.ts +62 -0
  57. package/src/hooks/gsapParsedTween.ts +243 -0
  58. package/src/hooks/gsapPositionDetection.test.ts +78 -0
  59. package/src/hooks/gsapPositionDetection.ts +52 -33
  60. package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
  61. package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
  62. package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
  63. package/src/hooks/gsapResizeIntercept.test.ts +63 -11
  64. package/src/hooks/gsapResizeIntercept.ts +79 -169
  65. package/src/hooks/gsapResizeSweep.test.ts +16 -33
  66. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
  67. package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
  68. package/src/hooks/gsapRuntimeBridge.ts +105 -131
  69. package/src/hooks/gsapRuntimeKeyframes.ts +16 -3
  70. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  71. package/src/hooks/gsapScriptCommitTypes.ts +9 -1
  72. package/src/hooks/gsapShared.ts +7 -0
  73. package/src/hooks/gsapShared.writeTarget.test.ts +26 -58
  74. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
  75. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
  76. package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
  77. package/src/hooks/gsapValueAtPlayhead.ts +292 -0
  78. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
  79. package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
  80. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  81. package/src/hooks/newTweenTarget.test.ts +41 -76
  82. package/src/hooks/plainRotation.ts +5 -1
  83. package/src/hooks/timelineTimingSync.test.ts +1 -0
  84. package/src/hooks/timelineTimingSync.ts +2 -1
  85. package/src/hooks/useAskAgentModal.ts +2 -1
  86. package/src/hooks/useBlockCatalog.ts +2 -1
  87. package/src/hooks/useCaptionDetection.ts +2 -1
  88. package/src/hooks/useCompositionContentLoader.ts +2 -1
  89. package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
  90. package/src/hooks/useDomEditPersist.test.tsx +335 -0
  91. package/src/hooks/useDomEditSession.test.tsx +30 -1
  92. package/src/hooks/useDomEditSession.ts +10 -0
  93. package/src/hooks/useDomEditTextCommits.test.tsx +23 -0
  94. package/src/hooks/useDomEditTextCommits.ts +8 -2
  95. package/src/hooks/useDomGeometryCommit.test.tsx +5 -1
  96. package/src/hooks/useDomGeometryCommit.ts +7 -9
  97. package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
  98. package/src/hooks/useEditHistoryActions.test.tsx +15 -0
  99. package/src/hooks/useEditHistoryActions.ts +16 -5
  100. package/src/hooks/useElementLifecycleOps.ts +2 -1
  101. package/src/hooks/useEnableKeyframes.test.ts +32 -1
  102. package/src/hooks/useEnableKeyframes.ts +2 -1
  103. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +19 -0
  104. package/src/hooks/useExternalFileChangeCoordinator.ts +4 -2
  105. package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
  106. package/src/hooks/useFileManager.ts +14 -10
  107. package/src/hooks/useFileTree.ts +3 -2
  108. package/src/hooks/useFrameCapture.ts +5 -1
  109. package/src/hooks/useFreezeFrame.test.ts +39 -3
  110. package/src/hooks/useFreezeFrame.ts +7 -3
  111. package/src/hooks/useGroupCommits.ts +2 -1
  112. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
  113. package/src/hooks/useGsapAwareEditing.test.tsx +42 -29
  114. package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
  115. package/src/hooks/useGsapAwareEditing.ts +17 -8
  116. package/src/hooks/useGsapAwareGroupMove.ts +18 -18
  117. package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
  118. package/src/hooks/useGsapScriptCommits.test.tsx +72 -2
  119. package/src/hooks/useGsapScriptCommits.ts +21 -17
  120. package/src/hooks/useLintModal.ts +2 -1
  121. package/src/hooks/usePersistentEditHistory.test.ts +302 -3
  122. package/src/hooks/usePersistentEditHistory.ts +50 -17
  123. package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
  124. package/src/hooks/usePreviewPersistence.ts +13 -11
  125. package/src/hooks/useProjectFileWriter.ts +20 -19
  126. package/src/hooks/useRemoveBackground.ts +2 -1
  127. package/src/hooks/useSdkSession.ts +2 -1
  128. package/src/hooks/useServerConnection.ts +4 -3
  129. package/src/hooks/useStudioSelectionPublisher.ts +2 -1
  130. package/src/hooks/useThumbnailStill.test.ts +100 -0
  131. package/src/hooks/useThumbnailStill.ts +37 -0
  132. package/src/hooks/useTimelineDeleteOps.ts +2 -1
  133. package/src/index.ts +4 -0
  134. package/src/player/components/AudioWaveform.tsx +3 -2
  135. package/src/player/components/CompositionThumbnail.tsx +2 -1
  136. package/src/player/components/clipMenuNormalize.ts +2 -1
  137. package/src/player/components/clipPeakMap.ts +2 -1
  138. package/src/player/hooks/useTimelinePlayer.ts +5 -1
  139. package/src/player/lib/thumbnailScheduler.test.ts +16 -0
  140. package/src/player/lib/thumbnailScheduler.ts +10 -2
  141. package/src/player/sceneSwap.ts +2 -1
  142. package/src/telemetry/client.ts +2 -1
  143. package/src/utils/blockInstaller.ts +2 -1
  144. package/src/utils/elementGsap.test.ts +9 -0
  145. package/src/utils/elementGsap.ts +7 -0
  146. package/src/utils/gsapSoftReload.test.ts +39 -0
  147. package/src/utils/gsapSoftReload.ts +9 -14
  148. package/src/utils/gsapUndoRestore.browser.test.ts +94 -41
  149. package/src/utils/gsapUndoRestore.ts +3 -6
  150. package/src/utils/previewPrefetch.ts +2 -1
  151. package/src/utils/razorSplitTransaction.ts +12 -8
  152. package/src/utils/softReloadTargets.ts +5 -6
  153. package/src/utils/studioApiFetch.test.ts +75 -0
  154. package/src/utils/studioApiFetch.ts +13 -0
  155. package/src/utils/studioFileHistory.ts +5 -1
  156. package/src/utils/studioHelpers.ts +7 -1
  157. package/src/utils/studioPendingEdits.test.ts +23 -6
  158. package/src/utils/studioPendingEdits.ts +32 -8
  159. package/src/utils/studioTelemetry.ts +2 -1
  160. package/src/utils/timelineCompositionInsert.ts +3 -2
  161. package/src/webmcp/StudioAgentTools.tsx +2 -1
  162. package/dist/chunk-TXZMMOMA.js.map +0 -1
  163. /package/dist/{domEditingLayers-D7VYFJJD.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
@@ -1,41 +1,16 @@
1
1
  // @vitest-environment jsdom
2
2
  import { afterEach, describe, expect, it } from "vitest";
3
- import type { DomEditSelection } from "./domEditingTypes";
4
- import { buildStableSelector, getSelectorIndex } from "./domEditingDom";
3
+ import { mountGroupSiblings, stableSelectionFor } from "../../hooks/domSelectionTestHarness";
5
4
  import { selectorFor } from "./motionPathSelection";
6
5
 
7
6
  afterEach(() => {
8
7
  document.body.innerHTML = "";
9
8
  });
10
9
 
11
- function selectionFor(el: HTMLElement): DomEditSelection {
12
- const selector = buildStableSelector(el);
13
- return {
14
- element: el,
15
- id: el.id || undefined,
16
- hfId: el.getAttribute("data-hf-id") || undefined,
17
- selector,
18
- selectorIndex: getSelectorIndex(document, el, selector, "index.html", null),
19
- sourceFile: "index.html",
20
- dataAttributes: { start: "0", duration: "2" },
21
- } as unknown as DomEditSelection;
22
- }
23
-
24
- function mountGroupSiblings(): HTMLElement[] {
25
- document.body.innerHTML = `
26
- <div id="scene" class="clip" data-start="0" data-duration="2">
27
- <div class="group"></div>
28
- <div class="group"></div>
29
- <div class="group"></div>
30
- </div>
31
- `;
32
- return Array.from(document.querySelectorAll<HTMLElement>(".group"));
33
- }
34
-
35
10
  describe("selectorFor", () => {
36
11
  it("addresses one element for a class-only sibling", () => {
37
- const groups = mountGroupSiblings();
38
- const selector = selectorFor(selectionFor(groups[2]!));
12
+ const groups = mountGroupSiblings(3);
13
+ const selector = selectorFor(stableSelectionFor(groups[2]!));
39
14
 
40
15
  // The bare ".group" both measured home off the FIRST sibling and wrote the
41
16
  // new motion path onto all three.
@@ -48,7 +23,7 @@ describe("selectorFor", () => {
48
23
  document.body.innerHTML = `<div id="hero"></div>`;
49
24
  const el = document.querySelector<HTMLElement>("#hero")!;
50
25
 
51
- expect(selectorFor(selectionFor(el))).toBe("#hero");
26
+ expect(selectorFor(stableSelectionFor(el))).toBe("#hero");
52
27
  });
53
28
 
54
29
  it("returns null with no selection", () => {
@@ -56,8 +31,8 @@ describe("selectorFor", () => {
56
31
  });
57
32
 
58
33
  it("returns null when no rung addresses one element", () => {
59
- const groups = mountGroupSiblings();
60
- const selection = selectionFor(groups[1]!);
34
+ const groups = mountGroupSiblings(3);
35
+ const selection = stableSelectionFor(groups[1]!);
61
36
  groups[1]!.remove();
62
37
 
63
38
  expect(selectorFor(selection)).toBeNull();
@@ -1,5 +1,5 @@
1
1
  import { roundTo3 } from "../../utils/rounding";
2
- import { gsapWritesPosition, gsapWritesRotation } from "../../hooks/gsapRuntimeKeyframes";
2
+ import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
3
3
  import { readCssRotation } from "../../hooks/draggedGsapPosition";
4
4
  import { readStudioPathOffset, readStudioRotation, styleUsesStudioOffset } from "./manualEditsDom";
5
5
  import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
@@ -142,10 +142,12 @@ export function writeTranslatePx(el: HTMLElement, p: Point): void {
142
142
  }
143
143
 
144
144
  /** The position the panel shows and edits: the translate a move writes, unless GSAP positions it. */
145
- export function readMoveOffset(el: HTMLElement, plainTranslate = !gsapWritesPosition(el)): Point {
145
+ export function readMoveOffset(el: HTMLElement, plainTranslate = editsPlainCss(el, "move")): Point {
146
146
  return plainTranslate ? readTranslatePx(el) : readStudioPathOffset(el);
147
147
  }
148
148
 
149
149
  export function readShownRotation(el: HTMLElement): { angle: number } {
150
- return gsapWritesRotation(el) ? readStudioRotation(el) : { angle: roundTo3(readCssRotation(el)) };
150
+ return editsPlainCss(el, "rotate")
151
+ ? { angle: roundTo3(readCssRotation(el)) }
152
+ : readStudioRotation(el);
151
153
  }
@@ -1,4 +1,5 @@
1
1
  import { buildProjectApiPath } from "../../utils/projectRouting";
2
+ import { studioApiFetch } from "../../utils/studioApiFetch";
2
3
 
3
4
  type ProbeTarget = { id?: string; hfId?: string; selector?: string; selectorIndex?: number };
4
5
 
@@ -12,7 +13,7 @@ const pending = new Map<string, PendingProbe[]>();
12
13
  async function sendBatch(projectId: string, sourceFile: string, probes: PendingProbe[]) {
13
14
  let exists: boolean[] = [];
14
15
  try {
15
- const response = await fetch(
16
+ const response = await studioApiFetch(
16
17
  buildProjectApiPath(
17
18
  projectId,
18
19
  `/file-mutations/probe-elements/${encodeURIComponent(sourceFile)}`,
@@ -20,6 +20,7 @@ import {
20
20
  } from "./propertyPanelHelpers";
21
21
  import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
22
22
  import { runWhenInputIdle } from "./overlayFrameLoop";
23
+ import { studioApiFetch } from "../../utils/studioApiFetch";
23
24
 
24
25
  /* ------------------------------------------------------------------ */
25
26
  /* Font helper functions */
@@ -135,7 +136,7 @@ let fontListsRequest: Promise<void> | null = null;
135
136
  const fontListListeners = new Set<() => void>();
136
137
 
137
138
  async function fetchFontList(url: string): Promise<string[]> {
138
- const data = (await (await fetch(url)).json()) as { fonts?: unknown };
139
+ const data = (await (await studioApiFetch(url)).json()) as { fonts?: unknown };
139
140
  if (!Array.isArray(data.fonts)) throw new Error(`${url} returned no font list`);
140
141
  return data.fonts as string[];
141
142
  }
@@ -356,7 +357,7 @@ export function FontFamilyField({
356
357
 
357
358
  const importSystemFont = async (family: string): Promise<ImportedFontAsset | null> => {
358
359
  if (!onImportFonts) return null;
359
- const response = await fetch(`/api/fonts/file?family=${encodeURIComponent(family)}`);
360
+ const response = await studioApiFetch(`/api/fonts/file?family=${encodeURIComponent(family)}`);
360
361
  if (!response.ok) return null;
361
362
  const blob = await response.blob();
362
363
  const ext = response.headers.get("Content-Disposition")?.match(/\.(\w+)"?$/)?.[1] ?? "ttf";
@@ -9,7 +9,7 @@ import {
9
9
  playbackStartAttributeForElement,
10
10
  readPlaybackStartAttributes,
11
11
  } from "../../player/lib/timelineElementHelpers";
12
- import { GSAP_TRANSFORM_KEYS, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
12
+ import { editsPlainCss, GSAP_TRANSFORM_KEYS } from "../../hooks/gsapRuntimeKeyframes";
13
13
 
14
14
  export type {
15
15
  BackgroundRemovalProgress,
@@ -496,7 +496,7 @@ export function readGsapRuntimeValuesForPanel(
496
496
  const el = findPreviewNode(iframe.contentDocument, element);
497
497
  if (!el) return null;
498
498
  const propKeys = collectPanelPropKeys(gsapAnimations);
499
- const readsTransform = gsapWritesPosition(el);
499
+ const readsTransform = !editsPlainCss(el, "move");
500
500
  const result: Record<string, number> = {};
501
501
  for (const prop of propKeys) {
502
502
  if (!readsTransform && GSAP_TRANSFORM_KEYS.has(prop)) continue;
@@ -1,4 +1,4 @@
1
- import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
1
+ import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
2
2
  import type { DomEditSelection } from "./domEditingTypes";
3
3
  import { readStudioBoxSize } from "./manualEdits";
4
4
  import { readMoveOffset, UNREADABLE_TRANSLATE } from "./plainTranslate";
@@ -75,7 +75,7 @@ export function createTransformCommitHandlers({
75
75
  )
76
76
  )
77
77
  return;
78
- const plainTranslate = !gsapWritesPosition(element.element);
78
+ const plainTranslate = editsPlainCss(element.element, "move");
79
79
  const current = readMoveOffset(element.element, plainTranslate);
80
80
  if (!Number.isFinite(current.x) || !Number.isFinite(current.y))
81
81
  return void showToast?.(UNREADABLE_TRANSLATE);
@@ -51,7 +51,8 @@ export interface CssRotationTarget {
51
51
  inline: boolean;
52
52
  }
53
53
 
54
- export type RotationCommit = { angle: number; plain?: CssRotationTarget };
54
+ export type PressChoseGsap = null;
55
+ export type RotationCommit = { angle: number; plain?: CssRotationTarget | PressChoseGsap };
55
56
 
56
57
  const TRANSLATE = /^translate(?:3d|X|Y|Z)?\(/i;
57
58
  const OWN_TURN = /^rotate\(\s*-?[\d.]+(?:e[+-]?\d+)?deg\s*\)$/i;
@@ -29,6 +29,7 @@ import {
29
29
  type ColorGradingPresetPreviews,
30
30
  type ColorGradingPreviewOptions,
31
31
  } from "./useColorGradingPreviews";
32
+ import { studioApiFetch } from "../../utils/studioApiFetch";
32
33
 
33
34
  export type { ColorGradingPresetPreviews, ColorGradingPreviewOptions };
34
35
 
@@ -290,7 +291,7 @@ export function useColorGradingController({
290
291
  return;
291
292
  }
292
293
  const controller = new AbortController();
293
- fetch(cacheKey, { signal: controller.signal })
294
+ studioApiFetch(cacheKey, { signal: controller.signal })
294
295
  .then(async (response) => {
295
296
  if (!response.ok) return { ok: false as const };
296
297
  const data: MediaMetadataResponse | null = await response.json();
@@ -442,7 +442,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
442
442
  }
443
443
  // Hold the final angle while the commit lands.
444
444
  applyRotationDraft(sel.element, finalRotation.angle, g.plainRotation);
445
- const commit = g.plainRotation ? { ...finalRotation, plain: g.plainRotation } : finalRotation;
445
+ const commit = { ...finalRotation, plain: g.plainRotation };
446
446
  void Promise.resolve(opts.onRotationCommitRef.current(sel, commit))
447
447
  .then((result) => trackPreviewEditResult("rotate", "drag", result))
448
448
  .catch((error) => {
@@ -536,7 +536,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
536
536
  if (finalOffset) restoreStudioPathOffset(sel.element, g.initialPathOffset);
537
537
  };
538
538
  void Promise.resolve(
539
- opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore),
539
+ opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore, member),
540
540
  )
541
541
  .then((result) => trackPreviewEditResult("resize", "drag", result))
542
542
  .catch((error) => {
@@ -24,6 +24,7 @@ import {
24
24
  import { fxAutomationTarget, type HfAutomationLane } from "@hyperframes/core/audio-automation";
25
25
  import { clipStart } from "./propertyPanelAudioFxGroupUtils.js";
26
26
  import { clipAudioOnItsClock, type ClipClock } from "./clipAudioClock.js";
27
+ import { studioApiFetch } from "../../utils/studioApiFetch";
27
28
 
28
29
  /** Decode rate every carve measurement shares — see `measureCarve`. */
29
30
  const DECODE_SAMPLE_RATE = 48000;
@@ -95,7 +96,7 @@ export async function measureCarve(
95
96
  if (!Ctor) return null;
96
97
  // Each clip as it plays: trimmed to its in-point and duration, and at its own speed.
97
98
  const decode = async (relative: string, clock: ClipClock): Promise<Float32Array> => {
98
- const res = await fetch(new URL(relative, doc.baseURI).href);
99
+ const res = await studioApiFetch(new URL(relative, doc.baseURI).href);
99
100
  const buffer = await new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(
100
101
  await res.arrayBuffer(),
101
102
  );
@@ -26,6 +26,7 @@ import { trackLeveller } from "./audioFxTelemetry.js";
26
26
  import type { DomEditSelection } from "./domEditingTypes";
27
27
  import { clipSourceWindow, readClipClock } from "./clipAudioClock.js";
28
28
  import { useAuditionTransport } from "./useAuditionTransport.js";
29
+ import { studioApiFetch } from "../../utils/studioApiFetch";
29
30
 
30
31
  /**
31
32
  * Rate the track is decoded at. Analysis is self-consistent because it reads
@@ -63,7 +64,7 @@ export function useFxLevelling(
63
64
  (window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext })
64
65
  .webkitOfflineAudioContext;
65
66
  if (!Ctor) return null;
66
- const res = await fetch(new URL(src, doc.baseURI).href);
67
+ const res = await studioApiFetch(new URL(src, doc.baseURI).href);
67
68
  const buffer = await new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(
68
69
  await res.arrayBuffer(),
69
70
  );
@@ -17,6 +17,7 @@ import { buildProjectApiPath } from "../../utils/projectRouting";
17
17
 
18
18
  import type { FeedbackContext } from "./feedbackTrigger";
19
19
  import { isMediaFile } from "../../utils/mediaTypes";
20
+ import { studioApiFetch } from "../../utils/studioApiFetch";
20
21
 
21
22
  /** Written by `hyperframes init`; absent in a hand-made or copied project. */
22
23
  const CONFIG_FILE = "hyperframes.json";
@@ -53,7 +54,7 @@ export async function captureProjectProvenance(
53
54
  if (!scaffolded) return;
54
55
 
55
56
  try {
56
- const res = await fetch(
57
+ const res = await studioApiFetch(
57
58
  buildProjectApiPath(projectId, `/files/${encodeURIComponent(CONFIG_FILE)}`),
58
59
  );
59
60
  if (!res.ok) return;
@@ -126,6 +126,7 @@ describe("NLEProvider — composition source-map scoping", () => {
126
126
  expect(observed.filter((map) => map.size === 0)).toHaveLength(2);
127
127
  expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b/files/index.html", {
128
128
  signal: expect.any(AbortSignal),
129
+ credentials: "omit",
129
130
  });
130
131
  expect(observed.at(-1)).toEqual(new Map([["b-comp", "compositions/b.html"]]));
131
132
 
@@ -9,6 +9,7 @@ import { setCompositionSourceMap } from "../editor/domEditingDom";
9
9
  import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
10
10
  import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
11
11
  import { createStableContext } from "../../utils/hmrStableContext";
12
+ import { studioApiFetch } from "../../utils/studioApiFetch";
12
13
 
13
14
  export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
14
15
  return compositionLoading;
@@ -209,7 +210,7 @@ export function NLEProvider({
209
210
  setCompositionSourceMap(emptyMap);
210
211
  onCompIdToSrcChangeRef.current?.(emptyMap);
211
212
 
212
- fetch(buildProjectApiPath(projectId, `/files/index.html`), {
213
+ studioApiFetch(buildProjectApiPath(projectId, `/files/index.html`), {
213
214
  signal: controller.signal,
214
215
  })
215
216
  .then((r) => {
@@ -12,7 +12,7 @@ import {
12
12
  type RefObject,
13
13
  } from "react";
14
14
  import { Player } from "../../player";
15
- import type { PreviewIframeSlot } from "../../player/hooks/useTimelineSyncCallbacks";
15
+ import type { NLEContextValue } from "./NLEContext";
16
16
  import {
17
17
  DEFAULT_PREVIEW_ZOOM,
18
18
  canStartPreviewPan,
@@ -29,16 +29,18 @@ import { RULER_GUTTER_PX, usePreviewGuidesStore } from "../editor/previewGuidesS
29
29
  import { PreviewZoomOverlay, usePreviewNavigator } from "./PreviewZoomOverlay";
30
30
  import { usePreviewFirstFrameTelemetry } from "../../player/hooks/usePreviewFirstFrameTelemetry";
31
31
  import { PreviewPoster, usePreviewPoster } from "./PreviewPoster";
32
- interface NLEPreviewProps {
33
- projectId: string;
32
+ interface NLEPreviewProps extends Pick<
33
+ NLEContextValue,
34
+ | "projectId"
35
+ | "onIframeLoad"
36
+ | "previewSlots"
37
+ | "onShadowIframeLoad"
38
+ | "onShadowReadyChange"
39
+ | "onShadowError"
40
+ | "setShadowIframeNode"
41
+ | "resetPreviewSlots"
42
+ > {
34
43
  iframeRef: RefObject<HTMLIFrameElement | null>;
35
- onIframeLoad: () => void;
36
- previewSlots: PreviewIframeSlot[];
37
- onShadowIframeLoad: (gen: number) => void;
38
- onShadowReadyChange: (gen: number, ready: boolean) => void;
39
- onShadowError: (gen: number, message: string) => void;
40
- setShadowIframeNode: (node: HTMLIFrameElement | null) => void;
41
- resetPreviewSlots: () => void;
42
44
  onCompositionLoadingChange?: (loading: boolean) => void;
43
45
  portrait?: boolean;
44
46
  directUrl?: string;
@@ -6,6 +6,7 @@ import {
6
6
  type ThumbnailRequest,
7
7
  } from "../../player/lib/thumbnailScheduler";
8
8
  import type { PreviewCompositionSize } from "../../utils/previewCompositionSize";
9
+ import { studioApiFetch } from "../../utils/studioApiFetch";
9
10
 
10
11
  /** Frame 0 as the thumbnail route last rendered it; `cachedOnly` never starts a render. */
11
12
  function previewPosterUrl(projectId: string, cachedOnly: boolean): string {
@@ -107,7 +108,7 @@ export function renderPosterForNextOpen(projectId: string): void {
107
108
  priority: "overscan",
108
109
  discardWhenReleased: true,
109
110
  load: async (signal) => {
110
- await (await fetch(url, { signal })).arrayBuffer();
111
+ await (await studioApiFetch(url, { signal })).arrayBuffer();
111
112
  return { value: { kind: "image", url, aspect: 16 / 9 }, weight: 0 };
112
113
  },
113
114
  };
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { studioApiFetch } from "../../utils/studioApiFetch";
2
3
 
3
4
  /**
4
5
  * What the dev server knows about this machine's FFmpeg, as reported by
@@ -59,7 +60,7 @@ function parseStatus(body: unknown): ProbeResult {
59
60
 
60
61
  async function probe(): Promise<ProbeResult> {
61
62
  try {
62
- const res = await fetch("/api/environment/ffmpeg");
63
+ const res = await studioApiFetch("/api/environment/ffmpeg");
63
64
  return res.ok ? parseStatus(await res.json()) : null;
64
65
  } catch {
65
66
  return null;
@@ -8,6 +8,7 @@ import { generateId } from "../../utils/generateId";
8
8
  import { readServerError } from "./serverError";
9
9
  import { ffmpegInstallMessage, useFfmpegStatus } from "./useFfmpegStatus";
10
10
  import { requestStudioFeedback, type FeedbackContext } from "../feedback/feedbackTrigger";
11
+ import { studioApiFetch } from "../../utils/studioApiFetch";
11
12
 
12
13
  export interface RenderJob {
13
14
  id: string;
@@ -126,7 +127,7 @@ export function useRenderQueue(
126
127
  const loadRenders = useCallback(async () => {
127
128
  if (!projectId) return;
128
129
  try {
129
- const res = await fetch(buildProjectApiPath(projectId, `/renders`));
130
+ const res = await studioApiFetch(buildProjectApiPath(projectId, `/renders`));
130
131
  if (!res.ok) {
131
132
  setLoadError(`Couldn't load render history (server error ${res.status}).`);
132
133
  return;
@@ -262,7 +263,7 @@ export function useRenderQueue(
262
263
  }
263
264
  let res: Response;
264
265
  try {
265
- res = await fetch(buildProjectApiPath(projectId, `/render`), {
266
+ res = await studioApiFetch(buildProjectApiPath(projectId, `/render`), {
266
267
  method: "POST",
267
268
  headers: { "Content-Type": "application/json" },
268
269
  body: JSON.stringify(body),
@@ -376,7 +377,7 @@ export function useRenderQueue(
376
377
  ),
377
378
  );
378
379
  try {
379
- const res = await fetch(`/api/render/${jobId}/cancel`, { method: "POST" });
380
+ const res = await studioApiFetch(`/api/render/${jobId}/cancel`, { method: "POST" });
380
381
  if (!res.ok && res.status !== 404) {
381
382
  setActionError("Couldn't cancel on the server — the render may still be running.");
382
383
  return;
@@ -403,7 +404,7 @@ export function useRenderQueue(
403
404
  setActionError(null);
404
405
  closeActiveEventSource(jobId);
405
406
  try {
406
- const res = await fetch(`/api/render/${jobId}`, { method: "DELETE" });
407
+ const res = await studioApiFetch(`/api/render/${jobId}`, { method: "DELETE" });
407
408
  if (!res.ok) {
408
409
  setActionError("Couldn't delete the render — it's still on disk.");
409
410
  return;
@@ -16,6 +16,7 @@ import {
16
16
  import { AudioRow } from "./AudioRow";
17
17
  import { GlobalAssetsView } from "./GlobalAssetsView";
18
18
  import { AssetCard, FontRow } from "./AssetCard";
19
+ import { studioApiFetch } from "../../utils/studioApiFetch";
19
20
 
20
21
  interface AssetsTabProps {
21
22
  projectId: string;
@@ -217,7 +218,7 @@ export const AssetsTab = memo(function AssetsTab({
217
218
  useEffect(() => {
218
219
  if (manifest404Ref.current.has(projectId)) return;
219
220
  let cancelled = false;
220
- fetch(buildProjectApiPath(projectId, `/preview/.media/manifest.jsonl`))
221
+ studioApiFetch(buildProjectApiPath(projectId, `/preview/.media/manifest.jsonl`))
221
222
  .then((r) => {
222
223
  if (!r.ok) {
223
224
  manifest404Ref.current.add(projectId);
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useMemo, useState } from "react";
2
+ import { studioApiFetch } from "../../utils/studioApiFetch";
2
3
 
3
4
  // Cross-project asset view — the global media-use cache (~/.media), fetched from
4
5
  // /api/assets/global. Self-contained (owns its fetch + state) so AssetsTab stays
@@ -43,7 +44,7 @@ export function GlobalAssetsView({ searchQuery }: { searchQuery: string }) {
43
44
  const [records, setRecords] = useState<GlobalAssetRecord[] | null>(null);
44
45
  useEffect(() => {
45
46
  let cancelled = false;
46
- fetch("/api/assets/global")
47
+ studioApiFetch("/api/assets/global")
47
48
  .then((r) => (r.ok ? r.json() : { assets: [] }))
48
49
  .then((d) => {
49
50
  if (!cancelled) setRecords(Array.isArray(d.assets) ? d.assets : []);
@@ -24,6 +24,7 @@ const SAVES_AN_EDIT = {
24
24
  handleDomAttributeBatchCommit: true,
25
25
  handleDomPathOffsetCommit: true,
26
26
  handleDomGroupPathOffsetCommit: true,
27
+ handleDomGroupMoveBy: true,
27
28
  handleDomZIndexReorderCommit: true,
28
29
  handleDomBoxSizeCommit: true,
29
30
  handleDomRotationCommit: true,
@@ -179,6 +180,7 @@ export function DomEditProvider({
179
180
  handleDomAttributeBatchCommit,
180
181
  handleDomPathOffsetCommit,
181
182
  handleDomGroupPathOffsetCommit,
183
+ handleDomGroupMoveBy,
182
184
  handleDomZIndexReorderCommit,
183
185
  handleDomBoxSizeCommit,
184
186
  handleDomRotationCommit,
@@ -274,6 +276,7 @@ export function DomEditProvider({
274
276
  handleDomAttributeBatchCommit,
275
277
  handleDomPathOffsetCommit,
276
278
  handleDomGroupPathOffsetCommit,
279
+ handleDomGroupMoveBy,
277
280
  handleDomZIndexReorderCommit,
278
281
  handleDomBoxSizeCommit,
279
282
  handleDomRotationCommit,
@@ -350,6 +353,7 @@ export function DomEditProvider({
350
353
  handleDomAttributeBatchCommit,
351
354
  handleDomPathOffsetCommit,
352
355
  handleDomGroupPathOffsetCommit,
356
+ handleDomGroupMoveBy,
353
357
  handleDomZIndexReorderCommit,
354
358
  handleDomBoxSizeCommit,
355
359
  handleDomRotationCommit,
@@ -4,6 +4,7 @@ import type React from "react";
4
4
  import { act } from "react";
5
5
  import { createRoot, type Root } from "react-dom/client";
6
6
  import type { DomEditSelection } from "../components/editor/domEditing";
7
+ import { buildStableSelector, getSelectorIndex } from "../components/editor/domEditingDom";
7
8
 
8
9
  export function installReactActEnvironment(): void {
9
10
  Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
@@ -61,3 +62,25 @@ export function makeSelection(label: string, element: HTMLElement): DomEditSelec
61
62
  },
62
63
  };
63
64
  }
65
+
66
+ /** Built as getDomLayerPatchTarget builds it: a bare class for an element with no id or hf-id. */
67
+ export function stableSelectionFor(el: HTMLElement): DomEditSelection {
68
+ const selector = buildStableSelector(el);
69
+ return {
70
+ element: el,
71
+ id: el.id || undefined,
72
+ hfId: el.getAttribute("data-hf-id") || undefined,
73
+ selector,
74
+ selectorIndex: getSelectorIndex(document, el, selector, "index.html", null),
75
+ sourceFile: "index.html",
76
+ dataAttributes: { start: "0", duration: "2" },
77
+ } as unknown as DomEditSelection;
78
+ }
79
+
80
+ export function mountGroupSiblings(count: number, withIds = false): HTMLElement[] {
81
+ const groups = Array.from({ length: count }, (_, i) =>
82
+ withIds ? `<div class="group" id="group-${i}"></div>` : `<div class="group"></div>`,
83
+ );
84
+ document.body.innerHTML = `<div id="scene" class="clip" data-start="0" data-duration="2">${groups.join("")}</div>`;
85
+ return Array.from(document.querySelectorAll<HTMLElement>(".group"));
86
+ }
@@ -0,0 +1,73 @@
1
+ // @vitest-environment jsdom
2
+ import { expect, it } from "vitest";
3
+ import type { DomEditSelection } from "../components/editor/domEditing";
4
+ import type { PatchOperation } from "../utils/sourcePatcher";
5
+ import { commitDomStyles, type DomStyleCommitContext } from "./domStyleCommit";
6
+
7
+ const selection = {
8
+ id: "target",
9
+ selector: "#target",
10
+ label: "Target",
11
+ tagName: "div",
12
+ sourceFile: "index.html",
13
+ compositionPath: "index.html",
14
+ textFields: [],
15
+ inlineStyles: {},
16
+ computedStyles: {},
17
+ dataAttributes: {},
18
+ capabilities: { canSelect: true, canEditStyles: true },
19
+ } as unknown as DomEditSelection;
20
+
21
+ async function savedPatches(styles: Record<string, string>) {
22
+ const saves: Array<{ operations: PatchOperation[]; label?: string }> = [];
23
+ const context: DomStyleCommitContext = {
24
+ activeCompPath: "index.html",
25
+ previewIframeRef: { current: null },
26
+ showToast: () => undefined,
27
+ versions: new Map(),
28
+ persistDomEditOperations: (async (
29
+ _selection: DomEditSelection,
30
+ operations: PatchOperation[],
31
+ options?: { label?: string },
32
+ ) => {
33
+ saves.push({ operations, label: options?.label });
34
+ }) as unknown as DomStyleCommitContext["persistDomEditOperations"],
35
+ };
36
+ await commitDomStyles(context, selection, styles);
37
+ return saves;
38
+ }
39
+
40
+ const written = (operations: PatchOperation[]) =>
41
+ operations.map((op) => [(op as { property: string }).property, (op as { value: string }).value]);
42
+
43
+ it("writes a style named the JavaScript way under its CSS name", async () => {
44
+ const saves = await savedPatches({ backgroundColor: "#ff0000" });
45
+ expect(saves.map((save) => written(save.operations))).toEqual([
46
+ [["background-color", "#ff0000"]],
47
+ ]);
48
+ });
49
+
50
+ it("saves a map mixing both forms as one patch and one undo step, all under CSS names", async () => {
51
+ const saves = await savedPatches({
52
+ borderRadius: "4",
53
+ "border-color": "red",
54
+ WebkitTextStroke: "1px black",
55
+ msTransform: "none",
56
+ });
57
+ expect(saves).toHaveLength(1);
58
+ expect(saves[0]!.label).toBe("Edit layer style");
59
+ expect(written(saves[0]!.operations)).toEqual([
60
+ ["border-radius", "4px"],
61
+ ["border-color", "red"],
62
+ ["-webkit-text-stroke", "1px black"],
63
+ ["-ms-transform", "none"],
64
+ ]);
65
+ });
66
+
67
+ it("keeps a custom property's name exactly as given", async () => {
68
+ const saves = await savedPatches({ "--brand": "#123456", "--brandAccent": "#654321" });
69
+ expect(written(saves[0]!.operations)).toEqual([
70
+ ["--brand", "#123456"],
71
+ ["--brandAccent", "#654321"],
72
+ ]);
73
+ });
@@ -1,5 +1,6 @@
1
1
  import type { PatchOperation } from "../utils/sourcePatcher";
2
2
  import {
3
+ cssPropertyName,
3
4
  isImageBackgroundValue,
4
5
  isManualGeometryStyleProperty,
5
6
  normalizeDomEditStyleValue,
@@ -43,8 +44,14 @@ export interface DomStyleCommitContext {
43
44
  export async function commitDomStyles(
44
45
  context: DomStyleCommitContext,
45
46
  selection: DomEditSelection,
46
- styles: Record<string, string>,
47
+ stylesInEitherCase: Record<string, string>,
47
48
  ): Promise<DomEditCommitOutcome> {
49
+ const styles = Object.fromEntries(
50
+ Object.entries(stylesInEitherCase).map(([property, value]) => [
51
+ cssPropertyName(property),
52
+ value,
53
+ ]),
54
+ );
48
55
  const entries = Object.entries(styles);
49
56
  if (entries.length === 0) return domEditCommitDeclined("no-selection");
50
57
  if (entries.some(([property]) => isManualGeometryStyleProperty(property)))
@@ -75,3 +75,13 @@ export function computeDraggedGsapPosition(
75
75
  baseGsapY,
76
76
  };
77
77
  }
78
+
79
+ /** Puts the drag's preview offset back once the written position renders instead. */
80
+ export function restoreDragOffset(element: HTMLElement): void {
81
+ const origin = (axis: "x" | "y") =>
82
+ Number.parseFloat(element.getAttribute(`data-hf-drag-initial-offset-${axis}`) ?? "") || 0;
83
+ element.style.setProperty("--hf-studio-offset-x", `${origin("x")}px`);
84
+ element.style.setProperty("--hf-studio-offset-y", `${origin("y")}px`);
85
+ element.removeAttribute("data-hf-drag-initial-offset-x");
86
+ element.removeAttribute("data-hf-drag-initial-offset-y");
87
+ }