Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mulerouter",
"version": "0.4.0",
"version": "0.4.2",
"description": "CLI for MuleRouter/MuleRun multimodal AI APIs — generate images, videos, speech, and music",
"type": "module",
"license": "MIT",
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/tests/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,12 @@ describe("run command internals", () => {
language_boost: "English",
});
expect(result.audio_setting).toEqual({
output_format: "url",
format: "mp3",
sample_rate: 44100,
bitrate: 128000,
});
// output_format is at body root per upstream schema, NOT inside audio_setting
expect(result.output_format).toBe("url");
expect(result.english_normalization).toBe(true);
});

Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@mulerouter/core",
"version": "0.4.0",
"version": "0.4.2",
"description": "Core library for MuleRouter/MuleRun multimodal AI APIs",
"type": "module",
"license": "MIT",
Expand Down
173 changes: 141 additions & 32 deletions packages/core/src/models/alibaba/wan2.1-vace-plus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const endpoint: ModelEndpoint = {
provider: "alibaba",
modelName: "wan2.1-vace-plus",
description:
"Wan2.1 VACE Plus: Advanced video editing — outpainting, inpainting, interpolation, and more",
"Wan2.1 VACE Plus: Advanced video editing — image_reference, video_repainting, video_edit, video_extension, video_outpainting",
inputTypes: ["text", "image", "video"],
outputType: "video",
apiPath: "/vendors/alibaba/v1/wan2.1-vace-plus/generation",
Expand All @@ -24,45 +24,154 @@ const endpoint: ModelEndpoint = {
{
name: "function",
type: "string",
description: "VACE function to use",
description:
"VACE function. image_reference: multi-image fusion to video (needs --images, 1-3). video_repainting: repaint video by control features (needs --video-url + --control-condition). video_edit: local edit by mask (needs --video-url + one of --mask-image-url/--mask-video-url). video_extension: extend a video (combine --first/last-frame-url, --first/last-clip-url, --video-url). video_outpainting: expand canvas (needs --video-url + --top/bottom/left/right-scale).",
required: true,
enum: ["outpainting", "video_extend", "reference_generation", "interpolation", "inpainting"],
},
{ name: "prompt", type: "string", description: "Text prompt", required: true },
{ name: "negative_prompt", type: "string", description: "Text describing what to avoid" },
{ name: "ref_images_url", type: "array", description: "Reference image URLs" },
{ name: "obj_or_bg", type: "string", description: "Object or background mode" },
{ name: "video_url", type: "string", description: "Input video URL" },
{ name: "control_condition", type: "string", description: "Control condition" },
{ name: "strength", type: "number", description: "Control strength" },
{ name: "mask_image_url", type: "string", description: "Mask image URL" },
{ name: "mask_video_url", type: "string", description: "Mask video URL" },
{ name: "mask_frame_id", type: "string", description: "Mask frame ID" },
{ name: "mask_type", type: "string", description: "Mask type" },
{ name: "expand_ratio", type: "number", description: "Expand ratio for outpainting" },
{ name: "expand_mode", type: "string", description: "Expand mode for outpainting" },
{ name: "first_frame_url", type: "string", description: "First frame image URL" },
{ name: "last_frame_url", type: "string", description: "Last frame image URL" },
{ name: "first_clip_url", type: "string", description: "First clip URL" },
{ name: "last_clip_url", type: "string", description: "Last clip URL" },
{ name: "top_scale", type: "number", description: "Top scale for outpainting" },
{ name: "bottom_scale", type: "number", description: "Bottom scale for outpainting" },
{ name: "left_scale", type: "number", description: "Left scale for outpainting" },
{ name: "right_scale", type: "number", description: "Right scale for outpainting" },
{ name: "size", type: "string", description: "Output resolution" },
{ name: "duration", type: "integer", description: "Video duration in seconds", default: 5 },
enum: [
"image_reference",
"video_repainting",
"video_edit",
"video_extension",
"video_outpainting",
],
},
{
name: "prompt",
type: "string",
description: "Text prompt (≤800 chars)",
required: true,
},
{
name: "images",
type: "array",
description:
"Input reference image URLs (JSON array). image_reference: 1-3 images. video_repainting/video_edit: exactly 1 (optional). Example: --images '[\"https://...png\"]'",
},
{
name: "obj_or_bg",
type: "array",
description:
"image_reference only: per-image role tags, parallel to --images. Each entry 'obj' or 'bg'. 'bg' may appear at most once. ⚠ Upstream Bailian requires length == --images length EVEN for a single image (mule-router accepts None for single image but Bailian rejects with InvalidParameter); for 1 image pass '[\"obj\"]' or '[\"bg\"]'. Required when --images length > 1.",
},
{
name: "video_url",
type: "string",
description:
"Input video URL. Required for video_repainting, video_edit, video_outpainting; optional for video_extension (if set, --control-condition becomes required).",
},
{
name: "control_condition",
type: "string",
description:
"Feature-extraction mode. video_repainting: posebodyface|posebody|depth|scribble (required). video_edit: posebodyface|depth. video_extension: posebodyface|depth (required when --video-url is provided).",
},
{
name: "strength",
type: "number",
description: "video_repainting only: control strength, 0.0–1.0 (default 1.0).",
},
{
name: "mask_image_url",
type: "string",
description:
"video_edit only: mask image URL. Mutually exclusive with --mask-video-url; exactly one required.",
},
{
name: "mask_video_url",
type: "string",
description:
"video_edit only: mask video URL. Mutually exclusive with --mask-image-url; exactly one required.",
},
{
name: "mask_frame_id",
type: "integer",
description: "video_edit only: 1-based frame index where the mask target appears.",
},
{
name: "mask_type",
type: "string",
description: "video_edit only: mask type. Effective when --mask-image-url is set.",
enum: ["tracking", "fixed"],
},
{
name: "expand_ratio",
type: "number",
description: "video_edit only with --mask-type tracking: mask expansion ratio, 0.0–1.0.",
},
{
name: "expand_mode",
type: "string",
description:
"video_edit only with --mask-type tracking: mask region shape. Note: 'orginal' is the upstream spelling (verbatim).",
enum: ["hull", "bbox", "orginal"],
},
{
name: "first_frame_url",
type: "string",
description: "video_extension only: first frame image URL.",
},
{
name: "last_frame_url",
type: "string",
description: "video_extension only: last frame image URL.",
},
{
name: "first_clip_url",
type: "string",
description: "video_extension only: first video clip URL (mp4, ≤3s, ≤50MB).",
},
{
name: "last_clip_url",
type: "string",
description: "video_extension only: last video clip URL (mp4, ≤3s, ≤50MB).",
},
{
name: "top_scale",
type: "number",
description:
"video_outpainting only: upward expansion scale, 1.0–2.0 (default 1.0 = no expansion).",
},
{
name: "bottom_scale",
type: "number",
description: "video_outpainting only: downward expansion scale, 1.0–2.0 (default 1.0).",
},
{
name: "left_scale",
type: "number",
description: "video_outpainting only: leftward expansion scale, 1.0–2.0 (default 1.0).",
},
{
name: "right_scale",
type: "number",
description: "video_outpainting only: rightward expansion scale, 1.0–2.0 (default 1.0).",
},
{
name: "size",
type: "string",
description: "Output resolution. Applies to image_reference and video_edit.",
enum: ["1280x720", "720x1280", "960x960", "832x1088", "1088x832"],
},
{
name: "duration",
type: "integer",
description: "Video duration in seconds. Currently fixed at 5.",
default: 5,
},
{
name: "prompt_extend",
type: "boolean",
description: "Whether to extend the prompt",
default: true,
description: "Whether to enable prompt smart-rewrite.",
},
{
name: "seed",
type: "integer",
description: "Random seed for reproducibility (0–2147483647).",
},
{ name: "seed", type: "integer", description: "Random seed for reproducibility" },
{
name: "safety_filter",
type: "boolean",
description: "Enable safety filtering",
default: true,
description: "Enable content safety filter. Default: enabled.",
},
],
};
Expand Down
14 changes: 12 additions & 2 deletions packages/core/src/models/minimax/_builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ export function buildSpeechRequestBody(params: Record<string, unknown>): Record<
if (Object.keys(voiceSetting).length > 0) body.voice_setting = voiceSetting;

const audioSetting: Record<string, unknown> = {};
if (params.output_format !== undefined) audioSetting.output_format = params.output_format;
if (params.audio_format !== undefined) audioSetting.format = params.audio_format;
if (params.sample_rate !== undefined) audioSetting.sample_rate = params.sample_rate;
if (params.bitrate !== undefined) audioSetting.bitrate = params.bitrate;
Expand All @@ -29,6 +28,11 @@ export function buildSpeechRequestBody(params: Record<string, unknown>): Record<
body.english_normalization = params.english_normalization;
}

// output_format belongs at body root per upstream ExternalSpeechGenerationRequest
// schema (mule-router tasks/handlers/minimax/models/external.py:254). Nesting it
// under audio_setting causes upstream pydantic to silently drop it and default to HEX.
if (params.output_format !== undefined) body.output_format = params.output_format;

return body;
}

Expand Down Expand Up @@ -81,7 +85,13 @@ export const speechParameters: ModelParameter[] = [
description:
"Optimize for a specific language (e.g., zh, en, ja, ko, es, pt, fr, id, de, ru, it, ar, tr, uk, nl, vi, th, pl, ro, el, cs, fi, hi)",
},
{ name: "output_format", type: "string", description: "Output format", enum: ["url", "hex"] },
{
name: "output_format",
type: "string",
description:
"Output schema for audios[0]. 'url' → HTTPS download link (recommended). 'hex' → hex-encoded raw audio bytes (decode with `xxd -r -p > out.mp3`); the bytes are an MP3 with ID3 header regardless of --audio-format. Upstream MiniMax default: hex.",
enum: ["url", "hex"],
},
{
name: "audio_format",
type: "string",
Expand Down
68 changes: 64 additions & 4 deletions packages/core/tests/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,13 +139,54 @@ describe("models", () => {
expect(endpoint?.resultKey).toBe("images");
});

it("should include wan2.1-vace-plus with VACE functions", () => {
it("should include wan2.1-vace-plus aligned with VideoEditPayload union", () => {
const endpoint = registry.get("alibaba/wan2.1-vace-plus", "generation");
expect(endpoint).toBeDefined();
const funcParam = endpoint?.parameters.find((p) => p.name === "function");
if (!endpoint) return;
const params = endpoint.parameters;

// function enum: 5 new upstream-accepted values, no legacy values
const funcParam = params.find((p) => p.name === "function");
expect(funcParam?.required).toBe(true);
expect(funcParam?.enum).toContain("outpainting");
expect(funcParam?.enum).toContain("inpainting");
expect(funcParam?.enum).toEqual(
expect.arrayContaining([
"image_reference",
"video_repainting",
"video_edit",
"video_extension",
"video_outpainting",
]),
);
expect(funcParam?.enum).toHaveLength(5);

// negative_prompt removed (not in VideoEditBasePayload)
expect(params.find((p) => p.name === "negative_prompt")).toBeUndefined();

// ref_images_url renamed to images (array)
expect(params.find((p) => p.name === "ref_images_url")).toBeUndefined();
const images = params.find((p) => p.name === "images");
expect(images?.type).toBe("array");

// obj_or_bg retyped string → array
expect(params.find((p) => p.name === "obj_or_bg")?.type).toBe("array");

// mask_frame_id retyped string → integer
expect(params.find((p) => p.name === "mask_frame_id")?.type).toBe("integer");

// enum constraints from upstream Literal types
expect(params.find((p) => p.name === "mask_type")?.enum).toEqual(["tracking", "fixed"]);
expect(params.find((p) => p.name === "expand_mode")?.enum).toEqual([
"hull",
"bbox",
"orginal",
]);
expect(params.find((p) => p.name === "size")?.enum).toEqual([
"1280x720",
"720x1280",
"960x960",
"832x1088",
"1088x832",
]);
});
});

Expand Down Expand Up @@ -227,10 +268,29 @@ describe("models", () => {
voice_id: "Charming_Lady",
speed: 1.0,
audio_format: "mp3",
output_format: "url",
});
expect(result?.prompt).toBe("Hello");
expect(result?.voice_setting).toEqual({ voice_id: "Charming_Lady", speed: 1.0 });
expect(result?.audio_setting).toEqual({ format: "mp3" });
// output_format must be at body root, NOT under audio_setting
expect(result?.output_format).toBe("url");
});

it("should not nest output_format under audio_setting (regression: upstream schema)", () => {
// Upstream ExternalSpeechGenerationRequest declares output_format at body root.
// Nesting it inside audio_setting causes pydantic to silently drop it → default HEX.
const speech = registry.get("minimax/speech-2.8-turbo", "generation");
const result = speech?.buildRequestBody?.({
prompt: "regression",
voice_id: "male-qn-qingse",
audio_format: "mp3",
output_format: "url",
});
const audioSetting = result?.audio_setting as Record<string, unknown> | undefined;
expect(audioSetting).toBeDefined();
expect(Object.keys(audioSetting ?? {})).not.toContain("output_format");
expect(result?.output_format).toBe("url");
});

it("should have custom buildRequestBody for music models", () => {
Expand Down
Loading