@hyperframes/studio 0.7.37 → 0.7.38
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-BLRTwY5l.js +396 -0
- package/dist/assets/{index-BXYJbIax.js → index-ClUipc8i.js} +1 -1
- package/dist/assets/index-DJaiR8T2.css +1 -0
- package/dist/assets/{index-Ct-X2wlf.js → index-Ykq7ihge.js} +1 -1
- package/dist/index.d.ts +35 -10
- package/dist/index.html +2 -2
- package/dist/index.js +5146 -3321
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +19 -18
- 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/StudioRightPanel.tsx +154 -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/PropertyPanel.tsx +18 -17
- package/src/components/editor/colorGradingScopePatch.test.ts +74 -0
- package/src/components/editor/colorGradingScopePatch.ts +57 -0
- package/src/components/editor/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +0 -6
- 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 +22 -91
- package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
- package/src/components/editor/propertyPanelTypes.ts +111 -0
- 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/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/useCompositionContentLoader.ts +40 -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/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
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { memo, useState, useRef, useEffect } from "react";
|
|
1
|
+
import { memo, useState, useRef, useEffect, useId } from "react";
|
|
2
2
|
import { RenderQueueItem } from "./RenderQueueItem";
|
|
3
|
+
import { Button } from "../ui/Button";
|
|
3
4
|
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
|
|
4
5
|
import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings";
|
|
5
6
|
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
@@ -20,9 +21,17 @@ interface RenderQueueProps {
|
|
|
20
21
|
jobs: RenderJob[];
|
|
21
22
|
projectId: string;
|
|
22
23
|
onDelete: (jobId: string) => void;
|
|
24
|
+
onCancel?: (jobId: string) => void;
|
|
23
25
|
onClearCompleted: () => void;
|
|
24
26
|
onStartRender: StartRenderHandler;
|
|
25
27
|
isRendering: boolean;
|
|
28
|
+
/** History fetch failure (null when the last load succeeded). */
|
|
29
|
+
loadError?: string | null;
|
|
30
|
+
/** Retry a failed history load. */
|
|
31
|
+
onRetryLoad?: () => void;
|
|
32
|
+
/** Failure of a delete/cancel action, shown inline until dismissed. */
|
|
33
|
+
actionError?: string | null;
|
|
34
|
+
onDismissActionError?: () => void;
|
|
26
35
|
/**
|
|
27
36
|
* Authored dimensions of the active composition. Used to pick the
|
|
28
37
|
* matching preset (landscape / portrait / square) when the user selects
|
|
@@ -110,9 +119,15 @@ function scaleOptionLabel(
|
|
|
110
119
|
dims: CompositionDimensions | null | undefined,
|
|
111
120
|
): string {
|
|
112
121
|
const resolved = resolvedDimensions(scale, dims);
|
|
113
|
-
|
|
122
|
+
const base = resolved
|
|
114
123
|
? `${SCALE_LABEL[scale]} · ${resolved.width}×${resolved.height}`
|
|
115
124
|
: SCALE_LABEL[scale];
|
|
125
|
+
// Explain *why* an option is disabled instead of greying it silently:
|
|
126
|
+
// the preset must be an exact integer upscale of the authored size.
|
|
127
|
+
if (dims && !scaleApplies(scale, dims)) {
|
|
128
|
+
return `${base} — not an integer scale of ${dims.width}×${dims.height}`;
|
|
129
|
+
}
|
|
130
|
+
return base;
|
|
116
131
|
}
|
|
117
132
|
|
|
118
133
|
const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string }> = {
|
|
@@ -127,9 +142,14 @@ const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string
|
|
|
127
142
|
},
|
|
128
143
|
};
|
|
129
144
|
|
|
145
|
+
// Rich format guidance in a keyboard-reachable disclosure: the trigger is a
|
|
146
|
+
// real button (focusable, labelled), the panel is tied to it via
|
|
147
|
+
// aria-describedby, and Escape dismisses (WCAG 1.4.13). Content is too rich
|
|
148
|
+
// for the one-line ui/Tooltip primitive, so this stays a local popover.
|
|
130
149
|
function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
|
|
131
150
|
const [open, setOpen] = useState(false);
|
|
132
151
|
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
152
|
+
const panelId = useId();
|
|
133
153
|
|
|
134
154
|
const show = () => {
|
|
135
155
|
clearTimeout(timeoutRef.current);
|
|
@@ -141,27 +161,51 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
|
|
|
141
161
|
|
|
142
162
|
useEffect(() => () => clearTimeout(timeoutRef.current), []);
|
|
143
163
|
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (!open) return;
|
|
166
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
167
|
+
if (e.key === "Escape") setOpen(false);
|
|
168
|
+
};
|
|
169
|
+
document.addEventListener("keydown", onKeyDown);
|
|
170
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
171
|
+
}, [open]);
|
|
172
|
+
|
|
144
173
|
const info = FORMAT_INFO[format];
|
|
145
174
|
|
|
146
175
|
return (
|
|
147
176
|
<div className="relative" onPointerEnter={show} onPointerLeave={hide}>
|
|
148
|
-
<
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
className="text-panel-text-5 hover:text-panel-text-3 transition-colors cursor-help"
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
aria-label="About video formats"
|
|
180
|
+
aria-expanded={open}
|
|
181
|
+
aria-describedby={open ? panelId : undefined}
|
|
182
|
+
onFocus={show}
|
|
183
|
+
onBlur={hide}
|
|
184
|
+
onClick={() => setOpen((prev) => !prev)}
|
|
185
|
+
className="flex items-center justify-center p-0.5 -m-0.5 rounded text-panel-text-5 hover:text-panel-text-3 transition-colors cursor-help outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent"
|
|
158
186
|
>
|
|
159
|
-
<
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
187
|
+
<svg
|
|
188
|
+
width="12"
|
|
189
|
+
height="12"
|
|
190
|
+
viewBox="0 0 24 24"
|
|
191
|
+
fill="none"
|
|
192
|
+
stroke="currentColor"
|
|
193
|
+
strokeWidth="2"
|
|
194
|
+
strokeLinecap="round"
|
|
195
|
+
strokeLinejoin="round"
|
|
196
|
+
aria-hidden="true"
|
|
197
|
+
>
|
|
198
|
+
<circle cx="12" cy="12" r="10" />
|
|
199
|
+
<path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
|
|
200
|
+
<line x1="12" y1="17" x2="12.01" y2="17" />
|
|
201
|
+
</svg>
|
|
202
|
+
</button>
|
|
163
203
|
{open && (
|
|
164
|
-
<div
|
|
204
|
+
<div
|
|
205
|
+
id={panelId}
|
|
206
|
+
role="tooltip"
|
|
207
|
+
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"
|
|
208
|
+
>
|
|
165
209
|
<p className="text-[10px] font-semibold text-panel-text-1 mb-0.5">{info.label}</p>
|
|
166
210
|
<p className="text-[9px] text-panel-text-3 leading-tight">{info.desc}</p>
|
|
167
211
|
<div className="mt-1.5 pt-1.5 border-t border-neutral-800">
|
|
@@ -191,14 +235,21 @@ const QUALITY_OPTIONS: {
|
|
|
191
235
|
{ value: "high", label: "High Quality", title: "Best quality, larger file" },
|
|
192
236
|
];
|
|
193
237
|
|
|
238
|
+
function formatEta(ms: number): string {
|
|
239
|
+
const s = Math.round(ms / 1000);
|
|
240
|
+
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
241
|
+
}
|
|
242
|
+
|
|
194
243
|
function FormatExportButton({
|
|
195
244
|
onStartRender,
|
|
196
245
|
isRendering,
|
|
197
246
|
compositionDimensions,
|
|
247
|
+
lastRenderDurationMs,
|
|
198
248
|
}: {
|
|
199
249
|
onStartRender: StartRenderHandler;
|
|
200
250
|
isRendering: boolean;
|
|
201
251
|
compositionDimensions?: CompositionDimensions | null;
|
|
252
|
+
lastRenderDurationMs?: number;
|
|
202
253
|
}) {
|
|
203
254
|
const persisted = getPersistedRenderSettings();
|
|
204
255
|
const [format, setFormat] = useState<"mp4" | "webm" | "mov">(persisted.format);
|
|
@@ -293,16 +344,26 @@ function FormatExportButton({
|
|
|
293
344
|
</div>
|
|
294
345
|
)}
|
|
295
346
|
</div>
|
|
296
|
-
<
|
|
347
|
+
<Button
|
|
348
|
+
variant="primary"
|
|
349
|
+
size="md"
|
|
350
|
+
loading={isRendering}
|
|
297
351
|
onClick={() => {
|
|
352
|
+
// loading already disables the button; this guard also stops a
|
|
353
|
+
// double-click in the same frame from enqueueing two renders.
|
|
354
|
+
if (isRendering) return;
|
|
298
355
|
trackStudioEvent("render_start", { format, quality, resolution, fps });
|
|
299
356
|
void onStartRender(format, quality, resolution, fps);
|
|
300
357
|
}}
|
|
301
|
-
|
|
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"
|
|
358
|
+
className="w-full text-[11px] font-semibold"
|
|
303
359
|
>
|
|
304
|
-
{isRendering ? "Rendering
|
|
305
|
-
</
|
|
360
|
+
{isRendering ? "Rendering…" : "Export"}
|
|
361
|
+
</Button>
|
|
362
|
+
{lastRenderDurationMs !== undefined && !isRendering && (
|
|
363
|
+
<p className="text-[9px] text-panel-text-5 text-center -mt-1.5">
|
|
364
|
+
Last render took {formatEta(lastRenderDurationMs)}
|
|
365
|
+
</p>
|
|
366
|
+
)}
|
|
306
367
|
</div>
|
|
307
368
|
);
|
|
308
369
|
}
|
|
@@ -311,9 +372,14 @@ export const RenderQueue = memo(function RenderQueue({
|
|
|
311
372
|
jobs,
|
|
312
373
|
projectId,
|
|
313
374
|
onDelete,
|
|
375
|
+
onCancel,
|
|
314
376
|
onClearCompleted,
|
|
315
377
|
onStartRender,
|
|
316
378
|
isRendering,
|
|
379
|
+
loadError,
|
|
380
|
+
onRetryLoad,
|
|
381
|
+
actionError,
|
|
382
|
+
onDismissActionError,
|
|
317
383
|
compositionDimensions,
|
|
318
384
|
}: RenderQueueProps) {
|
|
319
385
|
const listRef = useRef<HTMLDivElement>(null);
|
|
@@ -327,6 +393,9 @@ export const RenderQueue = memo(function RenderQueue({
|
|
|
327
393
|
}, [jobs.length]);
|
|
328
394
|
|
|
329
395
|
const completedCount = jobs.filter((j) => j.status !== "rendering").length;
|
|
396
|
+
const lastRenderDurationMs = [...jobs]
|
|
397
|
+
.reverse()
|
|
398
|
+
.find((j) => j.status === "complete" && j.durationMs !== undefined)?.durationMs;
|
|
330
399
|
|
|
331
400
|
return (
|
|
332
401
|
<div className="flex flex-col h-full">
|
|
@@ -335,12 +404,40 @@ export const RenderQueue = memo(function RenderQueue({
|
|
|
335
404
|
onStartRender={onStartRender}
|
|
336
405
|
isRendering={isRendering}
|
|
337
406
|
compositionDimensions={compositionDimensions}
|
|
407
|
+
lastRenderDurationMs={lastRenderDurationMs}
|
|
338
408
|
/>
|
|
339
409
|
</div>
|
|
340
410
|
|
|
411
|
+
{actionError && (
|
|
412
|
+
<div
|
|
413
|
+
role="alert"
|
|
414
|
+
className="flex items-start justify-between gap-2 px-3 py-2 border-b border-panel-border bg-red-500/10"
|
|
415
|
+
>
|
|
416
|
+
<span className="text-[10px] text-red-400">{actionError}</span>
|
|
417
|
+
{onDismissActionError && (
|
|
418
|
+
<button
|
|
419
|
+
onClick={onDismissActionError}
|
|
420
|
+
aria-label="Dismiss error"
|
|
421
|
+
className="text-[10px] text-panel-text-4 hover:text-panel-text-2 flex-shrink-0"
|
|
422
|
+
>
|
|
423
|
+
✕
|
|
424
|
+
</button>
|
|
425
|
+
)}
|
|
426
|
+
</div>
|
|
427
|
+
)}
|
|
428
|
+
|
|
341
429
|
{/* Job list */}
|
|
342
430
|
<div ref={listRef} className="flex-1 overflow-y-auto">
|
|
343
|
-
{jobs.length === 0 ? (
|
|
431
|
+
{loadError && jobs.length === 0 ? (
|
|
432
|
+
<div className="flex flex-col items-center justify-center h-full px-4 gap-2" role="alert">
|
|
433
|
+
<p className="text-[10px] text-red-400 text-center">{loadError}</p>
|
|
434
|
+
{onRetryLoad && (
|
|
435
|
+
<Button size="sm" variant="secondary" onClick={onRetryLoad}>
|
|
436
|
+
Retry
|
|
437
|
+
</Button>
|
|
438
|
+
)}
|
|
439
|
+
</div>
|
|
440
|
+
) : jobs.length === 0 ? (
|
|
344
441
|
<div className="flex flex-col items-center justify-center h-full px-4 gap-2">
|
|
345
442
|
<svg
|
|
346
443
|
width="20"
|
|
@@ -376,11 +473,14 @@ export const RenderQueue = memo(function RenderQueue({
|
|
|
376
473
|
<span className="text-[10px] text-panel-text-4">
|
|
377
474
|
{jobs.length} render{jobs.length === 1 ? "" : "s"}
|
|
378
475
|
</span>
|
|
476
|
+
{/* "Hide", not "Clear": files stay on disk (delete is per-row
|
|
477
|
+
and confirmed); hidden rows don't resurrect on reload. */}
|
|
379
478
|
<button
|
|
380
479
|
onClick={onClearCompleted}
|
|
480
|
+
title="Hide finished renders from this list (files stay on disk)"
|
|
381
481
|
className="text-[10px] text-panel-text-4 hover:text-panel-text-2 transition-colors"
|
|
382
482
|
>
|
|
383
|
-
|
|
483
|
+
Hide finished
|
|
384
484
|
</button>
|
|
385
485
|
</div>
|
|
386
486
|
)}
|
|
@@ -390,6 +490,7 @@ export const RenderQueue = memo(function RenderQueue({
|
|
|
390
490
|
job={job}
|
|
391
491
|
projectId={projectId}
|
|
392
492
|
onDelete={() => onDelete(job.id)}
|
|
493
|
+
onCancel={() => onCancel?.(job.id)}
|
|
393
494
|
/>
|
|
394
495
|
))}
|
|
395
496
|
</div>
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { memo, useCallback, useState } from "react";
|
|
2
2
|
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
|
3
|
+
import { Button } from "../ui/Button";
|
|
3
4
|
import type { RenderJob } from "./useRenderQueue";
|
|
4
5
|
|
|
5
6
|
interface RenderQueueItemProps {
|
|
6
7
|
job: RenderJob;
|
|
7
8
|
projectId: string;
|
|
8
9
|
onDelete: () => void;
|
|
10
|
+
onCancel: () => void;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
function formatDuration(ms: number): string {
|
|
@@ -27,8 +29,11 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
|
|
27
29
|
job,
|
|
28
30
|
projectId,
|
|
29
31
|
onDelete,
|
|
32
|
+
onCancel,
|
|
30
33
|
}: RenderQueueItemProps) {
|
|
31
34
|
const [hovered, setHovered] = useState(false);
|
|
35
|
+
const [videoReady, setVideoReady] = useState(false);
|
|
36
|
+
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
|
32
37
|
|
|
33
38
|
// Direct file URL — serves from disk, survives server restarts
|
|
34
39
|
const fileSrc = `/api/projects/${projectId}/renders/file/${job.filename}`;
|
|
@@ -50,25 +55,35 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
|
|
50
55
|
|
|
51
56
|
const viewSrc = fileSrc;
|
|
52
57
|
const isComplete = job.status === "complete";
|
|
58
|
+
const isRendering = job.status === "rendering";
|
|
53
59
|
|
|
54
60
|
return (
|
|
55
61
|
<div
|
|
56
62
|
onPointerEnter={() => setHovered(true)}
|
|
57
|
-
onPointerLeave={() =>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
.filter(Boolean)
|
|
64
|
-
.join(" ")}
|
|
63
|
+
onPointerLeave={() => {
|
|
64
|
+
setHovered(false);
|
|
65
|
+
setVideoReady(false);
|
|
66
|
+
setConfirmingDelete(false);
|
|
67
|
+
}}
|
|
68
|
+
className="px-3 py-2.5 border-b border-panel-border last:border-0 transition-colors duration-150 hover:bg-panel-hover/30"
|
|
65
69
|
>
|
|
66
70
|
<div className="flex items-center gap-2.5">
|
|
67
|
-
{/* Thumbnail — static frame; swaps to live video on hover
|
|
68
|
-
|
|
71
|
+
{/* Thumbnail — static frame; swaps to live video on hover.
|
|
72
|
+
A real button so keyboard users can open the render too. */}
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
onClick={isComplete ? handleOpen : undefined}
|
|
76
|
+
disabled={!isComplete}
|
|
77
|
+
aria-label={isComplete ? `Open ${job.filename} in a new tab` : undefined}
|
|
78
|
+
className={[
|
|
79
|
+
"w-20 h-[45px] rounded-md overflow-hidden bg-panel-input flex-shrink-0 relative",
|
|
80
|
+
"outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent",
|
|
81
|
+
isComplete ? "cursor-pointer" : "cursor-default",
|
|
82
|
+
].join(" ")}
|
|
83
|
+
>
|
|
69
84
|
{isComplete && (
|
|
70
85
|
<>
|
|
71
|
-
{/* Live video —
|
|
86
|
+
{/* Live video — fades in over the static frame once it can play */}
|
|
72
87
|
{hovered && (
|
|
73
88
|
<video
|
|
74
89
|
src={viewSrc}
|
|
@@ -76,21 +91,23 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
|
|
76
91
|
muted
|
|
77
92
|
loop
|
|
78
93
|
playsInline
|
|
79
|
-
|
|
94
|
+
onCanPlay={() => setVideoReady(true)}
|
|
95
|
+
className="absolute inset-0 w-full h-full object-contain transition-opacity duration-150"
|
|
96
|
+
style={{ opacity: videoReady ? 1 : 0 }}
|
|
80
97
|
/>
|
|
81
98
|
)}
|
|
82
99
|
{/* Static frame — visible when not hovering */}
|
|
83
100
|
<div
|
|
84
101
|
className="absolute inset-0 transition-opacity duration-150"
|
|
85
|
-
style={{ opacity: hovered ? 0 : 1 }}
|
|
102
|
+
style={{ opacity: hovered && videoReady ? 0 : 1 }}
|
|
86
103
|
>
|
|
87
104
|
<VideoFrameThumbnail src={viewSrc} />
|
|
88
105
|
</div>
|
|
89
106
|
</>
|
|
90
107
|
)}
|
|
91
|
-
{
|
|
108
|
+
{isRendering && (
|
|
92
109
|
<div className="w-full h-full flex items-center justify-center">
|
|
93
|
-
<div className="w-2 h-2 rounded-full bg-panel-accent animate-pulse" />
|
|
110
|
+
<div className="w-2 h-2 rounded-full bg-panel-accent animate-pulse motion-reduce:animate-none" />
|
|
94
111
|
</div>
|
|
95
112
|
)}
|
|
96
113
|
{job.status === "failed" && (
|
|
@@ -103,7 +120,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
|
|
103
120
|
<div className="w-2 h-2 rounded-full bg-neutral-600" />
|
|
104
121
|
</div>
|
|
105
122
|
)}
|
|
106
|
-
</
|
|
123
|
+
</button>
|
|
107
124
|
|
|
108
125
|
{/* Info */}
|
|
109
126
|
<div className="flex-1 min-w-0">
|
|
@@ -118,13 +135,20 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
|
|
118
135
|
)}
|
|
119
136
|
</div>
|
|
120
137
|
|
|
121
|
-
{
|
|
138
|
+
{isRendering && (
|
|
122
139
|
<div className="mt-1">
|
|
123
140
|
<div className="flex items-center justify-between mb-0.5">
|
|
124
141
|
<span className="text-[9px] text-panel-text-4">{job.stage || "Rendering"}</span>
|
|
125
142
|
<span className="text-[9px] font-mono text-panel-accent">{job.progress}%</span>
|
|
126
143
|
</div>
|
|
127
|
-
<div
|
|
144
|
+
<div
|
|
145
|
+
className="w-full h-1 bg-panel-border rounded-full overflow-hidden"
|
|
146
|
+
role="progressbar"
|
|
147
|
+
aria-valuenow={job.progress}
|
|
148
|
+
aria-valuemin={0}
|
|
149
|
+
aria-valuemax={100}
|
|
150
|
+
aria-label={`Render progress: ${job.progress}%`}
|
|
151
|
+
>
|
|
128
152
|
<div
|
|
129
153
|
className="h-full bg-panel-accent rounded-full transition-all duration-300"
|
|
130
154
|
style={{ width: `${job.progress}%` }}
|
|
@@ -136,59 +160,103 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
|
|
136
160
|
{job.status === "failed" && job.error && (
|
|
137
161
|
<span className="text-[9px] text-red-400 mt-0.5 block">{job.error}</span>
|
|
138
162
|
)}
|
|
163
|
+
{job.status === "cancelled" && (
|
|
164
|
+
<span className="text-[9px] text-panel-text-4 mt-0.5 block">Cancelled</span>
|
|
165
|
+
)}
|
|
139
166
|
|
|
140
|
-
{
|
|
167
|
+
{!isRendering && (
|
|
141
168
|
<span className="text-[9px] text-panel-text-5">{formatTimeAgo(job.createdAt)}</span>
|
|
142
169
|
)}
|
|
143
170
|
</div>
|
|
144
171
|
|
|
145
172
|
{/* Actions — always visible to prevent layout shifts */}
|
|
146
|
-
<div className="flex items-center gap-1 flex-shrink-0">
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
disabled={!isComplete}
|
|
156
|
-
>
|
|
157
|
-
<svg
|
|
158
|
-
width="12"
|
|
159
|
-
height="12"
|
|
160
|
-
viewBox="0 0 24 24"
|
|
161
|
-
fill="none"
|
|
162
|
-
stroke="currentColor"
|
|
163
|
-
strokeWidth="2"
|
|
164
|
-
strokeLinecap="round"
|
|
165
|
-
strokeLinejoin="round"
|
|
166
|
-
>
|
|
167
|
-
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
|
168
|
-
<polyline points="7 10 12 15 17 10" />
|
|
169
|
-
<line x1="12" y1="15" x2="12" y2="3" />
|
|
170
|
-
</svg>
|
|
171
|
-
</button>
|
|
172
|
-
<button
|
|
173
|
-
onClick={(e) => {
|
|
174
|
-
e.stopPropagation();
|
|
175
|
-
onDelete();
|
|
176
|
-
}}
|
|
177
|
-
className="p-1 rounded text-panel-text-5 hover:text-red-400 transition-colors"
|
|
178
|
-
title="Remove"
|
|
179
|
-
>
|
|
180
|
-
<svg
|
|
181
|
-
width="12"
|
|
182
|
-
height="12"
|
|
183
|
-
viewBox="0 0 24 24"
|
|
184
|
-
fill="none"
|
|
185
|
-
stroke="currentColor"
|
|
186
|
-
strokeWidth="2"
|
|
187
|
-
strokeLinecap="round"
|
|
173
|
+
<div className="flex items-center gap-1.5 flex-shrink-0">
|
|
174
|
+
{isRendering ? (
|
|
175
|
+
<Button
|
|
176
|
+
size="sm"
|
|
177
|
+
variant="secondary"
|
|
178
|
+
onClick={(e) => {
|
|
179
|
+
e.stopPropagation();
|
|
180
|
+
onCancel();
|
|
181
|
+
}}
|
|
188
182
|
>
|
|
189
|
-
|
|
190
|
-
</
|
|
191
|
-
|
|
183
|
+
Cancel
|
|
184
|
+
</Button>
|
|
185
|
+
) : confirmingDelete ? (
|
|
186
|
+
<>
|
|
187
|
+
<Button
|
|
188
|
+
size="sm"
|
|
189
|
+
variant="danger"
|
|
190
|
+
onClick={(e) => {
|
|
191
|
+
e.stopPropagation();
|
|
192
|
+
setConfirmingDelete(false);
|
|
193
|
+
onDelete();
|
|
194
|
+
}}
|
|
195
|
+
>
|
|
196
|
+
Delete?
|
|
197
|
+
</Button>
|
|
198
|
+
<Button
|
|
199
|
+
size="sm"
|
|
200
|
+
variant="ghost"
|
|
201
|
+
onClick={(e) => {
|
|
202
|
+
e.stopPropagation();
|
|
203
|
+
setConfirmingDelete(false);
|
|
204
|
+
}}
|
|
205
|
+
>
|
|
206
|
+
Keep
|
|
207
|
+
</Button>
|
|
208
|
+
</>
|
|
209
|
+
) : (
|
|
210
|
+
<>
|
|
211
|
+
<button
|
|
212
|
+
onClick={isComplete ? handleDownload : undefined}
|
|
213
|
+
className={`p-1.5 min-w-6 min-h-6 rounded transition-colors outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent ${
|
|
214
|
+
isComplete
|
|
215
|
+
? "text-panel-text-5 hover:text-panel-accent"
|
|
216
|
+
: "text-panel-text-5/30 cursor-default"
|
|
217
|
+
}`}
|
|
218
|
+
title={isComplete ? "Download" : undefined}
|
|
219
|
+
aria-label={`Download ${job.filename}`}
|
|
220
|
+
disabled={!isComplete}
|
|
221
|
+
>
|
|
222
|
+
<svg
|
|
223
|
+
width="12"
|
|
224
|
+
height="12"
|
|
225
|
+
viewBox="0 0 24 24"
|
|
226
|
+
fill="none"
|
|
227
|
+
stroke="currentColor"
|
|
228
|
+
strokeWidth="2"
|
|
229
|
+
strokeLinecap="round"
|
|
230
|
+
strokeLinejoin="round"
|
|
231
|
+
>
|
|
232
|
+
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
|
233
|
+
<polyline points="7 10 12 15 17 10" />
|
|
234
|
+
<line x1="12" y1="15" x2="12" y2="3" />
|
|
235
|
+
</svg>
|
|
236
|
+
</button>
|
|
237
|
+
<button
|
|
238
|
+
onClick={(e) => {
|
|
239
|
+
e.stopPropagation();
|
|
240
|
+
setConfirmingDelete(true);
|
|
241
|
+
}}
|
|
242
|
+
className="p-1.5 min-w-6 min-h-6 rounded text-panel-text-5 hover:text-red-400 transition-colors outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent"
|
|
243
|
+
title="Delete render file"
|
|
244
|
+
aria-label={`Delete ${job.filename}`}
|
|
245
|
+
>
|
|
246
|
+
<svg
|
|
247
|
+
width="12"
|
|
248
|
+
height="12"
|
|
249
|
+
viewBox="0 0 24 24"
|
|
250
|
+
fill="none"
|
|
251
|
+
stroke="currentColor"
|
|
252
|
+
strokeWidth="2"
|
|
253
|
+
strokeLinecap="round"
|
|
254
|
+
>
|
|
255
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
256
|
+
</svg>
|
|
257
|
+
</button>
|
|
258
|
+
</>
|
|
259
|
+
)}
|
|
192
260
|
</div>
|
|
193
261
|
</div>
|
|
194
262
|
</div>
|