diff --git a/schemas/audioasset.yaml b/schemas/audioasset.yaml index 6146604..af0d5ed 100644 --- a/schemas/audioasset.yaml +++ b/schemas/audioasset.yaml @@ -1,8 +1,15 @@ AudioAsset: - description: >- - The AudioAsset is used to add sound effects and audio at specific intervals on - the timeline. The src must be a publicly accessible URL to an audio resource such - as an mp3 file. + description: | + The AudioAsset adds audio to a Clip. The audio can be sourced from a URL + (`src`) or generated from a text prompt (`prompt`). Exactly one of `src` or + `prompt` must be provided. + + - **Source URL:** set `src` to a publicly accessible audio URL (e.g. mp3). + - **Generated speech:** set `prompt` to the spoken text and `voice` to a voice + identifier (text-to-speech). Optionally set `language`/`newscaster`. + - **Generated music or SFX:** set `prompt` describing the sound; omit `voice`. + - Use `model` to choose the generator. The generated `src` is filled in + automatically. type: object properties: type: @@ -13,11 +20,43 @@ src: description: >- The audio source URL. The URL must be publicly accessible or include - credentials. + credentials. Provide either `src` or `prompt`, not both. type: string minLength: 1 pattern: '\S' example: https://s3-ap-northeast-1.amazonaws.com/my-bucket/sound.mp3 + prompt: + description: >- + A text prompt. When `voice` is set, the prompt is the spoken text + (text-to-speech). Without `voice`, the prompt describes generated music + or sound effects. The generated `src` is filled in automatically. + type: string + maxLength: 4000 + example: Welcome to today's broadcast. + voice: + description: >- + Voice identifier for text-to-speech generation (e.g. `Matthew`, `Joanna`). + Only meaningful when `prompt` is set. + type: string + example: Matthew + language: + description: >- + Optional BCP-47 language code (e.g. `en-US`) for text-to-speech. + Only meaningful when `prompt` and `voice` are set. + type: string + example: en-US + newscaster: + description: >- + Set to `true` to use the voice's newscaster mode when supported. + Only meaningful when `prompt` and `voice` are set. + type: boolean + default: false + model: + description: >- + The generation model to use when `prompt` is set (e.g. `polly-neural`). + Defaults to the platform's preferred generator if omitted. + type: string + example: polly-neural trim: description: >- The start trim point of the audio clip, in seconds (defaults to 0). Audio @@ -46,7 +85,7 @@ speed: description: >- Adjust the playback speed of the audio clip between 0 (paused) and 10 (10x normal speed), where 1 is normal - speed (defaults to 1). Adjusting the speed will also adjust the duration of the clip and may require you to + speed (defaults to 1). Adjusting the speed will also adjust the duration of the clip and may require you to adjust the Clip length. For example, if you set speed to 0.5, the clip will need to be 2x as long to play the entire audio (i.e. original length / 0.5). If you set speed to 2, the clip will need to be half as long to play the entire audio (i.e. original length / 2). @@ -72,4 +111,3 @@ additionalProperties: false required: - type - - src diff --git a/schemas/imageasset.yaml b/schemas/imageasset.yaml index 9623f61..d324337 100644 --- a/schemas/imageasset.yaml +++ b/schemas/imageasset.yaml @@ -1,7 +1,12 @@ ImageAsset: - description: >- - The ImageAsset is used to create video from images to compose an image. The src must be a - publicly accessible URL to an image resource such as a jpg or png file. + description: | + The ImageAsset adds an image to a Clip. The image can be sourced from a URL + (`src`) or generated from a text prompt (`prompt`). Exactly one of `src` or + `prompt` must be provided. + + - **Source URL:** set `src` to the publicly accessible URL of a jpg or png file. + - **Generated:** set `prompt` to describe the image; the engine generates it + using the provider chosen by `model` and fills `src` in automatically. type: object properties: type: @@ -12,14 +17,27 @@ src: description: >- The image source URL. The URL must be publicly accessible or include - credentials. + credentials. Provide either `src` or `prompt`, not both. type: string minLength: 1 pattern: '\S' example: https://s3-ap-northeast-1.amazonaws.com/my-bucket/image.jpg + prompt: + description: >- + A text prompt to generate the image from. When set without `src`, the + engine generates an image and fills `src` automatically. Use `model` to + choose the generator. + type: string + maxLength: 4000 + example: A serene landscape with a crystal-clear mountain lake at sunrise. + model: + description: >- + The generation model to use when `prompt` is set (e.g. `flux-schnell`). + Defaults to the platform's preferred generator if omitted. + type: string + example: flux-schnell crop: $ref: "./crop.yaml#/Crop" additionalProperties: false required: - type - - src diff --git a/schemas/imagetovideoasset.yaml b/schemas/imagetovideoasset.yaml index b1e0d04..e6c02b0 100644 --- a/schemas/imagetovideoasset.yaml +++ b/schemas/imagetovideoasset.yaml @@ -1,5 +1,11 @@ ImageToVideoAsset: - description: >- + deprecated: true + description: | + **Notice: ImageToVideoAsset is deprecated. Use [VideoAsset](#tocs_videoasset) + with `prompt` and `seed` instead.** This type continues to function and is + internally rewritten to VideoAsset; no behaviour change for existing + integrations. + The ImageToVideoAsset lets you create a video from an image and a text prompt. properties: type: diff --git a/schemas/texttoimageasset.yaml b/schemas/texttoimageasset.yaml index c28f2ef..a29ed95 100644 --- a/schemas/texttoimageasset.yaml +++ b/schemas/texttoimageasset.yaml @@ -1,5 +1,10 @@ TextToImageAsset: - description: >- + deprecated: true + description: | + **Notice: TextToImageAsset is deprecated. Use [ImageAsset](#tocs_imageasset) + with `prompt` instead.** This type continues to function and is internally + rewritten to ImageAsset; no behaviour change for existing integrations. + The TextToImageAsset lets you create a dynamic image from a text prompt. properties: type: diff --git a/schemas/texttospeechasset.yaml b/schemas/texttospeechasset.yaml index 4c43345..de05d93 100644 --- a/schemas/texttospeechasset.yaml +++ b/schemas/texttospeechasset.yaml @@ -1,5 +1,11 @@ TextToSpeechAsset: - description: >- + deprecated: true + description: | + **Notice: TextToSpeechAsset is deprecated. Use [AudioAsset](#tocs_audioasset) + with `prompt` (the spoken text) and `voice` instead.** This type continues to + function and is internally rewritten to AudioAsset; no behaviour change for + existing integrations. + The TextToSpeechAsset lets you generate a voice over from text using a text-to-speech service. The generated audio can be trimmed, faded and have its volume and speed adjusted using the same properties available on the AudioAsset. diff --git a/schemas/videoasset.yaml b/schemas/videoasset.yaml index 97c7134..9eb7c43 100644 --- a/schemas/videoasset.yaml +++ b/schemas/videoasset.yaml @@ -1,8 +1,14 @@ VideoAsset: - description: >- - The VideoAsset is used to create video sequences from video files. The - src must be a publicly accessible URL to a video resource such as an mp4 - file. + description: | + The VideoAsset adds a video to a Clip. The video can be sourced from a URL + (`src`) or generated from a text prompt (`prompt`), optionally seeded from a + starting image (`seed`). Exactly one of `src` or `prompt` must be provided. + + - **Source URL:** set `src` to the URL of an mp4 (or compatible) video file. + - **Generated:** set `prompt` to describe the motion. Optionally set `seed` to + a starting image URL (image-to-video). Use `model` to choose the generator + (e.g. `luma-ray-3`, `runpod-itv-mini`). The generated `src` is filled in + automatically. type: object properties: type: @@ -13,11 +19,34 @@ src: description: >- The video source URL. The URL must be publicly accessible or include - credentials. + credentials. Provide either `src` or `prompt`, not both. type: string minLength: 1 pattern: '\S' example: https://s3-ap-northeast-1.amazonaws.com/my-bucket/video.mp4 + prompt: + description: >- + A text prompt to generate the video from. When set without `src`, the + engine generates a video and fills `src` automatically. Optionally pair + with `seed` for image-to-video. Use `model` to choose the generator. + type: string + maxLength: 4000 + example: Slowly zoom out and orbit left around the object. + seed: + description: >- + Seed image URL for image-to-video generation. The image is used as the + starting frame; `prompt` describes the motion. Has no effect unless + `prompt` is set. + type: string + minLength: 1 + example: https://s3-ap-northeast-1.amazonaws.com/my-bucket/seed-image.jpg + model: + description: >- + The generation model to use when `prompt` is set (e.g. `luma-ray-3`, + `runpod-itv-mini`). Defaults to the platform's preferred generator if + omitted. + type: string + example: luma-ray-3 transcode: type: boolean description: >- @@ -69,7 +98,7 @@ speed: description: >- Adjust the playback speed of the video clip between 0 (paused) and 10 (10x normal speed) where 1 is normal - speed (defaults to 1). Adjusting the speed will also adjust the duration of the clip and may require you to + speed (defaults to 1). Adjusting the speed will also adjust the duration of the clip and may require you to adjust the Clip length. For example, if you set speed to 0.5, the clip will need to be 2x as long to play the entire video (i.e. original length / 0.5). If you set speed to 2, the clip will need to be half as long to play the entire video (i.e. original length / 2). @@ -85,4 +114,3 @@ additionalProperties: false required: - type - - src diff --git a/scripts/fix-discriminator.cjs b/scripts/fix-discriminator.cjs index 6229d86..3f74615 100644 --- a/scripts/fix-discriminator.cjs +++ b/scripts/fix-discriminator.cjs @@ -361,8 +361,45 @@ function addLegacyTextWrapMigrationError(code, zPrefix) { return code; } +function addSrcOrPromptRefine(code, zPrefix) { + const schemaNames = [ + 'imageassetImageAssetSchema', + 'videoassetVideoAssetSchema', + 'audioassetAudioAssetSchema', + ]; + const strictMarker = '.strict()'; + const superRefine = strictMarker + '.superRefine((data, ctx) => {\n' + + ' var hasSrc = typeof data.src === "string" && data.src.trim().length > 0;\n' + + ' var hasPrompt = typeof data.prompt === "string" && data.prompt.trim().length > 0;\n' + + ' if (!hasSrc && !hasPrompt) {\n' + + ' ctx.addIssue({\n' + + ' code: ' + zPrefix + '.ZodIssueCode.custom,\n' + + ' message: "A media asset requires either \\"src\\" (a source URL) or \\"prompt\\" (to generate the asset). Provide one.",\n' + + ' path: ["src"],\n' + + ' });\n' + + ' }\n' + + '})'; + + for (const schemaName of schemaNames) { + const idx = code.indexOf(schemaName + ' = '); + if (idx === -1) { + console.log("⚠ src/prompt refine: could not find " + schemaName + " (" + zPrefix + ")"); + continue; + } + const strictIdx = code.indexOf(strictMarker, idx); + if (strictIdx === -1 || strictIdx - idx > 6000) { + console.log("⚠ src/prompt refine: no .strict() for " + schemaName + " (" + zPrefix + ")"); + continue; + } + code = code.substring(0, strictIdx) + superRefine + code.substring(strictIdx + strictMarker.length); + console.log("✓ Added src/prompt refine to " + schemaName + " (" + zPrefix + ")"); + } + return code; +} + content = addStrictToObjects(content, "z"); content = addLegacyTextWrapMigrationError(content, "z"); +content = addSrcOrPromptRefine(content, "z"); fs.writeFileSync(zodGenPath, content); @@ -584,6 +621,7 @@ const clipClipSchemaWithFitFilter = exports.clipClipSchema.transform((clip) => { cjsContent = addStrictToObjects(cjsContent, "zod_1.z"); cjsContent = addLegacyTextWrapMigrationError(cjsContent, "zod_1.z"); + cjsContent = addSrcOrPromptRefine(cjsContent, "zod_1.z"); fs.writeFileSync(zodGenCjsPath, cjsContent); } @@ -717,6 +755,7 @@ const clipClipSchemaWithFitFilter = clipClipSchema.transform((clip) => { jsContent = addStrictToObjects(jsContent, "z"); jsContent = addLegacyTextWrapMigrationError(jsContent, "z"); + jsContent = addSrcOrPromptRefine(jsContent, "z"); fs.writeFileSync(zodGenJsPath, jsContent); } diff --git a/tests/smoke.cjs b/tests/smoke.cjs index 328bfd3..de3c923 100644 --- a/tests/smoke.cjs +++ b/tests/smoke.cjs @@ -148,6 +148,208 @@ async function run() { }); }); + console.log("\n--- Unified asset schema: existing shape (regression guard) ---\n"); + + check("Parse imageAsset with src only (existing shape)", () => { + const result = zodCjs.imageAssetSchema.parse({ + type: "image", + src: "https://example.com/image.jpg", + }); + assert.strictEqual(result.type, "image"); + assert.strictEqual(result.src, "https://example.com/image.jpg"); + }); + + check("Parse videoAsset with src only (existing shape)", () => { + const result = zodCjs.videoAssetSchema.parse({ + type: "video", + src: "https://example.com/video.mp4", + }); + assert.strictEqual(result.type, "video"); + assert.strictEqual(result.src, "https://example.com/video.mp4"); + }); + + check("Parse audioAsset with src only (existing shape)", () => { + const result = zodCjs.audioAssetSchema.parse({ + type: "audio", + src: "https://example.com/audio.mp3", + }); + assert.strictEqual(result.type, "audio"); + assert.strictEqual(result.src, "https://example.com/audio.mp3"); + }); + + console.log("\n--- Unified asset schema: prompt-based shape (new) ---\n"); + + check("Parse imageAsset with prompt only", () => { + const result = zodCjs.imageAssetSchema.parse({ + type: "image", + prompt: "A serene mountain lake at sunrise", + }); + assert.strictEqual(result.type, "image"); + assert.strictEqual(result.prompt, "A serene mountain lake at sunrise"); + }); + + check("Parse imageAsset with prompt + model", () => { + const result = zodCjs.imageAssetSchema.parse({ + type: "image", + prompt: "A serene landscape", + model: "flux-schnell", + }); + assert.strictEqual(result.prompt, "A serene landscape"); + assert.strictEqual(result.model, "flux-schnell"); + }); + + check("Parse videoAsset with prompt + seed", () => { + const result = zodCjs.videoAssetSchema.parse({ + type: "video", + prompt: "Slowly zoom out and orbit left around the object", + seed: "https://example.com/seed-image.jpg", + }); + assert.strictEqual(result.prompt, "Slowly zoom out and orbit left around the object"); + assert.strictEqual(result.seed, "https://example.com/seed-image.jpg"); + }); + + check("Parse videoAsset with prompt + seed + model", () => { + const result = zodCjs.videoAssetSchema.parse({ + type: "video", + prompt: "Camera pans right", + seed: "https://example.com/seed.jpg", + model: "luma-ray-3", + }); + assert.strictEqual(result.model, "luma-ray-3"); + }); + + check("Parse audioAsset with prompt + voice", () => { + const result = zodCjs.audioAssetSchema.parse({ + type: "audio", + prompt: "This is a text-to-speech example", + voice: "Matthew", + }); + assert.strictEqual(result.prompt, "This is a text-to-speech example"); + assert.strictEqual(result.voice, "Matthew"); + }); + + check("Parse audioAsset with prompt + voice + language + newscaster", () => { + const result = zodCjs.audioAssetSchema.parse({ + type: "audio", + prompt: "Breaking news from around the world", + voice: "Matthew", + language: "en-US", + newscaster: true, + }); + assert.strictEqual(result.language, "en-US"); + assert.strictEqual(result.newscaster, true); + }); + + check("Parse audioAsset with prompt only (music/SFX generator)", () => { + const result = zodCjs.audioAssetSchema.parse({ + type: "audio", + prompt: "Upbeat synthwave background music", + }); + assert.strictEqual(result.prompt, "Upbeat synthwave background music"); + }); + + console.log("\n--- Deprecated asset schemas (must still parse for back-compat) ---\n"); + + check("Parse deprecated textToImageAsset", () => { + const result = zodCjs.textToImageAssetSchema.parse({ + type: "text-to-image", + prompt: "A serene landscape", + }); + assert.strictEqual(result.type, "text-to-image"); + assert.strictEqual(result.prompt, "A serene landscape"); + }); + + check("Parse deprecated imageToVideoAsset", () => { + const result = zodCjs.imageToVideoAssetSchema.parse({ + type: "image-to-video", + src: "https://example.com/image.jpg", + prompt: "Pan slowly", + }); + assert.strictEqual(result.type, "image-to-video"); + }); + + check("Parse deprecated textToSpeechAsset", () => { + const result = zodCjs.textToSpeechAssetSchema.parse({ + type: "text-to-speech", + text: "Hello world", + voice: "Matthew", + }); + assert.strictEqual(result.type, "text-to-speech"); + }); + + console.log("\n--- Unified asset: src-or-prompt rule (ADR 0001) ---\n"); + + // At-least-one: src OR prompt required on image/video/audio. Both allowed. + // Neither → rejected. seed/voice are modifiers and never satisfy the rule. + + check("image with src only is valid", () => { + zodCjs.imageAssetSchema.parse({ type: "image", src: "https://example.com/a.jpg" }); + }); + + check("image with both src and prompt is valid (src wins downstream)", () => { + zodCjs.imageAssetSchema.parse({ + type: "image", + src: "https://example.com/a.jpg", + prompt: "a serene lake", + }); + }); + + check("REJECT image with neither src nor prompt", () => { + assert.throws(() => zodCjs.imageAssetSchema.parse({ type: "image" })); + }); + + check("REJECT image with empty-string prompt and no src", () => { + assert.throws(() => zodCjs.imageAssetSchema.parse({ type: "image", prompt: "" })); + }); + + check("REJECT video with neither src nor prompt", () => { + assert.throws(() => zodCjs.videoAssetSchema.parse({ type: "video" })); + }); + + check("REJECT video with seed but no src and no prompt (seed is a modifier)", () => { + assert.throws(() => + zodCjs.videoAssetSchema.parse({ type: "video", seed: "https://example.com/seed.jpg" }) + ); + }); + + check("REJECT audio with voice but no src and no prompt (voice is a modifier)", () => { + assert.throws(() => zodCjs.audioAssetSchema.parse({ type: "audio", voice: "Matthew" })); + }); + + check("audio with prompt + voice is valid (text-to-speech shape)", () => { + zodCjs.audioAssetSchema.parse({ type: "audio", prompt: "hello", voice: "Matthew" }); + }); + + console.log("\n--- Deprecation markers in bundled spec ---\n"); + + const bundledSpec = JSON.parse( + fs.readFileSync(path.join(distDir, "api.bundled.json"), "utf8") + ); + + check("TextToImageAsset has deprecated: true in bundled spec", () => { + assert.strictEqual( + bundledSpec.components.schemas.TextToImageAsset.deprecated, + true, + "TextToImageAsset is not marked deprecated in api.bundled.json" + ); + }); + + check("ImageToVideoAsset has deprecated: true in bundled spec", () => { + assert.strictEqual( + bundledSpec.components.schemas.ImageToVideoAsset.deprecated, + true, + "ImageToVideoAsset is not marked deprecated in api.bundled.json" + ); + }); + + check("TextToSpeechAsset has deprecated: true in bundled spec", () => { + assert.strictEqual( + bundledSpec.components.schemas.TextToSpeechAsset.deprecated, + true, + "TextToSpeechAsset is not marked deprecated in api.bundled.json" + ); + }); + console.log("\n--- JSON schema checks ---\n"); const jsonSchemaDir = path.join(distDir, "json-schema");