diff --git a/packages/cli/package.json b/packages/cli/package.json index 83467bb..2364bbc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/tests/run.test.ts b/packages/cli/tests/run.test.ts index 45552f2..69082a6 100644 --- a/packages/cli/tests/run.test.ts +++ b/packages/cli/tests/run.test.ts @@ -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); }); diff --git a/packages/core/package.json b/packages/core/package.json index dd365f5..d9075fd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/models/alibaba/wan2.1-vace-plus.ts b/packages/core/src/models/alibaba/wan2.1-vace-plus.ts index f8bdd91..9f370a0 100644 --- a/packages/core/src/models/alibaba/wan2.1-vace-plus.ts +++ b/packages/core/src/models/alibaba/wan2.1-vace-plus.ts @@ -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", @@ -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.", }, ], }; diff --git a/packages/core/src/models/minimax/_builders.ts b/packages/core/src/models/minimax/_builders.ts index 21ab7be..79506ca 100644 --- a/packages/core/src/models/minimax/_builders.ts +++ b/packages/core/src/models/minimax/_builders.ts @@ -19,7 +19,6 @@ export function buildSpeechRequestBody(params: Record): Record< if (Object.keys(voiceSetting).length > 0) body.voice_setting = voiceSetting; const audioSetting: Record = {}; - 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; @@ -29,6 +28,11 @@ export function buildSpeechRequestBody(params: Record): 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; } @@ -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", diff --git a/packages/core/tests/models.test.ts b/packages/core/tests/models.test.ts index f606fb9..2288ccb 100644 --- a/packages/core/tests/models.test.ts +++ b/packages/core/tests/models.test.ts @@ -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", + ]); }); }); @@ -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 | 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", () => {