Skip to content

Commit 38a5352

Browse files
committed
feat(storyboard): highlight the next render step
1 parent bd4c22d commit 38a5352

2 files changed

Lines changed: 183 additions & 12 deletions

File tree

web/src/components/storyboard/StoryboardBoard.tsx

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -203,12 +203,21 @@ const captionsByShotId = (
203203
* the label alone and says why in the tooltip — a bare "—" next to a render
204204
* button reads as broken.
205205
*/
206-
const RenderBatchButton: React.FC<{
206+
interface RenderBatchButtonProps {
207207
label: string;
208208
estimate: RenderBatchCostEstimate;
209209
disabled: boolean;
210+
highlighted: boolean;
210211
onClick: () => void;
211-
}> = ({ label, estimate, disabled, onClick }) => {
212+
}
213+
214+
const RenderBatchButton: React.FC<RenderBatchButtonProps> = ({
215+
label,
216+
estimate,
217+
disabled,
218+
highlighted,
219+
onClick
220+
}) => {
212221
const { shotCount, cost, pricedCount, reasons, notes } = estimate;
213222
const priced = pricedCount > 0 && cost > 0;
214223
const partial = priced && pricedCount < shotCount;
@@ -248,13 +257,19 @@ const RenderBatchButton: React.FC<{
248257
}
249258
>
250259
{/* A disabled button swallows pointer events, so the tooltip needs a host. */}
251-
<Box component="span" sx={{ display: "inline-flex" }}>
252-
<EditorButton variant="outlined" onClick={onClick} disabled={disabled}>
260+
<FlexRow component="span">
261+
<EditorButton
262+
variant={highlighted ? "contained" : "outlined"}
263+
color="primary"
264+
aria-current={highlighted ? "step" : undefined}
265+
onClick={onClick}
266+
disabled={disabled}
267+
>
253268
{`${label}${shotCount > 0 ? ` (${shotCount})` : ""}${
254269
priced ? ` · ~${formatUsd(cost)}` : ""
255270
}`}
256271
</EditorButton>
257-
</Box>
272+
</FlexRow>
258273
</Tooltip>
259274
);
260275
};
@@ -505,11 +520,18 @@ const StoryboardBoardInner: React.FC<StoryboardBoardProps> = ({
505520
const pendingStills = useMemo(
506521
() =>
507522
shots.filter(
508-
(s) => !s.keyframe && (s.status === "planned" || s.status === "failed")
523+
(s) =>
524+
shotRenderMode(s) !== "direct" &&
525+
!s.keyframe &&
526+
(s.status === "planned" || s.status === "failed")
509527
),
510528
[shots]
511529
);
512530

531+
const hasIncompleteStills = shots.some(
532+
(s) => shotRenderMode(s) !== "direct" && !s.keyframe
533+
);
534+
513535
const pendingClips = useMemo(
514536
() =>
515537
shots.filter(
@@ -522,6 +544,20 @@ const StoryboardBoardInner: React.FC<StoryboardBoardProps> = ({
522544
[shots]
523545
);
524546

547+
const nextRenderStep = hasIncompleteStills
548+
? pendingStills.length > 0
549+
? "stills"
550+
: null
551+
: pendingClips.length > 0
552+
? "clips"
553+
: null;
554+
const stillStepActive = nextRenderStep === "stills";
555+
const clipStepActive = nextRenderStep === "clips";
556+
const missingNextModel =
557+
(stillStepActive && !imageModel?.id) ||
558+
(clipStepActive && !videoModel?.id);
559+
const settingsVisible = settingsOpen || missingNextModel;
560+
525561
// The toolbar's one-line summary: how big the board is, how it looks, and
526562
// who is in it — the fields the folded form would otherwise hide.
527563
const { data: allEntities } = useEntities();
@@ -649,20 +685,26 @@ const StoryboardBoardInner: React.FC<StoryboardBoardProps> = ({
649685
variant="outlined"
650686
startIcon={<TuneIcon fontSize="small" />}
651687
onClick={toggleSettings}
652-
aria-expanded={settingsOpen}
688+
aria-expanded={settingsVisible}
653689
>
654690
Board settings
655691
</EditorButton>
656692
<RenderBatchButton
657693
label="Render stills"
658694
estimate={stillsCost}
659-
disabled={pendingStills.length === 0 || !!directing}
695+
disabled={
696+
pendingStills.length === 0 || !imageModel?.id || !!directing
697+
}
698+
highlighted={stillStepActive && !!imageModel?.id}
660699
onClick={handleGenerateAllStills}
661700
/>
662701
<RenderBatchButton
663702
label="Render clips"
664703
estimate={clipsCost}
665-
disabled={pendingClips.length === 0 || !!directing}
704+
disabled={
705+
pendingClips.length === 0 || !videoModel?.id || !!directing
706+
}
707+
highlighted={clipStepActive && !!videoModel?.id}
666708
onClick={handleGenerateAllClips}
667709
/>
668710
</FlexRow>
@@ -680,7 +722,7 @@ const StoryboardBoardInner: React.FC<StoryboardBoardProps> = ({
680722
)}
681723

682724
{!readOnly && (
683-
<Collapse in={settingsOpen} timeout="auto" unmountOnExit>
725+
<Collapse in={settingsVisible} timeout="auto" unmountOnExit>
684726
<Panel padding={SPACING.xl} sx={{ maxWidth: "1100px" }}>
685727
<FlexColumn gap={SPACING.xl}>
686728
<FormGrid stackBelow={FORM_STACK_BELOW}>
@@ -733,6 +775,11 @@ const StoryboardBoardInner: React.FC<StoryboardBoardProps> = ({
733775
task={STILL_MODEL_TASKS}
734776
onChange={(value) => setImageModel(boardId, value)}
735777
/>
778+
{stillStepActive && !imageModel?.id && (
779+
<Caption color="warning" role="alert">
780+
Choose a still model before rendering stills.
781+
</Caption>
782+
)}
736783
{entitiesNeedEditModel && (
737784
<Caption color="warning">
738785
Entities carry reference images, but this model only
@@ -746,6 +793,11 @@ const StoryboardBoardInner: React.FC<StoryboardBoardProps> = ({
746793
task="image_to_video"
747794
onChange={(value) => setVideoModel(boardId, value)}
748795
/>
796+
{clipStepActive && !videoModel?.id && (
797+
<Caption color="warning" role="alert">
798+
Choose a clip model before rendering clips.
799+
</Caption>
800+
)}
749801
</FormField>
750802
<FormField label="Aspect ratio">
751803
<SelectField

web/src/components/storyboard/__tests__/StoryboardBoard.test.tsx

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,18 @@ const stub = (name: string) => ({
161161
default: () => <div data-testid={name} />
162162
});
163163
jest.mock("../../properties/LanguageModelSelect", () => stub("lang-model"));
164-
jest.mock("../../properties/ImageModelSelect", () => stub("image-model"));
165-
jest.mock("../../properties/VideoModelSelect", () => stub("video-model"));
164+
jest.mock("../../properties/ImageModelSelect", () => ({
165+
__esModule: true,
166+
default: () => (
167+
<div role="combobox" aria-label="Still model" data-testid="image-model" />
168+
)
169+
}));
170+
jest.mock("../../properties/VideoModelSelect", () => ({
171+
__esModule: true,
172+
default: () => (
173+
<div role="combobox" aria-label="Clip model" data-testid="video-model" />
174+
)
175+
}));
166176
// The card's own behaviour has its own suite (ShotCard.test.tsx); this stub
167177
// keeps the contract the board drives — the shot id hook the keyboard
168178
// navigation focuses, selection on click, and the drag callbacks.
@@ -261,6 +271,10 @@ beforeEach(() => {
261271
mockGenre = "";
262272
mockEntities = [];
263273
mockPresets = [];
274+
boardModels = {
275+
imageModel: { id: "fal-ai/flux/schnell", provider: "fal_ai" },
276+
videoModel: { id: "pixverse/720p", provider: "fal_ai" }
277+
};
264278
});
265279

266280
const renderBoard = (onDirect: (n: number) => void) =>
@@ -438,12 +452,117 @@ describe("StoryboardBoard toolbar", () => {
438452
expect(
439453
screen.getByRole("button", { name: "Render stills (1)" })
440454
).toBeEnabled();
455+
expect(
456+
screen.getByRole("button", { name: "Render stills (1)" })
457+
).toHaveAttribute("aria-current", "step");
441458
expect(screen.getByRole("button", { name: "Render clips" })).toBeDisabled();
442459
expect(
443460
screen.getByRole("button", { name: "Assemble timeline" })
444461
).toBeDisabled();
445462
});
446463

464+
it("highlights clip rendering after every shot has a still", () => {
465+
mockShots = [
466+
{
467+
...makeShot("s1"),
468+
status: "keyframe_ready",
469+
keyframe: { type: "image", asset_id: "still-1" }
470+
}
471+
];
472+
renderBoard(jest.fn());
473+
474+
expect(
475+
screen.getByRole("button", { name: "Render clips (1)" })
476+
).toHaveAttribute("aria-current", "step");
477+
expect(
478+
screen.getByRole("button", { name: "Render stills" })
479+
).not.toHaveAttribute("aria-current");
480+
});
481+
482+
it("treats a URI-backed legacy keyframe as a completed still", () => {
483+
mockShots = [
484+
{
485+
...makeShot("s1"),
486+
status: "keyframe_ready",
487+
keyframe: { type: "image", uri: "asset://still-1.png" }
488+
}
489+
];
490+
renderBoard(jest.fn());
491+
492+
expect(
493+
screen.getByRole("button", { name: "Render clips (1)" })
494+
).toHaveAttribute("aria-current", "step");
495+
});
496+
497+
it("highlights clip rendering for a direct-render shot", () => {
498+
mockShots = [{ ...makeShot("s1"), render_mode: "direct" }];
499+
renderBoard(jest.fn());
500+
501+
expect(
502+
screen.getByRole("button", { name: "Render clips (1)" })
503+
).toHaveAttribute("aria-current", "step");
504+
expect(
505+
screen.getByRole("button", { name: "Render stills" })
506+
).toBeDisabled();
507+
});
508+
509+
it("does not advance to clips while another still is rendering", () => {
510+
mockShots = [
511+
{ ...makeShot("s1"), status: "keyframe_generating" },
512+
{
513+
...makeShot("s2"),
514+
status: "keyframe_ready",
515+
keyframe: { type: "image", asset_id: "still-2" }
516+
}
517+
];
518+
renderBoard(jest.fn());
519+
520+
expect(
521+
screen.getByRole("button", { name: "Render clips (1)" })
522+
).not.toHaveAttribute("aria-current");
523+
});
524+
525+
it("opens the still model picker and asks for a model before rendering", () => {
526+
boardModels = { imageModel: null, videoModel: null };
527+
mockShots = [makeShot("s1")];
528+
renderBoard(jest.fn());
529+
530+
expect(
531+
screen.getByRole("combobox", { name: "Still model" })
532+
).toBeInTheDocument();
533+
expect(screen.getByRole("alert")).toHaveTextContent(
534+
"Choose a still model before rendering stills."
535+
);
536+
expect(
537+
screen.getByRole("button", { name: "Render stills (1)" })
538+
).toBeDisabled();
539+
});
540+
541+
it("opens the clip model picker and asks for a model before rendering", () => {
542+
boardModels = {
543+
imageModel: { id: "fal-ai/flux/schnell", provider: "fal_ai" },
544+
videoModel: null
545+
};
546+
mockShots = [
547+
{
548+
...makeShot("s1"),
549+
status: "keyframe_ready",
550+
keyframe: { type: "image", asset_id: "still-1" }
551+
}
552+
];
553+
renderBoard(jest.fn());
554+
555+
expect(
556+
screen.getByRole("combobox", { name: "Clip model" })
557+
).toBeInTheDocument();
558+
expect(screen.getByRole("alert")).toHaveTextContent(
559+
"Choose a clip model before rendering clips."
560+
);
561+
expect(
562+
screen.getByRole("button", { name: "Render clips (1)" })
563+
).toBeDisabled();
564+
});
565+
447566
it("puts each batch's price on its own button", () => {
448567
// Two shots await a still; only the one already holding a keyframe can be
449568
// animated, so the two buttons quote different batches.

0 commit comments

Comments
 (0)