fix(video): harden DashScope task and media lifecycle (#117303)

* fix(video): validate DashScope task outputs and release request guards

* fix(video): validate DashScope task outputs and release request guards

* refactor(video): use shared empty-asset validation

* fix(video): align DashScope Wan request contracts

* refactor(video): use shared empty-asset validation

* fix(video): enforce catalog modes before provider calls

* fix(video): keep model checks fallback-aware

* fix(video): centralize fallback-aware capability checks

* docs(video): document Wan 2.7 fixed audio

---------

Co-authored-by: Peter Steinberger <steipete@macos.shared>
This commit is contained in:
Peter Steinberger
2026-08-05 10:19:09 -07:00
committed by GitHub
parent 0a99a85ef4
commit 414616f583
18 changed files with 991 additions and 207 deletions
+14 -7
View File
@@ -82,15 +82,22 @@ The bundled `alibaba` plugin registers a video-generation provider for Wan model
## Capabilities and limits
All three modes share the same per-request video count and duration cap; only the input shape differs.
Each model advertises only its matching runtime mode. Geometry also follows the
vendor protocol for that model family instead of sending one generic parameter shape.
| Mode | Max output videos | Max input images | Max input videos | Max duration | Supported controls |
| ------------------ | ----------------- | ---------------- | ---------------- | ------------ | --------------------------------------------------------- |
| Text-to-video | 1 | n/a | n/a | 10 s | `size`, `aspectRatio`, `resolution`, `audio`, `watermark` |
| Image-to-video | 1 | 1 | n/a | 10 s | `size`, `aspectRatio`, `resolution`, `audio`, `watermark` |
| Reference-to-video | 1 | n/a | 4 | 10 s | `size`, `aspectRatio`, `resolution`, `audio`, `watermark` |
| Mode | Max output videos | Reference limits | Max duration | Supported controls |
| ---------------------------- | ----------------- | ------------------------------------- | ------------ | -------------------------------------------------------------------- |
| Text-to-video | 1 | n/a | 15 s | `size`, `aspectRatio`, `resolution`, `audio`, `watermark` |
| Image-to-video | 1 | 1 image | 15 s | `resolution`, `audio`, `watermark` |
| Reference-to-video (Wan 2.6) | 1 | 5 total images/videos; up to 3 videos | 10 s | `size`, `aspectRatio`, `resolution`, `audio`, `watermark` |
| Reference-to-video (Wan 2.7) | 1 | 5 total images/videos; up to 3 videos | 10 s | `size`, `aspectRatio`, `resolution`, `watermark`; audio is always on |
A request that omits `durationSeconds` gets DashScope's accepted default of **5 seconds**. Set `durationSeconds` explicitly on the [video generation tool](/tools/video-generation) to extend up to 10 s.
Wan 2.6 text/reference models translate `resolution` plus `aspectRatio` to the
documented exact `size`. Wan 2.6 image-to-video sends the `resolution` tier and
uses the input image's aspect ratio. Wan 2.7 reference-to-video sends the newer
`media`, `resolution`, and `ratio` fields and always generates audio.
A request that omits `durationSeconds` gets DashScope's accepted default of **5 seconds**.
<Warning>
Reference image and video inputs must be remote `http(s)` URLs; DashScope's reference modes reject local file paths. Upload to object storage first, or use the [media tool](/tools/media-overview) flow that already produces a public URL.
+17 -6
View File
@@ -284,12 +284,23 @@ To make Qwen the default video provider:
}
```
Video-generation limits: 1 output video per request, up to 1 input image
(image-to-video), up to 4 input videos (video-to-video), max 10 seconds
duration. Supports `size`, `aspectRatio`, `resolution`, `audio`, and
`watermark`. Reference image/video inputs require remote http(s) URLs; local
file paths are rejected up front because the DashScope video endpoint does not
accept uploaded local buffers for those references.
Each Wan model advertises only its matching runtime mode:
| Mode | Models | Reference limits | Max duration | Supported controls |
| ---------------------------- | -------------------------------- | ------------------------------------- | ------------ | -------------------------------------------------------------------- |
| Text-to-video | `wan2.6-t2v` | n/a | 15 s | `size`, `aspectRatio`, `resolution`, `audio`, `watermark` |
| Image-to-video | `wan2.6-i2v` | 1 image | 15 s | `resolution`, `audio`, `watermark` |
| Reference-to-video (Wan 2.6) | `wan2.6-r2v`, `wan2.6-r2v-flash` | 5 total images/videos; up to 3 videos | 10 s | `size`, `aspectRatio`, `resolution`, `audio`, `watermark` |
| Reference-to-video (Wan 2.7) | `wan2.7-r2v` | 5 total images/videos; up to 3 videos | 10 s | `size`, `aspectRatio`, `resolution`, `watermark`; audio is always on |
Wan 2.6 text/reference models translate `resolution` plus `aspectRatio` to the
documented exact `size`. Wan 2.6 image-to-video sends the `resolution` tier and
uses the input image's aspect ratio. Wan 2.7 reference-to-video sends
`media`, `resolution`, and `ratio` and always generates audio.
Reference image/video inputs require remote http(s) URLs; local file paths are
rejected up front because the DashScope video endpoint does not accept uploaded
local buffers for those references.
<Note>
See [Video generation](/tools/video-generation) for shared tool parameters, provider selection, and failover behavior.
@@ -282,10 +282,10 @@ describe("alibaba video generation provider", () => {
expect(body.model).toBe("wan2.6-r2v-flash");
const input = requireRecord(body.input, "DashScope request input");
expect(input.prompt).toBe("animate this shot");
expect(input.img_url).toBe("https://example.com/ref.png");
expect(input.reference_urls).toEqual(["https://example.com/ref.png"]);
const parameters = requireRecord(body.parameters, "DashScope request parameters");
expect(parameters.duration).toBe(6);
expect(parameters.enable_audio).toBe(true);
expect(parameters.audio).toBe(true);
expect(parameters.watermark).toBe(false);
expectDashscopeVideoTaskPoll(fetchWithTimeoutMock);
expectSuccessfulDashscopeVideoResult(result);
@@ -255,11 +255,11 @@ describe("qwen video generation provider", () => {
model: "wan2.6-r2v-flash",
input: {
prompt: "animate this shot",
img_url: "https://example.com/ref.png",
reference_urls: ["https://example.com/ref.png"],
},
parameters: {
duration: 6,
enable_audio: true,
audio: true,
},
},
});
@@ -378,7 +378,7 @@ describe("qwen video generation provider", () => {
await expect(
provider.generateVideo({
provider: "qwen",
model: "wan2.6-r2v-flash",
model: "wan2.6-t2v",
prompt: "short video",
cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } },
}),
+59 -8
View File
@@ -1641,7 +1641,7 @@ describe("createVideoGenerateTool", () => {
});
});
it("rejects image-to-video when the provider disables that mode", async () => {
it("defers disabled primary modes to the fallback-aware runtime", async () => {
vi.spyOn(videoGenerationRuntime, "listRuntimeVideoGenerationProviders").mockReturnValue([
{
id: "video-plugin",
@@ -1657,7 +1657,7 @@ describe("createVideoGenerateTool", () => {
}),
},
]);
const generateSpy = vi.spyOn(videoGenerationRuntime, "generateVideo");
const generateSpy = mockSavedVideoResult();
const tool = createVideoGenerateTool({
config: asConfig({
@@ -1672,13 +1672,64 @@ describe("createVideoGenerateTool", () => {
throw new Error("expected video_generate tool");
}
await expect(
tool.execute("call-1", {
prompt: "lobster timelapse",
image: "data:image/png;base64,cG5n",
await tool.execute("call-1", {
prompt: "lobster timelapse",
image: "data:image/png;base64,cG5n",
});
const request = firstMockCallArg(generateSpy) as { inputImages?: unknown[] };
expect(request.inputImages).toHaveLength(1);
});
it("defers model-specific reference limits to runtime overlays", async () => {
vi.spyOn(videoGenerationRuntime, "listRuntimeVideoGenerationProviders").mockReturnValue([
{
id: "video-plugin",
defaultModel: "r2v",
models: ["r2v"],
capabilities: {
imageToVideo: {
enabled: true,
maxInputImages: 1,
},
},
catalogByModel: {
r2v: {
modes: ["imageToVideo"],
capabilities: {
imageToVideo: {
enabled: true,
maxInputImages: 5,
},
},
},
},
generateVideo: vi.fn(async () => {
throw new Error("not used");
}),
},
]);
const generateSpy = mockSavedVideoResult();
const tool = createVideoGenerateTool({
config: asConfig({
agents: {
defaults: {
videoGenerationModel: { primary: "video-plugin/r2v" },
},
},
}),
).rejects.toThrow("video-plugin does not support image-to-video reference inputs.");
expect(generateSpy).not.toHaveBeenCalled();
});
if (!tool) {
throw new Error("expected video_generate tool");
}
await tool.execute("call-r2v", {
prompt: "animate both references",
images: ["data:image/png;base64,cG5n", "data:image/png;base64,cG5nMg=="],
});
const request = firstMockCallArg(generateSpy) as { inputImages?: unknown[] };
expect(request.inputImages).toHaveLength(2);
});
it("warns when optional provider overrides are ignored", async () => {
-97
View File
@@ -17,10 +17,6 @@ import { isManifestPluginAvailableForControlPlane } from "../../plugins/manifest
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import { resolveUserPath } from "../../utils.js";
import type { DeliveryContext } from "../../utils/delivery-context.js";
import {
resolveVideoGenerationMode,
resolveVideoGenerationModeCapabilities,
} from "../../video-generation/capabilities.js";
import { parseVideoGenerationModelRef } from "../../video-generation/model-ref.js";
import {
generateVideo,
@@ -459,85 +455,6 @@ function resolveSelectedVideoGenerationProvider(params: {
});
}
function validateVideoGenerationCapabilities(params: {
provider: VideoGenerationProvider | undefined;
model?: string;
inputImageCount: number;
inputVideoCount: number;
inputAudioCount: number;
size?: string;
aspectRatio?: string;
resolution?: VideoGenerationResolution;
durationSeconds?: number;
audio?: boolean;
watermark?: boolean;
}) {
const provider = params.provider;
if (!provider) {
return;
}
const mode = resolveVideoGenerationMode({
inputImageCount: params.inputImageCount,
inputVideoCount: params.inputVideoCount,
});
const { capabilities: caps } = resolveVideoGenerationModeCapabilities({
provider,
model: params.model,
inputImageCount: params.inputImageCount,
inputVideoCount: params.inputVideoCount,
});
if (!caps && mode === "imageToVideo" && params.inputVideoCount === 0) {
throw new ToolInputError(`${provider.id} does not support image-to-video reference inputs.`);
}
if (!caps && mode === "videoToVideo" && params.inputImageCount === 0) {
throw new ToolInputError(`${provider.id} does not support video-to-video reference inputs.`);
}
if (!caps) {
return;
}
if (
mode === "imageToVideo" &&
"enabled" in caps &&
!caps.enabled &&
params.inputVideoCount === 0
) {
throw new ToolInputError(`${provider.id} does not support image-to-video reference inputs.`);
}
if (
mode === "videoToVideo" &&
"enabled" in caps &&
!caps.enabled &&
params.inputImageCount === 0
) {
throw new ToolInputError(`${provider.id} does not support video-to-video reference inputs.`);
}
if (params.inputImageCount > 0) {
const maxInputImages = caps.maxInputImages ?? MAX_INPUT_IMAGES;
if (params.inputImageCount > maxInputImages) {
throw new ToolInputError(
`${provider.id} supports at most ${maxInputImages} reference image${maxInputImages === 1 ? "" : "s"}.`,
);
}
}
if (params.inputVideoCount > 0) {
const maxInputVideos = caps.maxInputVideos ?? MAX_INPUT_VIDEOS;
if (params.inputVideoCount > maxInputVideos) {
throw new ToolInputError(
`${provider.id} supports at most ${maxInputVideos} reference video${maxInputVideos === 1 ? "" : "s"}.`,
);
}
}
// Audio-count validation is intentionally deferred to runtime.ts (generateVideo).
// The runtime guard skips per-candidate providers that lack audio support, allowing
// fallback candidates that do support audio to run. A ToolInputError here would fire
// against only the primary provider and prevent valid fallback-based audio requests.
// maxDurationSeconds validation is intentionally deferred to runtime.ts (generateVideo).
// The runtime guard skips per-candidate providers whose hard cap is below the requested
// duration, allowing a fallback with a higher cap to run — same rationale as the audio
// check above. When providers declare an explicit supportedDurationSeconds list, runtime
// normalization snaps to the nearest valid value instead of skipping.
}
function formatIgnoredVideoGenerationOverride(override: VideoGenerationIgnoredOverride): string {
return `${sanitizeGeneratedMediaDisplayText(override.key)}=${sanitizeGeneratedMediaDisplayText(String(override.value))}`;
}
@@ -1223,20 +1140,6 @@ export function createVideoGenerateTool(options?: {
asset.sourceAsset.role = role;
}
}
validateVideoGenerationCapabilities({
provider: selectedProvider,
model:
parseVideoGenerationModelRef(model)?.model ?? model ?? selectedProvider?.defaultModel,
inputImageCount: loadedReferenceImages.length,
inputVideoCount: loadedReferenceVideos.length,
inputAudioCount: loadedReferenceAudios.length,
size,
aspectRatio,
resolution,
durationSeconds,
audio,
watermark,
});
// Accepted tasks own their paid work independently; cancellation applies only before admission.
signal?.throwIfAborted();
const taskHandle = createVideoGenerationTaskRun({
+4
View File
@@ -6,6 +6,7 @@
import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
DASHSCOPE_WAN_VIDEO_CATALOG_BY_MODEL,
DASHSCOPE_WAN_VIDEO_CAPABILITIES,
DASHSCOPE_WAN_VIDEO_MODELS,
DEFAULT_DASHSCOPE_WAN_VIDEO_MODEL,
@@ -288,6 +289,9 @@ export function buildDashscopeVideoGenerationProvider(
label: options.label,
defaultModel: DEFAULT_DASHSCOPE_WAN_VIDEO_MODEL,
models: [...DASHSCOPE_WAN_VIDEO_MODELS],
catalogByModel: DASHSCOPE_WAN_VIDEO_CATALOG_BY_MODEL,
resolveModelCapabilities: ({ model }) =>
DASHSCOPE_WAN_VIDEO_CATALOG_BY_MODEL[model]?.capabilities,
isConfigured: (ctx) => {
const baseUrl = ctx.cfg?.models?.providers?.[options.providerId]?.baseUrl;
if (options.credentialPolicy?.acceptsBaseUrl?.(baseUrl) === false) {
@@ -2,7 +2,6 @@
import { describe, expect, it } from "vitest";
import {
listSupportedVideoGenerationModes,
resolveVideoGenerationMode,
resolveVideoGenerationModeCapabilities,
} from "./capabilities.js";
import type { VideoGenerationProvider } from "./types.js";
@@ -64,7 +63,6 @@ describe("video-generation capabilities", () => {
supportsAudio: true,
});
expect(resolveVideoGenerationMode({ inputImageCount: 1, inputVideoCount: 1 })).toBeNull();
expect(
resolveVideoGenerationModeCapabilities({
provider,
@@ -91,7 +89,6 @@ describe("video-generation capabilities", () => {
},
});
expect(resolveVideoGenerationMode({ inputImageCount: 1, inputVideoCount: 1 })).toBeNull();
expect(
resolveVideoGenerationModeCapabilities({
provider,
+1 -1
View File
@@ -8,7 +8,7 @@ import type {
// Video generation mode helpers derive the active mode from reference inputs
// and expose the provider capability block that applies to that mode/model.
export function resolveVideoGenerationMode(params: {
function resolveVideoGenerationMode(params: {
inputImageCount?: number;
inputVideoCount?: number;
}): VideoGenerationMode | null {
@@ -2,9 +2,14 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.js";
import {
buildReferenceInputCapabilityFailure,
buildVideoGenerationCapabilityFailure,
resolveProviderWithModelCapabilities,
} from "./capability-overlays.js";
import {
DASHSCOPE_WAN_VIDEO_CAPABILITIES,
DASHSCOPE_WAN_VIDEO_CATALOG_BY_MODEL,
DASHSCOPE_WAN_VIDEO_MODELS,
} from "./dashscope-compatible.js";
import type { VideoGenerationProvider, VideoGenerationProviderCapabilities } from "./types.js";
async function resolveCapabilitiesWithOverlay(
@@ -156,7 +161,7 @@ describe("video-generation capability overlays", () => {
});
expect(
buildReferenceInputCapabilityFailure({
buildVideoGenerationCapabilityFailure({
providerId: "openrouter",
model: "minimax/hailuo-2.3",
provider: activeProvider,
@@ -166,4 +171,50 @@ describe("video-generation capability overlays", () => {
}),
).toMatch(/supports at most 1 reference image\(s\), 2 requested/);
});
it.each(DASHSCOPE_WAN_VIDEO_MODELS)(
"enforces bundled Wan catalog modes before provider I/O for %s",
async (model) => {
const provider: VideoGenerationProvider = {
id: "qwen",
capabilities: DASHSCOPE_WAN_VIDEO_CAPABILITIES,
catalogByModel: DASHSCOPE_WAN_VIDEO_CATALOG_BY_MODEL,
resolveModelCapabilities: ({ model: selectedModel }) =>
DASHSCOPE_WAN_VIDEO_CATALOG_BY_MODEL[selectedModel]?.capabilities,
async generateVideo() {
throw new Error("should not be called");
},
};
const activeProvider = await resolveProviderWithModelCapabilities({
provider,
providerId: "qwen",
model,
cfg: {} as OpenClawConfig,
log: { debug: vi.fn() },
});
const declaredModes = DASHSCOPE_WAN_VIDEO_CATALOG_BY_MODEL[model]?.modes ?? [];
const requests = [
{ mode: "generate", inputImageCount: 0, inputVideoCount: 0 },
{ mode: "imageToVideo", inputImageCount: 1, inputVideoCount: 0 },
{ mode: "videoToVideo", inputImageCount: 0, inputVideoCount: 1 },
] as const;
for (const request of requests) {
const failure = buildVideoGenerationCapabilityFailure({
providerId: "qwen",
model,
provider: activeProvider,
inputImageCount: request.inputImageCount,
inputVideoCount: request.inputVideoCount,
inputAudioCount: 0,
});
if (declaredModes.includes(request.mode)) {
expect(failure, `${model}:${request.mode}`).toBeUndefined();
} else {
expect(failure, `${model}:${request.mode}`).toMatch(/does not support/u);
}
}
},
);
});
+12 -2
View File
@@ -17,7 +17,7 @@ function isVideoGenerationTransformCapabilities(
return Boolean(capabilities && "enabled" in capabilities);
}
export function buildReferenceInputCapabilityFailure(params: {
export function buildVideoGenerationCapabilityFailure(params: {
providerId: string;
model: string;
provider: VideoGenerationProvider;
@@ -27,12 +27,22 @@ export function buildReferenceInputCapabilityFailure(params: {
}): string | undefined {
const { providerId, model, provider, inputImageCount, inputVideoCount, inputAudioCount } = params;
const label = `${providerId}/${model}`;
const { capabilities } = resolveVideoGenerationModeCapabilities({
const { mode, capabilities } = resolveVideoGenerationModeCapabilities({
provider,
model,
inputImageCount,
inputVideoCount,
});
const catalogModes = provider.catalogByModel?.[model]?.modes;
if (mode && catalogModes && !catalogModes.includes(mode)) {
const modeLabel =
mode === "generate"
? "text-to-video generation"
: mode === "imageToVideo"
? "image-to-video generation"
: "video-to-video generation";
return `${label} does not support ${modeLabel}; skipping`;
}
if (inputImageCount > 0 || inputVideoCount > 0) {
// Reference inputs must be explicitly supported. Falling back to a provider
@@ -1,6 +1,27 @@
// DashScope-compatible download regressions: body idle after headers.
// DashScope-compatible lifecycle, task status, and generated-video regressions.
import { describe, expect, it, vi } from "vitest";
import { downloadDashscopeGeneratedVideos } from "./dashscope-compatible.js";
import {
DASHSCOPE_WAN_VIDEO_CATALOG_BY_MODEL,
buildDashscopeVideoGenerationInput,
buildDashscopeVideoGenerationParameters,
downloadDashscopeGeneratedVideos,
pollDashscopeVideoTaskUntilComplete,
runDashscopeVideoGenerationTask,
} from "./dashscope-compatible.js";
const providerLabels = ["Qwen", "Alibaba Wan"] as const;
const invalidGeneratedVideos = [
{ name: "JSON error", contentType: "application/json", body: '{"error":"not a video"}' },
{
name: "problem JSON error",
contentType: "application/problem+json",
body: '{"title":"not a video"}',
},
{ name: "HTML error", contentType: "text/html; charset=utf-8", body: "<html>error</html>" },
{ name: "image", contentType: "image/png", body: "image-bytes" },
{ name: "audio", contentType: "audio/mp4", body: "audio-bytes" },
] as const;
function neverChunkingVideoResponse(): Response {
return new Response(
@@ -16,7 +37,244 @@ function neverChunkingVideoResponse(): Response {
);
}
describe("DashScope Wan request contracts", () => {
it("advertises only the modes supported by each bundled Wan model", () => {
expect(DASHSCOPE_WAN_VIDEO_CATALOG_BY_MODEL["wan2.6-t2v"]?.modes).toEqual(["generate"]);
expect(
DASHSCOPE_WAN_VIDEO_CATALOG_BY_MODEL["wan2.6-t2v"]?.capabilities?.generate
?.supportsAspectRatio,
).toBe(true);
expect(DASHSCOPE_WAN_VIDEO_CATALOG_BY_MODEL["wan2.6-i2v"]?.modes).toEqual(["imageToVideo"]);
expect(DASHSCOPE_WAN_VIDEO_CATALOG_BY_MODEL["wan2.6-r2v"]?.modes).toEqual([
"imageToVideo",
"videoToVideo",
]);
expect(
DASHSCOPE_WAN_VIDEO_CATALOG_BY_MODEL["wan2.7-r2v"]?.capabilities?.videoToVideo?.supportsAudio,
).toBe(false);
expect(
DASHSCOPE_WAN_VIDEO_CATALOG_BY_MODEL["wan2.7-r2v"]?.capabilities?.videoToVideo
?.supportsAspectRatio,
).toBe(true);
});
it("builds mode-specific image and reference inputs", () => {
expect(
buildDashscopeVideoGenerationInput({
providerLabel: "Qwen",
req: {
provider: "qwen",
model: "wan2.6-i2v",
prompt: "animate",
cfg: {},
inputImages: [{ url: "https://example.com/frame.png" }],
},
}),
).toEqual({ prompt: "animate", img_url: "https://example.com/frame.png" });
expect(
buildDashscopeVideoGenerationInput({
providerLabel: "Qwen",
req: {
provider: "qwen",
model: "wan2.6-r2v",
prompt: "character1 waves",
cfg: {},
inputImages: [{ url: "https://example.com/character.png" }],
},
}),
).toEqual({
prompt: "character1 waves",
reference_urls: ["https://example.com/character.png"],
});
expect(
buildDashscopeVideoGenerationInput({
providerLabel: "Alibaba Wan",
req: {
provider: "alibaba",
model: "wan2.7-r2v",
prompt: "Image 1 greets Video 1",
cfg: {},
inputImages: [{ url: "https://example.com/character.png" }],
inputVideos: [{ url: "https://example.com/action.mp4", role: "reference_video" }],
},
}),
).toEqual({
prompt: "Image 1 greets Video 1",
media: [
{ type: "reference_image", url: "https://example.com/character.png" },
{ type: "reference_video", url: "https://example.com/action.mp4" },
],
});
});
it("rejects model and reference mode mismatches before submission", () => {
expect(() =>
buildDashscopeVideoGenerationInput({
providerLabel: "Qwen",
req: {
provider: "qwen",
model: "wan2.6-t2v",
prompt: "animate",
cfg: {},
inputImages: [{ url: "https://example.com/frame.png" }],
},
}),
).toThrow(/text-to-video.*does not accept reference media/u);
});
it.each([
{
name: "Wan 2.6 text-to-video",
req: {
provider: "qwen",
model: "wan2.6-t2v",
prompt: "video",
cfg: {},
resolution: "720P",
aspectRatio: "9:16",
audio: false,
},
expected: { size: "720*1280", audio: false },
},
{
name: "Wan 2.6 image-to-video",
req: {
provider: "qwen",
model: "wan2.6-i2v",
prompt: "video",
cfg: {},
resolution: "1080P",
inputImages: [{ url: "https://example.com/frame.png" }],
audio: true,
},
expected: { resolution: "1080P", audio: true },
},
{
name: "Wan 2.7 reference-to-video",
req: {
provider: "alibaba",
model: "wan2.7-r2v",
prompt: "video",
cfg: {},
size: "1920x1080",
inputVideos: [{ url: "https://example.com/reference.mp4" }],
audio: false,
},
expected: { resolution: "1080P", ratio: "16:9" },
},
])("builds documented $name parameters", ({ req, expected }) => {
expect(buildDashscopeVideoGenerationParameters(req)).toEqual(expected);
});
});
describe("downloadDashscopeGeneratedVideos", () => {
it.each(
providerLabels.flatMap((providerLabel) =>
invalidGeneratedVideos.map(({ name, contentType, body }) => ({
providerLabel,
name,
contentType,
body,
})),
),
)("rejects $providerLabel $name responses instead of returning a video", async (invalid) => {
const fetchFn = vi.fn(
async () =>
new Response(invalid.body, {
status: 200,
headers: { "content-type": invalid.contentType },
}),
);
await expect(
downloadDashscopeGeneratedVideos({
providerLabel: invalid.providerLabel,
urls: ["https://example.com/not-video.mp4"],
timeoutMs: 5_000,
fetchFn: fetchFn as typeof fetch,
maxBytes: 10 * 1024 * 1024,
}),
).rejects.toThrow(
`${invalid.providerLabel} generated video download: malformed video response`,
);
expect(fetchFn).toHaveBeenCalledOnce();
});
it.each(providerLabels)(
"cancels unread invalid %s video bodies before releasing them",
async (providerLabel) => {
const cancellationOrder: string[] = [];
const cancelBody = vi.fn(async () => {
cancellationOrder.push("cancel-started");
await Promise.resolve();
cancellationOrder.push("cancel-completed");
});
const fetchFn = vi.fn(
async () =>
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"error":"still streaming"}'));
},
cancel: cancelBody,
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
),
);
await expect(
downloadDashscopeGeneratedVideos({
providerLabel,
urls: ["https://example.com/still-streaming.mp4"],
timeoutMs: 80,
fetchFn: fetchFn as typeof fetch,
maxBytes: 10 * 1024 * 1024,
}),
).rejects.toThrow(`${providerLabel} generated video download: malformed video response`);
expect(cancelBody).toHaveBeenCalledOnce();
expect(cancellationOrder).toEqual(["cancel-started", "cancel-completed"]);
},
);
it.each([
{ contentType: "video/mp4", expectedMimeType: "video/mp4" },
{ contentType: "VIDEO/MP4; codecs=avc1", expectedMimeType: "VIDEO/MP4; codecs=avc1" },
{ contentType: "application/octet-stream", expectedMimeType: "application/octet-stream" },
{ contentType: undefined, expectedMimeType: "video/mp4" },
])(
"preserves valid generated video content type $contentType",
async ({ contentType, expectedMimeType }) => {
const fetchFn = vi.fn(
async () =>
new Response(new TextEncoder().encode("mp4-bytes"), {
status: 200,
...(contentType ? { headers: { "content-type": contentType } } : {}),
}),
);
const videos = await downloadDashscopeGeneratedVideos({
providerLabel: "Alibaba Wan",
urls: ["https://example.com/video.mp4"],
timeoutMs: 5_000,
fetchFn: fetchFn as typeof fetch,
maxBytes: 10 * 1024 * 1024,
});
expect(videos[0]).toMatchObject({
buffer: Buffer.from("mp4-bytes"),
fileName: "video-1.mp4",
mimeType: expectedMimeType,
});
},
);
it("aborts a stalled generated video body via chunk idle timeout", async () => {
const fetchFn = vi.fn(async () => neverChunkingVideoResponse());
const timeoutMs = 80;
@@ -141,3 +399,116 @@ describe("downloadDashscopeGeneratedVideos", () => {
}
});
});
describe("pollDashscopeVideoTaskUntilComplete", () => {
it.each(providerLabels)(
"immediately rejects documented UNKNOWN %s tasks",
async (providerLabel) => {
const fetchFn = vi.fn(
async () =>
new Response(JSON.stringify({ output: { task_status: " UNKNOWN " } }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
await expect(
pollDashscopeVideoTaskUntilComplete({
providerLabel,
taskId: "expired-task",
headers: new Headers(),
timeoutMs: 80,
fetchFn: fetchFn as typeof fetch,
baseUrl: "https://example.com",
}),
).rejects.toThrow(
`${providerLabel} video generation task expired-task is unknown or expired`,
);
expect(fetchFn).toHaveBeenCalledOnce();
},
);
it.each(providerLabels)(
"includes the provider reason when an UNKNOWN %s task expires",
async (providerLabel) => {
const fetchFn = vi.fn(
async () =>
new Response(
JSON.stringify({ output: { task_status: "UNKNOWN", message: "task was deleted" } }),
{
status: 200,
headers: { "content-type": "application/json" },
},
),
);
await expect(
pollDashscopeVideoTaskUntilComplete({
providerLabel,
taskId: "deleted-task",
headers: new Headers(),
timeoutMs: 80,
fetchFn: fetchFn as typeof fetch,
baseUrl: "https://example.com",
}),
).rejects.toThrow(
`${providerLabel} video generation task deleted-task is unknown or expired: task was deleted`,
);
expect(fetchFn).toHaveBeenCalledOnce();
},
);
});
describe("runDashscopeVideoGenerationTask", () => {
it("releases the submission request timeout before polling the task", async () => {
vi.useFakeTimers();
try {
let submissionTimerCount: number | undefined;
let pollTimerCount: number | undefined;
const fetchFn = vi.fn(async (url: string | URL | Request) => {
const requestUrl = url instanceof Request ? url.url : String(url);
if (requestUrl.includes("/video-synthesis")) {
submissionTimerCount = vi.getTimerCount();
return new Response(JSON.stringify({ output: { task_id: "task-123" } }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (requestUrl.includes("/tasks/task-123")) {
pollTimerCount = vi.getTimerCount();
return new Response(
JSON.stringify({
output: { task_status: "SUCCEEDED", video_url: "https://example.com/result.mp4" },
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
);
}
return new Response(new TextEncoder().encode("mp4-bytes"), {
status: 200,
headers: { "content-type": "video/mp4" },
});
});
await runDashscopeVideoGenerationTask({
providerLabel: "Qwen",
model: "wan2.6-t2v",
req: { provider: "qwen", model: "wan2.6-t2v", prompt: "video", cfg: {} },
url: "https://example.com/video-synthesis",
headers: new Headers(),
baseUrl: "https://example.com",
timeoutMs: 5_000,
fetchFn: fetchFn as typeof fetch,
});
expect(submissionTimerCount).toBeGreaterThan(0);
expect(pollTimerCount).toBe(submissionTimerCount);
} finally {
vi.useRealTimers();
}
});
});
+287 -61
View File
@@ -1,3 +1,4 @@
import { kindFromMime, normalizeMimeType } from "@openclaw/media-core/mime";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
// DashScope-compatible video provider adapts DashScope-style generation APIs.
@@ -17,6 +18,7 @@ import {
} from "../plugin-sdk/provider-http.js";
import type {
GeneratedVideoAsset,
VideoGenerationCatalogModelEntry,
VideoGenerationProviderCapabilities,
VideoGenerationRequest,
VideoGenerationResult,
@@ -33,10 +35,48 @@ export const DASHSCOPE_WAN_VIDEO_MODELS = [
"wan2.6-r2v-flash",
"wan2.7-r2v",
];
const DASHSCOPE_WAN_VIDEO_RESOLUTIONS = ["720P", "1080P"] as const;
const DASHSCOPE_WAN_VIDEO_ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4"] as const;
const DASHSCOPE_WAN_LONG_VIDEO_DURATIONS = [
2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
] as const;
const DASHSCOPE_WAN_SHORT_VIDEO_DURATIONS = [2, 3, 4, 5, 6, 7, 8, 9, 10] as const;
const DASHSCOPE_WAN_VIDEO_SIZE_BY_GEOMETRY: Readonly<
Record<string, Readonly<Record<string, string>>>
> = {
"480P": {
"16:9": "832*480",
"9:16": "480*832",
"1:1": "624*624",
},
"720P": {
"16:9": "1280*720",
"9:16": "720*1280",
"1:1": "960*960",
"4:3": "1088*832",
"3:4": "832*1088",
},
"1080P": {
"16:9": "1920*1080",
"9:16": "1080*1920",
"1:1": "1440*1440",
"4:3": "1632*1248",
"3:4": "1248*1632",
},
};
const DASHSCOPE_WAN_VIDEO_SIZES = DASHSCOPE_WAN_VIDEO_RESOLUTIONS.flatMap((resolution) =>
Object.values(DASHSCOPE_WAN_VIDEO_SIZE_BY_GEOMETRY[resolution] ?? {}),
);
export const DASHSCOPE_WAN_VIDEO_CAPABILITIES = {
generate: {
maxVideos: 1,
maxDurationSeconds: 10,
maxDurationSeconds: 15,
supportedDurationSeconds: DASHSCOPE_WAN_LONG_VIDEO_DURATIONS,
sizes: DASHSCOPE_WAN_VIDEO_SIZES,
aspectRatios: DASHSCOPE_WAN_VIDEO_ASPECT_RATIOS,
resolutions: DASHSCOPE_WAN_VIDEO_RESOLUTIONS,
supportsSize: true,
supportsAspectRatio: true,
supportsResolution: true,
@@ -47,9 +87,11 @@ export const DASHSCOPE_WAN_VIDEO_CAPABILITIES = {
enabled: true,
maxVideos: 1,
maxInputImages: 1,
maxDurationSeconds: 10,
supportsSize: true,
supportsAspectRatio: true,
maxDurationSeconds: 15,
supportedDurationSeconds: DASHSCOPE_WAN_LONG_VIDEO_DURATIONS,
resolutions: DASHSCOPE_WAN_VIDEO_RESOLUTIONS,
supportsSize: false,
supportsAspectRatio: false,
supportsResolution: true,
supportsAudio: true,
supportsWatermark: true,
@@ -57,8 +99,13 @@ export const DASHSCOPE_WAN_VIDEO_CAPABILITIES = {
videoToVideo: {
enabled: true,
maxVideos: 1,
maxInputVideos: 4,
maxInputImages: 5,
maxInputVideos: 3,
maxDurationSeconds: 10,
supportedDurationSeconds: DASHSCOPE_WAN_SHORT_VIDEO_DURATIONS,
sizes: DASHSCOPE_WAN_VIDEO_SIZES,
aspectRatios: DASHSCOPE_WAN_VIDEO_ASPECT_RATIOS,
resolutions: DASHSCOPE_WAN_VIDEO_RESOLUTIONS,
supportsSize: true,
supportsAspectRatio: true,
supportsResolution: true,
@@ -67,6 +114,61 @@ export const DASHSCOPE_WAN_VIDEO_CAPABILITIES = {
},
} satisfies VideoGenerationProviderCapabilities;
const disabledVideoTransform = { enabled: false } as const;
const dashscopeWanR2vCapabilities = {
...DASHSCOPE_WAN_VIDEO_CAPABILITIES,
imageToVideo: {
...DASHSCOPE_WAN_VIDEO_CAPABILITIES.videoToVideo,
enabled: true,
},
};
// One model catalog drives both agent-visible modes and request-local runtime
// capability overlays, so the tool cannot advertise a mode the model rejects.
export const DASHSCOPE_WAN_VIDEO_CATALOG_BY_MODEL: Readonly<
Record<string, VideoGenerationCatalogModelEntry>
> = {
"wan2.6-t2v": {
modes: ["generate"],
capabilities: {
generate: DASHSCOPE_WAN_VIDEO_CAPABILITIES.generate,
imageToVideo: disabledVideoTransform,
videoToVideo: disabledVideoTransform,
},
},
"wan2.6-i2v": {
modes: ["imageToVideo"],
capabilities: {
imageToVideo: DASHSCOPE_WAN_VIDEO_CAPABILITIES.imageToVideo,
videoToVideo: disabledVideoTransform,
},
},
"wan2.6-r2v": {
modes: ["imageToVideo", "videoToVideo"],
capabilities: dashscopeWanR2vCapabilities,
},
"wan2.6-r2v-flash": {
modes: ["imageToVideo", "videoToVideo"],
capabilities: dashscopeWanR2vCapabilities,
},
"wan2.7-r2v": {
modes: ["imageToVideo", "videoToVideo"],
capabilities: {
...dashscopeWanR2vCapabilities,
imageToVideo: {
...dashscopeWanR2vCapabilities.imageToVideo,
supportsAspectRatio: true,
supportsAudio: false,
},
videoToVideo: {
...dashscopeWanR2vCapabilities.videoToVideo,
supportsAspectRatio: true,
supportsAudio: false,
},
},
},
};
export const DEFAULT_VIDEO_GENERATION_DURATION_SECONDS = 5;
export const DEFAULT_VIDEO_GENERATION_TIMEOUT_MS = 120_000;
export const DEFAULT_VIDEO_RESOLUTION_TO_SIZE: Record<string, string> = {
@@ -97,12 +199,62 @@ export type DashscopeVideoGenerationResponse = {
message?: string;
};
type DashscopeWanVideoMode = "t2v" | "i2v" | "r2v";
function resolveDashscopeWanVideoMode(req: VideoGenerationRequest): DashscopeWanVideoMode {
const model = req.model.trim().toLowerCase();
if (model.includes("-i2v")) {
return "i2v";
}
if (model.includes("-r2v")) {
return "r2v";
}
if (model.includes("-t2v")) {
return "t2v";
}
if ((req.inputVideos?.length ?? 0) > 0 || (req.inputImages?.length ?? 0) > 1) {
return "r2v";
}
return (req.inputImages?.length ?? 0) === 1 ? "i2v" : "t2v";
}
function isDashscopeWan27Model(model: string): boolean {
return model.trim().toLowerCase().startsWith("wan2.7");
}
function assertDashscopeWanVideoInputs(params: {
providerLabel: string;
req: VideoGenerationRequest;
mode: DashscopeWanVideoMode;
}): void {
const imageCount = params.req.inputImages?.length ?? 0;
const videoCount = params.req.inputVideos?.length ?? 0;
if (params.mode === "t2v" && imageCount + videoCount > 0) {
throw new Error(
`${params.providerLabel} model ${params.req.model} is text-to-video and does not accept reference media; use an i2v or r2v Wan model.`,
);
}
if (params.mode === "i2v" && (imageCount !== 1 || videoCount > 0)) {
throw new Error(
`${params.providerLabel} model ${params.req.model} requires exactly one reference image and no reference videos.`,
);
}
if (params.mode === "r2v") {
const total = imageCount + videoCount;
if (total === 0 || total > 5 || videoCount > 3) {
throw new Error(
`${params.providerLabel} model ${params.req.model} requires 1-5 reference images/videos, with at most 3 videos.`,
);
}
}
}
export function buildDashscopeVideoGenerationInput(params: {
providerLabel: string;
req: VideoGenerationRequest;
}): Record<string, unknown> {
const unsupported = [...(params.req.inputImages ?? []), ...(params.req.inputVideos ?? [])].some(
(asset) => !asset.url?.trim() && asset.buffer,
(asset) => !asset.url?.trim(),
);
// DashScope accepts remote references in this path; buffer uploads require a
// different provider-specific flow, so fail before silently dropping refs.
@@ -114,17 +266,26 @@ export function buildDashscopeVideoGenerationInput(params: {
const input: Record<string, unknown> = {
prompt: params.req.prompt,
};
const mode = resolveDashscopeWanVideoMode(params.req);
assertDashscopeWanVideoInputs({ ...params, mode });
const referenceUrls = resolveVideoGenerationReferenceUrls(
params.req.inputImages,
params.req.inputVideos,
);
if (
referenceUrls.length === 1 &&
(params.req.inputImages?.length ?? 0) === 1 &&
!params.req.inputVideos?.length
) {
if (mode === "i2v") {
input.img_url = referenceUrls[0];
} else if (referenceUrls.length > 0) {
} else if (mode === "r2v" && isDashscopeWan27Model(params.req.model)) {
input.media = [
...(params.req.inputImages ?? []).map((asset) => ({
type: asset.role?.trim() || "reference_image",
url: asset.url?.trim() ?? "",
})),
...(params.req.inputVideos ?? []).map((asset) => ({
type: asset.role?.trim() || "reference_video",
url: asset.url?.trim() ?? "",
})),
];
} else if (mode === "r2v") {
input.reference_urls = referenceUrls;
}
return input;
@@ -144,18 +305,42 @@ export function buildDashscopeVideoGenerationParameters(
resolutionToSize: Record<string, string> = DEFAULT_VIDEO_RESOLUTION_TO_SIZE,
): Record<string, unknown> | undefined {
const parameters: Record<string, unknown> = {};
const size = req.size?.trim() || (req.resolution ? resolutionToSize[req.resolution] : undefined);
if (size) {
parameters.size = size;
}
if (req.aspectRatio?.trim()) {
parameters.aspect_ratio = req.aspectRatio.trim();
const mode = resolveDashscopeWanVideoMode(req);
const wan27 = isDashscopeWan27Model(req.model);
const requestedSize = req.size?.trim();
const sizeGeometry = requestedSize
? resolveDashscopeWanVideoSizeGeometry(requestedSize)
: undefined;
// Wan 2.6 I2V and all Wan 2.7 models use resolution tiers. Wan 2.6 T2V/R2V
// use exact dimensions in `size`; folding these together causes API rejection.
if (wan27 || mode === "i2v") {
const resolution = req.resolution?.trim() || sizeGeometry?.resolution;
if (resolution) {
parameters.resolution = resolution;
}
if (wan27 && mode !== "i2v") {
const ratio = req.aspectRatio?.trim() || sizeGeometry?.aspectRatio;
if (ratio) {
parameters.ratio = ratio;
}
}
} else {
const ratio = req.aspectRatio?.trim() || "16:9";
const size =
requestedSize ||
(req.resolution
? (DASHSCOPE_WAN_VIDEO_SIZE_BY_GEOMETRY[req.resolution]?.[ratio] ??
resolutionToSize[req.resolution])
: undefined);
if (size) {
parameters.size = size;
}
}
if (typeof req.durationSeconds === "number" && Number.isFinite(req.durationSeconds)) {
parameters.duration = Math.max(1, Math.round(req.durationSeconds));
}
if (typeof req.audio === "boolean") {
parameters.enable_audio = req.audio;
if (typeof req.audio === "boolean" && !wan27) {
parameters.audio = req.audio;
}
if (typeof req.watermark === "boolean") {
parameters.watermark = req.watermark;
@@ -163,6 +348,20 @@ export function buildDashscopeVideoGenerationParameters(
return Object.keys(parameters).length > 0 ? parameters : undefined;
}
function resolveDashscopeWanVideoSizeGeometry(
size: string,
): { resolution: string; aspectRatio: string } | undefined {
const normalizedSize = size.trim().toLowerCase().replace("x", "*");
for (const [resolution, sizes] of Object.entries(DASHSCOPE_WAN_VIDEO_SIZE_BY_GEOMETRY)) {
for (const [aspectRatio, candidate] of Object.entries(sizes)) {
if (candidate.toLowerCase() === normalizedSize) {
return { resolution, aspectRatio };
}
}
}
return undefined;
}
// DashScope may return videos in results[] or a top-level output.video_url.
// De-dupe so downstream downloads produce one asset per unique URL.
export function extractDashscopeVideoUrls(payload: DashscopeVideoGenerationResponse): string[] {
@@ -232,6 +431,16 @@ export async function pollDashscopeVideoTaskUntilComplete(params: {
if (status === "SUCCEEDED") {
return payload;
}
// DashScope reports missing or expired task IDs as UNKNOWN, not PENDING;
// waiting cannot recover them and hides the actionable provider outcome.
if (status === "UNKNOWN") {
const reason = payload.output?.message?.trim() || payload.message?.trim();
throw new Error(
`${params.providerLabel} video generation task ${params.taskId} is unknown or expired${
reason ? `: ${reason}` : ""
}`,
);
}
// Terminal failure statuses carry provider messages; nonterminal statuses
// continue until the shared operation deadline or max poll attempts wins.
if (status === "FAILED" || status === "CANCELED") {
@@ -292,55 +501,55 @@ export async function runDashscopeVideoGenerationTask(params: {
dispatcherPolicy: params.dispatcherPolicy,
});
let submitted: DashscopeVideoGenerationResponse;
try {
await assertOkOrThrowHttpError(response, `${params.providerLabel} video generation failed`);
const submitted = await readProviderJsonResponse<DashscopeVideoGenerationResponse>(
submitted = await readProviderJsonResponse<DashscopeVideoGenerationResponse>(
response,
`${params.providerLabel} video generation`,
);
const taskId = submitted.output?.task_id?.trim();
if (!taskId) {
throw new Error(`${params.providerLabel} video generation response missing task_id`);
}
const completed = await pollDashscopeVideoTaskUntilComplete({
providerLabel: params.providerLabel,
taskId,
headers: params.headers,
timeoutMs: resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs }),
fetchFn: params.fetchFn,
baseUrl: params.baseUrl,
allowPrivateNetwork: params.allowPrivateNetwork,
dispatcherPolicy: params.dispatcherPolicy,
defaultTimeoutMs,
});
const urls = extractDashscopeVideoUrls(completed);
if (urls.length === 0) {
throw new Error(
`${params.providerLabel} video generation completed without output video URLs`,
);
}
const videos = await downloadDashscopeGeneratedVideos({
providerLabel: params.providerLabel,
urls,
timeoutMs: createProviderOperationTimeoutResolver({ deadline, defaultTimeoutMs }),
fetchFn: params.fetchFn,
allowPrivateNetwork: params.allowPrivateNetwork,
dispatcherPolicy: params.dispatcherPolicy,
defaultTimeoutMs,
maxBytes: resolveGeneratedMediaMaxBytes(params.req.cfg, "video"),
});
return {
videos,
model: params.model,
metadata: {
requestId: submitted.request_id,
taskId,
taskStatus: completed.output?.task_status,
},
};
} finally {
await release();
}
const taskId = submitted.output?.task_id?.trim();
if (!taskId) {
throw new Error(`${params.providerLabel} video generation response missing task_id`);
}
const completed = await pollDashscopeVideoTaskUntilComplete({
providerLabel: params.providerLabel,
taskId,
headers: params.headers,
timeoutMs: resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs }),
fetchFn: params.fetchFn,
baseUrl: params.baseUrl,
allowPrivateNetwork: params.allowPrivateNetwork,
dispatcherPolicy: params.dispatcherPolicy,
defaultTimeoutMs,
});
const urls = extractDashscopeVideoUrls(completed);
if (urls.length === 0) {
throw new Error(`${params.providerLabel} video generation completed without output video URLs`);
}
const videos = await downloadDashscopeGeneratedVideos({
providerLabel: params.providerLabel,
urls,
timeoutMs: createProviderOperationTimeoutResolver({ deadline, defaultTimeoutMs }),
fetchFn: params.fetchFn,
allowPrivateNetwork: params.allowPrivateNetwork,
dispatcherPolicy: params.dispatcherPolicy,
defaultTimeoutMs,
maxBytes: resolveGeneratedMediaMaxBytes(params.req.cfg, "video"),
});
return {
videos,
model: params.model,
metadata: {
requestId: submitted.request_id,
taskId,
taskStatus: completed.output?.task_status,
},
};
}
function resolveDashscopeVideoDownloadTimeoutMs(
@@ -374,6 +583,7 @@ export async function downloadDashscopeGeneratedVideos(params: {
maxBytes: number;
}): Promise<GeneratedVideoAsset[]> {
const videos: GeneratedVideoAsset[] = [];
const downloadLabel = `${params.providerLabel} generated video download`;
for (const [index, url] of params.urls.entries()) {
const result = await executeProviderOperationWithRetry({
provider: params.providerLabel,
@@ -409,6 +619,22 @@ export async function downloadDashscopeGeneratedVideos(params: {
let buffer: Buffer;
let mimeType: string;
try {
try {
const contentType = normalizeMimeType(result.response.headers.get("content-type"));
if (
contentType &&
contentType !== "application/octet-stream" &&
kindFromMime(contentType) !== "video"
) {
throw new Error(`${downloadLabel}: malformed video response`);
}
} catch (error) {
// Header rejection happens before the body reader, so explicitly cancel
// unread streams before their guarded dispatcher and timeout release.
await result.response.body?.cancel(error).catch(() => undefined);
throw error;
}
// Re-resolve after headers so the body uses the remaining operation budget.
let downloadTimeoutMs: number;
try {
@@ -9,9 +9,20 @@ import {
redactLiveApiKey,
resolveConfiguredLiveVideoModels,
resolveLiveVideoAuthStore,
resolveLiveVideoResolution,
} from "./live-test-helpers.js";
describe("video-generation live-test helpers", () => {
it.each([
["alibaba", "alibaba/wan2.6-t2v", "720P"],
["qwen", "qwen/wan2.6-t2v", "720P"],
["minimax", "minimax/MiniMax-Hailuo-2.3", "768P"],
["pixverse", "pixverse/v6", "540P"],
["google", "google/veo-3.1-fast-generate-preview", "480P"],
] as const)("uses a supported %s live resolution", (providerId, modelRef, expected) => {
expect(resolveLiveVideoResolution({ providerId, modelRef })).toBe(expected);
});
it("parses provider filters and treats empty/all as unfiltered", () => {
expect(parseCsvFilter()).toBeNull();
expect(parseCsvFilter("all")).toBeNull();
@@ -50,6 +50,9 @@ export function resolveLiveVideoResolution(params: {
if (providerId === "pixverse") {
return "540P";
}
if (providerId === "alibaba" || providerId === "qwen") {
return "720P";
}
return "480P";
}
+138
View File
@@ -1,6 +1,12 @@
// Video generation runtime tests cover provider execution and fallback behavior.
import { beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.js";
import {
DASHSCOPE_WAN_VIDEO_CAPABILITIES,
DASHSCOPE_WAN_VIDEO_CATALOG_BY_MODEL,
DASHSCOPE_WAN_VIDEO_MODELS,
buildDashscopeVideoGenerationParameters,
} from "./dashscope-compatible.js";
import {
generateVideo,
listRuntimeVideoGenerationProviders,
@@ -782,6 +788,138 @@ describe("video-generation runtime", () => {
expect(attempt.error).toMatch(/supports at most 1 reference image\(s\), 2 requested/);
});
it("falls back when the primary model catalog rejects the requested mode", async () => {
const seenModels: string[] = [];
providers = [
{
id: "qwen",
defaultModel: "wan2.6-t2v",
models: [...DASHSCOPE_WAN_VIDEO_MODELS],
capabilities: DASHSCOPE_WAN_VIDEO_CAPABILITIES,
catalogByModel: DASHSCOPE_WAN_VIDEO_CATALOG_BY_MODEL,
resolveModelCapabilities: ({ model }) =>
DASHSCOPE_WAN_VIDEO_CATALOG_BY_MODEL[model]?.capabilities,
isConfigured: () => true,
async generateVideo(req) {
seenModels.push(req.model);
return {
videos: [{ buffer: Buffer.from("mp4-bytes"), mimeType: "video/mp4" }],
model: req.model,
};
},
},
];
const result = await runGenerateVideo({
cfg: {
agents: {
defaults: {
videoGenerationModel: {
primary: "qwen/wan2.6-t2v",
fallbacks: ["qwen/wan2.6-i2v"],
},
},
},
} as OpenClawConfig,
prompt: "animate the reference",
inputImages: [{ url: "https://example.com/reference.png" }],
});
expect(seenModels).toEqual(["wan2.6-i2v"]);
expect(result.model).toBe("wan2.6-i2v");
expect(result.attempts).toHaveLength(1);
expect(requireAttempt(result, 0).error).toMatch(/does not support image-to-video generation/u);
});
it("applies model-specific R2V reference limits during fallback-aware selection", async () => {
let seenImageCount = 0;
providers = [
{
id: "qwen",
defaultModel: "wan2.6-t2v",
models: [...DASHSCOPE_WAN_VIDEO_MODELS],
capabilities: DASHSCOPE_WAN_VIDEO_CAPABILITIES,
catalogByModel: DASHSCOPE_WAN_VIDEO_CATALOG_BY_MODEL,
resolveModelCapabilities: ({ model }) =>
DASHSCOPE_WAN_VIDEO_CATALOG_BY_MODEL[model]?.capabilities,
async generateVideo(req) {
seenImageCount = req.inputImages?.length ?? 0;
return {
videos: [{ buffer: Buffer.from("mp4-bytes"), mimeType: "video/mp4" }],
model: req.model,
};
},
},
];
const result = await runGenerateVideo({
cfg: {
agents: {
defaults: {
videoGenerationModel: { primary: "qwen/wan2.6-r2v" },
},
},
} as OpenClawConfig,
prompt: "animate all references",
inputImages: Array.from({ length: 5 }, (_, index) => ({
url: `https://example.com/reference-${index}.png`,
})),
});
expect(seenImageCount).toBe(5);
expect(result.model).toBe("wan2.6-r2v");
expect(result.attempts).toEqual([]);
});
it("preserves Wan 2.6 resolution and aspect ratio until adapter mapping", async () => {
let seenRequest:
| { size?: string; resolution?: string; aspectRatio?: string; parameters?: unknown }
| undefined;
providers = [
{
id: "qwen",
defaultModel: "wan2.6-t2v",
models: [...DASHSCOPE_WAN_VIDEO_MODELS],
capabilities: DASHSCOPE_WAN_VIDEO_CAPABILITIES,
catalogByModel: DASHSCOPE_WAN_VIDEO_CATALOG_BY_MODEL,
resolveModelCapabilities: ({ model }) =>
DASHSCOPE_WAN_VIDEO_CATALOG_BY_MODEL[model]?.capabilities,
async generateVideo(req) {
seenRequest = {
size: req.size,
resolution: req.resolution,
aspectRatio: req.aspectRatio,
parameters: buildDashscopeVideoGenerationParameters(req),
};
return {
videos: [{ buffer: Buffer.from("mp4-bytes"), mimeType: "video/mp4" }],
model: req.model,
};
},
},
];
await runGenerateVideo({
cfg: {
agents: {
defaults: {
videoGenerationModel: { primary: "qwen/wan2.6-t2v" },
},
},
} as OpenClawConfig,
prompt: "portrait video",
resolution: "1080P",
aspectRatio: "9:16",
});
expect(seenRequest).toEqual({
size: undefined,
resolution: "1080P",
aspectRatio: "9:16",
parameters: { size: "1080*1920" },
});
});
it("skips providers whose live model capabilities disable video inputs", async () => {
providers = [
{
+9 -10
View File
@@ -14,7 +14,7 @@ import {
import { getProviderEnvVars } from "../secrets/provider-env-vars.js";
import { resolveVideoGenerationModeCapabilities } from "./capabilities.js";
import {
buildReferenceInputCapabilityFailure,
buildVideoGenerationCapabilityFailure,
resolveProviderWithModelCapabilities,
} from "./capability-overlays.js";
import { resolveVideoGenerationSupportedDurations } from "./duration-support.js";
@@ -176,13 +176,12 @@ export async function generateVideo(
log: logger,
});
// Guard: skip candidates that cannot satisfy reference-input counts so
// we never silently drop audio/image/video refs by falling over to a
// provider that ignores them and "succeeds" without the caller's assets.
// Guard: catalog modes and reference counts are authoritative before I/O,
// so fallback cannot select a model that will reject or drop the request.
const inputImageCount = params.inputImages?.length ?? 0;
const inputVideoCount = params.inputVideos?.length ?? 0;
const inputAudioCount = params.inputAudios?.length ?? 0;
const referenceInputMismatch = buildReferenceInputCapabilityFailure({
const capabilityMismatch = buildVideoGenerationCapabilityFailure({
providerId: candidate.provider,
model: candidate.model,
provider: activeProvider,
@@ -190,16 +189,16 @@ export async function generateVideo(
inputVideoCount,
inputAudioCount,
});
if (referenceInputMismatch) {
if (capabilityMismatch) {
attempts.push({
provider: candidate.provider,
model: candidate.model,
error: referenceInputMismatch,
error: capabilityMismatch,
});
lastError = new Error(referenceInputMismatch);
warnOnFirstSkip(referenceInputMismatch);
lastError = new Error(capabilityMismatch);
warnOnFirstSkip(capabilityMismatch);
logger.debug(
`video-generation candidate skipped (reference input capability): ${candidate.provider}/${candidate.model}`,
`video-generation candidate skipped (mode or reference capability): ${candidate.provider}/${candidate.model}`,
);
continue;
}
@@ -5,7 +5,7 @@ import type { PreparedModelRuntimeSnapshot } from "../../../../src/agents/prepar
import { createVideoGenerateTool } from "../../../../src/agents/tools/video-generate-tool.js";
import type { OpenClawConfig } from "../../../../src/config/types.js";
import { withEnvAsync } from "../../../../src/test-utils/env.js";
import { resolveVideoGenerationMode } from "../../../../src/video-generation/capabilities.js";
import { resolveVideoGenerationModeCapabilities } from "../../../../src/video-generation/capabilities.js";
import type {
VideoGenerationProvider,
VideoGenerationRequest,
@@ -143,10 +143,12 @@ describe("video generation invocation QA", () => {
providerOptions,
});
expect(
resolveVideoGenerationMode({
resolveVideoGenerationModeCapabilities({
provider: fallbackProvider,
model: fallbackRequest?.model,
inputImageCount: fallbackRequest?.inputImages?.length,
inputVideoCount: fallbackRequest?.inputVideos?.length,
}),
}).mode,
).toBe("imageToVideo");
expect(fallbackRequest?.inputImages).toEqual([
{