Skip to content

Commit b5ade7b

Browse files
committed
fix: surface normalized video durations
1 parent 09fe144 commit b5ade7b

10 files changed

Lines changed: 284 additions & 24 deletions

File tree

docs/tools/video-generation.md

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -58,24 +58,24 @@ Use `action: "list"` to inspect available providers and models at runtime:
5858

5959
## Tool parameters
6060

61-
| Parameter | Type | Description |
62-
| ----------------- | -------- | ------------------------------------------------------------------------------------- |
63-
| `prompt` | string | Video generation prompt (required for `action: "generate"`) |
64-
| `action` | string | `"generate"` (default) or `"list"` to inspect providers |
65-
| `model` | string | Provider/model override, e.g. `qwen/wan2.6-t2v` |
66-
| `image` | string | Single reference image path or URL |
67-
| `images` | string[] | Multiple reference images (up to 5) |
68-
| `video` | string | Single reference video path or URL |
69-
| `videos` | string[] | Multiple reference videos (up to 4) |
70-
| `size` | string | Size hint when the provider supports it |
71-
| `aspectRatio` | string | Aspect ratio: `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9` |
72-
| `resolution` | string | Resolution hint: `480P`, `720P`, or `1080P` |
73-
| `durationSeconds` | number | Target duration in seconds |
74-
| `audio` | boolean | Enable generated audio when the provider supports it |
75-
| `watermark` | boolean | Toggle provider watermarking when supported |
76-
| `filename` | string | Output filename hint |
77-
78-
Not all providers support all parameters. The tool validates provider capability limits before it submits the request.
61+
| Parameter | Type | Description |
62+
| ----------------- | -------- | -------------------------------------------------------------------------------------- |
63+
| `prompt` | string | Video generation prompt (required for `action: "generate"`) |
64+
| `action` | string | `"generate"` (default) or `"list"` to inspect providers |
65+
| `model` | string | Provider/model override, e.g. `qwen/wan2.6-t2v` |
66+
| `image` | string | Single reference image path or URL |
67+
| `images` | string[] | Multiple reference images (up to 5) |
68+
| `video` | string | Single reference video path or URL |
69+
| `videos` | string[] | Multiple reference videos (up to 4) |
70+
| `size` | string | Size hint when the provider supports it |
71+
| `aspectRatio` | string | Aspect ratio: `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9` |
72+
| `resolution` | string | Resolution hint: `480P`, `720P`, or `1080P` |
73+
| `durationSeconds` | number | Target duration in seconds. OpenClaw may round to the nearest provider-supported value |
74+
| `audio` | boolean | Enable generated audio when the provider supports it |
75+
| `watermark` | boolean | Toggle provider watermarking when supported |
76+
| `filename` | string | Output filename hint |
77+
78+
Not all providers support all parameters. The tool validates provider capability limits before it submits the request. When a provider or model only supports a discrete set of video lengths, OpenClaw rounds `durationSeconds` to the nearest supported value and reports the normalized duration in the tool result.
7979

8080
## Configuration
8181

extensions/google/video-generation-provider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ export function buildGoogleVideoGenerationProvider(): VideoGenerationProvider {
162162
maxInputImages: 1,
163163
maxInputVideos: 1,
164164
maxDurationSeconds: GOOGLE_VIDEO_MAX_DURATION_SECONDS,
165+
supportedDurationSeconds: GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS,
165166
supportsAspectRatio: true,
166167
supportsResolution: true,
167168
supportsSize: true,

extensions/minimax/video-generation-provider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ export function buildMinimaxVideoGenerationProvider(): VideoGenerationProvider {
232232
maxInputImages: 1,
233233
maxInputVideos: 0,
234234
maxDurationSeconds: 10,
235+
supportedDurationSecondsByModel: MINIMAX_MODEL_ALLOWED_DURATIONS,
235236
supportsResolution: true,
236237
supportsWatermark: false,
237238
},

extensions/openai/video-generation-provider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ export function buildOpenAIVideoGenerationProvider(): VideoGenerationProvider {
194194
maxInputImages: 1,
195195
maxInputVideos: 1,
196196
maxDurationSeconds: 12,
197+
supportedDurationSeconds: OPENAI_VIDEO_SECONDS,
197198
supportsSize: true,
198199
},
199200
async generateVideo(req) {

src/agents/tools/video-generate-tool.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ function asConfig(value: unknown): OpenClawConfig {
1111
describe("createVideoGenerateTool", () => {
1212
beforeEach(() => {
1313
vi.restoreAllMocks();
14+
vi.spyOn(videoGenerationRuntime, "listRuntimeVideoGenerationProviders").mockReturnValue([]);
1415
});
1516

1617
afterEach(() => {
@@ -88,4 +89,90 @@ describe("createVideoGenerateTool", () => {
8889
metadata: { taskId: "task-1" },
8990
});
9091
});
92+
93+
it("shows duration normalization details from runtime metadata", async () => {
94+
vi.spyOn(videoGenerationRuntime, "generateVideo").mockResolvedValue({
95+
provider: "google",
96+
model: "veo-3.1-fast-generate-preview",
97+
attempts: [],
98+
videos: [
99+
{
100+
buffer: Buffer.from("video-bytes"),
101+
mimeType: "video/mp4",
102+
fileName: "lobster.mp4",
103+
},
104+
],
105+
metadata: {
106+
requestedDurationSeconds: 5,
107+
normalizedDurationSeconds: 6,
108+
supportedDurationSeconds: [4, 6, 8],
109+
},
110+
});
111+
vi.spyOn(mediaStore, "saveMediaBuffer").mockResolvedValueOnce({
112+
path: "/tmp/generated-lobster.mp4",
113+
id: "generated-lobster.mp4",
114+
size: 11,
115+
contentType: "video/mp4",
116+
});
117+
118+
const tool = createVideoGenerateTool({
119+
config: asConfig({
120+
agents: {
121+
defaults: {
122+
videoGenerationModel: { primary: "google/veo-3.1-fast-generate-preview" },
123+
},
124+
},
125+
}),
126+
});
127+
if (!tool) {
128+
throw new Error("expected video_generate tool");
129+
}
130+
131+
const result = await tool.execute("call-1", {
132+
prompt: "friendly lobster surfing",
133+
durationSeconds: 5,
134+
});
135+
const text = (result.content?.[0] as { text: string } | undefined)?.text ?? "";
136+
137+
expect(text).toContain("Duration normalized: requested 5s; used 6s.");
138+
expect(result.details).toMatchObject({
139+
durationSeconds: 6,
140+
requestedDurationSeconds: 5,
141+
supportedDurationSeconds: [4, 6, 8],
142+
});
143+
});
144+
145+
it("lists supported provider durations when advertised", async () => {
146+
vi.spyOn(videoGenerationRuntime, "listRuntimeVideoGenerationProviders").mockReturnValue([
147+
{
148+
id: "google",
149+
defaultModel: "veo-3.1-fast-generate-preview",
150+
models: ["veo-3.1-fast-generate-preview"],
151+
capabilities: {
152+
maxDurationSeconds: 8,
153+
supportedDurationSeconds: [4, 6, 8],
154+
},
155+
generateVideo: vi.fn(async () => {
156+
throw new Error("not used");
157+
}),
158+
},
159+
]);
160+
161+
const tool = createVideoGenerateTool({
162+
config: asConfig({
163+
agents: {
164+
defaults: {
165+
videoGenerationModel: { primary: "google/veo-3.1-fast-generate-preview" },
166+
},
167+
},
168+
}),
169+
});
170+
if (!tool) {
171+
throw new Error("expected video_generate tool");
172+
}
173+
174+
const result = await tool.execute("call-1", { action: "list" });
175+
const text = (result.content?.[0] as { text: string } | undefined)?.text ?? "";
176+
expect(text).toContain("supportedDurationSeconds=4/6/8");
177+
});
91178
});

src/agents/tools/video-generate-tool.ts

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { loadWebMedia } from "../../media/web-media.js";
66
import { readSnakeCaseParamRaw } from "../../param-key.js";
77
import { getProviderEnvVars } from "../../secrets/provider-env-vars.js";
88
import { resolveUserPath } from "../../utils.js";
9+
import { resolveVideoGenerationSupportedDurations } from "../../video-generation/duration-support.js";
910
import { parseVideoGenerationModelRef } from "../../video-generation/model-ref.js";
1011
import {
1112
generateVideo,
@@ -114,7 +115,8 @@ const VideoGenerateToolSchema = Type.Object({
114115
),
115116
durationSeconds: Type.Optional(
116117
Type.Number({
117-
description: "Optional target duration in seconds.",
118+
description:
119+
"Optional target duration in seconds. OpenClaw may round this to the nearest provider-supported duration.",
118120
minimum: 1,
119121
}),
120122
),
@@ -329,6 +331,7 @@ function resolveSelectedVideoGenerationProvider(params: {
329331

330332
function validateVideoGenerationCapabilities(params: {
331333
provider: VideoGenerationProvider | undefined;
334+
model?: string;
332335
inputImageCount: number;
333336
inputVideoCount: number;
334337
size?: string;
@@ -371,6 +374,10 @@ function validateVideoGenerationCapabilities(params: {
371374
if (
372375
typeof params.durationSeconds === "number" &&
373376
Number.isFinite(params.durationSeconds) &&
377+
!resolveVideoGenerationSupportedDurations({
378+
provider,
379+
model: params.model,
380+
}) &&
374381
typeof caps.maxDurationSeconds === "number" &&
375382
params.durationSeconds > caps.maxDurationSeconds
376383
) {
@@ -535,7 +542,7 @@ export function createVideoGenerateTool(options?: {
535542
name: "video_generate",
536543
displaySummary: "Generate videos",
537544
description:
538-
"Generate videos using configured providers. Generated videos are saved under OpenClaw-managed media storage and delivered automatically as attachments.",
545+
"Generate videos using configured providers. Generated videos are saved under OpenClaw-managed media storage and delivered automatically as attachments. Duration requests may be rounded to the nearest provider-supported value.",
539546
parameters: VideoGenerateToolSchema,
540547
execute: async (_toolCallId, rawArgs) => {
541548
const args = rawArgs as Record<string, unknown>;
@@ -564,6 +571,17 @@ export function createVideoGenerateTool(options?: {
564571
provider.capabilities.maxDurationSeconds
565572
? `maxDurationSeconds=${provider.capabilities.maxDurationSeconds}`
566573
: null,
574+
provider.capabilities.supportedDurationSeconds?.length
575+
? `supportedDurationSeconds=${provider.capabilities.supportedDurationSeconds.join("/")}`
576+
: null,
577+
provider.capabilities.supportedDurationSecondsByModel &&
578+
Object.keys(provider.capabilities.supportedDurationSecondsByModel).length > 0
579+
? `supportedDurationSecondsByModel=${Object.entries(
580+
provider.capabilities.supportedDurationSecondsByModel,
581+
)
582+
.map(([modelId, durations]) => `${modelId}:${durations.join("/")}`)
583+
.join("; ")}`
584+
: null,
567585
provider.capabilities.supportsResolution ? "resolution" : null,
568586
provider.capabilities.supportsAspectRatio ? "aspectRatio" : null,
569587
provider.capabilities.supportsSize ? "size" : null,
@@ -639,6 +657,8 @@ export function createVideoGenerateTool(options?: {
639657
});
640658
validateVideoGenerationCapabilities({
641659
provider: selectedProvider,
660+
model:
661+
parseVideoGenerationModelRef(model)?.model ?? model ?? selectedProvider?.defaultModel,
642662
inputImageCount: loadedReferenceImages.length,
643663
inputVideoCount: loadedReferenceVideos.length,
644664
size,
@@ -674,10 +694,30 @@ export function createVideoGenerateTool(options?: {
674694
),
675695
),
676696
);
697+
const requestedDurationSeconds =
698+
typeof result.metadata?.requestedDurationSeconds === "number" &&
699+
Number.isFinite(result.metadata.requestedDurationSeconds)
700+
? result.metadata.requestedDurationSeconds
701+
: durationSeconds;
702+
const normalizedDurationSeconds =
703+
typeof result.metadata?.normalizedDurationSeconds === "number" &&
704+
Number.isFinite(result.metadata.normalizedDurationSeconds)
705+
? result.metadata.normalizedDurationSeconds
706+
: requestedDurationSeconds;
707+
const supportedDurationSeconds = Array.isArray(result.metadata?.supportedDurationSeconds)
708+
? result.metadata.supportedDurationSeconds.filter(
709+
(entry): entry is number => typeof entry === "number" && Number.isFinite(entry),
710+
)
711+
: undefined;
677712
const lines = [
678713
`Generated ${savedVideos.length} video${savedVideos.length === 1 ? "" : "s"} with ${result.provider}/${result.model}.`,
714+
typeof requestedDurationSeconds === "number" &&
715+
typeof normalizedDurationSeconds === "number" &&
716+
requestedDurationSeconds !== normalizedDurationSeconds
717+
? `Duration normalized: requested ${requestedDurationSeconds}s; used ${normalizedDurationSeconds}s.`
718+
: null,
679719
...savedVideos.map((video) => `MEDIA:${video.path}`),
680-
];
720+
].filter((entry): entry is string => Boolean(entry));
681721

682722
return {
683723
content: [{ type: "text", text: lines.join("\n") }],
@@ -722,7 +762,17 @@ export function createVideoGenerateTool(options?: {
722762
...(size ? { size } : {}),
723763
...(aspectRatio ? { aspectRatio } : {}),
724764
...(resolution ? { resolution } : {}),
725-
...(typeof durationSeconds === "number" ? { durationSeconds } : {}),
765+
...(typeof normalizedDurationSeconds === "number"
766+
? { durationSeconds: normalizedDurationSeconds }
767+
: {}),
768+
...(typeof requestedDurationSeconds === "number" &&
769+
typeof normalizedDurationSeconds === "number" &&
770+
requestedDurationSeconds !== normalizedDurationSeconds
771+
? { requestedDurationSeconds }
772+
: {}),
773+
...(supportedDurationSeconds && supportedDurationSeconds.length > 0
774+
? { supportedDurationSeconds }
775+
: {}),
726776
...(typeof audio === "boolean" ? { audio } : {}),
727777
...(typeof watermark === "boolean" ? { watermark } : {}),
728778
...(filename ? { filename } : {}),
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { VideoGenerationProvider } from "./types.js";
2+
3+
function normalizeSupportedDurationValues(
4+
values: readonly number[] | undefined,
5+
): number[] | undefined {
6+
if (!Array.isArray(values) || values.length === 0) {
7+
return undefined;
8+
}
9+
const normalized = [...new Set(values)]
10+
.filter((value) => Number.isFinite(value) && value > 0)
11+
.map((value) => Math.round(value))
12+
.filter((value) => value > 0)
13+
.toSorted((left, right) => left - right);
14+
return normalized.length > 0 ? normalized : undefined;
15+
}
16+
17+
export function resolveVideoGenerationSupportedDurations(params: {
18+
provider?: VideoGenerationProvider;
19+
model?: string;
20+
}): number[] | undefined {
21+
const caps = params.provider?.capabilities;
22+
const model = params.model?.trim();
23+
const modelSpecific =
24+
model && caps?.supportedDurationSecondsByModel
25+
? caps.supportedDurationSecondsByModel[model]
26+
: undefined;
27+
return normalizeSupportedDurationValues(modelSpecific ?? caps?.supportedDurationSeconds);
28+
}
29+
30+
export function normalizeVideoGenerationDuration(params: {
31+
provider?: VideoGenerationProvider;
32+
model?: string;
33+
durationSeconds?: number;
34+
}): number | undefined {
35+
if (typeof params.durationSeconds !== "number" || !Number.isFinite(params.durationSeconds)) {
36+
return undefined;
37+
}
38+
const rounded = Math.max(1, Math.round(params.durationSeconds));
39+
const supported = resolveVideoGenerationSupportedDurations(params);
40+
if (!supported || supported.length === 0) {
41+
return rounded;
42+
}
43+
return supported.reduce((best, current) => {
44+
const currentDistance = Math.abs(current - rounded);
45+
const bestDistance = Math.abs(best - rounded);
46+
if (currentDistance < bestDistance) {
47+
return current;
48+
}
49+
if (currentDistance === bestDistance && current > best) {
50+
return current;
51+
}
52+
return best;
53+
});
54+
}

src/video-generation/runtime.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,43 @@ describe("video-generation runtime", () => {
150150
expect(mocks.listVideoGenerationProviders).toHaveBeenCalledWith({} as OpenClawConfig);
151151
});
152152

153+
it("normalizes requested durations to supported provider values", async () => {
154+
let seenDurationSeconds: number | undefined;
155+
mocks.resolveAgentModelPrimaryValue.mockReturnValue("video-plugin/vid-v1");
156+
mocks.getVideoGenerationProvider.mockReturnValue({
157+
id: "video-plugin",
158+
capabilities: {
159+
supportedDurationSeconds: [4, 6, 8],
160+
},
161+
generateVideo: async (req) => {
162+
seenDurationSeconds = req.durationSeconds;
163+
return {
164+
videos: [{ buffer: Buffer.from("mp4-bytes"), mimeType: "video/mp4" }],
165+
model: "vid-v1",
166+
};
167+
},
168+
});
169+
170+
const result = await generateVideo({
171+
cfg: {
172+
agents: {
173+
defaults: {
174+
videoGenerationModel: { primary: "video-plugin/vid-v1" },
175+
},
176+
},
177+
} as OpenClawConfig,
178+
prompt: "animate a cat",
179+
durationSeconds: 5,
180+
});
181+
182+
expect(seenDurationSeconds).toBe(6);
183+
expect(result.metadata).toMatchObject({
184+
requestedDurationSeconds: 5,
185+
normalizedDurationSeconds: 6,
186+
supportedDurationSeconds: [4, 6, 8],
187+
});
188+
});
189+
153190
it("builds a generic config hint without hardcoded provider ids", async () => {
154191
mocks.listVideoGenerationProviders.mockReturnValue([
155192
{

0 commit comments

Comments
 (0)