@hyperframes/studio 0.6.0-alpha.9 → 0.6.0

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 (66) hide show
  1. package/dist/assets/{hyperframes-player-DjsVzYFP.js → hyperframes-player-DOFETgjy.js} +1 -1
  2. package/dist/assets/index-D1JDq7Gg.css +1 -0
  3. package/dist/assets/index-DUqUmaoH.js +117 -0
  4. package/dist/favicon.svg +14 -0
  5. package/dist/index.html +3 -2
  6. package/package.json +9 -9
  7. package/src/App.tsx +428 -4299
  8. package/src/components/AskAgentModal.tsx +120 -0
  9. package/src/components/StudioHeader.tsx +133 -0
  10. package/src/components/StudioLeftSidebar.tsx +125 -0
  11. package/src/components/StudioPreviewArea.tsx +163 -0
  12. package/src/components/StudioRightPanel.tsx +198 -0
  13. package/src/components/TimelineToolbar.tsx +89 -0
  14. package/src/components/editor/DomEditOverlay.tsx +15 -1
  15. package/src/components/editor/PropertyPanel.test.ts +0 -49
  16. package/src/components/editor/PropertyPanel.tsx +132 -2763
  17. package/src/components/editor/domEditing.ts +38 -5
  18. package/src/components/editor/manualEditingAvailability.test.ts +2 -2
  19. package/src/components/editor/manualEditingAvailability.ts +1 -1
  20. package/src/components/editor/manualEdits.ts +32 -0
  21. package/src/components/editor/propertyPanelColor.tsx +371 -0
  22. package/src/components/editor/propertyPanelFill.tsx +421 -0
  23. package/src/components/editor/propertyPanelFont.tsx +455 -0
  24. package/src/components/editor/propertyPanelHelpers.ts +401 -0
  25. package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
  26. package/src/components/editor/propertyPanelSections.tsx +453 -0
  27. package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
  28. package/src/components/nle/NLELayout.tsx +8 -11
  29. package/src/components/nle/NLEPreview.tsx +3 -0
  30. package/src/components/renders/RenderQueue.tsx +102 -31
  31. package/src/components/renders/useRenderQueue.ts +8 -2
  32. package/src/components/sidebar/LeftSidebar.tsx +186 -186
  33. package/src/contexts/DomEditContext.tsx +137 -0
  34. package/src/contexts/FileManagerContext.tsx +110 -0
  35. package/src/contexts/PanelLayoutContext.tsx +68 -0
  36. package/src/contexts/StudioContext.tsx +135 -0
  37. package/src/hooks/useAppHotkeys.ts +326 -0
  38. package/src/hooks/useAskAgentModal.ts +162 -0
  39. package/src/hooks/useCaptionDetection.ts +132 -0
  40. package/src/hooks/useCompositionDimensions.ts +25 -0
  41. package/src/hooks/useConsoleErrorCapture.ts +60 -0
  42. package/src/hooks/useDomEditCommits.ts +437 -0
  43. package/src/hooks/useDomEditSession.ts +342 -0
  44. package/src/hooks/useDomEditTextCommits.ts +330 -0
  45. package/src/hooks/useDomSelection.ts +398 -0
  46. package/src/hooks/useFileManager.ts +431 -0
  47. package/src/hooks/useFrameCapture.ts +77 -0
  48. package/src/hooks/useLintModal.ts +35 -0
  49. package/src/hooks/useManifestPersistence.ts +492 -0
  50. package/src/hooks/usePanelLayout.ts +68 -0
  51. package/src/hooks/usePreviewInteraction.ts +153 -0
  52. package/src/hooks/useRenderClipContent.ts +124 -0
  53. package/src/hooks/useTimelineEditing.ts +472 -0
  54. package/src/player/components/Player.tsx +33 -2
  55. package/src/player/components/Timeline.test.ts +0 -8
  56. package/src/player/components/Timeline.tsx +10 -103
  57. package/src/player/components/TimelineClip.tsx +9 -244
  58. package/src/player/hooks/useTimelinePlayer.ts +140 -103
  59. package/src/utils/domEditHelpers.ts +50 -0
  60. package/src/utils/studioFontHelpers.ts +83 -0
  61. package/src/utils/studioHelpers.ts +214 -0
  62. package/src/utils/studioPreviewHelpers.ts +185 -0
  63. package/src/utils/timelineDiscovery.ts +1 -1
  64. package/dist/assets/index-14zH9lqh.css +0 -1
  65. package/dist/assets/index-DYCiFGWQ.js +0 -108
  66. package/src/player/components/TimelineClip.test.ts +0 -92
@@ -0,0 +1,411 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Eye, Layers, Palette, Settings, Square, Zap } from "../../icons/SystemIcons";
3
+ import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
4
+ import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
5
+ import {
6
+ buildBoxShadowPresetValue,
7
+ buildClipPathValue,
8
+ buildInsetClipPathValue,
9
+ buildStrokeStyleUpdates,
10
+ buildStrokeWidthStyleUpdates,
11
+ extractBackgroundImageUrl,
12
+ formatNumericValue,
13
+ formatPxMetricValue,
14
+ getCssFilterFunctionPx,
15
+ getClipPathInsetPx,
16
+ inferBoxShadowPreset,
17
+ inferClipPathPreset,
18
+ LABEL,
19
+ normalizePanelPxValue,
20
+ parseNumericValue,
21
+ parsePxMetricValue,
22
+ RESPONSIVE_GRID,
23
+ setCssFilterFunctionPx,
24
+ type BoxShadowPreset,
25
+ } from "./propertyPanelHelpers";
26
+ import {
27
+ DetailField,
28
+ MetricField,
29
+ Section,
30
+ SegmentedControl,
31
+ SelectField,
32
+ SliderControl,
33
+ } from "./propertyPanelPrimitives";
34
+ import { ColorField } from "./propertyPanelColor";
35
+ import { GradientField, ImageFillField } from "./propertyPanelFill";
36
+
37
+ export function StyleSections({
38
+ projectId,
39
+ element,
40
+ styles,
41
+ assets,
42
+ onSetStyle,
43
+ onImportAssets,
44
+ }: {
45
+ projectId: string;
46
+ element: DomEditSelection;
47
+ styles: Record<string, string>;
48
+ assets: string[];
49
+ onSetStyle: (prop: string, value: string) => void | Promise<void>;
50
+ onImportAssets?: (files: FileList) => Promise<string[]>;
51
+ }) {
52
+ const styleEditingDisabled = !element.capabilities.canEditStyles;
53
+ const isFlex = styles.display === "flex" || styles.display === "inline-flex";
54
+ const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
55
+ const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100);
56
+ const borderWidthValue =
57
+ parsePxMetricValue(styles["border-width"] ?? "") ??
58
+ parsePxMetricValue(styles["border-top-width"] ?? "") ??
59
+ 0;
60
+ const hasVisualBackground =
61
+ (styles.background != null && styles.background !== "none" && styles.background !== "") ||
62
+ (styles["background-color"] != null &&
63
+ styles["background-color"] !== "transparent" &&
64
+ styles["background-color"] !== "rgba(0, 0, 0, 0)" &&
65
+ styles["background-color"] !== "") ||
66
+ (styles["background-image"] != null &&
67
+ styles["background-image"] !== "none" &&
68
+ styles["background-image"] !== "") ||
69
+ borderWidthValue > 0;
70
+ const borderStyleValue = styles["border-style"] || styles["border-top-style"] || "none";
71
+ const borderColorValue =
72
+ styles["border-color"] || styles["border-top-color"] || "rgba(255, 255, 255, 0.18)";
73
+ const boxShadowPreset = inferBoxShadowPreset(styles["box-shadow"]);
74
+ const filterBlurValue = getCssFilterFunctionPx(styles.filter, "blur");
75
+ const backdropBlurValue = getCssFilterFunctionPx(styles["backdrop-filter"], "blur");
76
+ const clipPathValue = styles["clip-path"] || "none";
77
+ const clipPathPreset = inferClipPathPreset(clipPathValue);
78
+ const clipInsetValue = getClipPathInsetPx(clipPathValue);
79
+ const backgroundImage = styles["background-image"] ?? "none";
80
+ const hasTextControls = isTextEditableSelection(element);
81
+
82
+ const fillMode =
83
+ backgroundImage && backgroundImage !== "none"
84
+ ? backgroundImage.includes("gradient")
85
+ ? "Gradient"
86
+ : "Image"
87
+ : "Solid";
88
+ const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
89
+ const imageUrl = extractBackgroundImageUrl(backgroundImage);
90
+
91
+ useEffect(() => {
92
+ setPreferredFillMode(fillMode);
93
+ }, [fillMode, element.id, element.selector, backgroundImage]);
94
+
95
+ const handleFillModeChange = (nextMode: string) => {
96
+ setPreferredFillMode(nextMode);
97
+ if (nextMode === "Solid") {
98
+ onSetStyle("background-image", "none");
99
+ return;
100
+ }
101
+ if (nextMode === "Gradient" && !backgroundImage.includes("gradient")) {
102
+ onSetStyle(
103
+ "background-image",
104
+ serializeGradient(buildDefaultGradientModel(styles["background-color"])),
105
+ );
106
+ }
107
+ };
108
+
109
+ return (
110
+ <>
111
+ {isFlex && (
112
+ <Section title="Flex" icon={<Layers size={15} />}>
113
+ <div className="space-y-4">
114
+ <SegmentedControl
115
+ disabled={styleEditingDisabled}
116
+ value={styles["flex-direction"] || "row"}
117
+ onChange={(next) => onSetStyle("flex-direction", next)}
118
+ options={[
119
+ { label: "→ Row", value: "row" },
120
+ { label: "↓ Column", value: "column" },
121
+ ]}
122
+ />
123
+ <div className={RESPONSIVE_GRID}>
124
+ <SelectField
125
+ label="Justify"
126
+ value={styles["justify-content"] || "flex-start"}
127
+ disabled={styleEditingDisabled}
128
+ onChange={(next) => onSetStyle("justify-content", next)}
129
+ options={[
130
+ "flex-start",
131
+ "center",
132
+ "space-between",
133
+ "space-around",
134
+ "space-evenly",
135
+ "flex-end",
136
+ ]}
137
+ />
138
+ <SelectField
139
+ label="Align"
140
+ value={styles["align-items"] || "stretch"}
141
+ disabled={styleEditingDisabled}
142
+ onChange={(next) => onSetStyle("align-items", next)}
143
+ options={["stretch", "flex-start", "center", "flex-end", "baseline"]}
144
+ />
145
+ </div>
146
+ <DetailField
147
+ label="Gap"
148
+ value={styles.gap ?? "0px"}
149
+ disabled={styleEditingDisabled}
150
+ onCommit={(next) => onSetStyle("gap", next.endsWith("px") ? next : `${next}px`)}
151
+ />
152
+ </div>
153
+ </Section>
154
+ )}
155
+
156
+ {hasVisualBackground && (
157
+ <Section title="Radius" icon={<Settings size={15} />}>
158
+ <SliderControl
159
+ value={radiusValue}
160
+ min={0}
161
+ max={Math.max(240, Math.ceil(radiusValue))}
162
+ step={1}
163
+ disabled={styleEditingDisabled}
164
+ displayValue={`${formatNumericValue(radiusValue)}px`}
165
+ formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
166
+ onCommit={(next) => onSetStyle("border-radius", `${formatNumericValue(next)}px`)}
167
+ />
168
+ </Section>
169
+ )}
170
+
171
+ <Section title="Stroke" icon={<Square size={15} />}>
172
+ <div className="space-y-4">
173
+ <div className={RESPONSIVE_GRID}>
174
+ <MetricField
175
+ label="Width"
176
+ value={formatPxMetricValue(borderWidthValue)}
177
+ disabled={styleEditingDisabled}
178
+ liveCommit
179
+ onCommit={async (next) => {
180
+ const normalized = normalizePanelPxValue(next, {
181
+ min: 0,
182
+ max: 200,
183
+ fallback: borderWidthValue,
184
+ });
185
+ if (!normalized) return;
186
+ for (const [property, value] of buildStrokeWidthStyleUpdates(
187
+ normalized,
188
+ borderStyleValue,
189
+ )) {
190
+ await onSetStyle(property, value);
191
+ }
192
+ }}
193
+ />
194
+ <SelectField
195
+ label="Style"
196
+ value={borderStyleValue}
197
+ disabled={styleEditingDisabled}
198
+ onChange={async (next) => {
199
+ for (const [property, value] of buildStrokeStyleUpdates(
200
+ next,
201
+ formatPxMetricValue(borderWidthValue),
202
+ )) {
203
+ await onSetStyle(property, value);
204
+ }
205
+ }}
206
+ options={[
207
+ "none",
208
+ "solid",
209
+ "dashed",
210
+ "dotted",
211
+ "double",
212
+ "hidden",
213
+ "groove",
214
+ "ridge",
215
+ "inset",
216
+ "outset",
217
+ ]}
218
+ />
219
+ </div>
220
+ <ColorField
221
+ label="Stroke color"
222
+ value={borderColorValue}
223
+ disabled={styleEditingDisabled}
224
+ onCommit={(next) => onSetStyle("border-color", next)}
225
+ />
226
+ </div>
227
+ </Section>
228
+
229
+ <Section title="Effects" icon={<Zap size={15} />}>
230
+ <div className="space-y-4">
231
+ <SelectField
232
+ label="Shadow"
233
+ value={boxShadowPreset}
234
+ disabled={styleEditingDisabled}
235
+ onChange={(next) => {
236
+ if (next === "custom") return;
237
+ onSetStyle(
238
+ "box-shadow",
239
+ buildBoxShadowPresetValue(next as BoxShadowPreset, styles["box-shadow"]),
240
+ );
241
+ }}
242
+ options={["custom", "none", "soft", "lift", "glow"]}
243
+ />
244
+ <div className={RESPONSIVE_GRID}>
245
+ <div className="grid min-w-0 gap-1.5">
246
+ <span className={LABEL}>Layer blur</span>
247
+ <SliderControl
248
+ value={filterBlurValue}
249
+ min={0}
250
+ max={Math.max(40, Math.ceil(filterBlurValue))}
251
+ step={1}
252
+ disabled={styleEditingDisabled}
253
+ displayValue={`${formatNumericValue(filterBlurValue)}px`}
254
+ formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
255
+ onCommit={(next) =>
256
+ onSetStyle("filter", setCssFilterFunctionPx(styles.filter, "blur", next))
257
+ }
258
+ />
259
+ </div>
260
+ <div className="grid min-w-0 gap-1.5">
261
+ <span className={LABEL}>Backdrop</span>
262
+ <SliderControl
263
+ value={backdropBlurValue}
264
+ min={0}
265
+ max={Math.max(60, Math.ceil(backdropBlurValue))}
266
+ step={1}
267
+ disabled={styleEditingDisabled}
268
+ displayValue={`${formatNumericValue(backdropBlurValue)}px`}
269
+ formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
270
+ onCommit={(next) =>
271
+ onSetStyle(
272
+ "backdrop-filter",
273
+ setCssFilterFunctionPx(styles["backdrop-filter"], "blur", next),
274
+ )
275
+ }
276
+ />
277
+ </div>
278
+ </div>
279
+ </div>
280
+ </Section>
281
+
282
+ <Section title="Clip" icon={<Layers size={15} />}>
283
+ <div className="space-y-4">
284
+ <div className={RESPONSIVE_GRID}>
285
+ <SelectField
286
+ label="Overflow"
287
+ value={styles.overflow || "visible"}
288
+ disabled={styleEditingDisabled}
289
+ onChange={(next) => onSetStyle("overflow", next)}
290
+ options={["visible", "hidden", "clip", "auto", "scroll"]}
291
+ />
292
+ <SelectField
293
+ label="Mask"
294
+ value={clipPathPreset}
295
+ disabled={styleEditingDisabled}
296
+ onChange={(next) => {
297
+ if (next === "custom") return;
298
+ onSetStyle(
299
+ "clip-path",
300
+ buildClipPathValue(
301
+ next as "none" | "inset" | "circle",
302
+ radiusValue,
303
+ clipPathValue,
304
+ ),
305
+ );
306
+ }}
307
+ options={["custom", "none", "inset", "circle"]}
308
+ />
309
+ </div>
310
+ <div className="grid min-w-0 gap-1.5">
311
+ <span className={LABEL}>Mask inset</span>
312
+ <SliderControl
313
+ value={clipInsetValue}
314
+ min={0}
315
+ max={Math.max(120, Math.ceil(clipInsetValue))}
316
+ step={1}
317
+ disabled={styleEditingDisabled}
318
+ displayValue={`${formatNumericValue(clipInsetValue)}px`}
319
+ formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
320
+ onCommit={(next) =>
321
+ onSetStyle("clip-path", buildInsetClipPathValue(next, radiusValue))
322
+ }
323
+ />
324
+ </div>
325
+ </div>
326
+ </Section>
327
+
328
+ <Section title="Transparency" icon={<Eye size={15} />}>
329
+ <div className="space-y-4">
330
+ <SliderControl
331
+ value={opacityValue}
332
+ min={0}
333
+ max={100}
334
+ step={1}
335
+ disabled={styleEditingDisabled}
336
+ displayValue={`${opacityValue}%`}
337
+ formatDisplayValue={(next) => `${Math.round(next)}%`}
338
+ onCommit={(next) => onSetStyle("opacity", formatNumericValue(next / 100))}
339
+ />
340
+ <SelectField
341
+ label="Mode"
342
+ value={styles["mix-blend-mode"] || "normal"}
343
+ disabled={styleEditingDisabled}
344
+ onChange={(next) => onSetStyle("mix-blend-mode", next)}
345
+ options={["normal", "multiply", "screen", "overlay", "darken", "lighten"]}
346
+ />
347
+ </div>
348
+ </Section>
349
+
350
+ <Section
351
+ title="Fill"
352
+ icon={<Palette size={15} />}
353
+ accessory={
354
+ <div className="rounded-full border border-neutral-700 bg-neutral-900 px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-400">
355
+ {preferredFillMode}
356
+ </div>
357
+ }
358
+ >
359
+ <div className="space-y-4">
360
+ <SegmentedControl
361
+ disabled={styleEditingDisabled}
362
+ value={preferredFillMode}
363
+ onChange={handleFillModeChange}
364
+ options={[
365
+ { label: "Solid", value: "Solid" },
366
+ { label: "Gradient", value: "Gradient" },
367
+ { label: "Image", value: "Image" },
368
+ ]}
369
+ />
370
+ {preferredFillMode === "Solid" ? (
371
+ <ColorField
372
+ label="Fill color"
373
+ value={styles["background-color"] ?? "transparent"}
374
+ disabled={styleEditingDisabled}
375
+ onCommit={(next) => onSetStyle("background-color", next)}
376
+ />
377
+ ) : preferredFillMode === "Gradient" ? (
378
+ <GradientField
379
+ value={
380
+ backgroundImage !== "none"
381
+ ? backgroundImage
382
+ : serializeGradient(buildDefaultGradientModel(styles["background-color"]))
383
+ }
384
+ fallbackColor={styles["background-color"]}
385
+ disabled={styleEditingDisabled}
386
+ onCommit={(next) => onSetStyle("background-image", next)}
387
+ />
388
+ ) : (
389
+ <ImageFillField
390
+ projectId={projectId}
391
+ sourceFile={element.sourceFile}
392
+ value={imageUrl}
393
+ assets={assets}
394
+ disabled={styleEditingDisabled}
395
+ onCommit={(next) => onSetStyle("background-image", next)}
396
+ onImportAssets={onImportAssets}
397
+ />
398
+ )}
399
+ {!hasTextControls && (
400
+ <ColorField
401
+ label="Text color"
402
+ value={styles.color ?? "rgb(0, 0, 0)"}
403
+ disabled={styleEditingDisabled}
404
+ onCommit={(next) => onSetStyle("color", next)}
405
+ />
406
+ )}
407
+ </div>
408
+ </Section>
409
+ </>
410
+ );
411
+ }
@@ -52,9 +52,6 @@ interface NLELayoutProps {
52
52
  ) => Promise<void> | void;
53
53
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
54
54
  onSelectTimelineElement?: (element: TimelineElement | null) => void;
55
- onInspectTimelineElement?: (element: TimelineElement) => void;
56
- inspectedTimelineElementId?: string | null;
57
- timelineLayerChildCounts?: ReadonlyMap<string, number>;
58
55
  /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
59
56
  onCompIdToSrcChange?: (map: Map<string, string>) => void;
60
57
  /** Whether the timeline panel is visible (default: true) */
@@ -91,9 +88,6 @@ export const NLELayout = memo(function NLELayout({
91
88
  onResizeElement,
92
89
  onBlockedEditAttempt,
93
90
  onSelectTimelineElement,
94
- onInspectTimelineElement,
95
- inspectedTimelineElementId,
96
- timelineLayerChildCounts,
97
91
  onCompIdToSrcChange,
98
92
  timelineVisible,
99
93
  onToggleTimeline,
@@ -217,7 +211,13 @@ export const NLELayout = memo(function NLELayout({
217
211
 
218
212
  // Resizable timeline height
219
213
  const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
220
- const [compositionLoading, setCompositionLoading] = useState(true);
214
+ const hasLoadedOnceRef = useRef(false);
215
+ const [compositionLoading, setCompositionLoadingRaw] = useState(true);
216
+ const setCompositionLoading = useCallback((loading: boolean) => {
217
+ if (!loading) hasLoadedOnceRef.current = true;
218
+ if (loading && hasLoadedOnceRef.current) return;
219
+ setCompositionLoadingRaw(loading);
220
+ }, []);
221
221
  const timelineDisabled = shouldDisableTimelineWhileCompositionLoading(compositionLoading);
222
222
 
223
223
  useEffect(() => {
@@ -395,6 +395,7 @@ export const NLELayout = memo(function NLELayout({
395
395
  portrait={portrait}
396
396
  directUrl={directUrl}
397
397
  refreshKey={refreshKey}
398
+ suppressLoadingOverlay={hasLoadedOnceRef.current}
398
399
  />
399
400
  {previewOverlay}
400
401
  </div>
@@ -453,10 +454,6 @@ export const NLELayout = memo(function NLELayout({
453
454
  onResizeElement={onResizeElement}
454
455
  onBlockedEditAttempt={onBlockedEditAttempt}
455
456
  onSelectElement={onSelectTimelineElement}
456
- onInspectElement={onInspectTimelineElement}
457
- inspectedElementId={inspectedTimelineElementId}
458
- layerChildCounts={timelineLayerChildCounts}
459
- disabled={timelineDisabled}
460
457
  />
461
458
  </div>
462
459
  {timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
@@ -9,6 +9,7 @@ interface NLEPreviewProps {
9
9
  portrait?: boolean;
10
10
  directUrl?: string;
11
11
  refreshKey?: number;
12
+ suppressLoadingOverlay?: boolean;
12
13
  }
13
14
 
14
15
  export function getPreviewPlayerKey({
@@ -41,6 +42,7 @@ export const NLEPreview = memo(function NLEPreview({
41
42
  portrait,
42
43
  directUrl,
43
44
  refreshKey,
45
+ suppressLoadingOverlay,
44
46
  }: NLEPreviewProps) {
45
47
  const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
46
48
  const prevRefreshKeyRef = useRef(refreshKey);
@@ -93,6 +95,7 @@ export const NLEPreview = memo(function NLEPreview({
93
95
  onCompositionLoadingChange={onCompositionLoadingChange}
94
96
  portrait={portrait}
95
97
  style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
98
+ suppressLoadingOverlay={suppressLoadingOverlay}
96
99
  />
97
100
  </div>
98
101
  </div>
@@ -2,6 +2,11 @@ import { memo, useState, useRef, useEffect } from "react";
2
2
  import { RenderQueueItem } from "./RenderQueueItem";
3
3
  import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
4
4
 
5
+ export interface CompositionDimensions {
6
+ width: number;
7
+ height: number;
8
+ }
9
+
5
10
  type StartRenderHandler = (
6
11
  format: "mp4" | "webm" | "mov",
7
12
  quality: "draft" | "standard" | "high",
@@ -16,37 +21,97 @@ interface RenderQueueProps {
16
21
  onClearCompleted: () => void;
17
22
  onStartRender: StartRenderHandler;
18
23
  isRendering: boolean;
24
+ /**
25
+ * Authored dimensions of the active composition. Used to pick the
26
+ * matching preset (landscape / portrait / square) when the user selects
27
+ * a 1080p or 4K scale. `null` falls back to landscape (legacy default).
28
+ */
29
+ compositionDimensions?: CompositionDimensions | null;
19
30
  }
20
31
 
21
- // Indexing the table by `ResolutionPreset | "auto"` makes adding a new preset
22
- // to `core.types` (e.g. an 8K row) a TypeScript error here instead of a
23
- // silently missing dropdown entry. Order is fixed by the array below.
24
- const RESOLUTION_LABELS: Record<ResolutionPreset | "auto", { label: string; title: string }> = {
25
- auto: { label: "Auto", title: "Render at the composition's authored resolution" },
26
- landscape: { label: "1080p ↔", title: "1920×1080 landscape" },
27
- portrait: { label: "1080p ↕", title: "1080×1920 portrait" },
28
- "landscape-4k": {
29
- label: "4K ↔",
30
- title: "3840×2160 — supersamples a 1080p composition via Chrome DPR. Slower, larger files.",
31
- },
32
- "portrait-4k": {
33
- label: "4K ↕",
34
- title: "2160×3840 — supersamples a 1080p portrait composition via Chrome DPR.",
35
- },
32
+ // Orientation is derived from the composition's authored aspect ratio,
33
+ // not chosen by the user picking "1080p portrait" for a landscape comp
34
+ // would just produce a wrong-aspect render.
35
+ type RenderScale = "auto" | "1080p" | "4k";
36
+
37
+ const SCALE_OPTION_ORDER: RenderScale[] = ["auto", "1080p", "4k"];
38
+
39
+ const SCALE_LABEL: Record<RenderScale, string> = {
40
+ auto: "Auto",
41
+ "1080p": "1080p",
42
+ "4k": "4K",
36
43
  };
37
44
 
38
- const RESOLUTION_OPTION_ORDER: (ResolutionPreset | "auto")[] = [
39
- "auto",
40
- "landscape",
41
- "portrait",
42
- "landscape-4k",
43
- "portrait-4k",
44
- ];
45
+ // Mirrors `CANVAS_DIMENSIONS` in @hyperframes/core. Studio can't import from
46
+ // the core barrel (it transitively pulls in node:fs) and the values are stable.
47
+ const CANVAS_DIMENSIONS: Record<ResolutionPreset, CompositionDimensions> = {
48
+ landscape: { width: 1920, height: 1080 },
49
+ portrait: { width: 1080, height: 1920 },
50
+ "landscape-4k": { width: 3840, height: 2160 },
51
+ "portrait-4k": { width: 2160, height: 3840 },
52
+ square: { width: 1080, height: 1080 },
53
+ "square-4k": { width: 2160, height: 2160 },
54
+ };
55
+
56
+ type CompAspect = "landscape" | "portrait" | "square";
45
57
 
46
- const RESOLUTION_OPTIONS = RESOLUTION_OPTION_ORDER.map((value) => ({
47
- value,
48
- ...RESOLUTION_LABELS[value],
49
- }));
58
+ function compAspect(dims: CompositionDimensions | null | undefined): CompAspect {
59
+ // Missing dims fall through to landscape (legacy default — "landscape" was
60
+ // the first preset). Studio shows resolved dims inline, so the user can see
61
+ // when this fallback is in effect.
62
+ if (dims == null) return "landscape";
63
+ if (dims.width === dims.height) return "square";
64
+ return dims.height > dims.width ? "portrait" : "landscape";
65
+ }
66
+
67
+ function resolveResolution(
68
+ scale: RenderScale,
69
+ dims: CompositionDimensions | null | undefined,
70
+ ): ResolutionPreset | "auto" {
71
+ if (scale === "auto") return "auto";
72
+ const aspect = compAspect(dims);
73
+ if (scale === "1080p") return aspect;
74
+ return aspect === "landscape"
75
+ ? "landscape-4k"
76
+ : aspect === "portrait"
77
+ ? "portrait-4k"
78
+ : "square-4k";
79
+ }
80
+
81
+ function resolvedDimensions(
82
+ scale: RenderScale,
83
+ dims: CompositionDimensions | null | undefined,
84
+ ): CompositionDimensions | null {
85
+ if (scale === "auto") return dims ?? null;
86
+ const preset = resolveResolution(scale, dims);
87
+ return preset === "auto" ? null : CANVAS_DIMENSIONS[preset];
88
+ }
89
+
90
+ // Mirrors the producer's resolveDeviceScaleFactor validation
91
+ // (renderOrchestrator.ts:608): the chosen preset must match the comp's aspect
92
+ // ratio exactly (cross-multiplied), can't downsample, and must be an integer
93
+ // scale factor. Without this guard the user can pick a preset that throws at
94
+ // render time — e.g. 1080p on a 1080×1080 square or 1080p on a 1280×720 comp
95
+ // (1.5× isn't integer).
96
+ function scaleApplies(scale: RenderScale, dims: CompositionDimensions | null | undefined): boolean {
97
+ if (scale === "auto" || dims == null) return true;
98
+ const preset = resolveResolution(scale, dims);
99
+ if (preset === "auto") return true;
100
+ const target = CANVAS_DIMENSIONS[preset];
101
+ if (target.width * dims.height !== target.height * dims.width) return false;
102
+ if (target.width < dims.width) return false;
103
+ return Number.isInteger(target.width / dims.width);
104
+ }
105
+
106
+ function scaleOptionLabel(
107
+ scale: RenderScale,
108
+ dims: CompositionDimensions | null | undefined,
109
+ ): string {
110
+ const resolved = resolvedDimensions(scale, dims);
111
+ return resolved
112
+ ? `${SCALE_LABEL[scale]} · ${resolved.width}×${resolved.height}`
113
+ : SCALE_LABEL[scale];
114
+ }
50
115
 
51
116
  const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string }> = {
52
117
  mp4: { label: "MP4", desc: "Best for general use. Smallest file, universal playback." },
@@ -127,9 +192,11 @@ const QUALITY_OPTIONS: {
127
192
  function FormatExportButton({
128
193
  onStartRender,
129
194
  isRendering,
195
+ compositionDimensions,
130
196
  }: {
131
197
  onStartRender: StartRenderHandler;
132
198
  isRendering: boolean;
199
+ compositionDimensions?: CompositionDimensions | null;
133
200
  }) {
134
201
  const [format, setFormat] = useState<"mp4" | "webm" | "mov">("mp4");
135
202
  const [quality, setQuality] = useState<"draft" | "standard" | "high">("standard");
@@ -150,12 +217,11 @@ function FormatExportButton({
150
217
  value={resolution}
151
218
  onChange={(e) => setResolution(e.target.value as ResolutionPreset | "auto")}
152
219
  disabled={isRendering}
153
- title={RESOLUTION_OPTIONS.find((r) => r.value === resolution)?.title}
154
220
  className="h-5 px-1 text-[10px] rounded-l bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
155
221
  >
156
- {RESOLUTION_OPTIONS.map((r) => (
157
- <option key={r.value} value={r.value} title={r.title}>
158
- {r.label}
222
+ {SCALE_OPTION_ORDER.map((value) => (
223
+ <option key={value} value={value} disabled={!scaleApplies(value, compositionDimensions)}>
224
+ {scaleOptionLabel(value, compositionDimensions)}
159
225
  </option>
160
226
  ))}
161
227
  </select>
@@ -215,6 +281,7 @@ export const RenderQueue = memo(function RenderQueue({
215
281
  onClearCompleted,
216
282
  onStartRender,
217
283
  isRendering,
284
+ compositionDimensions,
218
285
  }: RenderQueueProps) {
219
286
  const listRef = useRef<HTMLDivElement>(null);
220
287
 
@@ -241,7 +308,11 @@ export const RenderQueue = memo(function RenderQueue({
241
308
  Clear
242
309
  </button>
243
310
  )}
244
- <FormatExportButton onStartRender={onStartRender} isRendering={isRendering} />
311
+ <FormatExportButton
312
+ onStartRender={onStartRender}
313
+ isRendering={isRendering}
314
+ compositionDimensions={compositionDimensions}
315
+ />
245
316
  </div>
246
317
  </div>
247
318