@hyperframes/studio 0.8.144 → 0.8.146

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 (119) hide show
  1. package/dist/assets/{hyperframes-player-Bw_jIaSX.js → hyperframes-player-tkxyWf5H.js} +3 -3
  2. package/dist/assets/{index-Cj7W_c7p.js → index-BrdqOkqP.js} +1 -1
  3. package/dist/assets/{index-BVM9NuWl.js → index-Cg48BxAo.js} +145 -145
  4. package/dist/assets/{index-DyH9dT7b.js → index-Dllxg4aU.js} +1 -1
  5. package/dist/{chunk-EEXSYY5F.js → chunk-C5BDT7NG.js} +0 -1
  6. package/dist/{chunk-PEAKCOUW.js → chunk-HZDCMUG4.js} +2 -100
  7. package/dist/{chunk-PZ5AY32C.js → chunk-MLKGABMK.js} +0 -1
  8. package/dist/{chunk-G3EDKRZH.js → chunk-PHGBG7OG.js} +100 -5
  9. package/dist/{chunk-Y2OHHNM2.js → chunk-PVFBY7KR.js} +0 -1
  10. package/dist/{chunk-3GHTHPZD.js → chunk-T43DM7Q6.js} +101 -3
  11. package/dist/{domEditingLayers-PYST4G45.js → domEditingLayers-SMVA3FEW.js} +3 -4
  12. package/dist/index.css +0 -1
  13. package/dist/index.d.ts +8 -5
  14. package/dist/index.html +1 -1
  15. package/dist/index.js +509 -529
  16. package/dist/player.js +4 -5
  17. package/dist/styles/tailwind-preset.js +1 -2
  18. package/dist/ui.js +3 -4
  19. package/package.json +8 -8
  20. package/src/captions/components/CaptionOverlay.tsx +1 -1
  21. package/src/captions/hooks/useCaptionSync.ts +1 -1
  22. package/src/components/LinkedSelectionToggle.tsx +2 -2
  23. package/src/components/LintModal.tsx +1 -5
  24. package/src/components/MediaPreview.tsx +2 -2
  25. package/src/components/ProjectUnreachableBanner.tsx +1 -1
  26. package/src/components/StudioLeftPanels.tsx +2 -2
  27. package/src/components/StudioRightPanels.tsx +1 -1
  28. package/src/components/TimelineToolbar.test.tsx +2 -2
  29. package/src/components/TimelineToolbar.tsx +6 -6
  30. package/src/components/editor/AnimationCard.tsx +3 -3
  31. package/src/components/editor/AnimationCardParts.tsx +1 -1
  32. package/src/components/editor/BlockParamsPanel.tsx +3 -3
  33. package/src/components/editor/ComputedTweenNotice.tsx +2 -2
  34. package/src/components/editor/FileTree.tsx +1 -1
  35. package/src/components/editor/GsapAnimationList.tsx +1 -1
  36. package/src/components/editor/GsapAnimationSection.tsx +2 -2
  37. package/src/components/editor/LayersPanel.test.ts +4 -4
  38. package/src/components/editor/LayersPanel.tsx +2 -2
  39. package/src/components/editor/OffCanvasIndicators.tsx +1 -1
  40. package/src/components/editor/PromotableControl.tsx +1 -1
  41. package/src/components/editor/PropertyPanel.tsx +2 -2
  42. package/src/components/editor/SnapToolbar.tsx +2 -2
  43. package/src/components/editor/TopologyLens.test.tsx +5 -4
  44. package/src/components/editor/Transform3DCube.tsx +1 -1
  45. package/src/components/editor/gsapAnimationConstants.ts +2 -2
  46. package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
  47. package/src/components/editor/gsapAnimationHelpers.ts +1 -1
  48. package/src/components/editor/propertyPanelColorGradingControls.tsx +1 -1
  49. package/src/components/editor/propertyPanelFill.tsx +1 -1
  50. package/src/components/editor/propertyPanelFlatMotionSection.tsx +3 -3
  51. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  52. package/src/components/editor/propertyPanelFlatSelectRow.tsx +1 -1
  53. package/src/components/editor/propertyPanelFlatTextSection.tsx +1 -1
  54. package/src/components/editor/propertyPanelFont.tsx +3 -3
  55. package/src/components/editor/propertyPanelFxBandRuler.tsx +2 -2
  56. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +2 -2
  57. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  58. package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
  59. package/src/components/editor/useColorGradingController.ts +1 -1
  60. package/src/components/panels/SlideshowSubPanels.tsx +2 -2
  61. package/src/components/panels/VariablesBindElement.tsx +1 -1
  62. package/src/components/panels/VariablesPanel.tsx +3 -3
  63. package/src/components/renders/GpuEncodingToggle.tsx +28 -0
  64. package/src/components/renders/RenderQueue.test.tsx +20 -2
  65. package/src/components/renders/RenderQueue.tsx +15 -3
  66. package/src/components/renders/RenderQueuePanel.tsx +2 -1
  67. package/src/components/renders/renderSettings.ts +5 -2
  68. package/src/components/renders/useRenderQueue.ts +6 -3
  69. package/src/components/renders/useRenderQueueComposition.test.tsx +12 -0
  70. package/src/components/sidebar/AssetCard.tsx +2 -2
  71. package/src/components/sidebar/AudioRow.tsx +1 -1
  72. package/src/components/sidebar/BlocksTab.tsx +2 -2
  73. package/src/components/sidebar/CompositionsTab.tsx +2 -2
  74. package/src/components/sidebar/PromptPreviewModal.tsx +1 -1
  75. package/src/hooks/timelineAudioGroupCreate.ts +1 -1
  76. package/src/hooks/timelineEditingHelpers.ts +1 -1
  77. package/src/hooks/useBlockHandlers.ts +1 -1
  78. package/src/hooks/useConsoleErrorCapture.test.tsx +21 -2
  79. package/src/hooks/useConsoleErrorCapture.ts +3 -2
  80. package/src/hooks/useDomEditSession.ts +1 -1
  81. package/src/hooks/useEditorSave.ts +1 -1
  82. package/src/hooks/useFrameCapture.ts +1 -1
  83. package/src/hooks/useGestureCommit.ts +4 -4
  84. package/src/hooks/useGsapScriptCommits.ts +1 -1
  85. package/src/index.ts +1 -0
  86. package/src/player/components/AudioGainDialog.test.tsx +1 -1
  87. package/src/player/components/AudioGainDialog.tsx +2 -1
  88. package/src/player/components/EditModal.tsx +2 -2
  89. package/src/player/components/KeyframeDiamondContextMenu.tsx +1 -1
  90. package/src/player/components/OutOfSyncBadge.tsx +1 -1
  91. package/src/player/components/Player.test.ts +44 -0
  92. package/src/player/components/Player.tsx +12 -1
  93. package/src/player/components/ShortcutsPanel.tsx +2 -2
  94. package/src/player/components/Timeline.virtualization.test.tsx +1 -1
  95. package/src/player/components/TimelineAutomationLaneSlot.tsx +1 -1
  96. package/src/player/components/TimelineClip.tsx +1 -1
  97. package/src/player/components/TimelineFxButton.tsx +2 -2
  98. package/src/player/components/TimelineTrackHeader.test.tsx +3 -3
  99. package/src/player/components/timelineClipDragCommit.ts +2 -2
  100. package/src/player/components/trackHeaderLabelRows.tsx +1 -1
  101. package/src/player/lib/playbackAdapter.ts +1 -1
  102. package/src/player/lib/playbackTypes.ts +1 -0
  103. package/src/player/sceneSwap.test.ts +27 -1
  104. package/src/player/sceneSwap.ts +16 -2
  105. package/src/utils/hmrStableContext.ts +1 -1
  106. package/src/utils/razorSplitTransaction.ts +0 -2
  107. package/src/utils/sdkCutover.linkedTiming.test.ts +1 -1
  108. package/dist/chunk-3GHTHPZD.js.map +0 -1
  109. package/dist/chunk-EEXSYY5F.js.map +0 -1
  110. package/dist/chunk-G3EDKRZH.js.map +0 -1
  111. package/dist/chunk-PEAKCOUW.js.map +0 -1
  112. package/dist/chunk-PZ5AY32C.js.map +0 -1
  113. package/dist/chunk-Y2OHHNM2.js.map +0 -1
  114. package/dist/domEditingLayers-PYST4G45.js.map +0 -1
  115. package/dist/index.css.map +0 -1
  116. package/dist/index.js.map +0 -1
  117. package/dist/player.js.map +0 -1
  118. package/dist/styles/tailwind-preset.js.map +0 -1
  119. package/dist/ui.js.map +0 -1
@@ -290,7 +290,7 @@ export function FontFamilyField({
290
290
  setFontNotice("No supported font files were imported.");
291
291
  }
292
292
  } catch {
293
- setFontNotice("Font import failed — the files were not added. Try again.");
293
+ setFontNotice("Font import failed. The files were not added. Try again.");
294
294
  } finally {
295
295
  setImportingFonts(false);
296
296
  }
@@ -389,10 +389,10 @@ export function FontFamilyField({
389
389
  } else {
390
390
  // Committing an un-imported family would render a silent fallback,
391
391
  // so surface the failure and keep the current font instead.
392
- setFontNotice(`Couldn't import "${option.family}" — the font was not applied.`);
392
+ setFontNotice(`Couldn't import "${option.family}", so the font was not applied.`);
393
393
  }
394
394
  } catch {
395
- setFontNotice(`Couldn't import "${option.family}" — the font was not applied.`);
395
+ setFontNotice(`Couldn't import "${option.family}", so the font was not applied.`);
396
396
  } finally {
397
397
  setImportingFonts(false);
398
398
  }
@@ -49,7 +49,7 @@ export function FxBandRuler({ band, at }: FxBandRulerProps) {
49
49
  return (
50
50
  <span
51
51
  key={range.name}
52
- title={`${range.name} — ${range.says}`}
52
+ title={`${range.name}: ${range.says}`}
53
53
  className={
54
54
  range.name === here.name
55
55
  ? "hf-fx-ruler-band bg-panel-accent"
@@ -65,7 +65,7 @@ export function FxBandRuler({ band, at }: FxBandRulerProps) {
65
65
  })}
66
66
  </div>
67
67
  <p className="hf-fx-ruler-label truncate pt-0.5 text-[9px] text-panel-text-2">
68
- <span className="hf-fx-ruler-name text-panel-text-1">{here.name}</span> — {here.says}
68
+ <span className="hf-fx-ruler-name text-panel-text-1">{here.name}</span>: {here.says}
69
69
  </p>
70
70
  </div>
71
71
  );
@@ -245,11 +245,11 @@ export function FxNodeOpenBody({
245
245
  onClick={onToggleDetails}
246
246
  >
247
247
  <span aria-hidden="true">{details ? "▾" : "▸"}</span>
248
- Details — {registryDef.label}
248
+ Details: {registryDef.label}
249
249
  </button>
250
250
  ) : (
251
251
  <p className="hf-fx-node-mechanism border-t border-panel-border-input px-1.5 pt-1 font-mono text-[9px] uppercase tracking-wide text-panel-text-2">
252
- Details — {registryDef.label}
252
+ Details: {registryDef.label}
253
253
  </p>
254
254
  )}
255
255
  {details || !oneKnob ? (
@@ -84,7 +84,7 @@ export function TimingSection({
84
84
  </div>
85
85
  {derived && (
86
86
  <p className="mt-2 text-[10px] leading-snug text-neutral-500">
87
- Inferred from this element’s animation — edit to pin an explicit clip range.
87
+ Inferred from this element’s animation. Edit to pin an explicit clip range.
88
88
  </p>
89
89
  )}
90
90
  </Section>
@@ -71,7 +71,7 @@ export function createTransformCommitHandlers({
71
71
  await commitAnimatedTransformValue(
72
72
  axis,
73
73
  parsed,
74
- "This element's position can't be edited here yet — it is driven by its animation",
74
+ "This element's position can't be edited here yet: its animation drives it",
75
75
  )
76
76
  )
77
77
  return;
@@ -97,7 +97,7 @@ export function createTransformCommitHandlers({
97
97
  return;
98
98
  }
99
99
  if (hasGsapAnimation) {
100
- showToast?.("This element's size can't be edited here yet — it is driven by its animation");
100
+ showToast?.("This element's size can't be edited here yet: its animation drives it");
101
101
  return;
102
102
  }
103
103
  const current = readStudioBoxSize(element.element);
@@ -124,7 +124,7 @@ export function createTransformCommitHandlers({
124
124
  await commitAnimatedTransformValue(
125
125
  "rotation",
126
126
  parsed,
127
- "This element's rotation can't be edited here yet — it is driven by its animation",
127
+ "This element's rotation can't be edited here yet: its animation drives it",
128
128
  )
129
129
  )
130
130
  return;
@@ -352,7 +352,7 @@ export function useColorGradingController({
352
352
  const reverted = confirmedGradingRef.current;
353
353
  latestGradingRef.current = reverted;
354
354
  setGrading(reverted);
355
- setRuntimeStatus({ state: "unavailable", message: "Save failed — reverted" });
355
+ setRuntimeStatus({ state: "unavailable", message: "Save failed, changes reverted" });
356
356
  };
357
357
  // The callback is the primary result signal; the rejection path supports
358
358
  // other setAttributeLive implementations.
@@ -489,7 +489,7 @@ export function HotspotTool({
489
489
  )}
490
490
  {sequences.length === 0 && (
491
491
  <p className="text-[10px] text-neutral-500 italic">
492
- Create a branch in the Branches section first — hotspots jump to a branch.
492
+ Create a branch in the Branches section first. Hotspots jump to a branch.
493
493
  </p>
494
494
  )}
495
495
  <label className="text-[11px] text-neutral-400">Hotspot label</label>
@@ -508,7 +508,7 @@ export function HotspotTool({
508
508
  onChange={(e) => setTargetSequenceId(e.target.value)}
509
509
  aria-label="Target branch sequence"
510
510
  >
511
- <option value="">— select branch —</option>
511
+ <option value="">Select branch</option>
512
512
  {sequences.map((seq) => (
513
513
  <option key={seq.id} value={seq.id}>
514
514
  {seq.label}
@@ -220,7 +220,7 @@ export function VariablesBindElement({
220
220
  {existingDecl.default !== undefined && (
221
221
  <span className="font-mono"> ({String(existingDecl.default)})</span>
222
222
  )}
223
- , not the element's own — binding won't change "{trimmedId}".
223
+ , not the element's own, so binding won't change "{trimmedId}".
224
224
  </p>
225
225
  )}
226
226
  <div className="flex items-center justify-end gap-2">
@@ -235,7 +235,7 @@ function HandoffFooter({
235
235
 
236
236
  const EMPTY_STATE = (
237
237
  <p className="text-[10px] leading-relaxed text-neutral-500">
238
- No variables declared. Variables make parts of this composition dynamic — declare them here (or
238
+ No variables declared. Variables make parts of this composition dynamic. Declare them here (or
239
239
  in <code className="font-mono">data-composition-variables</code>), read them with{" "}
240
240
  <code className="font-mono">getVariables()</code>, and pass values at render time with{" "}
241
241
  <code className="font-mono">--variables</code>.
@@ -437,7 +437,7 @@ export const VariablesPanel = memo(function VariablesPanel({
437
437
  const wanted = action.declaration(id).type;
438
438
  if (existing && existing.type !== wanted) {
439
439
  showToast(
440
- `"${id}" is already a ${existing.type} variable — pick another id for this ${wanted} binding`,
440
+ `"${id}" is already a ${existing.type} variable. Pick another id for this ${wanted} binding`,
441
441
  "error",
442
442
  );
443
443
  return;
@@ -508,7 +508,7 @@ export const VariablesPanel = memo(function VariablesPanel({
508
508
  />
509
509
  {usage?.scanIncomplete && (
510
510
  <p className="text-[9px] text-neutral-600">
511
- Scripts access variables dynamically — usage info may be incomplete.
511
+ Scripts access variables dynamically, so usage info may be incomplete.
512
512
  </p>
513
513
  )}
514
514
  {declarations.length > 0 && (
@@ -0,0 +1,28 @@
1
+ export function GpuEncodingToggle({
2
+ format,
3
+ checked,
4
+ disabled,
5
+ onToggle,
6
+ }: {
7
+ format: "mp4" | "webm" | "mov";
8
+ checked: boolean;
9
+ disabled: boolean;
10
+ onToggle: () => void;
11
+ }) {
12
+ // GPU encoders exist for H.264/H.265 only, so only MP4 can use one.
13
+ if (format !== "mp4") return null;
14
+ return (
15
+ <label
16
+ className={`flex items-center gap-2 text-step-10 text-text-4 ${disabled ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
17
+ >
18
+ <input
19
+ type="checkbox"
20
+ checked={checked}
21
+ disabled={disabled}
22
+ onChange={onToggle}
23
+ className="accent-studio-accent"
24
+ />
25
+ <span>Use GPU encoding</span>
26
+ </label>
27
+ );
28
+ }
@@ -142,7 +142,7 @@ describe("RenderQueue controls", () => {
142
142
  exportButtonIn(host).dispatchEvent(new MouseEvent("click", { bubbles: true }));
143
143
  });
144
144
 
145
- expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "landscape-4k", 30);
145
+ expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "landscape-4k", 30, false);
146
146
  });
147
147
 
148
148
  it("refuses a resolution the composition cannot reach, and says why", async () => {
@@ -172,7 +172,23 @@ describe("RenderQueue controls", () => {
172
172
  exportButtonIn(host).dispatchEvent(new MouseEvent("click", { bubbles: true }));
173
173
  });
174
174
 
175
- expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "auto", 30);
175
+ expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "auto", 30, false);
176
+ });
177
+
178
+ it("exports with GPU encoding once the box is ticked, and remembers it", () => {
179
+ const onStartRender: Mock<StartRenderHandler> = vi.fn();
180
+ const host = mountRenderQueue(onStartRender);
181
+ const gpu = [...host.querySelectorAll("label")].find((label) =>
182
+ label.textContent?.includes("Use GPU encoding"),
183
+ );
184
+
185
+ act(() => gpu?.querySelector("input")?.click());
186
+ act(() => {
187
+ exportButtonIn(host).dispatchEvent(new MouseEvent("click", { bubbles: true }));
188
+ });
189
+
190
+ expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "auto", 30, true);
191
+ expect(getPersistedRenderSettings().gpu).toBe(true);
176
192
  });
177
193
 
178
194
  it("persists a changed format as the literal union value", async () => {
@@ -185,7 +201,9 @@ describe("RenderQueue controls", () => {
185
201
  format: "mov",
186
202
  quality: "standard",
187
203
  fps: 30,
204
+ gpu: false,
188
205
  });
206
+ expect(host.textContent).not.toContain("Use GPU encoding");
189
207
  });
190
208
  });
191
209
 
@@ -11,6 +11,7 @@ import { Tooltip } from "../ui/Tooltip";
11
11
  import { resolveFloatingPanelPosition, type FloatingPosition } from "../editor/floatingPanel";
12
12
  import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
13
13
  import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings";
14
+ import { GpuEncodingToggle } from "./GpuEncodingToggle";
14
15
  import { trackStudioEvent } from "../../utils/studioTelemetry";
15
16
 
16
17
  export interface CompositionDimensions {
@@ -23,6 +24,7 @@ export type StartRenderHandler = (
23
24
  quality: "draft" | "standard" | "high",
24
25
  resolution: ResolutionPreset | "auto",
25
26
  fps: 24 | 30 | 60,
27
+ gpu?: boolean,
26
28
  ) => void | Promise<void>;
27
29
 
28
30
  export interface RenderQueueProps {
@@ -129,7 +131,7 @@ function scaleOptionLabel(
129
131
  // Explain *why* an option is disabled instead of greying it silently:
130
132
  // the preset must be an exact integer upscale of the authored size.
131
133
  if (dims && !scaleApplies(scale, dims)) {
132
- return `${base} — not an integer scale of ${dims.width}×${dims.height}`;
134
+ return `${base} (not an integer scale of ${dims.width}×${dims.height})`;
133
135
  }
134
136
  return base;
135
137
  }
@@ -246,7 +248,7 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
246
248
  .map((f) => (
247
249
  <p key={f} className="text-step-9 text-text-4 leading-relaxed">
248
250
  <span className="text-text-3 font-medium">{FORMAT_INFO[f].label}</span>
249
- {" — "}
251
+ {": "}
250
252
  {FORMAT_INFO[f].desc}
251
253
  </p>
252
254
  ))}
@@ -303,6 +305,7 @@ function FormatExportButton({
303
305
  const [quality, setQuality] = useState<"draft" | "standard" | "high">(persisted.quality);
304
306
  const [resolution, setResolution] = useState<RenderScale>("auto");
305
307
  const [fps, setFps] = useState<24 | 30 | 60>(persisted.fps);
308
+ const [gpu, setGpu] = useState(persisted.gpu);
306
309
 
307
310
  // Only a definite "not installed" blocks Export. A null status means the
308
311
  // probe gave no answer, and refusing to export on no answer would break
@@ -385,6 +388,15 @@ function FormatExportButton({
385
388
  </div>
386
389
  )}
387
390
  </div>
391
+ <GpuEncodingToggle
392
+ format={format}
393
+ checked={gpu}
394
+ disabled={isRendering}
395
+ onToggle={() => {
396
+ setGpu(!gpu);
397
+ persistRenderSettings(format, quality, fps, !gpu);
398
+ }}
399
+ />
388
400
  <Button
389
401
  variant="primary"
390
402
  size="md"
@@ -398,7 +410,7 @@ function FormatExportButton({
398
410
  if (isRendering || missingFfmpeg) return;
399
411
  const outputResolution = resolveResolution(resolution, compositionDimensions);
400
412
  trackStudioEvent("render_start", { format, quality, resolution: outputResolution, fps });
401
- void onStartRender(format, quality, outputResolution, fps);
413
+ void onStartRender(format, quality, outputResolution, fps, gpu);
402
414
  }}
403
415
  // Width only. A type size or a weight here would win the merge against
404
416
  // the size recipe and leave this Export a step away from the header's
@@ -29,7 +29,7 @@ export const RenderQueuePanel = memo(function RenderQueuePanel() {
29
29
  ffmpeg={renderQueue.ffmpeg}
30
30
  ffmpegChecking={renderQueue.ffmpegChecking}
31
31
  onRecheckFfmpeg={renderQueue.recheckFfmpeg}
32
- onStartRender={async (format, quality, resolution, fps) => {
32
+ onStartRender={async (format, quality, resolution, fps, gpu) => {
33
33
  await waitForPendingDomEditSaves();
34
34
  // No `composition`: startRender targets the active one by default.
35
35
  await renderQueue.startRender({
@@ -37,6 +37,7 @@ export const RenderQueuePanel = memo(function RenderQueuePanel() {
37
37
  quality,
38
38
  format,
39
39
  resolution,
40
+ gpu,
40
41
  // Render what the user is previewing: active variable overrides
41
42
  // from the Variables panel ride along (undefined = defaults).
42
43
  variables: usePreviewVariablesStore.getState().values ?? undefined,
@@ -4,6 +4,7 @@ export interface PersistedRenderSettings {
4
4
  format: "mp4" | "webm" | "mov";
5
5
  quality: "draft" | "standard" | "high";
6
6
  fps: 24 | 30 | 60;
7
+ gpu: boolean;
7
8
  }
8
9
 
9
10
  export function getPersistedRenderSettings(): PersistedRenderSettings {
@@ -17,21 +18,23 @@ export function getPersistedRenderSettings(): PersistedRenderSettings {
17
18
  ? parsed.quality
18
19
  : "standard",
19
20
  fps: [24, 30, 60].includes(parsed.fps) ? parsed.fps : 30,
21
+ gpu: parsed.gpu === true,
20
22
  };
21
23
  }
22
24
  } catch {
23
25
  /* ignore */
24
26
  }
25
- return { format: "mp4", quality: "standard", fps: 30 };
27
+ return { format: "mp4", quality: "standard", fps: 30, gpu: false };
26
28
  }
27
29
 
28
30
  export function persistRenderSettings(
29
31
  format: PersistedRenderSettings["format"],
30
32
  quality: PersistedRenderSettings["quality"],
31
33
  fps: PersistedRenderSettings["fps"],
34
+ gpu: boolean = getPersistedRenderSettings().gpu,
32
35
  ): void {
33
36
  try {
34
- localStorage.setItem(RENDER_SETTINGS_KEY, JSON.stringify({ format, quality, fps }));
37
+ localStorage.setItem(RENDER_SETTINGS_KEY, JSON.stringify({ format, quality, fps, gpu }));
35
38
  } catch {
36
39
  /* ignore */
37
40
  }
@@ -32,6 +32,7 @@ export interface StartRenderOptions {
32
32
  format?: "mp4" | "webm" | "mov";
33
33
  /** `"auto"` (default) renders at the composition's authored dimensions. */
34
34
  resolution?: ResolutionPreset | "auto";
35
+ gpu?: boolean;
35
36
  /**
36
37
  * Render a specific composition file. Omit it to render the composition the
37
38
  * user currently has open — only the sidebar's per-composition Render button
@@ -234,6 +235,7 @@ export function useRenderQueue(
234
235
  resolution?: string;
235
236
  composition?: string;
236
237
  variables?: Record<string, unknown>;
238
+ gpu?: boolean;
237
239
  telemetryDistinctId?: string;
238
240
  telemetryOptOut?: boolean;
239
241
  } = {
@@ -258,6 +260,7 @@ export function useRenderQueue(
258
260
  }
259
261
  if (resolution && resolution !== "auto") body.resolution = resolution;
260
262
  if (composition) body.composition = composition;
263
+ if (opts.gpu) body.gpu = true;
261
264
  if (opts.variables && Object.keys(opts.variables).length > 0) {
262
265
  body.variables = opts.variables;
263
266
  }
@@ -379,7 +382,7 @@ export function useRenderQueue(
379
382
  try {
380
383
  const res = await studioApiFetch(`/api/render/${jobId}/cancel`, { method: "POST" });
381
384
  if (!res.ok && res.status !== 404) {
382
- setActionError("Couldn't cancel on the server — the render may still be running.");
385
+ setActionError("Couldn't cancel on the server. The render may still be running.");
383
386
  return;
384
387
  }
385
388
  // Reconcile with the status the route reports: if the render actually
@@ -393,7 +396,7 @@ export function useRenderQueue(
393
396
  }
394
397
  }
395
398
  } catch {
396
- setActionError("Couldn't reach the server to cancel — the render may still be running.");
399
+ setActionError("Couldn't reach the server to cancel. The render may still be running.");
397
400
  }
398
401
  },
399
402
  [closeActiveEventSource, loadRenders],
@@ -406,7 +409,7 @@ export function useRenderQueue(
406
409
  try {
407
410
  const res = await studioApiFetch(`/api/render/${jobId}`, { method: "DELETE" });
408
411
  if (!res.ok) {
409
- setActionError("Couldn't delete the render — it's still on disk.");
412
+ setActionError("Couldn't delete the render. It's still on disk.");
410
413
  return;
411
414
  }
412
415
  } catch {
@@ -53,3 +53,15 @@ describe("render target composition", () => {
53
53
  expect(body["composition"]).toBeUndefined();
54
54
  });
55
55
  });
56
+
57
+ describe("GPU encoding", () => {
58
+ it("asks the server for GPU encoding when the export asks for it", async () => {
59
+ const body = await renderBody(null, { format: "mp4", gpu: true });
60
+ expect(body["gpu"]).toBe(true);
61
+ });
62
+
63
+ it("leaves GPU encoding off by default", async () => {
64
+ const body = await renderBody(null, { format: "mp4" });
65
+ expect(body["gpu"]).toBeUndefined();
66
+ });
67
+ });
@@ -209,7 +209,7 @@ export function AssetCard({
209
209
  draggable
210
210
  role="button"
211
211
  tabIndex={0}
212
- aria-label={`${name} — open, drag to timeline, right-click for actions`}
212
+ aria-label={`${name}: open, drag to timeline, right-click for actions`}
213
213
  onPointerDown={handlePointerDown}
214
214
  onPointerUp={handlePointerUp}
215
215
  onKeyDown={(e) => {
@@ -337,7 +337,7 @@ export function FontRow({
337
337
  draggable
338
338
  role="button"
339
339
  tabIndex={0}
340
- aria-label={`${name} — copy path, drag to timeline, right-click for actions`}
340
+ aria-label={`${name}: copy path, drag to timeline, right-click for actions`}
341
341
  onClick={() => onCopy(asset)}
342
342
  onKeyDown={(e) => {
343
343
  if (e.target !== e.currentTarget) return;
@@ -208,7 +208,7 @@ export function AudioRow({
208
208
  onPointerUp={handlePointerUp}
209
209
  role="button"
210
210
  tabIndex={0}
211
- aria-label={`${name} — open, drag to timeline, right-click for actions`}
211
+ aria-label={`${name}: open, drag to timeline, right-click for actions`}
212
212
  onKeyDown={(e) => {
213
213
  // Only when the row itself is focused — keydowns bubbling from the
214
214
  // inner controls (play button) must keep their native activation.
@@ -197,7 +197,7 @@ function formatCompositionContext(ctx: CompositionContext): string {
197
197
  `Elements visible at ${formatTime(ctx.currentTime)}:`,
198
198
  ...visibleNow.map(
199
199
  (el) =>
200
- `- ${el.label || el.id} (track ${el.track}, ${formatTime(el.start)}–${formatTime(el.start + el.duration)}${el.compositionSrc ? `, src: ${el.compositionSrc}` : ""})`,
200
+ `- ${el.label || el.id} (track ${el.track}, ${formatTime(el.start)} to ${formatTime(el.start + el.duration)}${el.compositionSrc ? `, src: ${el.compositionSrc}` : ""})`,
201
201
  ),
202
202
  );
203
203
  }
@@ -232,7 +232,7 @@ function buildAgentPrompt(
232
232
  transitions: [
233
233
  `Using /hyperframes, add the "${title}" transition (registry: ${name}) between my scenes.`,
234
234
  `${description}`,
235
- `Place this transition at the cut point between the current scene and the next. Set the duration to 0.5–1s, position it at the scene boundary on the timeline, and make sure the z-index is above both scenes. Adjust colors to match my palette.`,
235
+ `Place this transition at the cut point between the current scene and the next. Set the duration to 0.5 to 1s, position it at the scene boundary on the timeline, and make sure the z-index is above both scenes. Adjust colors to match my palette.`,
236
236
  ].join("\n\n"),
237
237
  effects: [
238
238
  `Using /hyperframes, add the "${title}" effect (registry: ${name}) as an overlay on my composition.`,
@@ -380,8 +380,8 @@ function CompCard({
380
380
  <span className="text-[11px] font-medium text-neutral-300 truncate">{name}</span>
381
381
  {isRoot && (
382
382
  <span
383
- aria-label="Root composition — opens automatically on load"
384
- title="Root composition — opens automatically on load"
383
+ aria-label="Root composition: opens automatically on load"
384
+ title="Root composition: opens automatically on load"
385
385
  className="flex-shrink-0 rounded-full bg-neutral-700/60 px-1.5 py-px text-[8px] font-bold uppercase tracking-wide text-neutral-300"
386
386
  >
387
387
  Root
@@ -55,7 +55,7 @@ export function PromptPreviewModal({
55
55
  ref={containerRef}
56
56
  role="dialog"
57
57
  aria-modal="true"
58
- aria-label={`Ask agent — ${title}`}
58
+ aria-label={`Ask agent: ${title}`}
59
59
  tabIndex={-1}
60
60
  className="w-[560px] max-h-[80vh] flex flex-col rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl outline-hidden"
61
61
  onClick={(e) => e.stopPropagation()}
@@ -181,7 +181,7 @@ export async function createAudioGroupAndAssignMembers({
181
181
  // then persists `sources: [groupId]` on success, so a quiet no-op leaves the
182
182
  // carve aimed at a group that does not exist.
183
183
  if (elements.length < 2) {
184
- throw new Error(`Cannot group ${elements.length} clip(s) — a group needs at least two`);
184
+ throw new Error(`Cannot group ${elements.length} clip(s): a group needs at least two`);
185
185
  }
186
186
  if (!GROUP_ID_PATTERN.test(groupId)) {
187
187
  throw new Error(`Invalid audio group id ${JSON.stringify(groupId)}`);
@@ -236,7 +236,7 @@ export function applyTimelineMoveAttributes(
236
236
  ): string {
237
237
  if (!Number.isFinite(start) || !Number.isFinite(duration)) {
238
238
  console.warn(
239
- `[Timeline] applyTimelineMoveAttributes: non-finite timing (start=${start}, duration=${duration}) — patch skipped`,
239
+ `[Timeline] applyTimelineMoveAttributes: non-finite timing (start=${start}, duration=${duration}). Patch skipped`,
240
240
  );
241
241
  return original;
242
242
  }
@@ -92,7 +92,7 @@ export function useBlockHandlers({
92
92
  const runBlockInstall = useCallback(
93
93
  async <T>(blockName: string, install: () => Promise<T>): Promise<T | null> => {
94
94
  if (installingBlockRef.current) {
95
- showToast("A block is already installing — one moment…", "info");
95
+ showToast("A block is already installing. One moment…", "info");
96
96
  return null;
97
97
  }
98
98
  installingBlockRef.current = true;
@@ -4,6 +4,7 @@ import React, { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, expect, it } from "vitest";
6
6
  import { STUDIO_PREVIEW_ERRORS } from "@hyperframes/core/studio-preview-mark";
7
+ import { announcePreviewDocumentLoaded } from "../player/sceneSwap";
7
8
  import { useConsoleErrorCapture } from "./useConsoleErrorCapture";
8
9
 
9
10
  Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
@@ -36,7 +37,7 @@ it("shows the errors a preview raised before its load, once each", async () => {
36
37
  root = createRoot(document.createElement("div"));
37
38
  await act(async () => root?.render(React.createElement(Harness, { iframe })));
38
39
  Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: [raisedBeforeLoad] });
39
- await act(async () => iframe.dispatchEvent(new Event("load")));
40
+ await act(async () => announcePreviewDocumentLoaded(iframe));
40
41
 
41
42
  expect(shown()).toEqual([raisedBeforeLoad]);
42
43
  });
@@ -79,9 +80,27 @@ it("clears a document's errors when its load replaces it, and when the preview g
79
80
  previewWindow.dispatchEvent(new ErrorEvent("error", { message: "Uncaught Error: old" })),
80
81
  );
81
82
  Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: ["Uncaught Error: new"] });
82
- await act(async () => iframe.dispatchEvent(new Event("load")));
83
+ await act(async () => announcePreviewDocumentLoaded(iframe));
83
84
  expect(shown()).toEqual(["Uncaught Error: new"]);
84
85
 
85
86
  await act(async () => root?.render(React.createElement(NullableHarness, { frame: null })));
86
87
  expect(capture.consoleErrors).toBeNull();
88
+ await act(async () => announcePreviewDocumentLoaded(iframe));
89
+ expect(capture.consoleErrors).toBeNull();
90
+ });
91
+
92
+ it("keeps a page's errors through its own late load, and clears them for the next announced page", async () => {
93
+ const { iframe, previewWindow } = previewFrame();
94
+ root = createRoot(document.createElement("div"));
95
+ await act(async () => root?.render(React.createElement(Harness, { iframe })));
96
+ await act(async () =>
97
+ previewWindow.dispatchEvent(new ErrorEvent("error", { message: "Uncaught Error: kept" })),
98
+ );
99
+
100
+ await act(async () => iframe.dispatchEvent(new Event("load")));
101
+ expect(shown()).toEqual(["Uncaught Error: kept"]);
102
+
103
+ Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: ["Uncaught Error: new page"] });
104
+ await act(async () => announcePreviewDocumentLoaded(iframe));
105
+ expect(shown()).toEqual(["Uncaught Error: new page"]);
87
106
  });
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { STUDIO_PREVIEW_ERRORS } from "@hyperframes/core/studio-preview-mark";
3
+ import { onPreviewDocumentLoaded } from "../player/sceneSwap";
3
4
  import type { LintFinding } from "../components/LintModal";
4
5
 
5
6
  /**
@@ -73,9 +74,9 @@ export function useConsoleErrorCapture(previewIframe: HTMLIFrameElement | null)
73
74
  };
74
75
 
75
76
  attachErrorCapture();
76
- previewIframe.addEventListener("load", attachErrorCapture);
77
+ const stopLoaded = onPreviewDocumentLoaded(previewIframe, attachErrorCapture);
77
78
  return () => {
78
- previewIframe.removeEventListener("load", attachErrorCapture);
79
+ stopLoaded();
79
80
  detachErrorCapture();
80
81
  };
81
82
  }, [previewIframe, resetErrors]);
@@ -340,7 +340,7 @@ export function useDomEditSession({
340
340
  if (members.some((m) => isAudioDomElement(m.element))) {
341
341
  showToast(
342
342
  members.every((m) => isAudioDomElement(m.element))
343
- ? "Audio clips group into a bus — use FX on the track header"
343
+ ? "Audio clips group into a bus. Use FX on the track header"
344
344
  : "Can't group audio clips with layout elements",
345
345
  "info",
346
346
  );
@@ -93,7 +93,7 @@ export function useEditorSave({
93
93
  ) {
94
94
  lastFailureToastAtRef.current = now;
95
95
  showToast(
96
- `Couldn't save ${path} — your latest edits are NOT persisted. Check the preview server; editing again retries the save.`,
96
+ `Couldn't save ${path}. Your latest edits are NOT persisted. Check the preview server; editing again retries the save.`,
97
97
  "error",
98
98
  );
99
99
  }
@@ -95,7 +95,7 @@ export function useFrameCapture({
95
95
  } catch (fetchErr) {
96
96
  clearTimeout(timeout);
97
97
  if (fetchErr instanceof DOMException && fetchErr.name === "AbortError") {
98
- throw new Error("Capture timed out — the server took too long to respond");
98
+ throw new Error("Capture timed out: the server took too long to respond");
99
99
  }
100
100
  throw fetchErr;
101
101
  }