@hyperframes/studio 0.8.53-desktop.0 → 0.8.54

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 (42) hide show
  1. package/dist/assets/{hyperframes-player-PTEnAHhg.js → hyperframes-player-Cy_AwUa8.js} +1 -1
  2. package/dist/assets/{index-BjslOS9g.js → index-CTa-6Kso.js} +1 -1
  3. package/dist/assets/{index-CNyviK_l.js → index-DPUJoO8n.js} +203 -203
  4. package/dist/assets/{index--myXp8ui.js → index-DkAzKuPZ.js} +1 -1
  5. package/dist/assets/{index-BnXZuIJD.css → index-DrHQAuGO.css} +1 -1
  6. package/dist/index.d.ts +43 -8
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7896 -7717
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -0
  11. package/package.json +8 -7
  12. package/src/components/StudioHeader.tsx +1 -1
  13. package/src/components/nle/TimelinePane.tsx +4 -1
  14. package/src/components/renders/RenderQueue.test.tsx +133 -25
  15. package/src/components/renders/RenderQueue.tsx +99 -92
  16. package/src/components/renders/RenderQueueItem.tsx +69 -64
  17. package/src/components/ui/Select.tsx +4 -0
  18. package/src/headerExports.test.tsx +23 -0
  19. package/src/hooks/timelineAudioGroupCreate.ts +17 -1
  20. package/src/hooks/timelineAudioGroupVolume.ts +35 -2
  21. package/src/hooks/timelineElementFxAttribute.ts +34 -2
  22. package/src/hooks/useRightPanelIntents.ts +2 -1
  23. package/src/hooks/useTimelineAssetDropOps.test.tsx +62 -4
  24. package/src/hooks/useTimelineAssetDropOps.ts +40 -3
  25. package/src/hooks/useTimelineEditing.test.tsx +160 -2
  26. package/src/hooks/useTimelineEditing.ts +76 -39
  27. package/src/index.ts +5 -2
  28. package/src/main.tsx +26 -0
  29. package/src/player/components/Timeline.tsx +6 -6
  30. package/src/player/components/TimelineTrackHeader.test.tsx +5 -26
  31. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  32. package/src/player/components/TimelineTypes.ts +3 -0
  33. package/src/player/components/timelineTheme.test.ts +4 -4
  34. package/src/player/components/timelineTheme.ts +30 -30
  35. package/src/player/components/usePublishRangeSelection.test.tsx +60 -0
  36. package/src/player/components/usePublishRangeSelection.ts +42 -0
  37. package/src/player/index.ts +1 -0
  38. package/src/player/store/playerStore.ts +8 -51
  39. package/src/player/store/rangeSelectionSlice.ts +13 -0
  40. package/src/player/store/timelineResetState.ts +51 -0
  41. package/src/styles/hex-baseline.json +2 -2
  42. package/src/styles/theme.css +0 -27
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.53-desktop.0",
3
+ "version": "0.8.54",
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/parsers": "0.8.53-desktop.0",
54
- "@hyperframes/core": "0.8.53-desktop.0",
55
- "@hyperframes/studio-server": "0.8.53-desktop.0",
56
- "@hyperframes/sdk": "0.8.53-desktop.0",
57
- "@hyperframes/player": "0.8.53-desktop.0"
54
+ "@hyperframes/core": "0.8.54",
55
+ "@hyperframes/parsers": "0.8.54",
56
+ "@hyperframes/player": "0.8.54",
57
+ "@hyperframes/studio-server": "0.8.54",
58
+ "@hyperframes/sdk": "0.8.54"
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-desktop.0"
74
+ "@hyperframes/producer": "0.8.54"
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;
@@ -1,6 +1,6 @@
1
1
  import { useCallback, type ReactNode } from "react";
2
2
  import { Timeline } from "../../player";
3
- import type { TimelineElement } from "../../player";
3
+ import type { TimelineElement, TimelineTimeRange } from "../../player";
4
4
  import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
5
5
  import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
6
6
  import { trackStudioExpandedClipEdit } from "../../telemetry/events";
@@ -104,6 +104,7 @@ export interface TimelinePaneProps {
104
104
  ) => Promise<void> | void;
105
105
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
106
106
  onSelectTimelineElement?: (element: TimelineElement | null) => void;
107
+ onRangeSelect?: (range: TimelineTimeRange | null) => void;
107
108
  /** Copy/paste/duplicate act on the store's own selection, not a passed
108
109
  * element, so unlike onDeleteElement they need no composition-basis wrapper. */
109
110
  onCopyClip?: () => boolean;
@@ -125,6 +126,7 @@ export function TimelinePane({
125
126
  onCompositionDrop,
126
127
  onBlockedEditAttempt,
127
128
  onSelectTimelineElement,
129
+ onRangeSelect,
128
130
  onCopyClip,
129
131
  onPasteClip,
130
132
  onDuplicateClip,
@@ -282,6 +284,7 @@ export function TimelinePane({
282
284
  onBlockedEditAttempt={onBlockedEditAttempt}
283
285
  onSplitElement={handleSplitElement}
284
286
  onSelectElement={onSelectTimelineElement}
287
+ onRangeSelect={onRangeSelect}
285
288
  onCopyClip={onCopyClip}
286
289
  onPasteClip={onPasteClip}
287
290
  onDuplicateClip={onDuplicateClip}
@@ -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(onStartRender: Mock) {
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={{ width: 1920, height: 1080 }}
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
- describe("RenderQueue resolution submission", () => {
53
- it("submits the canonical landscape 4K preset selected by the user", () => {
54
- const onStartRender = vi.fn();
55
- const host = mountRenderQueue(onStartRender);
56
- const resolutionSelect = [...host.querySelectorAll("select")].find((select) =>
57
- [...select.options].some((option) => option.textContent?.startsWith("4K")),
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
- if (!resolutionSelect) throw new Error("resolution selector did not render");
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
- resolutionSelect.value = "4k";
63
- resolutionSelect.dispatchEvent(new Event("change", { bubbles: true }));
142
+ exportButtonIn(host).dispatchEvent(new MouseEvent("click", { bubbles: true }));
64
143
  });
65
144
 
66
- const exportButton = [...host.querySelectorAll("button")].find(
67
- (button) => button.textContent === "Export",
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
- if (!exportButton) throw new Error("export button did not render");
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
- exportButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
172
+ exportButtonIn(host).dispatchEvent(new MouseEvent("click", { bubbles: true }));
72
173
  });
73
174
 
74
- expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "landscape-4k", 30);
175
+ expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "auto", 30);
75
176
  });
76
- });
77
177
 
78
- function exportButtonIn(host: HTMLElement): HTMLButtonElement {
79
- const button = [...host.querySelectorAll("button")].find((b) => b.textContent === "Export");
80
- if (!button) throw new Error("export button did not render");
81
- return button;
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-panel-text-5 hover:text-panel-text-3 transition-colors cursor-help outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-studio-accent"
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-panel-input border border-neutral-700 shadow-lg z-200"
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-[10px] font-semibold text-panel-text-1 mb-0.5">{info.label}</p>
239
- <p className="text-[9px] text-panel-text-3 leading-tight">{info.desc}</p>
240
- <div className="mt-1.5 pt-1.5 border-t border-neutral-800">
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-[9px] text-panel-text-4 leading-relaxed">
245
- <span className="text-panel-text-3 font-medium">{FORMAT_INFO[f].label}</span>
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 QUALITY_OPTIONS: {
259
- value: "draft" | "standard" | "high";
260
- label: string;
261
- title: string;
262
- }[] = [
263
- { value: "draft", label: "Draft", title: "Fast render, smaller file" },
264
- { value: "standard", label: "Standard", title: "Good quality, balanced file size" },
265
- { value: "high", label: "High Quality", title: "Best quality, larger file" },
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-[10px] text-panel-text-4">Format</span>
328
+ <span className="text-step-10 text-text-4">Format</span>
321
329
  <FormatInfoTooltip format={format} />
322
330
  </div>
323
- <select
331
+ <Select
332
+ label="Format"
324
333
  value={format}
325
- onChange={(e) => {
326
- const v = e.target.value as "mp4" | "webm" | "mov";
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
- disabled={isRendering}
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-[10px] text-panel-text-4">Resolution</span>
340
- <select
344
+ <span className="text-step-10 text-text-4">Resolution</span>
345
+ <Select
346
+ label="Resolution"
341
347
  value={resolution}
342
- onChange={(e) => setResolution(e.target.value as RenderScale)}
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
- className={selectCls}
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-[10px] text-panel-text-4">Frame rate</span>
359
- <select
360
- value={fps}
361
- onChange={(e) => {
362
- const v = Number(e.target.value) as 24 | 30 | 60;
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
- disabled={isRendering}
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-[10px] text-panel-text-4">Quality</span>
377
- <select
373
+ <span className="text-step-10 text-text-4">Quality</span>
374
+ <Select
375
+ label="Quality"
378
376
  value={quality}
379
- onChange={(e) => {
380
- const v = e.target.value as "draft" | "standard" | "high";
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
- disabled={isRendering}
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
- className="w-full font-semibold"
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-[9px] text-panel-text-5 text-center -mt-1.5">
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-panel-border shrink-0">
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-panel-border bg-red-500/10"
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-[10px] text-red-400">{actionError}</span>
470
+ <span className="text-step-10 text-danger">{actionError}</span>
475
471
  {onDismissActionError && (
476
- <button
472
+ <IconButton
473
+ size="sm"
477
474
  onClick={onDismissActionError}
478
475
  aria-label="Dismiss error"
479
- className="text-[10px] text-panel-text-4 hover:text-panel-text-2 shrink-0"
480
- >
481
- ✕
482
- </button>
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-[10px] text-red-400 text-center">{loadError}</p>
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-panel-text-5"
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-[10px] text-panel-text-5 text-center">No renders yet</p>
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-panel-border">
531
- <span className="text-[10px] text-panel-text-4">
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
- <button
537
- onClick={onClearCompleted}
538
- title="Hide finished renders from this list (files stay on disk)"
539
- className="text-[10px] text-panel-text-4 hover:text-panel-text-2 transition-colors"
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) => (