@hyperframes/studio 0.8.53-desktop.0 → 0.8.53
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-PTEnAHhg.js → hyperframes-player-DKWM-Ons.js} +1 -1
- package/dist/assets/{index-BnXZuIJD.css → index-DrHQAuGO.css} +1 -1
- package/dist/assets/{index-CNyviK_l.js → index-HfP3sap2.js} +200 -200
- package/dist/assets/{index-BjslOS9g.js → index-UvW6BEwf.js} +1 -1
- package/dist/assets/{index--myXp8ui.js → index-Y4OnpTEM.js} +1 -1
- package/dist/index.d.ts +11 -406
- package/dist/index.html +2 -2
- package/dist/index.js +7570 -7709
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -0
- package/package.json +8 -7
- package/src/components/StudioHeader.tsx +1 -1
- package/src/components/nle/TimelinePane.tsx +0 -7
- package/src/components/renders/RenderQueue.test.tsx +133 -25
- package/src/components/renders/RenderQueue.tsx +99 -92
- package/src/components/renders/RenderQueueItem.tsx +69 -64
- package/src/components/ui/Select.tsx +4 -0
- package/src/headerExports.test.tsx +23 -0
- package/src/hooks/useFileManager.ts +123 -21
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/useRightPanelIntents.ts +2 -1
- package/src/hooks/useTimelineDeleteOps.test.tsx +0 -111
- package/src/hooks/useTimelineEditing.test.tsx +3 -322
- package/src/hooks/useTimelineEditing.ts +16 -103
- package/src/hooks/useTimelineEditingTypes.ts +0 -3
- package/src/index.ts +2 -37
- package/src/main.tsx +26 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +5 -26
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/timelineTheme.test.ts +4 -4
- package/src/player/components/timelineTheme.ts +30 -30
- package/src/styles/hex-baseline.json +2 -2
- package/src/styles/theme.css +0 -27
- package/src/hooks/timelineEditPermission.ts +0 -31
- package/src/hooks/useProjectFileWriter.ts +0 -150
- package/src/hooks/useTrackPendingTimelineEdit.test.tsx +0 -62
- package/src/hooks/useTrackPendingTimelineEdit.ts +0 -22
- package/src/hostMount.test.tsx +0 -222
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.53
|
|
3
|
+
"version": "0.8.53",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"types": "./dist/styles/tailwind-preset.d.ts"
|
|
25
25
|
},
|
|
26
26
|
"./theme.css": "./src/styles/theme.css",
|
|
27
|
+
"./styles.css": "./dist/styles.css",
|
|
27
28
|
"./package.json": "./package.json"
|
|
28
29
|
},
|
|
29
30
|
"publishConfig": {
|
|
@@ -50,11 +51,11 @@
|
|
|
50
51
|
"dockview-react": "8.3.1",
|
|
51
52
|
"gsap": "^3.13.0",
|
|
52
53
|
"mediabunny": "^1.45.3",
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/
|
|
55
|
-
"@hyperframes/
|
|
56
|
-
"@hyperframes/
|
|
57
|
-
"@hyperframes/
|
|
54
|
+
"@hyperframes/core": "0.8.53",
|
|
55
|
+
"@hyperframes/sdk": "0.8.53",
|
|
56
|
+
"@hyperframes/player": "0.8.53",
|
|
57
|
+
"@hyperframes/parsers": "0.8.53",
|
|
58
|
+
"@hyperframes/studio-server": "0.8.53"
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
60
61
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -70,7 +71,7 @@
|
|
|
70
71
|
"vite": "^6.4.2",
|
|
71
72
|
"vitest": "^4.1.11",
|
|
72
73
|
"zustand": "^5.0.0",
|
|
73
|
-
"@hyperframes/producer": "0.8.53
|
|
74
|
+
"@hyperframes/producer": "0.8.53"
|
|
74
75
|
},
|
|
75
76
|
"peerDependencies": {
|
|
76
77
|
"react": "19",
|
|
@@ -17,7 +17,7 @@ export interface StudioHeaderProps {
|
|
|
17
17
|
onExport?: () => void;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
function HyperframesLogo() {
|
|
20
|
+
export function HyperframesLogo() {
|
|
21
21
|
// Full logo from logo-dark.svg (263×79): heygen label + gradient mark + hyperframes wordmark.
|
|
22
22
|
// All fill="black" paths inverted to white for the dark header.
|
|
23
23
|
const height = 28;
|
|
@@ -77,9 +77,6 @@ export interface TimelinePaneProps {
|
|
|
77
77
|
timelineToolbar?: ReactNode;
|
|
78
78
|
/** Slot rendered below the timeline tracks */
|
|
79
79
|
timelineFooter?: ReactNode;
|
|
80
|
-
/** Slot for a host's own overlay spanning the whole timeline area (e.g. a draft/ghost
|
|
81
|
-
* clip), positioned absolutely the same way PreviewPane's previewOverlay is. */
|
|
82
|
-
timelineOverlay?: ReactNode;
|
|
83
80
|
/** Custom clip content renderer for timeline (thumbnails, waveforms, etc.) */
|
|
84
81
|
renderClipContent?: (
|
|
85
82
|
element: TimelineElement,
|
|
@@ -116,7 +113,6 @@ export interface TimelinePaneProps {
|
|
|
116
113
|
export function TimelinePane({
|
|
117
114
|
timelineToolbar,
|
|
118
115
|
timelineFooter,
|
|
119
|
-
timelineOverlay,
|
|
120
116
|
renderClipContent,
|
|
121
117
|
onFileDrop,
|
|
122
118
|
onDeleteElement,
|
|
@@ -289,9 +285,6 @@ export function TimelinePane({
|
|
|
289
285
|
/>
|
|
290
286
|
</div>
|
|
291
287
|
{timelineFooter && <div className="shrink-0">{timelineFooter}</div>}
|
|
292
|
-
{timelineOverlay && (
|
|
293
|
-
<div className="pointer-events-none absolute inset-0 z-20">{timelineOverlay}</div>
|
|
294
|
-
)}
|
|
295
288
|
{timelineDisabled && (
|
|
296
289
|
<div
|
|
297
290
|
className="absolute inset-0 z-30 cursor-not-allowed bg-black/18 flex items-center justify-center"
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
import { act } from "react";
|
|
4
4
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
|
6
|
-
import { RenderQueue } from "./RenderQueue";
|
|
6
|
+
import { RenderQueue, type StartRenderHandler } from "./RenderQueue";
|
|
7
|
+
import { getPersistedRenderSettings } from "./renderSettings";
|
|
8
|
+
import { buttonBase, buttonSizes, buttonVariants, cn } from "../ui";
|
|
9
|
+
import { isTypingTarget } from "../../utils/typingTarget";
|
|
10
|
+
import { shouldIgnorePlaybackShortcutTarget } from "../../player/lib/playbackShortcuts";
|
|
7
11
|
import type { FfmpegStatus } from "./useFfmpegStatus";
|
|
8
12
|
|
|
9
13
|
// Encoder availability arrives as a prop (useRenderQueue owns the probe), so
|
|
@@ -18,6 +22,9 @@ let root: Root | null = null;
|
|
|
18
22
|
beforeEach(() => {
|
|
19
23
|
ffmpegStatus = { ok: true };
|
|
20
24
|
recheck.mockClear();
|
|
25
|
+
// The format, frame-rate and quality controls write through to the real
|
|
26
|
+
// store, so each case has to start from the shipped defaults.
|
|
27
|
+
localStorage.clear();
|
|
21
28
|
});
|
|
22
29
|
|
|
23
30
|
afterEach(() => {
|
|
@@ -26,7 +33,10 @@ afterEach(() => {
|
|
|
26
33
|
document.body.innerHTML = "";
|
|
27
34
|
});
|
|
28
35
|
|
|
29
|
-
function mountRenderQueue(
|
|
36
|
+
function mountRenderQueue(
|
|
37
|
+
onStartRender: Mock<StartRenderHandler>,
|
|
38
|
+
compositionDimensions = { width: 1920, height: 1080 },
|
|
39
|
+
) {
|
|
30
40
|
const host = document.createElement("div");
|
|
31
41
|
document.body.append(host);
|
|
32
42
|
root = createRoot(host);
|
|
@@ -39,7 +49,7 @@ function mountRenderQueue(onStartRender: Mock) {
|
|
|
39
49
|
onClearCompleted={vi.fn()}
|
|
40
50
|
onStartRender={onStartRender}
|
|
41
51
|
isRendering={false}
|
|
42
|
-
compositionDimensions={
|
|
52
|
+
compositionDimensions={compositionDimensions}
|
|
43
53
|
ffmpeg={ffmpegStatus}
|
|
44
54
|
ffmpegChecking={false}
|
|
45
55
|
onRecheckFfmpeg={recheck}
|
|
@@ -49,37 +59,135 @@ function mountRenderQueue(onStartRender: Mock) {
|
|
|
49
59
|
return host;
|
|
50
60
|
}
|
|
51
61
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
/** Base UI moves focus a task later than React renders; happy-dom is no faster. */
|
|
63
|
+
const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 0))));
|
|
64
|
+
|
|
65
|
+
function fire(el: Element, type: string, key?: string) {
|
|
66
|
+
const event =
|
|
67
|
+
key === undefined
|
|
68
|
+
? new MouseEvent(type, { bubbles: true })
|
|
69
|
+
: new KeyboardEvent(type, { bubbles: true, key });
|
|
70
|
+
act(() => void el.dispatchEvent(event));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function triggerFor(host: HTMLElement, label: string): HTMLElement {
|
|
74
|
+
const trigger = host.querySelector<HTMLElement>(`[role="combobox"][aria-label="${label}"]`);
|
|
75
|
+
if (!trigger) throw new Error(`no select labelled ${label}`);
|
|
76
|
+
return trigger;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Opens a Select and arrows down `steps` items before committing, the way a keyboard user
|
|
80
|
+
* does. happy-dom does not synthesise the click Space would trigger, so it is dispatched here. */
|
|
81
|
+
async function chooseByArrowing(trigger: HTMLElement, steps: number) {
|
|
82
|
+
fire(trigger, "keydown", " ");
|
|
83
|
+
fire(trigger, "keyup", " ");
|
|
84
|
+
act(() => trigger.click());
|
|
85
|
+
await settle();
|
|
86
|
+
for (let i = 0; i < steps; i += 1) {
|
|
87
|
+
fire(document.activeElement ?? document.body, "keydown", "ArrowDown");
|
|
88
|
+
await settle();
|
|
89
|
+
}
|
|
90
|
+
fire(document.activeElement ?? document.body, "keydown", "Enter");
|
|
91
|
+
await settle();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function exportButtonIn(host: HTMLElement): HTMLButtonElement {
|
|
95
|
+
const button = host.querySelector<HTMLButtonElement>('[data-testid="renders-export"]');
|
|
96
|
+
if (!button) throw new Error("export button did not render");
|
|
97
|
+
return button;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe("RenderQueue controls", () => {
|
|
101
|
+
it("has no native select left in the panel", () => {
|
|
102
|
+
// R8. The four format / resolution / frame-rate / quality controls are the
|
|
103
|
+
// shared Select now; a native one would bring back an OS popup that no
|
|
104
|
+
// token can reach.
|
|
105
|
+
expect(mountRenderQueue(vi.fn()).querySelector("select")).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("wears exactly the header Export's recipe, plus the full width", () => {
|
|
109
|
+
// AE3. Set equality, not "contains": the bug this replaces was an extra
|
|
110
|
+
// `text-[11px]` on this button, which `cn` resolved by dropping the size
|
|
111
|
+
// recipe's `text-step-12` and left the two Exports a type step apart.
|
|
112
|
+
const shared = cn(buttonBase, buttonVariants.primary, buttonSizes.md);
|
|
113
|
+
const classes = new Set(exportButtonIn(mountRenderQueue(vi.fn())).className.split(/\s+/));
|
|
114
|
+
|
|
115
|
+
expect(classes).toEqual(new Set([...shared.split(/\s+/), "w-full"]));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("classifies the format Select the way it classified the native one (KTD13)", async () => {
|
|
119
|
+
const host = mountRenderQueue(vi.fn());
|
|
120
|
+
const reference = document.createElement("select");
|
|
121
|
+
document.body.append(reference);
|
|
122
|
+
await settle();
|
|
123
|
+
|
|
124
|
+
const trigger = triggerFor(host, "Format");
|
|
125
|
+
|
|
126
|
+
// Both true, not merely equal: two falses would agree and prove nothing.
|
|
127
|
+
expect(isTypingTarget(reference)).toBe(true);
|
|
128
|
+
expect(shouldIgnorePlaybackShortcutTarget(reference)).toBe(true);
|
|
129
|
+
expect(isTypingTarget(trigger)).toBe(isTypingTarget(reference));
|
|
130
|
+
expect(shouldIgnorePlaybackShortcutTarget(trigger)).toBe(
|
|
131
|
+
shouldIgnorePlaybackShortcutTarget(reference),
|
|
58
132
|
);
|
|
59
|
-
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("submits the canonical landscape 4K preset selected by the user", async () => {
|
|
136
|
+
const onStartRender: Mock<StartRenderHandler> = vi.fn();
|
|
137
|
+
const host = mountRenderQueue(onStartRender);
|
|
60
138
|
|
|
139
|
+
// Auto, 1080p, 4K: two steps down from the default.
|
|
140
|
+
await chooseByArrowing(triggerFor(host, "Resolution"), 2);
|
|
61
141
|
act(() => {
|
|
62
|
-
|
|
63
|
-
resolutionSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
|
142
|
+
exportButtonIn(host).dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
64
143
|
});
|
|
65
144
|
|
|
66
|
-
|
|
67
|
-
|
|
145
|
+
expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "landscape-4k", 30);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("refuses a resolution the composition cannot reach, and says why", async () => {
|
|
149
|
+
// 1080p on a 1280x720 comp is a 1.5x scale, which the producer rejects; the
|
|
150
|
+
// option stays listed (its label explains why) but the keyboard skips it.
|
|
151
|
+
const onStartRender: Mock<StartRenderHandler> = vi.fn();
|
|
152
|
+
const host = mountRenderQueue(onStartRender, { width: 1280, height: 720 });
|
|
153
|
+
const trigger = triggerFor(host, "Resolution");
|
|
154
|
+
|
|
155
|
+
fire(trigger, "keydown", " ");
|
|
156
|
+
fire(trigger, "keyup", " ");
|
|
157
|
+
act(() => trigger.click());
|
|
158
|
+
await settle();
|
|
159
|
+
const blocked = [...document.querySelectorAll('[role="option"]')].filter((option) =>
|
|
160
|
+
option.hasAttribute("data-disabled"),
|
|
68
161
|
);
|
|
69
|
-
|
|
162
|
+
expect(blocked).toHaveLength(1);
|
|
163
|
+
expect(blocked[0]?.textContent).toContain("not an integer scale of 1280×720");
|
|
164
|
+
|
|
165
|
+
fire(document.activeElement ?? document.body, "keydown", "ArrowDown");
|
|
166
|
+
await settle();
|
|
167
|
+
fire(document.activeElement ?? document.body, "keydown", "Enter");
|
|
168
|
+
await settle();
|
|
169
|
+
fire(document.activeElement ?? document.body, "keydown", "Escape");
|
|
170
|
+
await settle();
|
|
70
171
|
act(() => {
|
|
71
|
-
|
|
172
|
+
exportButtonIn(host).dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
72
173
|
});
|
|
73
174
|
|
|
74
|
-
expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "
|
|
175
|
+
expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "auto", 30);
|
|
75
176
|
});
|
|
76
|
-
});
|
|
77
177
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
178
|
+
it("persists a changed format as the literal union value", async () => {
|
|
179
|
+
const host = mountRenderQueue(vi.fn());
|
|
180
|
+
|
|
181
|
+
// MP4, MOV, WebM: one step down commits "mov", not the label "MOV (ProRes)".
|
|
182
|
+
await chooseByArrowing(triggerFor(host, "Format"), 1);
|
|
183
|
+
|
|
184
|
+
expect(getPersistedRenderSettings()).toEqual({
|
|
185
|
+
format: "mov",
|
|
186
|
+
quality: "standard",
|
|
187
|
+
fps: 30,
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
83
191
|
|
|
84
192
|
describe("RenderQueue Export button", () => {
|
|
85
193
|
it("leaves the font size to the shared Button", () => {
|
|
@@ -97,7 +205,7 @@ describe("RenderQueue FFmpeg gate", () => {
|
|
|
97
205
|
hint: "brew install ffmpeg",
|
|
98
206
|
command: "brew install ffmpeg",
|
|
99
207
|
};
|
|
100
|
-
const onStartRender = vi.fn();
|
|
208
|
+
const onStartRender: Mock<StartRenderHandler> = vi.fn();
|
|
101
209
|
const host = mountRenderQueue(onStartRender);
|
|
102
210
|
|
|
103
211
|
expect(host.textContent).toContain("FFmpeg not found");
|
|
@@ -130,7 +238,7 @@ describe("RenderQueue FFmpeg gate", () => {
|
|
|
130
238
|
// as "not installed" would lock Export for setups that render fine.
|
|
131
239
|
it("leaves Export usable when the probe returns no answer", () => {
|
|
132
240
|
ffmpegStatus = null;
|
|
133
|
-
const onStartRender = vi.fn();
|
|
241
|
+
const onStartRender: Mock<StartRenderHandler> = vi.fn();
|
|
134
242
|
const host = mountRenderQueue(onStartRender);
|
|
135
243
|
|
|
136
244
|
expect(host.textContent).not.toContain("FFmpeg not found");
|
|
@@ -5,6 +5,9 @@ import { RenderQueueItem } from "./RenderQueueItem";
|
|
|
5
5
|
import { FfmpegRequiredNotice } from "./FfmpegRequiredNotice";
|
|
6
6
|
import type { FfmpegStatus } from "./useFfmpegStatus";
|
|
7
7
|
import { Button } from "../ui/Button";
|
|
8
|
+
import { IconButton } from "../ui/IconButton";
|
|
9
|
+
import { Select, type SelectOption } from "../ui/Select";
|
|
10
|
+
import { Tooltip } from "../ui/Tooltip";
|
|
8
11
|
import { resolveFloatingPanelPosition, type FloatingPosition } from "../editor/floatingPanel";
|
|
9
12
|
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
|
|
10
13
|
import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings";
|
|
@@ -15,7 +18,7 @@ export interface CompositionDimensions {
|
|
|
15
18
|
height: number;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
type StartRenderHandler = (
|
|
21
|
+
export type StartRenderHandler = (
|
|
19
22
|
format: "mp4" | "webm" | "mov",
|
|
20
23
|
quality: "draft" | "standard" | "high",
|
|
21
24
|
resolution: ResolutionPreset | "auto",
|
|
@@ -207,7 +210,7 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
|
|
|
207
210
|
onFocus={show}
|
|
208
211
|
onBlur={hide}
|
|
209
212
|
onClick={() => setOpen((prev) => !prev)}
|
|
210
|
-
className="flex items-center justify-center p-0.5 -m-0.5 rounded-sm text-
|
|
213
|
+
className="flex items-center justify-center p-0.5 -m-0.5 rounded-sm text-text-5 hover:text-text-3 transition-colors cursor-help outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
|
|
211
214
|
>
|
|
212
215
|
<svg
|
|
213
216
|
width="12"
|
|
@@ -232,17 +235,17 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
|
|
|
232
235
|
role="tooltip"
|
|
233
236
|
onPointerEnter={show}
|
|
234
237
|
onPointerLeave={hide}
|
|
235
|
-
className="fixed w-52 p-2 rounded-sm bg-
|
|
238
|
+
className="fixed w-52 p-2 rounded-sm bg-input border border-border-input shadow-menu z-200"
|
|
236
239
|
style={{ left: position?.left ?? -9999, top: position?.top ?? -9999 }}
|
|
237
240
|
>
|
|
238
|
-
<p className="text-
|
|
239
|
-
<p className="text-
|
|
240
|
-
<div className="mt-1.5 pt-1.5 border-t border-
|
|
241
|
+
<p className="text-step-10 font-semibold text-text-1 mb-0.5">{info.label}</p>
|
|
242
|
+
<p className="text-step-9 text-text-3 leading-tight">{info.desc}</p>
|
|
243
|
+
<div className="mt-1.5 pt-1.5 border-t border-border">
|
|
241
244
|
{(["mp4", "mov", "webm"] as const)
|
|
242
245
|
.filter((f) => f !== format)
|
|
243
246
|
.map((f) => (
|
|
244
|
-
<p key={f} className="text-
|
|
245
|
-
<span className="text-
|
|
247
|
+
<p key={f} className="text-step-9 text-text-4 leading-relaxed">
|
|
248
|
+
<span className="text-text-3 font-medium">{FORMAT_INFO[f].label}</span>
|
|
246
249
|
{" — "}
|
|
247
250
|
{FORMAT_INFO[f].desc}
|
|
248
251
|
</p>
|
|
@@ -255,14 +258,22 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
|
|
|
255
258
|
);
|
|
256
259
|
}
|
|
257
260
|
|
|
258
|
-
const
|
|
259
|
-
value: "
|
|
260
|
-
label:
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
{ value: "
|
|
261
|
+
const FORMAT_OPTIONS: SelectOption[] = [
|
|
262
|
+
{ value: "mp4", label: "MP4" },
|
|
263
|
+
{ value: "mov", label: "MOV (ProRes)" },
|
|
264
|
+
{ value: "webm", label: "WebM" },
|
|
265
|
+
];
|
|
266
|
+
|
|
267
|
+
const QUALITY_OPTIONS: SelectOption[] = [
|
|
268
|
+
{ value: "draft", label: "Draft" },
|
|
269
|
+
{ value: "standard", label: "Standard" },
|
|
270
|
+
{ value: "high", label: "High Quality" },
|
|
271
|
+
];
|
|
272
|
+
|
|
273
|
+
const FPS_OPTIONS: SelectOption[] = [
|
|
274
|
+
{ value: "24", label: "24 fps" },
|
|
275
|
+
{ value: "30", label: "30 fps" },
|
|
276
|
+
{ value: "60", label: "60 fps" },
|
|
266
277
|
];
|
|
267
278
|
|
|
268
279
|
function formatEta(ms: number): string {
|
|
@@ -302,9 +313,6 @@ function FormatExportButton({
|
|
|
302
313
|
// MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
|
|
303
314
|
const showQuality = format !== "mov";
|
|
304
315
|
|
|
305
|
-
const selectCls =
|
|
306
|
-
"h-7 w-full px-2 text-[11px] bg-panel-input rounded-md text-panel-text-1 outline-hidden cursor-pointer disabled:opacity-50 hover:bg-panel-hover transition-colors";
|
|
307
|
-
|
|
308
316
|
return (
|
|
309
317
|
<div className="flex flex-col gap-3">
|
|
310
318
|
{missingFfmpeg && (
|
|
@@ -317,85 +325,70 @@ function FormatExportButton({
|
|
|
317
325
|
<div className="grid grid-cols-2 gap-2">
|
|
318
326
|
<div className="flex flex-col gap-1">
|
|
319
327
|
<div className="flex items-center gap-1">
|
|
320
|
-
<span className="text-
|
|
328
|
+
<span className="text-step-10 text-text-4">Format</span>
|
|
321
329
|
<FormatInfoTooltip format={format} />
|
|
322
330
|
</div>
|
|
323
|
-
<
|
|
331
|
+
<Select
|
|
332
|
+
label="Format"
|
|
324
333
|
value={format}
|
|
325
|
-
|
|
326
|
-
|
|
334
|
+
options={FORMAT_OPTIONS}
|
|
335
|
+
disabled={isRendering}
|
|
336
|
+
onCommit={(next) => {
|
|
337
|
+
const v = next as "mp4" | "webm" | "mov";
|
|
327
338
|
setFormat(v);
|
|
328
339
|
persistRenderSettings(v, quality, fps);
|
|
329
340
|
}}
|
|
330
|
-
|
|
331
|
-
className={selectCls}
|
|
332
|
-
>
|
|
333
|
-
<option value="mp4">MP4</option>
|
|
334
|
-
<option value="mov">MOV (ProRes)</option>
|
|
335
|
-
<option value="webm">WebM</option>
|
|
336
|
-
</select>
|
|
341
|
+
/>
|
|
337
342
|
</div>
|
|
338
343
|
<div className="flex flex-col gap-1">
|
|
339
|
-
<span className="text-
|
|
340
|
-
<
|
|
344
|
+
<span className="text-step-10 text-text-4">Resolution</span>
|
|
345
|
+
<Select
|
|
346
|
+
label="Resolution"
|
|
341
347
|
value={resolution}
|
|
342
|
-
|
|
348
|
+
options={SCALE_OPTION_ORDER.map((value) => ({
|
|
349
|
+
value,
|
|
350
|
+
label: scaleOptionLabel(value, compositionDimensions),
|
|
351
|
+
disabled: !scaleApplies(value, compositionDimensions),
|
|
352
|
+
}))}
|
|
343
353
|
disabled={isRendering}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
{SCALE_OPTION_ORDER.map((value) => (
|
|
347
|
-
<option
|
|
348
|
-
key={value}
|
|
349
|
-
value={value}
|
|
350
|
-
disabled={!scaleApplies(value, compositionDimensions)}
|
|
351
|
-
>
|
|
352
|
-
{scaleOptionLabel(value, compositionDimensions)}
|
|
353
|
-
</option>
|
|
354
|
-
))}
|
|
355
|
-
</select>
|
|
354
|
+
onCommit={(next) => setResolution(next as RenderScale)}
|
|
355
|
+
/>
|
|
356
356
|
</div>
|
|
357
357
|
<div className="flex flex-col gap-1">
|
|
358
|
-
<span className="text-
|
|
359
|
-
<
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
358
|
+
<span className="text-step-10 text-text-4">Frame rate</span>
|
|
359
|
+
<Select
|
|
360
|
+
label="Frame rate"
|
|
361
|
+
value={String(fps)}
|
|
362
|
+
options={FPS_OPTIONS}
|
|
363
|
+
disabled={isRendering}
|
|
364
|
+
onCommit={(next) => {
|
|
365
|
+
const v = Number(next) as 24 | 30 | 60;
|
|
363
366
|
setFps(v);
|
|
364
367
|
persistRenderSettings(format, quality, v);
|
|
365
368
|
}}
|
|
366
|
-
|
|
367
|
-
className={selectCls}
|
|
368
|
-
>
|
|
369
|
-
<option value={24}>24 fps</option>
|
|
370
|
-
<option value={30}>30 fps</option>
|
|
371
|
-
<option value={60}>60 fps</option>
|
|
372
|
-
</select>
|
|
369
|
+
/>
|
|
373
370
|
</div>
|
|
374
371
|
{showQuality && (
|
|
375
372
|
<div className="flex flex-col gap-1">
|
|
376
|
-
<span className="text-
|
|
377
|
-
<
|
|
373
|
+
<span className="text-step-10 text-text-4">Quality</span>
|
|
374
|
+
<Select
|
|
375
|
+
label="Quality"
|
|
378
376
|
value={quality}
|
|
379
|
-
|
|
380
|
-
|
|
377
|
+
options={QUALITY_OPTIONS}
|
|
378
|
+
disabled={isRendering}
|
|
379
|
+
onCommit={(next) => {
|
|
380
|
+
const v = next as "draft" | "standard" | "high";
|
|
381
381
|
setQuality(v);
|
|
382
382
|
persistRenderSettings(format, v, fps);
|
|
383
383
|
}}
|
|
384
|
-
|
|
385
|
-
className={selectCls}
|
|
386
|
-
>
|
|
387
|
-
{QUALITY_OPTIONS.map((q) => (
|
|
388
|
-
<option key={q.value} value={q.value}>
|
|
389
|
-
{q.label}
|
|
390
|
-
</option>
|
|
391
|
-
))}
|
|
392
|
-
</select>
|
|
384
|
+
/>
|
|
393
385
|
</div>
|
|
394
386
|
)}
|
|
395
387
|
</div>
|
|
396
388
|
<Button
|
|
397
389
|
variant="primary"
|
|
398
390
|
size="md"
|
|
391
|
+
data-testid="renders-export"
|
|
399
392
|
loading={isRendering}
|
|
400
393
|
disabled={missingFfmpeg !== null}
|
|
401
394
|
title={missingFfmpeg ? "Install FFmpeg to export. See the note above." : undefined}
|
|
@@ -407,12 +400,15 @@ function FormatExportButton({
|
|
|
407
400
|
trackStudioEvent("render_start", { format, quality, resolution: outputResolution, fps });
|
|
408
401
|
void onStartRender(format, quality, outputResolution, fps);
|
|
409
402
|
}}
|
|
410
|
-
|
|
403
|
+
// Width only. A type size or a weight here would win the merge against
|
|
404
|
+
// the size recipe and leave this Export a step away from the header's
|
|
405
|
+
// (AE3), which is exactly what it used to do.
|
|
406
|
+
className="w-full"
|
|
411
407
|
>
|
|
412
408
|
{isRendering ? "Rendering…" : "Export"}
|
|
413
409
|
</Button>
|
|
414
410
|
{lastRenderDurationMs !== undefined && !isRendering && (
|
|
415
|
-
<p className="text-
|
|
411
|
+
<p className="text-step-9 text-text-5 text-center -mt-1.5">
|
|
416
412
|
Last render took {formatEta(lastRenderDurationMs)}
|
|
417
413
|
</p>
|
|
418
414
|
)}
|
|
@@ -454,7 +450,7 @@ export const RenderQueue = memo(function RenderQueue({
|
|
|
454
450
|
|
|
455
451
|
return (
|
|
456
452
|
<div className="flex flex-col h-full">
|
|
457
|
-
<div className="px-3 py-3 border-b border-
|
|
453
|
+
<div className="px-3 py-3 border-b border-border shrink-0">
|
|
458
454
|
<FormatExportButton
|
|
459
455
|
onStartRender={onStartRender}
|
|
460
456
|
isRendering={isRendering}
|
|
@@ -469,17 +465,30 @@ export const RenderQueue = memo(function RenderQueue({
|
|
|
469
465
|
{actionError && (
|
|
470
466
|
<div
|
|
471
467
|
role="alert"
|
|
472
|
-
className="flex items-start justify-between gap-2 px-3 py-2 border-b border-
|
|
468
|
+
className="flex items-start justify-between gap-2 px-3 py-2 border-b border-border bg-danger/10"
|
|
473
469
|
>
|
|
474
|
-
<span className="text-
|
|
470
|
+
<span className="text-step-10 text-danger">{actionError}</span>
|
|
475
471
|
{onDismissActionError && (
|
|
476
|
-
<
|
|
472
|
+
<IconButton
|
|
473
|
+
size="sm"
|
|
477
474
|
onClick={onDismissActionError}
|
|
478
475
|
aria-label="Dismiss error"
|
|
479
|
-
className="
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
476
|
+
className="shrink-0"
|
|
477
|
+
icon={
|
|
478
|
+
<svg
|
|
479
|
+
width="10"
|
|
480
|
+
height="10"
|
|
481
|
+
viewBox="0 0 24 24"
|
|
482
|
+
fill="none"
|
|
483
|
+
stroke="currentColor"
|
|
484
|
+
strokeWidth="2"
|
|
485
|
+
strokeLinecap="round"
|
|
486
|
+
aria-hidden="true"
|
|
487
|
+
>
|
|
488
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
489
|
+
</svg>
|
|
490
|
+
}
|
|
491
|
+
/>
|
|
483
492
|
)}
|
|
484
493
|
</div>
|
|
485
494
|
)}
|
|
@@ -488,7 +497,7 @@ export const RenderQueue = memo(function RenderQueue({
|
|
|
488
497
|
<div ref={listRef} className="flex-1 overflow-y-auto">
|
|
489
498
|
{loadError && jobs.length === 0 ? (
|
|
490
499
|
<div className="flex flex-col items-center justify-center h-full px-4 gap-2" role="alert">
|
|
491
|
-
<p className="text-
|
|
500
|
+
<p className="text-step-10 text-danger text-center">{loadError}</p>
|
|
492
501
|
{onRetryLoad && (
|
|
493
502
|
<Button size="sm" variant="secondary" onClick={onRetryLoad}>
|
|
494
503
|
Retry
|
|
@@ -504,7 +513,7 @@ export const RenderQueue = memo(function RenderQueue({
|
|
|
504
513
|
fill="none"
|
|
505
514
|
stroke="currentColor"
|
|
506
515
|
strokeWidth="1.5"
|
|
507
|
-
className="text-
|
|
516
|
+
className="text-text-5"
|
|
508
517
|
>
|
|
509
518
|
<rect
|
|
510
519
|
x="2"
|
|
@@ -522,24 +531,22 @@ export const RenderQueue = memo(function RenderQueue({
|
|
|
522
531
|
strokeLinejoin="round"
|
|
523
532
|
/>
|
|
524
533
|
</svg>
|
|
525
|
-
<p className="text-
|
|
534
|
+
<p className="text-step-10 text-text-5 text-center">No renders yet</p>
|
|
526
535
|
</div>
|
|
527
536
|
) : (
|
|
528
537
|
<div>
|
|
529
538
|
{completedCount > 0 && (
|
|
530
|
-
<div className="flex items-center justify-between px-3 py-1.5 border-b border-
|
|
531
|
-
<span className="text-
|
|
539
|
+
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border">
|
|
540
|
+
<span className="text-step-10 text-text-4">
|
|
532
541
|
{jobs.length} render{jobs.length === 1 ? "" : "s"}
|
|
533
542
|
</span>
|
|
534
543
|
{/* "Hide", not "Clear": files stay on disk (delete is per-row
|
|
535
544
|
and confirmed); hidden rows don't resurrect on reload. */}
|
|
536
|
-
<
|
|
537
|
-
onClick={onClearCompleted}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
>
|
|
541
|
-
Hide finished
|
|
542
|
-
</button>
|
|
545
|
+
<Tooltip label="Hide finished renders from this list (files stay on disk)">
|
|
546
|
+
<Button size="sm" variant="ghost" onClick={onClearCompleted}>
|
|
547
|
+
Hide finished
|
|
548
|
+
</Button>
|
|
549
|
+
</Tooltip>
|
|
543
550
|
</div>
|
|
544
551
|
)}
|
|
545
552
|
{jobs.map((job) => (
|