Skip to content

Commit 2157049

Browse files
authored
feat: add reference-to-video providers and storyboard rendering (#5696)
1 parent 0e9ec2e commit 2157049

96 files changed

Lines changed: 2046 additions & 279 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/agents/scripts/dump-creative-run.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ function createFalMediaBackend(fal: {
7979
return save(label, "png", bytes);
8080
},
8181
async video(from, prompt, label, durationSeconds) {
82-
const bytes = await fal.imageToVideo([from], {
82+
const bytes = await fal.imageToVideo(from, {
8383
model: videoModel,
8484
prompt,
8585
durationSeconds,

packages/agents/src/capabilities/models.specs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const SUPPORTED_CAPABILITIES = [
1717
"segment_image",
1818
"text_to_video",
1919
"image_to_video",
20+
"reference_to_video",
2021
"text_to_speech",
2122
"text_to_music",
2223
"automatic_speech_recognition",

packages/agents/src/capabilities/models.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ const CAPABILITY_REF_TYPE = {
153153
segment_image: "image_model",
154154
text_to_video: "video_model",
155155
image_to_video: "video_model",
156+
reference_to_video: "video_model",
156157
text_to_speech: "tts_model",
157158
// A music-typed node property takes a `music_model`, never a `tts_model`.
158159
// Handing back the wrong tag made every music ref unassignable: the property
@@ -201,6 +202,8 @@ function capabilityToRecommendedTasks(
201202
return new Set(["text_to_video"]);
202203
case "image_to_video":
203204
return new Set(["image_to_video"]);
205+
case "reference_to_video":
206+
return new Set(["reference_to_video"]);
204207
case "generate_embedding":
205208
return new Set(["embedding"]);
206209
case "generate_message":
@@ -222,6 +225,7 @@ function capabilityToRecommendedModalities(
222225
return new Set(["image"]);
223226
case "text_to_video":
224227
case "image_to_video":
228+
case "reference_to_video":
225229
return new Set(["video"]);
226230
case "text_to_speech":
227231
return new Set(["tts"]);
@@ -246,6 +250,7 @@ async function fetchModelsForCapability(
246250
return await provider.getAvailableImageModels();
247251
case "text_to_video":
248252
case "image_to_video":
253+
case "reference_to_video":
249254
return await provider.getAvailableVideoModels();
250255
case "text_to_speech":
251256
return await provider.getAvailableTTSModels();
@@ -287,6 +292,7 @@ function capabilityTask(capability: SupportedCapability): string | null {
287292
case "image_to_image":
288293
case "text_to_video":
289294
case "image_to_video":
295+
case "reference_to_video":
290296
case "text_to_speech":
291297
case "text_to_music":
292298
return capability;

packages/agents/src/capabilities/storyboards.specs.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,12 @@ export const RENDER_CLIPS_SCHEMA: JsonSchema = {
124124
},
125125
mode: {
126126
type: "string",
127-
enum: ["keyframe", "direct"],
127+
enum: ["keyframe", "direct", "reference"],
128128
description:
129129
"Override how the selected shots render, for this call only. " +
130130
"'keyframe' animates each shot's still (image_to_video); 'direct' " +
131-
"generates from the prompt with no still (text_to_video). Defaults " +
131+
"generates from the prompt with no still (text_to_video); 'reference' " +
132+
"uses entity reference images (reference_to_video). Defaults " +
132133
"to each shot's own render_mode, which defaults to 'keyframe'. Set " +
133134
"the shot's render_mode with edit_storyboard to make it stick."
134135
},
@@ -338,15 +339,16 @@ export const renderStoryboardClipsSpec: CapabilitySpec = {
338339
"directly — no workflow is created or run. A shot renders the way its " +
339340
"render_mode says: 'keyframe' (the default) animates its selected still " +
340341
"with image_to_video, 'direct' generates from the prompt with " +
341-
"text_to_video and needs no still. Pass `mode` to override both for this " +
342+
"text_to_video and needs no still; 'reference' uses entity reference images " +
343+
"with reference_to_video. Pass `mode` to override for this " +
342344
"call. Each clip is saved as an asset and attached to its shot (previous " +
343345
"takes are kept as versions), leaving the shot 'rendered' and ready for " +
344346
"assemble_storyboard_timeline. Omit `targets` to render every shot that " +
345347
"still needs a clip and can render one — a shot covered by another " +
346348
"shot's generation already has its picture and is skipped. " +
347349
"A keyframe-mode shot with no " +
348350
"still is reported, not rendered — run render_storyboard_stills first, or " +
349-
"set its render_mode to 'direct'. This is the expensive step.",
351+
"set its render_mode to 'direct' or 'reference'. This is the expensive step.",
350352
inputSchema: RENDER_CLIPS_SCHEMA,
351353
category: "write",
352354
userMessage: (params) => {

packages/agents/src/capabilities/storyboards.ts

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,7 @@ interface ShotOutcome {
438438
index: number;
439439
slug?: string;
440440
/** How the clip was rendered. Absent on a stills outcome. */
441-
render_mode?: "keyframe" | "direct";
441+
render_mode?: "keyframe" | "direct" | "reference";
442442
ok: boolean;
443443
asset_id?: string;
444444
asset_uri?: string;
@@ -790,36 +790,54 @@ const renderStoryboardClips: CapabilityExport = {
790790
const { row, doc } = board;
791791

792792
const override = params["mode"];
793-
if (override !== undefined && override !== "keyframe" && override !== "direct") {
794-
return { error: 'mode must be "keyframe" or "direct".' };
793+
if (override !== undefined && override !== "keyframe" && override !== "direct" && override !== "reference") {
794+
return { error: 'mode must be "keyframe", "direct", or "reference".' };
795795
}
796796
// The call's override wins over the shot's own setting, for this call only.
797797
const { shotRenderMode } = await import("@nodetool-ai/protocol");
798-
const modeOf = (shot: Shot): "keyframe" | "direct" =>
799-
override === "keyframe" || override === "direct"
798+
const modeOf = (shot: Shot): "keyframe" | "direct" | "reference" =>
799+
override === "keyframe" || override === "direct" || override === "reference"
800800
? override
801801
: shotRenderMode(shot);
802802

803-
const model = resolveModel(
804-
params,
805-
doc.videoModel,
806-
"clip",
807-
// A board renders one way or the other far more often than both, so name
808-
// the capability the selection actually needs.
809-
doc.shots.every((s) => modeOf(s) === "direct")
810-
? "text_to_video"
811-
: "image_to_video"
812-
);
813-
if (isError(model)) return model;
814-
815803
const selected = selectShots(
816804
doc.shots,
817805
params["targets"],
818806
(s) =>
819807
!shotHasPicture(s, doc.shots) &&
820-
(modeOf(s) === "direct" || !!s.keyframe)
808+
(modeOf(s) === "direct" || modeOf(s) === "reference" || !!s.keyframe)
821809
);
822810
if (isError(selected)) return selected;
811+
const requiredCapabilities = new Set(
812+
selected.map((shot) =>
813+
modeOf(shot) === "direct"
814+
? "text_to_video"
815+
: modeOf(shot) === "reference"
816+
? "reference_to_video"
817+
: "image_to_video"
818+
)
819+
);
820+
const capability = requiredCapabilities.has("reference_to_video")
821+
? "reference_to_video"
822+
: requiredCapabilities.has("image_to_video")
823+
? "image_to_video"
824+
: "text_to_video";
825+
const model = resolveModel(params, doc.videoModel, "clip", capability);
826+
if (isError(model)) return model;
827+
const declaredTasks = doc.videoModel?.supported_tasks;
828+
if (
829+
Array.isArray(declaredTasks) && declaredTasks.length > 0 &&
830+
model.model === doc.videoModel?.id && model.provider === doc.videoModel?.provider
831+
) {
832+
const unsupported = [...requiredCapabilities].find(
833+
(task) => !declaredTasks.includes(task)
834+
);
835+
if (unsupported) {
836+
return {
837+
error: `The selected clip model does not support ${unsupported}; choose a model that supports every selected shot mode.`
838+
};
839+
}
840+
}
823841
const entities = await loadBoardEntities(context, doc);
824842
const fresh = await filterStale(selected, params, doc, entities, "clip");
825843
const skipped = fresh.skipped;
@@ -1684,8 +1702,8 @@ function applyShotFields(
16841702
}
16851703
if (args["render_mode"] !== undefined) {
16861704
const mode = String(args["render_mode"]);
1687-
if (mode !== "keyframe" && mode !== "direct") {
1688-
throw new Error('render_mode must be "keyframe" or "direct".');
1705+
if (mode !== "keyframe" && mode !== "direct" && mode !== "reference") {
1706+
throw new Error('render_mode must be "keyframe", "direct", or "reference".');
16891707
}
16901708
next.render_mode = mode;
16911709
}

packages/agents/src/prompts/workflow-authoring-knowledge.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export type GenericNodeCapability =
2323
| "image_to_image"
2424
| "text_to_video"
2525
| "image_to_video"
26+
| "reference_to_video"
2627
| "text_to_speech"
2728
| "automatic_speech_recognition"
2829
| "generate_embedding"
@@ -79,6 +80,14 @@ export const GENERIC_AI_NODES: readonly GenericAINode[] = [
7980
"Animate a source image into a video. Required: image, prompt, model.",
8081
acceptsModel: true
8182
},
83+
{
84+
type: "nodetool.video.ReferenceToVideo",
85+
capability: "reference_to_video",
86+
task: "Reference → Video",
87+
summary:
88+
"Generate a video guided by ordered reference images and videos. Required: at least one reference image or video, prompt, model.",
89+
acceptsModel: true
90+
},
8291
{
8392
type: "nodetool.audio.TextToSpeech",
8493
capability: "text_to_speech",
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
2+
import { BaseProvider } from "@nodetool-ai/runtime";
3+
import type { ProcessingContext, ProviderId, VideoModel } from "@nodetool-ai/runtime";
4+
import { Asset, ModelObserver, Storyboard, initTestDb } from "@nodetool-ai/models";
5+
import type { Shot } from "@nodetool-ai/protocol";
6+
import { toolForCapabilityName } from "../src/capabilities/lazy-tool.js";
7+
import { createCapabilityRun, UNGATED } from "../src/capabilities/invoke.js";
8+
import { withGenerationSeam } from "./_helpers/generation-seam.js";
9+
10+
class ReferenceVideoProvider extends BaseProvider {
11+
constructor(private readonly models: VideoModel[]) {
12+
super("fal_ai" as ProviderId);
13+
}
14+
override async referenceToVideo(): Promise<Uint8Array> { return new Uint8Array(); }
15+
override async getAvailableVideoModels(): Promise<VideoModel[]> {
16+
return this.models;
17+
}
18+
}
19+
20+
const ctx = { userId: "u1" } as ProcessingContext;
21+
const shot = (overrides: Partial<Shot> & { id: string; index: number }): Shot => ({
22+
type: "shot",
23+
action: `action ${overrides.index}`,
24+
status: "planned",
25+
...overrides
26+
});
27+
28+
function renderContext(referenceIds: [string, string]) {
29+
return withGenerationSeam({
30+
userId: "u1",
31+
runProviderPrediction: vi.fn(async () => new Uint8Array([0, 0, 0, 24, 102, 116, 121, 112])),
32+
hasModelInterface: () => true,
33+
createAsset: vi.fn(async (args: { name: string; contentType: string; content: Uint8Array }) => {
34+
return Asset.create<Asset>({ user_id: "u1", name: args.name, content_type: args.contentType });
35+
}),
36+
resolveAssetBytes: vi.fn(async (uri: string) => ({
37+
bytes: uri.includes(referenceIds[0]) ? new Uint8Array([1, 2]) : new Uint8Array([3, 4])
38+
}))
39+
}) as ProcessingContext & { runProviderPrediction: ReturnType<typeof vi.fn> };
40+
}
41+
42+
describe("reference_to_video capability contracts", () => {
43+
beforeEach(() => initTestDb());
44+
afterEach(() => ModelObserver.clear());
45+
46+
it("find_model filters video models by reference_to_video", async () => {
47+
const provider = new ReferenceVideoProvider([
48+
{ id: "image-model", name: "Image model", provider: "fal_ai", supportedTasks: ["image_to_video"] },
49+
{ id: "reference-model", name: "Reference model", provider: "fal_ai", supportedTasks: ["reference_to_video"] }
50+
]);
51+
const tool = toolForCapabilityName("find_model", (context) =>
52+
createCapabilityRun({ context, gate: UNGATED, providers: { fal_ai: provider } })
53+
);
54+
const result = (await tool.process(ctx, { capability: "reference_to_video" })) as {
55+
results: Array<{ model_id: string }>;
56+
};
57+
expect(result.results.map((model) => model.model_id)).toEqual(["reference-model"]);
58+
});
59+
60+
it("render_storyboard_clips dispatches a reference shot through reference_to_video", async () => {
61+
const first = await Asset.create<Asset>({
62+
user_id: "u1", name: "ref-a.png", content_type: "image/png",
63+
metadata: { nodetool_entity: { kind: "character", name: "A", descriptor: "first" } }
64+
});
65+
const second = await Asset.create<Asset>({
66+
user_id: "u1", name: "ref-b.png", content_type: "image/png",
67+
metadata: { nodetool_entity: { kind: "character", name: "B", descriptor: "second" } }
68+
});
69+
const board = await Storyboard.create<Storyboard>({
70+
user_id: "u1", project_id: "default", name: "Reference board",
71+
document: JSON.stringify({
72+
screenplay: null,
73+
shots: [shot({ id: "s1", index: 0, render_mode: "reference", entity_ids: [first.id, second.id] })],
74+
brief: "", style: "", entityIds: [first.id, second.id], aspectRatio: "16:9",
75+
directorModel: null, imageModel: null,
76+
videoModel: { type: "video_model", id: "reference-model", provider: "fal_ai" }
77+
})
78+
});
79+
const context = renderContext([first.id, second.id]);
80+
const result = (await toolForCapabilityName("render_storyboard_clips").process(context, {
81+
storyboard_id: board.id
82+
})) as { rendered: number };
83+
expect(result.rendered).toBe(1);
84+
expect(context.runProviderPrediction.mock.calls[0][0]).toMatchObject({
85+
capability: "reference_to_video",
86+
model: "reference-model",
87+
params: { reference_images: [new Uint8Array([1, 2]), new Uint8Array([3, 4])] }
88+
});
89+
});
90+
});

packages/agents/tests/generic-ai-nodes.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const VALID_CAPABILITIES: GenericNodeCapability[] = [
1111
"image_to_image",
1212
"text_to_video",
1313
"image_to_video",
14+
"reference_to_video",
1415
"text_to_speech",
1516
"automatic_speech_recognition",
1617
"generate_embedding",

packages/agents/tests/storyboard-render-tools.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ describe("storyboard render tools", () => {
327327
ops: [{ op: "update_shot", target: "s1", render_mode: "sideways" }]
328328
})) as { failed: number; ops: Array<{ error?: string }> };
329329
expect(rejected.failed).toBe(1);
330-
expect(rejected.ops[0].error).toContain('"keyframe" or "direct"');
330+
expect(rejected.ops[0].error).toContain('"keyframe", "direct", or "reference"');
331331
});
332332

333333
it("revises a clip in place, keeping the previous take as a version", async () => {

packages/base-nodes/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ export {
226226
export {
227227
TextToVideoNode,
228228
ImageToVideoNode,
229+
ReferenceToVideoNode,
229230
LoadVideoFileNode,
230231
SaveVideoFileVideoNode,
231232
LoadVideoAssetsNode,

0 commit comments

Comments
 (0)