@hyperframes/studio 0.6.86 → 0.6.88
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/index-B9_ctmee.js +143 -0
- package/dist/assets/index-CGlIm_-E.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +159 -6
- package/src/components/StudioHeader.tsx +20 -7
- package/src/components/StudioPreviewArea.tsx +6 -1
- package/src/components/StudioRightPanel.tsx +13 -0
- package/src/components/StudioToast.tsx +47 -7
- package/src/components/TimelineToolbar.tsx +12 -122
- package/src/components/editor/AnimationCard.tsx +64 -10
- package/src/components/editor/ArcPathControls.tsx +131 -0
- package/src/components/editor/BorderRadiusEditor.tsx +209 -0
- package/src/components/editor/DomEditOverlay.tsx +70 -11
- package/src/components/editor/DopesheetStrip.tsx +141 -0
- package/src/components/editor/EaseCurveSection.tsx +82 -7
- package/src/components/editor/GestureTrailOverlay.tsx +132 -0
- package/src/components/editor/GsapAnimationSection.tsx +14 -1
- package/src/components/editor/KeyframeDiamond.tsx +27 -12
- package/src/components/editor/LayersPanel.tsx +14 -12
- package/src/components/editor/MotionPathOverlay.tsx +146 -0
- package/src/components/editor/PropertyPanel.tsx +196 -66
- package/src/components/editor/SourceEditor.tsx +0 -1
- package/src/components/editor/StaggerControls.tsx +61 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +13 -0
- package/src/components/editor/domEditOverlayGeometry.ts +2 -1
- package/src/components/editor/domEditing.test.ts +43 -0
- package/src/components/editor/domEditing.ts +2 -0
- package/src/components/editor/domEditingElement.ts +25 -2
- package/src/components/editor/domEditingLayers.test.ts +78 -0
- package/src/components/editor/domEditingLayers.ts +33 -13
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +3 -0
- package/src/components/editor/manualEditsDom.ts +23 -5
- package/src/components/editor/manualOffsetDrag.ts +59 -0
- package/src/components/editor/panelTokens.ts +10 -0
- package/src/components/editor/propertyPanelColor.tsx +2 -2
- package/src/components/editor/propertyPanelFill.tsx +1 -1
- package/src/components/editor/propertyPanelHelpers.ts +18 -2
- package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +38 -25
- package/src/components/editor/propertyPanelSections.tsx +4 -6
- package/src/components/editor/propertyPanelStyleSections.tsx +30 -8
- package/src/components/editor/useDomEditOverlayRects.ts +46 -2
- package/src/components/renders/RenderQueue.tsx +121 -100
- package/src/components/renders/RenderQueueItem.tsx +13 -13
- package/src/contexts/DomEditContext.tsx +12 -0
- package/src/contexts/FileManagerContext.tsx +3 -0
- package/src/contexts/StudioContext.tsx +0 -4
- package/src/hooks/gsapKeyframeCommit.ts +92 -0
- package/src/hooks/gsapRuntimeBridge.ts +147 -85
- package/src/hooks/gsapRuntimeKeyframes.ts +75 -24
- package/src/hooks/gsapRuntimePreview.ts +19 -0
- package/src/hooks/useAppHotkeys.ts +18 -0
- package/src/hooks/useAskAgentModal.ts +2 -4
- package/src/hooks/useDomEditCommits.ts +11 -17
- package/src/hooks/useDomEditSession.ts +47 -4
- package/src/hooks/useEnableKeyframes.ts +171 -0
- package/src/hooks/useFileManager.ts +7 -0
- package/src/hooks/useGestureRecording.ts +340 -0
- package/src/hooks/useGsapScriptCommits.ts +171 -35
- package/src/hooks/useGsapSelectionHandlers.ts +27 -8
- package/src/hooks/useGsapTweenCache.ts +169 -11
- package/src/hooks/useKeyframeKeyboard.ts +103 -0
- package/src/hooks/useStudioContextValue.ts +5 -4
- package/src/hooks/useStudioUrlState.ts +1 -2
- package/src/hooks/useTimelineEditing.ts +50 -3
- package/src/hooks/useToast.ts +6 -1
- package/src/player/components/ShortcutsPanel.tsx +40 -0
- package/src/player/components/TimelineClipDiamonds.tsx +3 -3
- package/src/player/components/TimelinePropertyRows.tsx +120 -0
- package/src/player/lib/timelineDOM.test.ts +55 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +51 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +43 -0
- package/src/utils/audioBeatDetection.ts +58 -0
- package/src/utils/globalTimeCompiler.test.ts +169 -0
- package/src/utils/globalTimeCompiler.ts +77 -0
- package/src/utils/gsapSoftReload.ts +30 -10
- package/src/utils/keyframeSnapping.test.ts +74 -0
- package/src/utils/keyframeSnapping.ts +63 -0
- package/src/utils/rdpSimplify.ts +183 -0
- package/src/utils/sourcePatcher.ts +2 -0
- package/dist/assets/index-BT9VHgSy.js +0 -140
- package/dist/assets/index-DHcptK1_.css +0 -1
|
@@ -257,9 +257,9 @@ export function SliderControl({
|
|
|
257
257
|
onMouseUp={() => commitDraft(draft)}
|
|
258
258
|
onTouchEnd={() => commitDraft(draft)}
|
|
259
259
|
onBlur={() => commitDraft(draft)}
|
|
260
|
-
className="h-
|
|
260
|
+
className="h-4 min-w-0 w-full cursor-pointer appearance-none bg-transparent disabled:cursor-not-allowed disabled:opacity-50 [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-panel-border [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0C0C0E,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing"
|
|
261
261
|
/>
|
|
262
|
-
<div className="min-w-[
|
|
262
|
+
<div className="min-w-[44px] rounded-md bg-panel-input px-2 py-1.5 text-right text-[11px] font-medium text-panel-text-1 tabular-nums">
|
|
263
263
|
{formatDisplayValue?.(draft) ?? displayValue}
|
|
264
264
|
</div>
|
|
265
265
|
</div>
|
|
@@ -279,7 +279,7 @@ export function SegmentedControl({
|
|
|
279
279
|
}) {
|
|
280
280
|
return (
|
|
281
281
|
<div
|
|
282
|
-
className="grid min-w-0 gap-
|
|
282
|
+
className="grid min-w-0 gap-[2px] rounded-md bg-panel-input p-[2px]"
|
|
283
283
|
style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
|
|
284
284
|
>
|
|
285
285
|
{options.map((option) => (
|
|
@@ -288,10 +288,10 @@ export function SegmentedControl({
|
|
|
288
288
|
type="button"
|
|
289
289
|
disabled={disabled}
|
|
290
290
|
onClick={() => onChange(option.value)}
|
|
291
|
-
className={`min-w-0 truncate rounded
|
|
291
|
+
className={`min-w-0 truncate rounded px-2 py-[5px] text-[11px] font-medium transition-colors disabled:cursor-not-allowed ${
|
|
292
292
|
option.value === value
|
|
293
|
-
? "bg-
|
|
294
|
-
: "text-
|
|
293
|
+
? "bg-panel-hover text-white"
|
|
294
|
+
: "text-panel-text-4 hover:text-panel-text-2"
|
|
295
295
|
}`}
|
|
296
296
|
>
|
|
297
297
|
{option.label}
|
|
@@ -336,7 +336,7 @@ export function SelectField({
|
|
|
336
336
|
|
|
337
337
|
export function Section({
|
|
338
338
|
title,
|
|
339
|
-
icon,
|
|
339
|
+
icon: _icon,
|
|
340
340
|
children,
|
|
341
341
|
accessory,
|
|
342
342
|
defaultCollapsed = false,
|
|
@@ -350,32 +350,45 @@ export function Section({
|
|
|
350
350
|
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
|
351
351
|
|
|
352
352
|
return (
|
|
353
|
-
<section className="min-w-0 border-t border-
|
|
353
|
+
<section className="min-w-0 border-t border-panel-border">
|
|
354
354
|
<button
|
|
355
355
|
type="button"
|
|
356
356
|
onClick={() => setCollapsed((v) => !v)}
|
|
357
|
-
className="flex w-full items-center justify-between gap-2 px-4 py-
|
|
357
|
+
className="flex w-full items-center justify-between gap-2 px-4 py-2.5"
|
|
358
358
|
>
|
|
359
|
-
<
|
|
360
|
-
<span className="flex-shrink-0 text-neutral-500">{icon}</span>
|
|
361
|
-
<h3 className="text-[11px] font-semibold uppercase tracking-[0.12em] text-neutral-300">
|
|
362
|
-
{title}
|
|
363
|
-
</h3>
|
|
364
|
-
</div>
|
|
359
|
+
<h3 className="text-[12px] font-semibold text-panel-text-1">{title}</h3>
|
|
365
360
|
<div className="flex items-center gap-2">
|
|
366
361
|
{accessory}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
362
|
+
{collapsed && (
|
|
363
|
+
<svg
|
|
364
|
+
width="12"
|
|
365
|
+
height="12"
|
|
366
|
+
viewBox="0 0 12 12"
|
|
367
|
+
fill="none"
|
|
368
|
+
className="flex-shrink-0 text-panel-text-5"
|
|
369
|
+
>
|
|
370
|
+
<path
|
|
371
|
+
d="M6 2.5v7M2.5 6h7"
|
|
372
|
+
stroke="currentColor"
|
|
373
|
+
strokeWidth="1.2"
|
|
374
|
+
strokeLinecap="round"
|
|
375
|
+
/>
|
|
376
|
+
</svg>
|
|
377
|
+
)}
|
|
378
|
+
{!collapsed && (
|
|
379
|
+
<svg
|
|
380
|
+
width="10"
|
|
381
|
+
height="10"
|
|
382
|
+
viewBox="0 0 10 10"
|
|
383
|
+
fill="currentColor"
|
|
384
|
+
className="flex-shrink-0 text-panel-text-5"
|
|
385
|
+
>
|
|
386
|
+
<path d="M2 3l3 4 3-4z" />
|
|
387
|
+
</svg>
|
|
388
|
+
)}
|
|
376
389
|
</div>
|
|
377
390
|
</button>
|
|
378
|
-
{!collapsed && <div className="px-4 pb-
|
|
391
|
+
{!collapsed && <div className="px-4 pb-3">{children}</div>}
|
|
379
392
|
</section>
|
|
380
393
|
);
|
|
381
394
|
}
|
|
@@ -262,15 +262,13 @@ function TextFieldEditor({
|
|
|
262
262
|
onRemoveTextField: (fieldKey: string) => void;
|
|
263
263
|
}) {
|
|
264
264
|
return (
|
|
265
|
-
<div className="space-y-
|
|
265
|
+
<div className="space-y-3">
|
|
266
266
|
<div className={showRemove ? "flex min-w-0 items-center justify-between gap-2" : "min-w-0"}>
|
|
267
267
|
<div className="min-w-0">
|
|
268
268
|
<div className="truncate text-[11px] font-medium text-neutral-100">
|
|
269
269
|
{formatTextFieldPreview(field.value) || "Text"}
|
|
270
270
|
</div>
|
|
271
|
-
<div className="text-[10px]
|
|
272
|
-
{field.tagName}
|
|
273
|
-
</div>
|
|
271
|
+
<div className="text-[10px] text-neutral-500">{field.tagName}</div>
|
|
274
272
|
</div>
|
|
275
273
|
{showRemove && (
|
|
276
274
|
<button
|
|
@@ -368,7 +366,7 @@ export function TextSection({
|
|
|
368
366
|
|
|
369
367
|
if (textFields.length === 1) {
|
|
370
368
|
return (
|
|
371
|
-
<Section title="Text" icon={<Type size={15} />}>
|
|
369
|
+
<Section title="Text" icon={<Type size={15} />} defaultCollapsed>
|
|
372
370
|
<TextFieldEditor
|
|
373
371
|
field={activeField}
|
|
374
372
|
styles={styles}
|
|
@@ -426,7 +424,7 @@ export function TextSection({
|
|
|
426
424
|
{formatTextFieldPreview(field.value) || `Text ${index + 1}`}
|
|
427
425
|
</span>
|
|
428
426
|
</div>
|
|
429
|
-
<span className="flex-shrink-0 rounded-md border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 text-[10px]
|
|
427
|
+
<span className="flex-shrink-0 rounded-md border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 text-[10px] text-neutral-500">
|
|
430
428
|
{field.tagName}
|
|
431
429
|
</span>
|
|
432
430
|
</div>
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from "./propertyPanelPrimitives";
|
|
34
34
|
import { ColorField } from "./propertyPanelColor";
|
|
35
35
|
import { GradientField, ImageFillField } from "./propertyPanelFill";
|
|
36
|
+
import { BorderRadiusEditor } from "./BorderRadiusEditor";
|
|
36
37
|
|
|
37
38
|
export function StyleSections({
|
|
38
39
|
projectId,
|
|
@@ -41,6 +42,7 @@ export function StyleSections({
|
|
|
41
42
|
assets,
|
|
42
43
|
onSetStyle,
|
|
43
44
|
onImportAssets,
|
|
45
|
+
gsapBorderRadius,
|
|
44
46
|
}: {
|
|
45
47
|
projectId: string;
|
|
46
48
|
element: DomEditSelection;
|
|
@@ -48,10 +50,19 @@ export function StyleSections({
|
|
|
48
50
|
assets: string[];
|
|
49
51
|
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
|
50
52
|
onImportAssets?: (files: FileList) => Promise<string[]>;
|
|
53
|
+
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
|
|
51
54
|
}) {
|
|
52
55
|
const styleEditingDisabled = !element.capabilities.canEditStyles;
|
|
53
56
|
const isFlex = styles.display === "flex" || styles.display === "inline-flex";
|
|
54
57
|
const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
|
|
58
|
+
const radiusTL =
|
|
59
|
+
gsapBorderRadius?.tl ?? parseNumericValue(styles["border-top-left-radius"]) ?? radiusValue;
|
|
60
|
+
const radiusTR =
|
|
61
|
+
gsapBorderRadius?.tr ?? parseNumericValue(styles["border-top-right-radius"]) ?? radiusValue;
|
|
62
|
+
const radiusBR =
|
|
63
|
+
gsapBorderRadius?.br ?? parseNumericValue(styles["border-bottom-right-radius"]) ?? radiusValue;
|
|
64
|
+
const radiusBL =
|
|
65
|
+
gsapBorderRadius?.bl ?? parseNumericValue(styles["border-bottom-left-radius"]) ?? radiusValue;
|
|
55
66
|
const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100);
|
|
56
67
|
const borderWidthValue =
|
|
57
68
|
parsePxMetricValue(styles["border-width"] ?? "") ??
|
|
@@ -155,15 +166,26 @@ export function StyleSections({
|
|
|
155
166
|
|
|
156
167
|
{hasVisualBackground && (
|
|
157
168
|
<Section title="Radius" icon={<Settings size={15} />} defaultCollapsed>
|
|
158
|
-
<
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
169
|
+
<BorderRadiusEditor
|
|
170
|
+
tl={radiusTL}
|
|
171
|
+
tr={radiusTR}
|
|
172
|
+
br={radiusBR}
|
|
173
|
+
bl={radiusBL}
|
|
163
174
|
disabled={styleEditingDisabled}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
175
|
+
onCommit={(corner, value) => {
|
|
176
|
+
const px = `${formatNumericValue(value)}px`;
|
|
177
|
+
if (corner === "all") {
|
|
178
|
+
onSetStyle("border-radius", px);
|
|
179
|
+
} else {
|
|
180
|
+
const prop = {
|
|
181
|
+
tl: "border-top-left-radius",
|
|
182
|
+
tr: "border-top-right-radius",
|
|
183
|
+
br: "border-bottom-right-radius",
|
|
184
|
+
bl: "border-bottom-left-radius",
|
|
185
|
+
}[corner];
|
|
186
|
+
onSetStyle(prop, px);
|
|
187
|
+
}
|
|
188
|
+
}}
|
|
167
189
|
/>
|
|
168
190
|
</Section>
|
|
169
191
|
)}
|
|
@@ -17,6 +17,14 @@ import {
|
|
|
17
17
|
toOverlayRect,
|
|
18
18
|
} from "./domEditOverlayGeometry";
|
|
19
19
|
|
|
20
|
+
function childRectsEqual(a: OverlayRect[], b: OverlayRect[]): boolean {
|
|
21
|
+
if (a.length !== b.length) return false;
|
|
22
|
+
for (let i = 0; i < a.length; i++) {
|
|
23
|
+
if (!rectsEqual(a[i]!, b[i]!)) return false;
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
20
28
|
interface UseDomEditOverlayRectsOptions {
|
|
21
29
|
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
22
30
|
overlayRef: RefObject<HTMLDivElement | null>;
|
|
@@ -37,6 +45,7 @@ interface UseDomEditOverlayRectsResult {
|
|
|
37
45
|
groupOverlayItems: GroupOverlayItem[];
|
|
38
46
|
groupOverlayItemsRef: RefObject<GroupOverlayItem[]>;
|
|
39
47
|
setGroupOverlayItems: (next: GroupOverlayItem[]) => void;
|
|
48
|
+
childRects: OverlayRect[];
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
export function useDomEditOverlayRects({
|
|
@@ -51,6 +60,7 @@ export function useDomEditOverlayRects({
|
|
|
51
60
|
const [overlayRect, setOverlayRectState] = useState<OverlayRect | null>(null);
|
|
52
61
|
const [hoverRect, setHoverRectState] = useState<OverlayRect | null>(null);
|
|
53
62
|
const [groupOverlayItems, setGroupOverlayItemsState] = useState<GroupOverlayItem[]>([]);
|
|
63
|
+
const [childRects, setChildRectsState] = useState<OverlayRect[]>([]);
|
|
54
64
|
|
|
55
65
|
const overlayRectRef = useRef<OverlayRect | null>(null);
|
|
56
66
|
const hoverRectRef = useRef<OverlayRect | null>(null);
|
|
@@ -58,6 +68,7 @@ export function useDomEditOverlayRects({
|
|
|
58
68
|
const resolvedElementRef = useRef<{ key: string; element: HTMLElement } | null>(null);
|
|
59
69
|
const resolvedHoverElementRef = useRef<{ key: string; element: HTMLElement } | null>(null);
|
|
60
70
|
const resolvedGroupElementRef = useRef<Map<string, HTMLElement>>(new Map());
|
|
71
|
+
const childRectsRef = useRef<OverlayRect[]>([]);
|
|
61
72
|
|
|
62
73
|
const setOverlayRect = (next: OverlayRect | null) => {
|
|
63
74
|
if (rectsEqual(overlayRectRef.current, next)) return;
|
|
@@ -102,7 +113,13 @@ export function useDomEditOverlayRects({
|
|
|
102
113
|
|
|
103
114
|
const update = () => {
|
|
104
115
|
frame = requestAnimationFrame(update);
|
|
105
|
-
if (rafPausedRef.current)
|
|
116
|
+
if (rafPausedRef.current) {
|
|
117
|
+
if (childRectsRef.current.length > 0) {
|
|
118
|
+
childRectsRef.current = [];
|
|
119
|
+
setChildRectsState([]);
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
106
123
|
|
|
107
124
|
const sel = selectionRef.current;
|
|
108
125
|
const iframe = iframeRef.current;
|
|
@@ -132,13 +149,39 @@ export function useDomEditOverlayRects({
|
|
|
132
149
|
resolvedElementRef as ResolvedElementRef,
|
|
133
150
|
);
|
|
134
151
|
if (el && isElementVisibleForOverlay(el)) {
|
|
135
|
-
|
|
152
|
+
const nextRect = toOverlayRect(overlayEl, iframe, el);
|
|
153
|
+
setOverlayRect(nextRect);
|
|
154
|
+
const descendants = el.querySelectorAll("*");
|
|
155
|
+
if (descendants.length > 0 && descendants.length <= 60) {
|
|
156
|
+
const nextChildRects: OverlayRect[] = [];
|
|
157
|
+
for (let i = 0; i < descendants.length; i++) {
|
|
158
|
+
const child = descendants[i] as HTMLElement;
|
|
159
|
+
if (!child.getBoundingClientRect) continue;
|
|
160
|
+
const r = toOverlayRect(overlayEl, iframe, child);
|
|
161
|
+
if (r && r.width > 2 && r.height > 2) nextChildRects.push(r);
|
|
162
|
+
}
|
|
163
|
+
if (!childRectsEqual(childRectsRef.current, nextChildRects)) {
|
|
164
|
+
childRectsRef.current = nextChildRects;
|
|
165
|
+
setChildRectsState(nextChildRects);
|
|
166
|
+
}
|
|
167
|
+
} else if (childRectsRef.current.length > 0) {
|
|
168
|
+
childRectsRef.current = [];
|
|
169
|
+
setChildRectsState([]);
|
|
170
|
+
}
|
|
136
171
|
} else {
|
|
137
172
|
setOverlayRect(null);
|
|
173
|
+
if (childRectsRef.current.length > 0) {
|
|
174
|
+
childRectsRef.current = [];
|
|
175
|
+
setChildRectsState([]);
|
|
176
|
+
}
|
|
138
177
|
}
|
|
139
178
|
} else {
|
|
140
179
|
resolvedElementRef.current = null;
|
|
141
180
|
setOverlayRect(null);
|
|
181
|
+
if (childRectsRef.current.length > 0) {
|
|
182
|
+
childRectsRef.current = [];
|
|
183
|
+
setChildRectsState([]);
|
|
184
|
+
}
|
|
142
185
|
}
|
|
143
186
|
|
|
144
187
|
const group = groupSelectionsRef.current;
|
|
@@ -203,5 +246,6 @@ export function useDomEditOverlayRects({
|
|
|
203
246
|
groupOverlayItems,
|
|
204
247
|
groupOverlayItemsRef,
|
|
205
248
|
setGroupOverlayItems,
|
|
249
|
+
childRects,
|
|
206
250
|
};
|
|
207
251
|
}
|
|
@@ -154,22 +154,22 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
|
|
|
154
154
|
strokeWidth="2"
|
|
155
155
|
strokeLinecap="round"
|
|
156
156
|
strokeLinejoin="round"
|
|
157
|
-
className="text-
|
|
157
|
+
className="text-panel-text-5 hover:text-panel-text-3 transition-colors cursor-help"
|
|
158
158
|
>
|
|
159
159
|
<circle cx="12" cy="12" r="10" />
|
|
160
160
|
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
|
|
161
161
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
|
162
162
|
</svg>
|
|
163
163
|
{open && (
|
|
164
|
-
<div className="absolute top-full right-0 mt-1.5 w-52 p-2 rounded bg-
|
|
165
|
-
<p className="text-[10px] font-semibold text-
|
|
166
|
-
<p className="text-[9px] text-
|
|
164
|
+
<div className="absolute top-full right-0 mt-1.5 w-52 p-2 rounded bg-panel-input border border-neutral-700 shadow-lg z-50">
|
|
165
|
+
<p className="text-[10px] font-semibold text-panel-text-1 mb-0.5">{info.label}</p>
|
|
166
|
+
<p className="text-[9px] text-panel-text-3 leading-tight">{info.desc}</p>
|
|
167
167
|
<div className="mt-1.5 pt-1.5 border-t border-neutral-800">
|
|
168
168
|
{(["mp4", "mov", "webm"] as const)
|
|
169
169
|
.filter((f) => f !== format)
|
|
170
170
|
.map((f) => (
|
|
171
|
-
<p key={f} className="text-[9px] text-
|
|
172
|
-
<span className="text-
|
|
171
|
+
<p key={f} className="text-[9px] text-panel-text-4 leading-relaxed">
|
|
172
|
+
<span className="text-panel-text-3 font-medium">{FORMAT_INFO[f].label}</span>
|
|
173
173
|
{" — "}
|
|
174
174
|
{FORMAT_INFO[f].desc}
|
|
175
175
|
</p>
|
|
@@ -209,80 +209,97 @@ function FormatExportButton({
|
|
|
209
209
|
// MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
|
|
210
210
|
const showQuality = format !== "mov";
|
|
211
211
|
|
|
212
|
+
const selectCls =
|
|
213
|
+
"h-7 w-full px-2 text-[11px] bg-panel-input rounded-md text-panel-text-1 outline-none cursor-pointer disabled:opacity-50 hover:bg-panel-hover transition-colors";
|
|
214
|
+
|
|
212
215
|
return (
|
|
213
|
-
<div className="flex
|
|
214
|
-
<
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
{
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
216
|
+
<div className="flex flex-col gap-3">
|
|
217
|
+
<div className="grid grid-cols-2 gap-2">
|
|
218
|
+
<div className="flex flex-col gap-1">
|
|
219
|
+
<div className="flex items-center gap-1">
|
|
220
|
+
<span className="text-[10px] text-panel-text-4">Format</span>
|
|
221
|
+
<FormatInfoTooltip format={format} />
|
|
222
|
+
</div>
|
|
223
|
+
<select
|
|
224
|
+
value={format}
|
|
225
|
+
onChange={(e) => {
|
|
226
|
+
const v = e.target.value as "mp4" | "webm" | "mov";
|
|
227
|
+
setFormat(v);
|
|
228
|
+
persistRenderSettings(v, quality, fps);
|
|
229
|
+
}}
|
|
230
|
+
disabled={isRendering}
|
|
231
|
+
className={selectCls}
|
|
232
|
+
>
|
|
233
|
+
<option value="mp4">MP4</option>
|
|
234
|
+
<option value="mov">MOV (ProRes)</option>
|
|
235
|
+
<option value="webm">WebM</option>
|
|
236
|
+
</select>
|
|
237
|
+
</div>
|
|
238
|
+
<div className="flex flex-col gap-1">
|
|
239
|
+
<span className="text-[10px] text-panel-text-4">Resolution</span>
|
|
240
|
+
<select
|
|
241
|
+
value={resolution}
|
|
242
|
+
onChange={(e) => setResolution(e.target.value as ResolutionPreset | "auto")}
|
|
243
|
+
disabled={isRendering}
|
|
244
|
+
className={selectCls}
|
|
245
|
+
>
|
|
246
|
+
{SCALE_OPTION_ORDER.map((value) => (
|
|
247
|
+
<option
|
|
248
|
+
key={value}
|
|
249
|
+
value={value}
|
|
250
|
+
disabled={!scaleApplies(value, compositionDimensions)}
|
|
251
|
+
>
|
|
252
|
+
{scaleOptionLabel(value, compositionDimensions)}
|
|
253
|
+
</option>
|
|
254
|
+
))}
|
|
255
|
+
</select>
|
|
256
|
+
</div>
|
|
257
|
+
<div className="flex flex-col gap-1">
|
|
258
|
+
<span className="text-[10px] text-panel-text-4">Frame rate</span>
|
|
259
|
+
<select
|
|
260
|
+
value={fps}
|
|
261
|
+
onChange={(e) => {
|
|
262
|
+
const v = Number(e.target.value) as 24 | 30 | 60;
|
|
263
|
+
setFps(v);
|
|
264
|
+
persistRenderSettings(format, quality, v);
|
|
265
|
+
}}
|
|
266
|
+
disabled={isRendering}
|
|
267
|
+
className={selectCls}
|
|
268
|
+
>
|
|
269
|
+
<option value={24}>24 fps</option>
|
|
270
|
+
<option value={30}>30 fps</option>
|
|
271
|
+
<option value={60}>60 fps</option>
|
|
272
|
+
</select>
|
|
273
|
+
</div>
|
|
274
|
+
{showQuality && (
|
|
275
|
+
<div className="flex flex-col gap-1">
|
|
276
|
+
<span className="text-[10px] text-panel-text-4">Quality</span>
|
|
277
|
+
<select
|
|
278
|
+
value={quality}
|
|
279
|
+
onChange={(e) => {
|
|
280
|
+
const v = e.target.value as "draft" | "standard" | "high";
|
|
281
|
+
setQuality(v);
|
|
282
|
+
persistRenderSettings(format, v, fps);
|
|
283
|
+
}}
|
|
284
|
+
disabled={isRendering}
|
|
285
|
+
className={selectCls}
|
|
286
|
+
>
|
|
287
|
+
{QUALITY_OPTIONS.map((q) => (
|
|
288
|
+
<option key={q.value} value={q.value}>
|
|
289
|
+
{q.label}
|
|
290
|
+
</option>
|
|
291
|
+
))}
|
|
292
|
+
</select>
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
</div>
|
|
279
296
|
<button
|
|
280
297
|
onClick={() => {
|
|
281
298
|
trackStudioEvent("render_start", { format, quality, resolution, fps });
|
|
282
299
|
void onStartRender(format, quality, resolution, fps);
|
|
283
300
|
}}
|
|
284
301
|
disabled={isRendering}
|
|
285
|
-
className="flex items-center
|
|
302
|
+
className="w-full flex items-center justify-center h-8 text-[11px] font-semibold rounded-md bg-panel-accent text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
|
|
286
303
|
>
|
|
287
304
|
{isRendering ? "Rendering..." : "Export"}
|
|
288
305
|
</button>
|
|
@@ -313,23 +330,12 @@ export const RenderQueue = memo(function RenderQueue({
|
|
|
313
330
|
|
|
314
331
|
return (
|
|
315
332
|
<div className="flex flex-col h-full">
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
{
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
className="text-[10px] text-neutral-600 hover:text-neutral-400 transition-colors"
|
|
323
|
-
>
|
|
324
|
-
Clear
|
|
325
|
-
</button>
|
|
326
|
-
)}
|
|
327
|
-
<FormatExportButton
|
|
328
|
-
onStartRender={onStartRender}
|
|
329
|
-
isRendering={isRendering}
|
|
330
|
-
compositionDimensions={compositionDimensions}
|
|
331
|
-
/>
|
|
332
|
-
</div>
|
|
333
|
+
<div className="px-3 py-3 border-b border-panel-border flex-shrink-0">
|
|
334
|
+
<FormatExportButton
|
|
335
|
+
onStartRender={onStartRender}
|
|
336
|
+
isRendering={isRendering}
|
|
337
|
+
compositionDimensions={compositionDimensions}
|
|
338
|
+
/>
|
|
333
339
|
</div>
|
|
334
340
|
|
|
335
341
|
{/* Job list */}
|
|
@@ -343,7 +349,7 @@ export const RenderQueue = memo(function RenderQueue({
|
|
|
343
349
|
fill="none"
|
|
344
350
|
stroke="currentColor"
|
|
345
351
|
strokeWidth="1.5"
|
|
346
|
-
className="text-
|
|
352
|
+
className="text-panel-text-5"
|
|
347
353
|
>
|
|
348
354
|
<rect
|
|
349
355
|
x="2"
|
|
@@ -361,17 +367,32 @@ export const RenderQueue = memo(function RenderQueue({
|
|
|
361
367
|
strokeLinejoin="round"
|
|
362
368
|
/>
|
|
363
369
|
</svg>
|
|
364
|
-
<p className="text-[10px] text-
|
|
370
|
+
<p className="text-[10px] text-panel-text-5 text-center">No renders yet</p>
|
|
365
371
|
</div>
|
|
366
372
|
) : (
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
373
|
+
<div>
|
|
374
|
+
{completedCount > 0 && (
|
|
375
|
+
<div className="flex items-center justify-between px-3 py-1.5 border-b border-panel-border">
|
|
376
|
+
<span className="text-[10px] text-panel-text-4">
|
|
377
|
+
{jobs.length} render{jobs.length === 1 ? "" : "s"}
|
|
378
|
+
</span>
|
|
379
|
+
<button
|
|
380
|
+
onClick={onClearCompleted}
|
|
381
|
+
className="text-[10px] text-panel-text-4 hover:text-panel-text-2 transition-colors"
|
|
382
|
+
>
|
|
383
|
+
Clear
|
|
384
|
+
</button>
|
|
385
|
+
</div>
|
|
386
|
+
)}
|
|
387
|
+
{jobs.map((job) => (
|
|
388
|
+
<RenderQueueItem
|
|
389
|
+
key={job.id}
|
|
390
|
+
job={job}
|
|
391
|
+
projectId={projectId}
|
|
392
|
+
onDelete={() => onDelete(job.id)}
|
|
393
|
+
/>
|
|
394
|
+
))}
|
|
395
|
+
</div>
|
|
375
396
|
)}
|
|
376
397
|
</div>
|
|
377
398
|
</div>
|