@hyperframes/studio 0.6.0-alpha.8 → 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.
- package/dist/assets/{hyperframes-player-DjsVzYFP.js → hyperframes-player-DOFETgjy.js} +1 -1
- package/dist/assets/index-D1JDq7Gg.css +1 -0
- package/dist/assets/index-DUqUmaoH.js +117 -0
- package/dist/favicon.svg +14 -0
- package/dist/index.html +3 -2
- package/package.json +9 -9
- package/src/App.tsx +428 -4299
- package/src/components/AskAgentModal.tsx +120 -0
- package/src/components/StudioHeader.tsx +133 -0
- package/src/components/StudioLeftSidebar.tsx +125 -0
- package/src/components/StudioPreviewArea.tsx +163 -0
- package/src/components/StudioRightPanel.tsx +198 -0
- package/src/components/TimelineToolbar.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +15 -1
- package/src/components/editor/PropertyPanel.test.ts +0 -49
- package/src/components/editor/PropertyPanel.tsx +132 -2763
- package/src/components/editor/domEditing.ts +38 -5
- package/src/components/editor/manualEditingAvailability.test.ts +2 -2
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +32 -0
- package/src/components/editor/propertyPanelColor.tsx +371 -0
- package/src/components/editor/propertyPanelFill.tsx +421 -0
- package/src/components/editor/propertyPanelFont.tsx +455 -0
- package/src/components/editor/propertyPanelHelpers.ts +401 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
- package/src/components/editor/propertyPanelSections.tsx +453 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
- package/src/components/nle/NLELayout.tsx +8 -11
- package/src/components/nle/NLEPreview.tsx +3 -0
- package/src/components/renders/RenderQueue.tsx +102 -31
- package/src/components/renders/useRenderQueue.ts +8 -2
- package/src/components/sidebar/LeftSidebar.tsx +186 -186
- package/src/contexts/DomEditContext.tsx +137 -0
- package/src/contexts/FileManagerContext.tsx +110 -0
- package/src/contexts/PanelLayoutContext.tsx +68 -0
- package/src/contexts/StudioContext.tsx +135 -0
- package/src/hooks/useAppHotkeys.ts +326 -0
- package/src/hooks/useAskAgentModal.ts +162 -0
- package/src/hooks/useCaptionDetection.ts +132 -0
- package/src/hooks/useCompositionDimensions.ts +25 -0
- package/src/hooks/useConsoleErrorCapture.ts +60 -0
- package/src/hooks/useDomEditCommits.ts +437 -0
- package/src/hooks/useDomEditSession.ts +342 -0
- package/src/hooks/useDomEditTextCommits.ts +330 -0
- package/src/hooks/useDomSelection.ts +398 -0
- package/src/hooks/useFileManager.ts +431 -0
- package/src/hooks/useFrameCapture.ts +77 -0
- package/src/hooks/useLintModal.ts +35 -0
- package/src/hooks/useManifestPersistence.ts +492 -0
- package/src/hooks/usePanelLayout.ts +68 -0
- package/src/hooks/usePreviewInteraction.ts +153 -0
- package/src/hooks/useRenderClipContent.ts +124 -0
- package/src/hooks/useTimelineEditing.ts +472 -0
- package/src/player/components/Player.tsx +35 -4
- package/src/player/components/Timeline.test.ts +0 -8
- package/src/player/components/Timeline.tsx +10 -103
- package/src/player/components/TimelineClip.tsx +9 -244
- package/src/player/hooks/useTimelinePlayer.ts +140 -103
- package/src/utils/domEditHelpers.ts +50 -0
- package/src/utils/studioFontHelpers.ts +83 -0
- package/src/utils/studioHelpers.ts +214 -0
- package/src/utils/studioPreviewHelpers.ts +185 -0
- package/src/utils/timelineDiscovery.ts +1 -1
- package/dist/assets/index-14zH9lqh.css +0 -1
- package/dist/assets/index-ClYcrksa.js +0 -108
- 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
|
|
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
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
{
|
|
157
|
-
<option key={
|
|
158
|
-
{
|
|
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
|
|
311
|
+
<FormatExportButton
|
|
312
|
+
onStartRender={onStartRender}
|
|
313
|
+
isRendering={isRendering}
|
|
314
|
+
compositionDimensions={compositionDimensions}
|
|
315
|
+
/>
|
|
245
316
|
</div>
|
|
246
317
|
</div>
|
|
247
318
|
|