@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.
- package/dist/assets/{hyperframes-player-Bw_jIaSX.js → hyperframes-player-tkxyWf5H.js} +3 -3
- package/dist/assets/{index-Cj7W_c7p.js → index-BrdqOkqP.js} +1 -1
- package/dist/assets/{index-BVM9NuWl.js → index-Cg48BxAo.js} +145 -145
- package/dist/assets/{index-DyH9dT7b.js → index-Dllxg4aU.js} +1 -1
- package/dist/{chunk-EEXSYY5F.js → chunk-C5BDT7NG.js} +0 -1
- package/dist/{chunk-PEAKCOUW.js → chunk-HZDCMUG4.js} +2 -100
- package/dist/{chunk-PZ5AY32C.js → chunk-MLKGABMK.js} +0 -1
- package/dist/{chunk-G3EDKRZH.js → chunk-PHGBG7OG.js} +100 -5
- package/dist/{chunk-Y2OHHNM2.js → chunk-PVFBY7KR.js} +0 -1
- package/dist/{chunk-3GHTHPZD.js → chunk-T43DM7Q6.js} +101 -3
- package/dist/{domEditingLayers-PYST4G45.js → domEditingLayers-SMVA3FEW.js} +3 -4
- package/dist/index.css +0 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.html +1 -1
- package/dist/index.js +509 -529
- package/dist/player.js +4 -5
- package/dist/styles/tailwind-preset.js +1 -2
- package/dist/ui.js +3 -4
- package/package.json +8 -8
- package/src/captions/components/CaptionOverlay.tsx +1 -1
- package/src/captions/hooks/useCaptionSync.ts +1 -1
- package/src/components/LinkedSelectionToggle.tsx +2 -2
- package/src/components/LintModal.tsx +1 -5
- package/src/components/MediaPreview.tsx +2 -2
- package/src/components/ProjectUnreachableBanner.tsx +1 -1
- package/src/components/StudioLeftPanels.tsx +2 -2
- package/src/components/StudioRightPanels.tsx +1 -1
- package/src/components/TimelineToolbar.test.tsx +2 -2
- package/src/components/TimelineToolbar.tsx +6 -6
- package/src/components/editor/AnimationCard.tsx +3 -3
- package/src/components/editor/AnimationCardParts.tsx +1 -1
- package/src/components/editor/BlockParamsPanel.tsx +3 -3
- package/src/components/editor/ComputedTweenNotice.tsx +2 -2
- package/src/components/editor/FileTree.tsx +1 -1
- package/src/components/editor/GsapAnimationList.tsx +1 -1
- package/src/components/editor/GsapAnimationSection.tsx +2 -2
- package/src/components/editor/LayersPanel.test.ts +4 -4
- package/src/components/editor/LayersPanel.tsx +2 -2
- package/src/components/editor/OffCanvasIndicators.tsx +1 -1
- package/src/components/editor/PromotableControl.tsx +1 -1
- package/src/components/editor/PropertyPanel.tsx +2 -2
- package/src/components/editor/SnapToolbar.tsx +2 -2
- package/src/components/editor/TopologyLens.test.tsx +5 -4
- package/src/components/editor/Transform3DCube.tsx +1 -1
- package/src/components/editor/gsapAnimationConstants.ts +2 -2
- package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
- package/src/components/editor/gsapAnimationHelpers.ts +1 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +1 -1
- package/src/components/editor/propertyPanelFill.tsx +1 -1
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +3 -3
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +1 -1
- package/src/components/editor/propertyPanelFlatTextSection.tsx +1 -1
- package/src/components/editor/propertyPanelFont.tsx +3 -3
- package/src/components/editor/propertyPanelFxBandRuler.tsx +2 -2
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +2 -2
- package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
- package/src/components/editor/useColorGradingController.ts +1 -1
- package/src/components/panels/SlideshowSubPanels.tsx +2 -2
- package/src/components/panels/VariablesBindElement.tsx +1 -1
- package/src/components/panels/VariablesPanel.tsx +3 -3
- package/src/components/renders/GpuEncodingToggle.tsx +28 -0
- package/src/components/renders/RenderQueue.test.tsx +20 -2
- package/src/components/renders/RenderQueue.tsx +15 -3
- package/src/components/renders/RenderQueuePanel.tsx +2 -1
- package/src/components/renders/renderSettings.ts +5 -2
- package/src/components/renders/useRenderQueue.ts +6 -3
- package/src/components/renders/useRenderQueueComposition.test.tsx +12 -0
- package/src/components/sidebar/AssetCard.tsx +2 -2
- package/src/components/sidebar/AudioRow.tsx +1 -1
- package/src/components/sidebar/BlocksTab.tsx +2 -2
- package/src/components/sidebar/CompositionsTab.tsx +2 -2
- package/src/components/sidebar/PromptPreviewModal.tsx +1 -1
- package/src/hooks/timelineAudioGroupCreate.ts +1 -1
- package/src/hooks/timelineEditingHelpers.ts +1 -1
- package/src/hooks/useBlockHandlers.ts +1 -1
- package/src/hooks/useConsoleErrorCapture.test.tsx +21 -2
- package/src/hooks/useConsoleErrorCapture.ts +3 -2
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useFrameCapture.ts +1 -1
- package/src/hooks/useGestureCommit.ts +4 -4
- package/src/hooks/useGsapScriptCommits.ts +1 -1
- package/src/index.ts +1 -0
- package/src/player/components/AudioGainDialog.test.tsx +1 -1
- package/src/player/components/AudioGainDialog.tsx +2 -1
- package/src/player/components/EditModal.tsx +2 -2
- package/src/player/components/KeyframeDiamondContextMenu.tsx +1 -1
- package/src/player/components/OutOfSyncBadge.tsx +1 -1
- package/src/player/components/Player.test.ts +44 -0
- package/src/player/components/Player.tsx +12 -1
- package/src/player/components/ShortcutsPanel.tsx +2 -2
- package/src/player/components/Timeline.virtualization.test.tsx +1 -1
- package/src/player/components/TimelineAutomationLaneSlot.tsx +1 -1
- package/src/player/components/TimelineClip.tsx +1 -1
- package/src/player/components/TimelineFxButton.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +3 -3
- package/src/player/components/timelineClipDragCommit.ts +2 -2
- package/src/player/components/trackHeaderLabelRows.tsx +1 -1
- package/src/player/lib/playbackAdapter.ts +1 -1
- package/src/player/lib/playbackTypes.ts +1 -0
- package/src/player/sceneSwap.test.ts +27 -1
- package/src/player/sceneSwap.ts +16 -2
- package/src/utils/hmrStableContext.ts +1 -1
- package/src/utils/razorSplitTransaction.ts +0 -2
- package/src/utils/sdkCutover.linkedTiming.test.ts +1 -1
- package/dist/chunk-3GHTHPZD.js.map +0 -1
- package/dist/chunk-EEXSYY5F.js.map +0 -1
- package/dist/chunk-G3EDKRZH.js.map +0 -1
- package/dist/chunk-PEAKCOUW.js.map +0 -1
- package/dist/chunk-PZ5AY32C.js.map +0 -1
- package/dist/chunk-Y2OHHNM2.js.map +0 -1
- package/dist/domEditingLayers-PYST4G45.js.map +0 -1
- package/dist/index.css.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/player.js.map +0 -1
- package/dist/styles/tailwind-preset.js.map +0 -1
- 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
|
|
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}"
|
|
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}"
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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=""
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
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}
|
|
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}
|
|
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)}
|
|
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
|
|
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
|
|
384
|
-
title="Root composition
|
|
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
|
|
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)
|
|
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})
|
|
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
|
|
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
|
|
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
|
|
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
|
|
77
|
+
const stopLoaded = onPreviewDocumentLoaded(previewIframe, attachErrorCapture);
|
|
77
78
|
return () => {
|
|
78
|
-
|
|
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
|
|
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}
|
|
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
|
|
98
|
+
throw new Error("Capture timed out: the server took too long to respond");
|
|
99
99
|
}
|
|
100
100
|
throw fetchErr;
|
|
101
101
|
}
|