@hyperframes/studio 0.7.37 → 0.7.39
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-BBrKzTGd.js +459 -0
- package/dist/assets/index-0P10SwC_.css +1 -0
- package/dist/assets/{index-BXYJbIax.js → index-312a3Ceu.js} +1 -1
- package/dist/assets/index-B9YvRJz1.js +396 -0
- package/dist/assets/{index-Ct-X2wlf.js → index-CQHiecE7.js} +1 -1
- package/dist/{chunk-JND3XUJL.js → chunk-RCLGSZ7C.js} +1 -1
- package/dist/chunk-RCLGSZ7C.js.map +1 -0
- package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-S6YOLVRG.js} +2 -2
- package/dist/index.d.ts +48 -10
- package/dist/index.html +2 -2
- package/dist/index.js +6045 -3507
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +26 -24
- package/src/components/AskAgentModal.tsx +23 -6
- package/src/components/LintModal.tsx +40 -10
- package/src/components/MediaPreview.tsx +36 -1
- package/src/components/SaveQueuePausedBanner.tsx +7 -6
- package/src/components/StudioErrorBoundary.tsx +19 -31
- package/src/components/StudioFeedbackBar.tsx +11 -2
- package/src/components/StudioGlobalDragOverlay.tsx +1 -1
- package/src/components/StudioHeader.tsx +184 -106
- package/src/components/StudioLeftSidebar.tsx +24 -3
- package/src/components/StudioOverlays.tsx +34 -8
- package/src/components/StudioPreviewArea.tsx +8 -0
- package/src/components/StudioRightPanel.tsx +160 -9
- package/src/components/StudioSplash.tsx +6 -3
- package/src/components/StudioToast.tsx +12 -11
- package/src/components/TimelineToolbar.test.tsx +9 -3
- package/src/components/TimelineToolbar.tsx +63 -29
- package/src/components/editor/DomEditCropHandles.tsx +238 -0
- package/src/components/editor/DomEditOverlay.tsx +111 -108
- package/src/components/editor/DomEditRotateHandle.tsx +41 -0
- package/src/components/editor/PropertyPanel.test.ts +48 -0
- package/src/components/editor/PropertyPanel.tsx +27 -47
- package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
- package/src/components/editor/SnapToolbar.tsx +20 -1
- package/src/components/editor/clipPathHelpers.ts +113 -0
- package/src/components/editor/colorGradingScopePatch.test.ts +74 -0
- package/src/components/editor/colorGradingScopePatch.ts +57 -0
- package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
- package/src/components/editor/domEditOverlayCrop.ts +115 -0
- package/src/components/editor/domEditOverlayGeometry.ts +14 -0
- package/src/components/editor/domEditOverlayShape.ts +39 -0
- package/src/components/editor/domEditing.test.ts +2 -0
- package/src/components/editor/domEditingTypes.ts +3 -0
- package/src/components/editor/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +0 -6
- package/src/components/editor/marqueeCommit.ts +3 -2
- package/src/components/editor/propertyPanelColorGradingControls.tsx +371 -329
- package/src/components/editor/propertyPanelColorGradingSection.tsx +274 -55
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +275 -0
- package/src/components/editor/propertyPanelHelpers.ts +32 -125
- package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
- package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
- package/src/components/editor/propertyPanelTypes.ts +113 -0
- package/src/components/editor/snapTargetCollection.ts +2 -3
- package/src/components/editor/useDomEditCompositionRect.ts +68 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
- package/src/components/editor/useDomEditOverlayRects.ts +5 -3
- package/src/components/nle/CompositionBreadcrumb.tsx +3 -2
- package/src/components/nle/NLELayout.tsx +54 -51
- package/src/components/nle/NLEPreview.tsx +41 -5
- package/src/components/nle/TimelineResizeDivider.tsx +97 -0
- package/src/components/nle/usePreviewBlockDrop.ts +31 -7
- package/src/components/renders/RenderQueue.tsx +125 -24
- package/src/components/renders/RenderQueueItem.tsx +132 -64
- package/src/components/renders/useRenderQueue.ts +136 -24
- package/src/components/sidebar/AssetsTab.test.ts +87 -0
- package/src/components/sidebar/AssetsTab.tsx +113 -15
- package/src/components/sidebar/BlocksTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +39 -16
- package/src/components/storyboard/StoryboardLoaded.tsx +19 -2
- package/src/components/storyboard/StoryboardView.tsx +6 -0
- package/src/components/studioColorGradingScope.ts +124 -0
- package/src/components/studioMediaJobs.ts +123 -0
- package/src/components/ui/Button.tsx +20 -11
- package/src/components/ui/HyperframesLoader.tsx +9 -2
- package/src/components/ui/SearchInput.tsx +53 -0
- package/src/components/ui/Tooltip.tsx +52 -6
- package/src/components/ui/VideoFrameThumbnail.tsx +26 -3
- package/src/components/ui/useDialogBehavior.ts +83 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -0
- package/src/contexts/StudioContext.tsx +7 -0
- package/src/hooks/domSelectionTestHarness.ts +1 -0
- package/src/hooks/useCompositionContentLoader.ts +40 -0
- package/src/hooks/useCropMode.ts +91 -0
- package/src/hooks/useDomEditCommits.test.tsx +2 -0
- package/src/hooks/useEditorSave.ts +14 -0
- package/src/hooks/useFileManager.ts +39 -25
- package/src/hooks/useFrameCapture.ts +12 -1
- package/src/hooks/usePanelLayout.ts +1 -0
- package/src/hooks/usePreviewPersistence.ts +24 -15
- package/src/hooks/useStudioContextValue.ts +5 -0
- package/src/hooks/useToast.ts +66 -13
- package/src/player/components/ShortcutsPanel.tsx +9 -0
- package/src/player/store/playerStore.ts +14 -0
- package/src/styles/studio.css +61 -0
- package/src/utils/sdkResolverShadow.test.ts +53 -3
- package/src/utils/sdkResolverShadow.ts +41 -1
- package/src/utils/studioPendingEdits.test.ts +43 -0
- package/src/utils/studioPendingEdits.ts +45 -0
- package/src/utils/studioUiPreferences.ts +4 -0
- package/dist/assets/hyperframes-player-BGuumSiM.js +0 -459
- package/dist/assets/index-DmkOvZns.css +0 -1
- package/dist/assets/index-SYSbQyK4.js +0 -396
- package/dist/chunk-JND3XUJL.js.map +0 -1
- /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-S6YOLVRG.js.map} +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
useCallback,
|
|
3
|
+
useEffect,
|
|
3
4
|
useMemo,
|
|
4
5
|
useRef,
|
|
5
6
|
useState,
|
|
@@ -27,6 +28,13 @@ import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
|
|
|
27
28
|
import { useFileManagerContext } from "../contexts/FileManagerContext";
|
|
28
29
|
import { useDomEditContext } from "../contexts/DomEditContext";
|
|
29
30
|
import { usePlayerStore } from "../player";
|
|
31
|
+
import { waitForMediaJob } from "./studioMediaJobs";
|
|
32
|
+
import {
|
|
33
|
+
applyColorGradingScopeUpdate,
|
|
34
|
+
EMPTY_COLOR_GRADING_SCOPE_RESULT,
|
|
35
|
+
type ColorGradingScope,
|
|
36
|
+
} from "./studioColorGradingScope";
|
|
37
|
+
import type { BackgroundRemovalProgress } from "./editor/propertyPanelTypes";
|
|
30
38
|
|
|
31
39
|
const MIN_INSPECTOR_SPLIT_PERCENT = 20;
|
|
32
40
|
const MAX_INSPECTOR_SPLIT_PERCENT = 75;
|
|
@@ -43,6 +51,8 @@ export interface StudioRightPanelProps {
|
|
|
43
51
|
recordingState?: "idle" | "recording" | "preview";
|
|
44
52
|
recordingDuration?: number;
|
|
45
53
|
onToggleRecording?: () => void;
|
|
54
|
+
cropMode?: boolean;
|
|
55
|
+
onCropModeChange?: (active: boolean) => void;
|
|
46
56
|
/** Dependencies for the Slideshow persist callback, threaded from App.tsx. */
|
|
47
57
|
sdkSession: Composition | null;
|
|
48
58
|
reloadPreview: () => void;
|
|
@@ -62,6 +72,8 @@ export function StudioRightPanel({
|
|
|
62
72
|
recordingState,
|
|
63
73
|
recordingDuration,
|
|
64
74
|
onToggleRecording,
|
|
75
|
+
cropMode,
|
|
76
|
+
onCropModeChange,
|
|
65
77
|
sdkSession,
|
|
66
78
|
reloadPreview,
|
|
67
79
|
domEditSaveTimestampRef,
|
|
@@ -69,6 +81,7 @@ export function StudioRightPanel({
|
|
|
69
81
|
}: StudioRightPanelProps) {
|
|
70
82
|
const {
|
|
71
83
|
rightWidth,
|
|
84
|
+
setRightWidth,
|
|
72
85
|
rightPanelTab,
|
|
73
86
|
setRightPanelTab,
|
|
74
87
|
rightInspectorPanes,
|
|
@@ -82,6 +95,7 @@ export function StudioRightPanel({
|
|
|
82
95
|
previewIframeRef,
|
|
83
96
|
projectId,
|
|
84
97
|
activeCompPath,
|
|
98
|
+
showToast,
|
|
85
99
|
compositionDimensions,
|
|
86
100
|
waitForPendingDomEditSaves,
|
|
87
101
|
renderQueue,
|
|
@@ -136,8 +150,10 @@ export function StudioRightPanel({
|
|
|
136
150
|
projectDir,
|
|
137
151
|
handleImportFiles,
|
|
138
152
|
handleImportFonts,
|
|
153
|
+
refreshFileTree,
|
|
139
154
|
readProjectFile,
|
|
140
155
|
writeProjectFile,
|
|
156
|
+
fileTree,
|
|
141
157
|
} = useFileManagerContext();
|
|
142
158
|
|
|
143
159
|
// Discrete ops (toggle, reorder, add/delete, hotspot): persist immediately,
|
|
@@ -172,6 +188,14 @@ export function StudioRightPanel({
|
|
|
172
188
|
startPercent: number;
|
|
173
189
|
height: number;
|
|
174
190
|
} | null>(null);
|
|
191
|
+
const backgroundRemovalAbortRef = useRef<AbortController | null>(null);
|
|
192
|
+
|
|
193
|
+
useEffect(
|
|
194
|
+
() => () => {
|
|
195
|
+
backgroundRemovalAbortRef.current?.abort();
|
|
196
|
+
},
|
|
197
|
+
[],
|
|
198
|
+
);
|
|
175
199
|
|
|
176
200
|
const renderJobs = renderQueue.jobs as RenderJob[];
|
|
177
201
|
const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers";
|
|
@@ -233,6 +257,90 @@ export function StudioRightPanel({
|
|
|
233
257
|
splitDragRef.current = null;
|
|
234
258
|
}, []);
|
|
235
259
|
|
|
260
|
+
const handleApplyColorGradingScope = useCallback(
|
|
261
|
+
async (scope: ColorGradingScope, value: string | null) =>
|
|
262
|
+
applyColorGradingScopeUpdate({
|
|
263
|
+
scope,
|
|
264
|
+
value,
|
|
265
|
+
selectedSourceFile: domEditSelection?.sourceFile || activeCompPath || "index.html",
|
|
266
|
+
fileTree,
|
|
267
|
+
projectId,
|
|
268
|
+
domEditSaveTimestampRef,
|
|
269
|
+
waitForPendingDomEditSaves,
|
|
270
|
+
readProjectFile,
|
|
271
|
+
writeProjectFile,
|
|
272
|
+
recordEdit,
|
|
273
|
+
reloadPreview,
|
|
274
|
+
showToast,
|
|
275
|
+
}).catch((error) => {
|
|
276
|
+
showToast(
|
|
277
|
+
`Couldn't apply color grading: ${error instanceof Error ? error.message : String(error)}`,
|
|
278
|
+
"error",
|
|
279
|
+
);
|
|
280
|
+
return EMPTY_COLOR_GRADING_SCOPE_RESULT;
|
|
281
|
+
}),
|
|
282
|
+
[
|
|
283
|
+
activeCompPath,
|
|
284
|
+
domEditSaveTimestampRef,
|
|
285
|
+
domEditSelection?.sourceFile,
|
|
286
|
+
fileTree,
|
|
287
|
+
projectId,
|
|
288
|
+
readProjectFile,
|
|
289
|
+
recordEdit,
|
|
290
|
+
reloadPreview,
|
|
291
|
+
showToast,
|
|
292
|
+
waitForPendingDomEditSaves,
|
|
293
|
+
writeProjectFile,
|
|
294
|
+
],
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const handleRemoveBackground = useCallback(
|
|
298
|
+
// fallow-ignore-next-line complexity
|
|
299
|
+
async (
|
|
300
|
+
inputPath: string,
|
|
301
|
+
options: {
|
|
302
|
+
createBackgroundPlate?: boolean;
|
|
303
|
+
quality?: "fast" | "balanced" | "best";
|
|
304
|
+
onProgress?: (progress: BackgroundRemovalProgress) => void;
|
|
305
|
+
},
|
|
306
|
+
) => {
|
|
307
|
+
const response = await fetch(
|
|
308
|
+
`/api/projects/${encodeURIComponent(projectId)}/media/remove-background`,
|
|
309
|
+
{
|
|
310
|
+
method: "POST",
|
|
311
|
+
headers: { "Content-Type": "application/json" },
|
|
312
|
+
body: JSON.stringify({
|
|
313
|
+
inputPath,
|
|
314
|
+
createBackgroundPlate: options.createBackgroundPlate === true,
|
|
315
|
+
quality: options.quality ?? "balanced",
|
|
316
|
+
}),
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
const data = (await response.json().catch(() => ({}))) as {
|
|
320
|
+
jobId?: string;
|
|
321
|
+
error?: string;
|
|
322
|
+
};
|
|
323
|
+
if (!response.ok || !data.jobId) {
|
|
324
|
+
throw new Error(data.error || `Background removal failed (${response.status})`);
|
|
325
|
+
}
|
|
326
|
+
showToast("Removing background...", "info");
|
|
327
|
+
backgroundRemovalAbortRef.current?.abort();
|
|
328
|
+
const controller = new AbortController();
|
|
329
|
+
backgroundRemovalAbortRef.current = controller;
|
|
330
|
+
try {
|
|
331
|
+
const result = await waitForMediaJob(data.jobId, options.onProgress, controller.signal);
|
|
332
|
+
await refreshFileTree();
|
|
333
|
+
showToast(`Created transparent asset: ${result.outputPath.split("/").pop()}`, "info");
|
|
334
|
+
return result;
|
|
335
|
+
} finally {
|
|
336
|
+
if (backgroundRemovalAbortRef.current === controller) {
|
|
337
|
+
backgroundRemovalAbortRef.current = null;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
[projectId, refreshFileTree, showToast],
|
|
342
|
+
);
|
|
343
|
+
|
|
236
344
|
const propertyPanel = (
|
|
237
345
|
<PropertyPanel
|
|
238
346
|
projectId={projectId}
|
|
@@ -246,7 +354,9 @@ export function StudioRightPanel({
|
|
|
246
354
|
onSetStyle={handleDomStyleCommit}
|
|
247
355
|
onSetAttribute={handleDomAttributeCommit}
|
|
248
356
|
onSetAttributeLive={handleDomAttributeLiveCommit}
|
|
357
|
+
onApplyColorGradingScope={handleApplyColorGradingScope}
|
|
249
358
|
onSetHtmlAttribute={handleDomHtmlAttributeCommit}
|
|
359
|
+
onRemoveBackground={handleRemoveBackground}
|
|
250
360
|
onSetManualOffset={handleDomPathOffsetCommit}
|
|
251
361
|
onSetManualSize={handleDomBoxSizeCommit}
|
|
252
362
|
onSetManualRotation={handleDomRotationCommit}
|
|
@@ -287,6 +397,8 @@ export function StudioRightPanel({
|
|
|
287
397
|
recordingState={recordingState}
|
|
288
398
|
recordingDuration={recordingDuration}
|
|
289
399
|
onToggleRecording={onToggleRecording}
|
|
400
|
+
cropMode={cropMode}
|
|
401
|
+
onCropModeChange={onCropModeChange}
|
|
290
402
|
/>
|
|
291
403
|
);
|
|
292
404
|
|
|
@@ -295,6 +407,11 @@ export function StudioRightPanel({
|
|
|
295
407
|
jobs={renderJobs}
|
|
296
408
|
projectId={projectId}
|
|
297
409
|
onDelete={renderQueue.deleteRender}
|
|
410
|
+
onCancel={renderQueue.cancelRender}
|
|
411
|
+
loadError={renderQueue.loadError}
|
|
412
|
+
onRetryLoad={renderQueue.reloadRenders}
|
|
413
|
+
actionError={renderQueue.actionError}
|
|
414
|
+
onDismissActionError={renderQueue.dismissActionError}
|
|
298
415
|
onClearCompleted={renderQueue.clearCompleted}
|
|
299
416
|
onStartRender={async (format, quality, resolution, fps) => {
|
|
300
417
|
await waitForPendingDomEditSaves();
|
|
@@ -316,30 +433,43 @@ export function StudioRightPanel({
|
|
|
316
433
|
return (
|
|
317
434
|
<>
|
|
318
435
|
<div
|
|
319
|
-
|
|
436
|
+
role="separator"
|
|
437
|
+
aria-label="Resize inspector panel"
|
|
438
|
+
aria-orientation="vertical"
|
|
439
|
+
tabIndex={0}
|
|
440
|
+
className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center outline-none focus-visible:bg-studio-accent/20"
|
|
320
441
|
style={{ touchAction: "none" }}
|
|
321
442
|
onPointerDown={(e) => handlePanelResizeStart("right", e)}
|
|
322
443
|
onPointerMove={handlePanelResizeMove}
|
|
323
444
|
onPointerUp={handlePanelResizeEnd}
|
|
445
|
+
onPointerCancel={handlePanelResizeEnd}
|
|
446
|
+
onKeyDown={(e) => {
|
|
447
|
+
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
|
448
|
+
e.preventDefault();
|
|
449
|
+
// Panel is right-anchored: ArrowLeft grows it, ArrowRight shrinks it.
|
|
450
|
+
const delta = e.key === "ArrowLeft" ? 16 : -16;
|
|
451
|
+
setRightWidth(Math.max(160, Math.min(600, rightWidth + delta)));
|
|
452
|
+
}}
|
|
324
453
|
>
|
|
325
454
|
<div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
|
|
326
455
|
</div>
|
|
327
456
|
<div
|
|
328
|
-
className="flex flex-col border-l border-neutral-800 bg-neutral-900
|
|
457
|
+
className="flex min-w-0 flex-shrink-0 flex-col overflow-hidden border-l border-neutral-800 bg-neutral-900"
|
|
329
458
|
style={{ width: rightWidth }}
|
|
330
459
|
>
|
|
331
460
|
{captionEditMode ? (
|
|
332
461
|
<CaptionPropertyPanel iframeRef={previewIframeRef} />
|
|
333
462
|
) : (
|
|
334
463
|
<>
|
|
335
|
-
<div className="flex items-center gap-1 border-b border-neutral-800 px-3 py-2">
|
|
464
|
+
<div className="flex min-w-0 items-center gap-1 overflow-hidden border-b border-neutral-800 px-3 py-2">
|
|
336
465
|
{STUDIO_INSPECTOR_PANELS_ENABLED && (
|
|
337
466
|
<>
|
|
338
467
|
<Tooltip label="Element styles and properties" side="bottom">
|
|
339
468
|
<button
|
|
340
469
|
type="button"
|
|
341
470
|
onClick={() => handleInspectorPaneButtonClick("design")}
|
|
342
|
-
|
|
471
|
+
aria-pressed={designPaneOpen}
|
|
472
|
+
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
|
|
343
473
|
designPaneOpen
|
|
344
474
|
? "bg-neutral-800 text-white"
|
|
345
475
|
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
|
@@ -352,7 +482,8 @@ export function StudioRightPanel({
|
|
|
352
482
|
<button
|
|
353
483
|
type="button"
|
|
354
484
|
onClick={() => handleInspectorPaneButtonClick("layers")}
|
|
355
|
-
|
|
485
|
+
aria-pressed={layersPaneOpen}
|
|
486
|
+
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
|
|
356
487
|
layersPaneOpen
|
|
357
488
|
? "bg-neutral-800 text-white"
|
|
358
489
|
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
|
@@ -367,7 +498,8 @@ export function StudioRightPanel({
|
|
|
367
498
|
<button
|
|
368
499
|
type="button"
|
|
369
500
|
onClick={() => setRightPanelTab("renders")}
|
|
370
|
-
|
|
501
|
+
aria-pressed={rightPanelTab === "renders"}
|
|
502
|
+
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
|
|
371
503
|
rightPanelTab === "renders"
|
|
372
504
|
? "bg-neutral-800 text-white"
|
|
373
505
|
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
|
@@ -380,7 +512,8 @@ export function StudioRightPanel({
|
|
|
380
512
|
<button
|
|
381
513
|
type="button"
|
|
382
514
|
onClick={() => setRightPanelTab("slideshow")}
|
|
383
|
-
|
|
515
|
+
aria-pressed={rightPanelTab === "slideshow"}
|
|
516
|
+
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
|
|
384
517
|
rightPanelTab === "slideshow"
|
|
385
518
|
? "bg-neutral-800 text-white"
|
|
386
519
|
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
|
@@ -390,7 +523,7 @@ export function StudioRightPanel({
|
|
|
390
523
|
</button>
|
|
391
524
|
</Tooltip>
|
|
392
525
|
</div>
|
|
393
|
-
<div className="min-h-0 flex-1">
|
|
526
|
+
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
|
|
394
527
|
{rightPanelTab === "block-params" && activeBlockParams ? (
|
|
395
528
|
<BlockParamsPanel
|
|
396
529
|
blockName={activeBlockParams.blockName}
|
|
@@ -406,7 +539,7 @@ export function StudioRightPanel({
|
|
|
406
539
|
onPersistNotes={onPersistSlideshowNotes}
|
|
407
540
|
/>
|
|
408
541
|
) : layersPaneOpen && designPaneOpen ? (
|
|
409
|
-
<div ref={splitContainerRef} className="flex h-full min-h-0 flex-col">
|
|
542
|
+
<div ref={splitContainerRef} className="flex h-full min-h-0 min-w-0 flex-col">
|
|
410
543
|
<div
|
|
411
544
|
className="min-h-[120px] overflow-hidden"
|
|
412
545
|
style={{ flexBasis: `${layersPanePercent}%`, flexShrink: 0 }}
|
|
@@ -432,6 +565,24 @@ export function StudioRightPanel({
|
|
|
432
565
|
<LayersPanel />
|
|
433
566
|
) : designPaneOpen ? (
|
|
434
567
|
propertyPanel
|
|
568
|
+
) : inspectorTabActive ? (
|
|
569
|
+
// Inspector tab selected but no pane can render (panes toggled
|
|
570
|
+
// off, or inspector inactive during playback/recording): show an
|
|
571
|
+
// explanation instead of silently rendering the render queue
|
|
572
|
+
// under a highlighted inspector tab.
|
|
573
|
+
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
|
|
574
|
+
<p className="text-xs text-neutral-500">
|
|
575
|
+
Inspector is unavailable right now — select the Design or Layers pane above, or
|
|
576
|
+
pause playback/recording to inspect elements.
|
|
577
|
+
</p>
|
|
578
|
+
<button
|
|
579
|
+
type="button"
|
|
580
|
+
onClick={() => setRightPanelTab("renders")}
|
|
581
|
+
className="h-7 rounded-md border border-neutral-800 px-3 text-[11px] font-medium text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200 active:scale-[0.98]"
|
|
582
|
+
>
|
|
583
|
+
Show Renders
|
|
584
|
+
</button>
|
|
585
|
+
</div>
|
|
435
586
|
) : (
|
|
436
587
|
renderQueuePanel
|
|
437
588
|
)}
|
|
@@ -2,15 +2,18 @@ export function StudioSplash({ waiting }: { waiting?: boolean }) {
|
|
|
2
2
|
return (
|
|
3
3
|
<div className="h-full w-full bg-neutral-950 flex items-center justify-center">
|
|
4
4
|
{waiting ? (
|
|
5
|
-
<div className="flex flex-col items-center gap-3 text-center px-6">
|
|
6
|
-
<div className="w-4 h-4 rounded-full border-2 border-neutral-700 border-t-neutral-500 animate-spin" />
|
|
5
|
+
<div className="flex flex-col items-center gap-3 text-center px-6" role="status">
|
|
6
|
+
<div className="w-4 h-4 rounded-full border-2 border-neutral-700 border-t-neutral-500 animate-spin motion-reduce:animate-none" />
|
|
7
7
|
<p className="text-xs text-neutral-600">
|
|
8
8
|
Waiting for preview server… run{" "}
|
|
9
9
|
<code className="text-neutral-500 font-mono">npm run dev</code>
|
|
10
10
|
</p>
|
|
11
11
|
</div>
|
|
12
12
|
) : (
|
|
13
|
-
<div className="
|
|
13
|
+
<div className="flex flex-col items-center gap-3 text-center px-6" role="status">
|
|
14
|
+
<div className="w-4 h-4 rounded-full bg-studio-accent animate-pulse motion-reduce:animate-none" />
|
|
15
|
+
<p className="text-xs text-neutral-600">Connecting to project…</p>
|
|
16
|
+
</div>
|
|
14
17
|
)}
|
|
15
18
|
</div>
|
|
16
19
|
);
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
interface StudioToastProps {
|
|
2
2
|
message: string;
|
|
3
3
|
tone?: "error" | "info";
|
|
4
|
+
/** Plays the exit animation when true (owner removes the node after ~160ms). */
|
|
5
|
+
leaving?: boolean;
|
|
4
6
|
onDismiss?: () => void;
|
|
5
7
|
}
|
|
6
8
|
|
|
7
|
-
export function StudioToast({ message, tone, onDismiss }: StudioToastProps) {
|
|
9
|
+
export function StudioToast({ message, tone, leaving, onDismiss }: StudioToastProps) {
|
|
8
10
|
const isError = tone === "error";
|
|
9
11
|
return (
|
|
10
12
|
<div
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
role={onDismiss ? "button" : undefined}
|
|
14
|
-
style={onDismiss ? { cursor: "pointer" } : undefined}
|
|
13
|
+
role={isError ? "alert" : "status"}
|
|
14
|
+
className={`motion-reduce:animate-none ${leaving ? "hf-toast-exit" : "hf-toast-enter"}`}
|
|
15
15
|
>
|
|
16
16
|
<div
|
|
17
|
-
className="relative flex items-center gap-3 overflow-hidden rounded-2xl pl-4 pr-2
|
|
17
|
+
className="relative flex max-w-[min(420px,calc(100vw-48px))] items-center gap-3 overflow-hidden rounded-2xl py-3 pl-4 pr-2 text-[12px]"
|
|
18
18
|
style={{
|
|
19
19
|
background: isError
|
|
20
20
|
? "linear-gradient(135deg, rgba(127,29,29,0.55), rgba(80,10,10,0.45))"
|
|
@@ -29,14 +29,15 @@ export function StudioToast({ message, tone, onDismiss }: StudioToastProps) {
|
|
|
29
29
|
].join(", "),
|
|
30
30
|
}}
|
|
31
31
|
>
|
|
32
|
-
<span
|
|
32
|
+
<span
|
|
33
|
+
className={`min-w-0 break-words leading-5 ${isError ? "text-red-200" : "text-neutral-200"}`}
|
|
34
|
+
>
|
|
35
|
+
{message}
|
|
36
|
+
</span>
|
|
33
37
|
{onDismiss && (
|
|
34
38
|
<button
|
|
35
39
|
type="button"
|
|
36
|
-
onClick={
|
|
37
|
-
e.stopPropagation();
|
|
38
|
-
onDismiss();
|
|
39
|
-
}}
|
|
40
|
+
onClick={onDismiss}
|
|
40
41
|
className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-300"
|
|
41
42
|
aria-label="Dismiss"
|
|
42
43
|
>
|
|
@@ -30,21 +30,27 @@ function renderToolbar() {
|
|
|
30
30
|
describe("TimelineToolbar — auto-keyframe toggle (#1808)", () => {
|
|
31
31
|
it("renders enabled (pressed) by default with no selection", () => {
|
|
32
32
|
const { host, root } = renderToolbar();
|
|
33
|
-
const btn = host.querySelector<HTMLButtonElement>(
|
|
33
|
+
const btn = host.querySelector<HTMLButtonElement>(
|
|
34
|
+
'button[aria-label="Auto-record manual edits as keyframes"]',
|
|
35
|
+
);
|
|
34
36
|
expect(btn).not.toBeNull();
|
|
37
|
+
expect(btn?.getAttribute("aria-pressed")).toBe("true");
|
|
35
38
|
act(() => root.unmount());
|
|
36
39
|
});
|
|
37
40
|
|
|
38
41
|
it("flips autoKeyframeEnabled in the store when clicked", () => {
|
|
39
42
|
const { host, root } = renderToolbar();
|
|
40
|
-
const btn = host.querySelector<HTMLButtonElement>(
|
|
43
|
+
const btn = host.querySelector<HTMLButtonElement>(
|
|
44
|
+
'button[aria-label="Auto-record manual edits as keyframes"]',
|
|
45
|
+
);
|
|
46
|
+
if (!btn) throw new Error("auto-keyframe toggle not rendered");
|
|
41
47
|
|
|
42
48
|
act(() => {
|
|
43
49
|
btn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
44
50
|
});
|
|
45
51
|
|
|
46
52
|
expect(usePlayerStore.getState().autoKeyframeEnabled).toBe(false);
|
|
47
|
-
expect(
|
|
53
|
+
expect(btn.getAttribute("aria-pressed")).toBe("false");
|
|
48
54
|
act(() => root.unmount());
|
|
49
55
|
});
|
|
50
56
|
});
|
|
@@ -112,7 +112,9 @@ export function TimelineToolbar({
|
|
|
112
112
|
<button
|
|
113
113
|
type="button"
|
|
114
114
|
onClick={() => setActiveTool("select")}
|
|
115
|
-
|
|
115
|
+
aria-label="Selection tool"
|
|
116
|
+
aria-pressed={activeTool === "select"}
|
|
117
|
+
className={`flex h-6 w-6 items-center justify-center transition-colors active:scale-[0.98] ${
|
|
116
118
|
activeTool === "select"
|
|
117
119
|
? "bg-neutral-700 text-neutral-200"
|
|
118
120
|
: "text-neutral-500 hover:text-neutral-300"
|
|
@@ -123,11 +125,13 @@ export function TimelineToolbar({
|
|
|
123
125
|
</svg>
|
|
124
126
|
</button>
|
|
125
127
|
</Tooltip>
|
|
126
|
-
<Tooltip label="Razor tool (B)">
|
|
128
|
+
<Tooltip label="Razor tool (B) — Shift+click splits all tracks">
|
|
127
129
|
<button
|
|
128
130
|
type="button"
|
|
129
131
|
onClick={() => setActiveTool("razor")}
|
|
130
|
-
|
|
132
|
+
aria-label="Razor tool"
|
|
133
|
+
aria-pressed={activeTool === "razor"}
|
|
134
|
+
className={`flex h-6 w-6 items-center justify-center transition-colors active:scale-[0.98] ${
|
|
131
135
|
activeTool === "razor"
|
|
132
136
|
? "bg-neutral-700 text-neutral-200"
|
|
133
137
|
: "text-neutral-500 hover:text-neutral-300"
|
|
@@ -153,7 +157,12 @@ export function TimelineToolbar({
|
|
|
153
157
|
<button
|
|
154
158
|
type="button"
|
|
155
159
|
onClick={onToggleKeyframe}
|
|
156
|
-
|
|
160
|
+
aria-label={
|
|
161
|
+
keyframeState === "active"
|
|
162
|
+
? "Remove keyframe at playhead"
|
|
163
|
+
: "Add keyframe at playhead"
|
|
164
|
+
}
|
|
165
|
+
className={`flex h-7 w-7 items-center justify-center rounded transition-colors active:scale-[0.98] ${
|
|
157
166
|
keyframeState === "active"
|
|
158
167
|
? "text-studio-accent"
|
|
159
168
|
: keyframeState === "inactive"
|
|
@@ -187,8 +196,9 @@ export function TimelineToolbar({
|
|
|
187
196
|
<button
|
|
188
197
|
type="button"
|
|
189
198
|
onClick={() => setAutoKeyframeEnabled(!autoKeyframeEnabled)}
|
|
199
|
+
aria-label="Auto-record manual edits as keyframes"
|
|
190
200
|
aria-pressed={autoKeyframeEnabled}
|
|
191
|
-
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
|
201
|
+
className={`flex h-7 w-7 items-center justify-center rounded transition-colors active:scale-[0.98] ${
|
|
192
202
|
autoKeyframeEnabled
|
|
193
203
|
? "text-red-400 hover:text-red-300"
|
|
194
204
|
: "text-neutral-600 hover:text-neutral-400"
|
|
@@ -213,23 +223,35 @@ export function TimelineToolbar({
|
|
|
213
223
|
)}
|
|
214
224
|
{onSplitElement &&
|
|
215
225
|
(() => {
|
|
226
|
+
// Render the button unconditionally (disabled when unusable):
|
|
227
|
+
// mounting/unmounting mid-task shifts the neighboring controls.
|
|
216
228
|
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
|
|
217
229
|
const el = selectedElementId
|
|
218
230
|
? elements.find((e) => (e.key ?? e.id) === selectedElementId)
|
|
219
231
|
: null;
|
|
220
|
-
|
|
221
|
-
const canSplit =
|
|
232
|
+
const splittable = el != null && canSplitElement(el);
|
|
233
|
+
const canSplit =
|
|
234
|
+
splittable && currentTime > el.start && currentTime < el.start + el.duration;
|
|
222
235
|
return (
|
|
223
|
-
<Tooltip
|
|
236
|
+
<Tooltip
|
|
237
|
+
label={
|
|
238
|
+
canSplit
|
|
239
|
+
? "Split clip at playhead (S)"
|
|
240
|
+
: splittable
|
|
241
|
+
? "Move the playhead inside the clip to split"
|
|
242
|
+
: "Select a clip to split"
|
|
243
|
+
}
|
|
244
|
+
>
|
|
224
245
|
<button
|
|
225
246
|
type="button"
|
|
226
247
|
disabled={!canSplit}
|
|
248
|
+
aria-label="Split clip at playhead"
|
|
227
249
|
onClick={() => {
|
|
228
|
-
if (canSplit) onSplitElement(el, currentTime);
|
|
250
|
+
if (canSplit && el) onSplitElement(el, currentTime);
|
|
229
251
|
}}
|
|
230
252
|
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
|
231
253
|
canSplit
|
|
232
|
-
? "text-neutral-500 hover:text-neutral-200"
|
|
254
|
+
? "text-neutral-500 hover:text-neutral-200 active:scale-[0.98]"
|
|
233
255
|
: "text-neutral-700 cursor-not-allowed"
|
|
234
256
|
}`}
|
|
235
257
|
>
|
|
@@ -239,26 +261,38 @@ export function TimelineToolbar({
|
|
|
239
261
|
);
|
|
240
262
|
})()}
|
|
241
263
|
{beatAnalysisReady &&
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
<
|
|
246
|
-
|
|
247
|
-
onClick={() => addBeatAtCompositionTime(currentTime)}
|
|
248
|
-
className="flex h-7 w-7 items-center justify-center rounded text-neutral-500 transition-colors hover:text-[#22c55e]"
|
|
264
|
+
(() => {
|
|
265
|
+
const canAdd = canAddBeatAt(currentTime);
|
|
266
|
+
return (
|
|
267
|
+
<Tooltip
|
|
268
|
+
label={canAdd ? "Add beat at playhead" : "A beat already exists at the playhead"}
|
|
249
269
|
>
|
|
250
|
-
<
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
270
|
+
<button
|
|
271
|
+
type="button"
|
|
272
|
+
disabled={!canAdd}
|
|
273
|
+
aria-label="Add beat at playhead"
|
|
274
|
+
onClick={() => {
|
|
275
|
+
if (canAdd) addBeatAtCompositionTime(currentTime);
|
|
276
|
+
}}
|
|
277
|
+
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
|
278
|
+
canAdd
|
|
279
|
+
? "text-neutral-500 hover:text-[#22c55e] active:scale-[0.98]"
|
|
280
|
+
: "text-neutral-700 cursor-not-allowed"
|
|
281
|
+
}`}
|
|
282
|
+
>
|
|
283
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
|
284
|
+
<path
|
|
285
|
+
d="M21 10C21 12.2091 16.9706 14 12 14M21 10C21 7.79086 16.9706 6 12 6C7.02944 6 3 7.79086 3 10M21 10V16C21 18.2091 16.9706 20 12 20M12 14C7.02944 14 3 12.2091 3 10M12 14V20M3 10V16C3 18.2091 7.02944 20 12 20M7 19.3264V13.3264M17 19.3264V13.3264M12 10L20 4"
|
|
286
|
+
stroke="currentColor"
|
|
287
|
+
strokeWidth="2"
|
|
288
|
+
strokeLinecap="round"
|
|
289
|
+
strokeLinejoin="round"
|
|
290
|
+
/>
|
|
291
|
+
</svg>
|
|
292
|
+
</button>
|
|
293
|
+
</Tooltip>
|
|
294
|
+
);
|
|
295
|
+
})()}
|
|
262
296
|
</div>
|
|
263
297
|
<div className="flex items-center gap-1">
|
|
264
298
|
<Tooltip label="Fit timeline to width">
|