diff --git a/packages/studio/src/components/renders/RenderQueue.test.tsx b/packages/studio/src/components/renders/RenderQueue.test.tsx index dee603c965..f949a05ee2 100644 --- a/packages/studio/src/components/renders/RenderQueue.test.tsx +++ b/packages/studio/src/components/renders/RenderQueue.test.tsx @@ -4,6 +4,10 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RenderQueue } from "./RenderQueue"; +import { getPersistedRenderSettings } from "./renderSettings"; +import { buttonBase, buttonSizes, buttonVariants, cn } from "../ui"; +import { isTypingTarget } from "../../utils/typingTarget"; +import { shouldIgnorePlaybackShortcutTarget } from "../../player/lib/playbackShortcuts"; import type { FfmpegStatus } from "./useFfmpegStatus"; // Encoder availability arrives as a prop (useRenderQueue owns the probe), so @@ -18,6 +22,9 @@ let root: Root | null = null; beforeEach(() => { ffmpegStatus = { ok: true }; recheck.mockClear(); + // The format, frame-rate and quality controls write through to the real + // store, so each case has to start from the shipped defaults. + localStorage.clear(); }); afterEach(() => { @@ -26,7 +33,10 @@ afterEach(() => { document.body.innerHTML = ""; }); -function mountRenderQueue(onStartRender: ReturnType) { +function mountRenderQueue( + onStartRender: ReturnType, + compositionDimensions = { width: 1920, height: 1080 }, +) { const host = document.createElement("div"); document.body.append(host); root = createRoot(host); @@ -39,7 +49,7 @@ function mountRenderQueue(onStartRender: ReturnType) { onClearCompleted={vi.fn()} onStartRender={onStartRender} isRendering={false} - compositionDimensions={{ width: 1920, height: 1080 }} + compositionDimensions={compositionDimensions} ffmpeg={ffmpegStatus} ffmpegChecking={false} onRecheckFfmpeg={recheck} @@ -49,37 +59,141 @@ function mountRenderQueue(onStartRender: ReturnType) { return host; } -describe("RenderQueue resolution submission", () => { - it("submits the canonical landscape 4K preset selected by the user", () => { +/** Base UI moves focus a task later than React renders; happy-dom is no faster. */ +const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 0)))); + +function fire(el: Element, type: string, key?: string) { + const event = + key === undefined + ? new MouseEvent(type, { bubbles: true }) + : new KeyboardEvent(type, { bubbles: true, key }); + act(() => void el.dispatchEvent(event)); +} + +function triggerFor(host: HTMLElement, label: string): HTMLElement { + const trigger = host.querySelector(`[role="combobox"][aria-label="${label}"]`); + if (!trigger) throw new Error(`no select labelled ${label}`); + return trigger; +} + +/** + * Opens a Select and walks the highlight down `steps` items before committing, + * the way a keyboard user does. The trigger is a real ` {lastRenderDurationMs !== undefined && !isRendering && ( -

+

Last render took {formatEta(lastRenderDurationMs)}

)} @@ -455,7 +450,7 @@ export const RenderQueue = memo(function RenderQueue({ return (
-
+
- {actionError} + {actionError} {onDismissActionError && ( - + className="shrink-0" + icon={ + + } + /> )}
)} @@ -489,7 +497,7 @@ export const RenderQueue = memo(function RenderQueue({
{loadError && jobs.length === 0 ? (
-

{loadError}

+

{loadError}

{onRetryLoad && (
) : (
{completedCount > 0 && ( -
- +
+ {jobs.length} render{jobs.length === 1 ? "" : "s"} {/* "Hide", not "Clear": files stay on disk (delete is per-row and confirmed); hidden rows don't resurrect on reload. */} - + + +
)} {jobs.map((job) => ( diff --git a/packages/studio/src/components/renders/RenderQueueItem.tsx b/packages/studio/src/components/renders/RenderQueueItem.tsx index bad11f0a5e..9cfa97f94d 100644 --- a/packages/studio/src/components/renders/RenderQueueItem.tsx +++ b/packages/studio/src/components/renders/RenderQueueItem.tsx @@ -1,6 +1,8 @@ import { memo, useCallback, useState } from "react"; import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail"; import { Button } from "../ui/Button"; +import { IconButton } from "../ui/IconButton"; +import { Tooltip } from "../ui/Tooltip"; import type { RenderJob } from "./useRenderQueue"; interface RenderQueueItemProps { @@ -65,7 +67,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({ setVideoReady(false); setConfirmingDelete(false); }} - className="px-3 py-2.5 border-b border-panel-border last:border-0 transition-colors duration-150 hover:bg-panel-hover/30" + className="px-3 py-2.5 border-b border-border last:border-0 transition-colors duration-150 hover:bg-hover/30" >
{/* Thumbnail — static frame; swaps to live video on hover. @@ -76,8 +78,8 @@ export const RenderQueueItem = memo(function RenderQueueItem({ disabled={!isComplete} aria-label={isComplete ? `Open ${job.filename} in a new tab` : undefined} className={[ - "w-20 h-[45px] rounded-md overflow-hidden bg-panel-input shrink-0 relative", - "outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-studio-accent", + "w-20 h-[45px] rounded-md overflow-hidden bg-input shrink-0 relative", + "outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent", isComplete ? "cursor-pointer" : "cursor-default", ].join(" ")} > @@ -107,17 +109,17 @@ export const RenderQueueItem = memo(function RenderQueueItem({ )} {isRendering && (
-
+
)} {job.status === "failed" && (
-
+
)} {job.status === "cancelled" && (
-
+
)} @@ -125,11 +127,9 @@ export const RenderQueueItem = memo(function RenderQueueItem({ {/* Info */}
- - {job.filename} - + {job.filename} {job.durationMs && ( - + {formatDuration(job.durationMs)} )} @@ -138,11 +138,11 @@ export const RenderQueueItem = memo(function RenderQueueItem({ {isRendering && (
- {job.stage || "Rendering"} - {job.progress}% + {job.stage || "Rendering"} + {job.progress}%
@@ -158,14 +158,14 @@ export const RenderQueueItem = memo(function RenderQueueItem({ )} {job.status === "failed" && job.error && ( - {job.error} + {job.error} )} {job.status === "cancelled" && ( - Cancelled + Cancelled )} {!isRendering && ( - {formatTimeAgo(job.createdAt)} + {formatTimeAgo(job.createdAt)} )}
@@ -208,53 +208,58 @@ export const RenderQueueItem = memo(function RenderQueueItem({ ) : ( <> - - + {/* The tooltip explains the control whether or not it is + reachable; `disabled:` on the shared Button keeps pointer + events alive precisely so a disabled one can still say why. */} + + + + { + e.stopPropagation(); + setConfirmingDelete(true); + }} + aria-label={`Delete ${job.filename}`} + icon={ + + } + /> + )}
diff --git a/packages/studio/src/components/ui/Select.tsx b/packages/studio/src/components/ui/Select.tsx index 08158f2531..15c15c18e8 100644 --- a/packages/studio/src/components/ui/Select.tsx +++ b/packages/studio/src/components/ui/Select.tsx @@ -24,6 +24,13 @@ import type { PreviewState } from "./Button"; export interface SelectOption { label: string; value: string; + /** + * Offered but not choosable. The option stays in the list with its label + * intact, because a label is where a caller explains *why* it is out of + * reach; filtering it out would leave the user hunting for a choice that + * silently vanished. + */ + disabled?: boolean; } export interface SelectProps { @@ -94,11 +101,13 @@ export function Select({ {option.label}