From 644d7eac3395af0fde46dffe70435782746a753b Mon Sep 17 00:00:00 2001 From: VickyXAI <115643921+VickyXAI@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:57:09 +0800 Subject: [PATCH] fix(video): seedance-2.5 takes reference clips; per-model reference ceiling The gateway enabled reference video/audio on seedance-2.5 on 2026-09-26 (enterprise#297): supportsReferenceMedia is true and the clip ceiling was probed at 30.2s, twice the 2.0 family's 15.2s. blockrun_video still refused reference_videos / reference_audios on 2.5 and reserved every clip at a flat 15.2s, which would have under-reserved a 2.5 clip by half on the account rail, where the reserve is the only pre-payment budget control. - REFERENCE_CEILING_SECONDS mirrors the gateway map (2.0 / 2.0-fast / 2.0-mini 15.2s, 2.5 30.2s); the reference-clip model set is derived from it. - estimateVideoCost prices clips at the model's own ceiling and throws for a token-priced model with no ceiling (1.5-pro) instead of borrowing one. - Tool description, field descriptions, README and seedance-capabilities doc name 2.5 for clips and give each family its ceiling. - Wallet-rail rule unchanged: both wallet gateways still 400 every reference field, 2.5 included. - Schema token table updated for the description change (14,046 full). --- CHANGELOG.md | 25 +++++++ README.md | 6 +- docs/mcp-schema-overhead.md | 4 +- docs/seedance-capabilities.md | 32 ++++++--- src/tools/video.ts | 79 ++++++++++++++------- test/video-models.test.ts | 2 +- test/video-reference-media.test.ts | 110 +++++++++++++++++++++++++---- 7 files changed, 205 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe0c0e9..9a0f6c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## Unreleased + +### Fixed — seedance-2.5 takes reference clips; the clip ceiling is per model + +The gateway turned on reference video/audio for seedance-2.5 on 2026-09-26 +(enterprise#297): its registry entry now carries `supportsReferenceMedia: true` +and "reference video/audio clips up to 30s each (billed per reference +second)". `blockrun_video` still refused `reference_videos` / +`reference_audios` on 2.5 by name. It now forwards them, with the same limits +as the 2.0 family: 1-3 clips of each type, audio only beside a reference image +or video, never with frame seeds, up to 30 reference images. + +The reserve priced every clip at a flat 15.2s. The provider's ceiling is per +model, and the gateway quotes each clip at its model's ceiling +(`REFERENCE_CEILING_SECONDS`): 15.2s on seedance-2.0 / 2.0-fast / 2.0-mini, +30.2s on 2.5. A 2.5 clip would have reserved half its price on the one rail +where the reserve is the only budget control. One reference video now puts a +5s 720p 2.5 render at ~$11.07 (~7x its ~$1.58 plain price). 1.5-pro still +takes no clips, and the estimator now throws instead of borrowing another +model's ceiling for it. + +Rail rules are unchanged: both wallet gateways (`blockrun.ai`, +`sol.blockrun.ai`) still refuse every reference field with a 400 before +quoting, so reference media stays account-rail only, 2.5 included. + ## 0.53.1 ### Fixed — seedance-2.5 first-and-last-frame is refused by name on Solana diff --git a/README.md b/README.md index 8d17cd4..5cc1d49 100644 --- a/README.md +++ b/README.md @@ -252,9 +252,9 @@ Package managers have shown install size for decades. Almost no MCP server shows | Profile | Tools | Context | |---------|-------|---------| -| `full` *(default)* | 19 | 14,014 | +| `full` *(default)* | 19 | 14,046 | | `trading` | 8 | 5,411 | -| `media` | 7 | 6,795 | +| `media` | 7 | 6,827 | | `research` | 5 | 2,755 | | `chat` | 3 | 2,082 | @@ -371,7 +371,7 @@ npx -y @blockrun/mcp@latest skills install --to ~/.codex/skills |------|-------------|------| | `blockrun_chat` | 82 LLMs (GPT, Claude, Gemini, DeepSeek, Kimi K3, GLM, NVIDIA free tier, …) with `mode` tier routing | per token | | `blockrun_image` | Generate: openai/gpt-image-2, gpt-image-1, google/nano-banana(-2/-pro), xai/grok-imagine-image(-pro), zai/cogview-4, bytedance/seedream-5-pro. Edit: img2img, inpaint, fusion. | $0.015–0.15 | -| `blockrun_video` | Sora 2 + xAI Grok Imagine Video + ByteDance Seedance 1.5/2.0-mini/2.0-fast/2.0/2.5 (720p + audio; 4K on 2.0, up to 30s on 2.5); RealFace asset → real-person video; [reference images, video and audio](docs/seedance-capabilities.md) on the account rail | $0.053–0.32/sec charged; reference clips billed at a 15.2s ceiling | +| `blockrun_video` | Sora 2 + xAI Grok Imagine Video + ByteDance Seedance 1.5/2.0-mini/2.0-fast/2.0/2.5 (720p + audio; 4K on 2.0, up to 30s on 2.5); RealFace asset → real-person video; [reference images, video and audio](docs/seedance-capabilities.md) on the account rail | $0.053–0.32/sec charged; reference clips billed at the model's ceiling (15.2s on 2.0, 30.2s on 2.5) | | `blockrun_realface` | Enroll a real person (phone liveness) or AI character (Virtual Portrait) as a `ta_xxxx` asset for Seedance 2.0 / 2.0-fast / 2.0-mini video (not 2.5) | free; $0.01 to enroll | | `blockrun_music` | MiniMax music generation | per track | | `blockrun_speech` | ElevenLabs TTS (Flash/Turbo/Multilingual/v3, 8 voices) + ByteDance Seed Audio (prompt-directed) + cinematic sound effects; free voice listing | $0.05–0.10/1k chars | diff --git a/docs/mcp-schema-overhead.md b/docs/mcp-schema-overhead.md index 81e07a7..30f4836 100644 --- a/docs/mcp-schema-overhead.md +++ b/docs/mcp-schema-overhead.md @@ -16,9 +16,9 @@ Written 2026-09-01, verified against `@modelcontextprotocol/sdk` 1.29.0. Numbers | Profile | Tools | Context | |---------|-------|---------| -| `full` *(default)* | 19 | 14,014 | +| `full` *(default)* | 19 | 14,046 | | `trading` | 8 | 5,411 | -| `media` | 7 | 6,795 | +| `media` | 7 | 6,827 | | `research` | 5 | 2,755 | | `chat` | 3 | 2,082 | diff --git a/docs/seedance-capabilities.md b/docs/seedance-capabilities.md index f12e8f4..9ad6c37 100644 --- a/docs/seedance-capabilities.md +++ b/docs/seedance-capabilities.md @@ -32,7 +32,7 @@ below. | --- | --- | --- | --- | | Seedance 1.5-pro | Yes | No | No | | Seedance 2.0 / 2.0-fast / 2.0-mini | Yes | 1–9 | Image + video, image + audio, video + audio, or all three; 1–3 clips of each type | -| Seedance 2.5 | Yes, except on the Solana wallet rail | 1–30 | No | +| Seedance 2.5 | Yes, except on the Solana wallet rail | 1–30 | Same combinations as 2.0; 1–3 clips of each type, up to 30s each | `sol.blockrun.ai` has not yet added 2.5 to its first-and-last-frame list. It refuses `last_frame_url` on 2.5 with a `400` before quoting, while @@ -40,14 +40,16 @@ refuses `last_frame_url` on 2.5 with a `400` before quoting, while on both (unsigned probes, 2026-09-29). The tool refuses that one combination on Solana by name, so no payment is taken; use Base or the account rail for it. -Seedance 2.5 takes reference **images** but no reference clips — the gateway -registry carries `supportsReferenceImages: true` with -`supportsReferenceMedia: false` for it, and the tool's guard matches. +Seedance 2.5 takes reference **clips** as well as images since 2026-09-26 +(enterprise#297): the gateway registry carries `supportsReferenceMedia: true` +for it, after its clip ceiling was probed at 30.2s and its per-second rate +measured. Its clips may run up to 30s each, twice the 2.0 family's, and are +billed accordingly (below). `image_url` means a first-frame seed. For a character or style image alongside a reference video, use `reference_image_urls`, not `image_url`. Frame seeding -and reference mode are mutually exclusive. Reference audio on the 2.0 family -requires at least one reference image or video. Upstream duration, size and +and reference mode are mutually exclusive. Reference audio requires at least one +reference image or video. Upstream duration, size and content constraints still apply; accepting a URL does not verify the remote file. @@ -71,11 +73,20 @@ since `"reference"` is the only value upstream honours. Reference **clips** are billed **per reference second**, not per clip — measured against token360 on 2026-09-23 (blockrun#730): a reference second costs what an output second costs (~21,600 tokens against 21,780), with no per-clip -component. Audio counts at 0.3x video. +component. Re-measured on seedance-2.5 on 2026-09-25 (enterprise#297): the same +rate. Audio counts at 0.3x video. The caller sends a URL and never declares the clip's length, so the gateway -quotes **every clip at the model's 15.2s ceiling** and this tool reserves the -same. Consequences worth knowing before you call: +quotes **every clip at its model's ceiling** and this tool reserves the same. +The ceiling is per model, probed from the provider's own validation error: + +| Model | Reference-clip ceiling | +| --- | --- | +| Seedance 2.0 / 2.0-fast / 2.0-mini | 15.2s | +| Seedance 2.5 | 30.2s | + +A 2.5 clip therefore reserves twice the seconds of a 2.0 clip. Consequences +worth knowing before you call: | Request | Reserved | | --- | --- | @@ -85,6 +96,9 @@ same. Consequences worth knowing before you call: | seedance-2.0, 5s output, 3 videos + 3 audios | ~$14.54 | | seedance-2.0, 5s output at **4K**, 3 videos + 3 audios | ~$130.88 | | seedance-2.0-mini, 5s output at **480p**, 3 videos + 3 audios | ~$4.91 | +| seedance-2.5, 5s output, no references | ~$1.58 | +| seedance-2.5, 5s output, 1 reference video | ~$11.07 | +| seedance-2.5, 5s output, 3 videos + 3 audios | ~$38.59 | A single reference clip therefore costs more than the render it conditions — roughly 4x a plain 5s 720p render, and about 24x for three videos plus three diff --git a/src/tools/video.ts b/src/tools/video.ts index 7392dcb..963889e 100644 --- a/src/tools/video.ts +++ b/src/tools/video.ts @@ -158,20 +158,22 @@ const REFERENCE_IMAGE_LIMIT: Record = { "bytedance/seedance-2.5": 30, }; -// Models that accept reference VIDEO/AUDIO clips (r2v). 2.5 is absent on -// purpose: the gateway registry carries supportsReferenceMedia:false for it -// while supportsReferenceImages is true, so images are allowed there and clips -// are not. -const REFERENCE_MEDIA_MODELS = new Set([ - "bytedance/seedance-2.0", - "bytedance/seedance-2.0-fast", - "bytedance/seedance-2.0-mini", -]); +// Models that accept reference VIDEO/AUDIO clips (r2v): the gateway registry's +// supportsReferenceMedia. 2.5 joined the 2.0 family on 2026-09-26 +// (enterprise#297) once its clip ceiling was probed and its per-second rate +// measured. Derived from the ceiling table below, so a model can never accept +// clips here without a ceiling to reserve them at — the gateway refuses a clip +// on a model with no probed ceiling for the same reason. // Models that accept the 2.x output-bitrate control, and the 1.5-pro-only // render controls. Named rather than pattern-matched so the guard and the // message below can never drift from each other. -const BITRATE_MODE_MODELS = new Set([...REFERENCE_MEDIA_MODELS, "bytedance/seedance-2.5"]); +const BITRATE_MODE_MODELS = new Set([ + "bytedance/seedance-2.0", + "bytedance/seedance-2.0-fast", + "bytedance/seedance-2.0-mini", + "bytedance/seedance-2.5", +]); const SEEDANCE_15_PRO = "bytedance/seedance-1.5-pro"; const SEEDANCE_25 = "bytedance/seedance-2.5"; @@ -186,17 +188,34 @@ const SEEDANCE_25 = "bytedance/seedance-2.5"; * term this replaces was right only when the clip happened to be as long as the * render, and under-reserved 2-3.2x otherwise. * + * Re-measured on seedance-2.5 on 2026-09-25 (enterprise#297): the same 21,600 + * tokens per reference second, so the rate follows the reference, not the tier. + * * The caller sends URLs, never durations, so the gateway quotes every clip at - * its model's probed ceiling and the estimate has to assume the same. 15.2s is - * the ceiling all three 2.0 SKUs enforce ("must be less than or equal to 15.2 - * … in r2v"). Over-reserving a short clip is the only direction the account - * rail can recover from: it bills at submit with no 402 to correct against. + * ITS MODEL's probed ceiling and the estimate has to assume the same. The + * ceiling is per model, not global: the provider's own validation error says + * "must be less than or equal to 15.2" on all three 2.0 SKUs and "… 30.2" on + * 2.5 — twice as long, so a flat 15.2 would under-reserve a 2.5 clip by half. + * Mirrors REFERENCE_CEILING_SECONDS in the gateway's lib/models.ts. + * Over-reserving a short clip is the only direction the account rail can + * recover from: it bills at submit with no 402 to correct against. */ const REFERENCE_TOKENS_PER_SECOND = 21_600; const REFERENCE_AUDIO_SECOND_FACTOR = 0.3; -const MAX_REFERENCE_SECONDS = 15.2; +const REFERENCE_CEILING_SECONDS: Record = { + "bytedance/seedance-2.0": 15.2, + "bytedance/seedance-2.0-fast": 15.2, + "bytedance/seedance-2.0-mini": 15.2, + "bytedance/seedance-2.5": 30.2, +}; +const REFERENCE_MEDIA_MODELS = new Set(Object.keys(REFERENCE_CEILING_SECONDS)); const MAX_REFERENCE_CLIPS = 3; +/** One reference clip's billed ceiling on `model`, in seconds, or undefined if it takes no clips. */ +export function referenceCeilingSeconds(model: string): number | undefined { + return Object.hasOwn(REFERENCE_CEILING_SECONDS, model) ? REFERENCE_CEILING_SECONDS[model] : undefined; +} + const VIDEO_DEFAULT_DURATION: Record = { "xai/grok-imagine-video": 8, "bytedance/seedance-1.5-pro": 5, @@ -293,7 +312,8 @@ const GROK_RESOLUTIONS: Record; note: string * account rail, which bills at submit with no quote, so for those calls the * number this function returns is the only budget control there is. It prices * reference clips the way the gateway does: per reference second, at the - * model's ceiling. See REFERENCE_TOKENS_PER_SECOND. + * model's own ceiling (15.2s on the 2.0 family, 30.2s on 2.5). See + * REFERENCE_TOKENS_PER_SECOND. */ export function estimateVideoCost(model: string, durationSeconds?: number, resolution?: string, references: { videos?: number; audios?: number } = {}): number { // Validated BEFORE the model dispatch below, not inside the Seedance branch. @@ -309,6 +329,12 @@ export function estimateVideoCost(model: string, durationSeconds?: number, resol if ((videos > 0 || audios > 0) && !Object.hasOwn(SEEDANCE_PRICE_PER_MTOKENS, model)) { throw new Error(`Model "${model}" is not token-priced, so reference media cannot be reserved for it — refusing to price ${videos} video / ${audios} audio references at zero.`); } + // A token-priced model with no probed ceiling (1.5-pro) takes no clips at + // all; borrowing another model's number would price a clip it cannot have. + const referenceCeiling = referenceCeilingSeconds(model); + if ((videos > 0 || audios > 0) && referenceCeiling === undefined) { + throw new Error(`Model "${model}" has no reference-clip ceiling, so reference media cannot be reserved for it — refusing to price ${videos} video / ${audios} audio references at another model's ceiling.`); + } // THROW, never default. A model or resolution missing from the tables below // can only mean someone added it to the zod enum and forgot the rate — and a // `?? 0.05` there would price a $0.32/sec render as grok, a 6x under-reserve @@ -327,9 +353,9 @@ export function estimateVideoCost(model: string, durationSeconds?: number, resol if (!Object.hasOwn(RESOLUTION_TOKEN_FACTOR, res)) { throw new Error(`No token factor for resolution "${res}" — refusing to reserve at the 720p rate.`); } - // Reference seconds, not clips: every clip is quoted at the ceiling because - // the caller sends a URL and nothing here can read its duration. - const referenceSeconds = MAX_REFERENCE_SECONDS * (videos + REFERENCE_AUDIO_SECOND_FACTOR * audios); + // Reference seconds, not clips: every clip is quoted at its model's ceiling + // because the caller sends a URL and nothing here can read its duration. + const referenceSeconds = (referenceCeiling ?? 0) * (videos + REFERENCE_AUDIO_SECOND_FACTOR * audios); // The reference term floors its resolution factor at 1, as the gateway's // does: 21,600 was measured at 720p and whether upstream scales reference // tokens by the OUTPUT resolution is unprobed, so 480p must not bill under @@ -410,11 +436,11 @@ Models. Every rate below is what you are CHARGED (margin and transaction fee inc - bytedance/seedance-2.0-mini (~$0.080/sec, 4-15s, 5s default) — 2.0-generation quality at roughly half the 2.0-fast rate; 720p ceiling; supports RealFace, first/last-frame and the full reference set. The CHEAPEST model that takes reference video/audio — prefer it over 2.0 for reference work unless you need 4K. - bytedance/seedance-2.0-fast (~$0.165/sec, 4-15s, ~60-80s gen) — sweet-spot price/quality; supports RealFace, first/last-frame and the full reference set - bytedance/seedance-2.0 (~$0.227/sec, 4-15s, up to 4K) — highest quality, and the ONLY model that renders true 4K; supports RealFace, first/last-frame and the full reference set. Also the priciest: 2.0-mini takes the same reference inputs at ~1/3 the rate. -- bytedance/seedance-2.5 (~$0.315/sec, 4-30s, 5s default) — long-form: double 2.0's length ceiling, multilingual. NOT a strict upgrade — it caps at 720p and takes no RealFace and no reference VIDEO/AUDIO, though it does take first/last-frame and up to 30 reference IMAGES. Use 2.0 for 1080p/4K or real-person video. +- bytedance/seedance-2.5 (~$0.315/sec, 4-30s, 5s default) — long-form: double 2.0's length ceiling, multilingual. NOT a strict upgrade — it caps at 720p and takes no RealFace, though it does take first/last-frame, up to 30 reference IMAGES and reference clips. Use 2.0 for 1080p/4K or real-person video. Image-to-video is NOT cheaper than text-to-video on Seedance — same per-second rate. Higher resolutions ARE more expensive (token-priced: 1080p ~2.25x, 4K ~9x the 720p rate); the 402 quote is authoritative and is what gets charged. -Reference media (reference_image_urls / reference_videos / reference_audios) is served ONLY on the BlockRun account rail (BLOCKRUN_API_KEY) — the Base and Solana gateways refuse it with a 400 before quoting, and this tool refuses it there first. Reference CLIPS are expensive: each one is billed at the 15.2s ceiling whatever its real length, and that price does NOT shrink with a shorter render or a lower resolution. On seedance-2.0-mini a 5s 720p render goes ~$0.40 -> ~$1.61 with one reference video (~4x), ~$5.10 with three videos plus three audios (~13x), and ~24x at 480p, where the discount reaches the render but not the clip. Reference IMAGES cost nothing extra. The account rail bills at submit with no quote to correct against, so check blockrun_wallet action:"report" before a large reference job. +Reference media (reference_image_urls / reference_videos / reference_audios) is served ONLY on the BlockRun account rail (BLOCKRUN_API_KEY) — the Base and Solana gateways refuse it with a 400 before quoting, and this tool refuses it there first. Reference CLIPS are expensive: each one is billed at its model's ceiling (15.2s on the 2.0 family, 30.2s on 2.5) whatever its real length, and that price does NOT shrink with a shorter render or a lower resolution. On seedance-2.0-mini a 5s 720p render goes ~$0.40 -> ~$1.61 with one reference video (~4x), ~$5.10 with three videos plus three audios (~13x), and ~24x at 480p, where the discount reaches the render but not the clip. Reference IMAGES cost nothing extra. The account rail bills at submit with no quote to correct against, so check blockrun_wallet action:"report" before a large reference job. RealFace: to generate video of a SPECIFIC real person, first enroll them with blockrun_realface (returns a ta_xxxx asset id), then pass real_face_asset_id here with seedance-2.0, seedance-2.0-fast, or seedance-2.0-mini. Mutually exclusive with image_url. @@ -431,8 +457,8 @@ Returns a permanent blockrun-hosted video URL (the gateway mirrors the asset to last_frame_url: z.string().url().optional().describe("Seedance 1.5-pro / 2.0 / 2.0-fast / 2.0-mini / 2.5 (2.5 not on the Solana wallet rail): first-and-last-frame interpolation. A second image URL that seeds the FINAL frame so the model tweens from image_url (first frame) → last_frame_url (last frame). Requires image_url; mutually exclusive with real_face_asset_id."), model: z.enum(["azure/sora-2", "xai/grok-imagine-video", "bytedance/seedance-1.5-pro", "bytedance/seedance-2.0-mini", "bytedance/seedance-2.0-fast", "bytedance/seedance-2.0", "bytedance/seedance-2.5"]).optional().default("xai/grok-imagine-video").describe("Video model to use"), reference_image_urls: z.array(z.string().url().max(2048)).min(1).max(30).optional().describe("ACCOUNT RAIL ONLY (BLOCKRUN_API_KEY) — the Base and Solana gateways refuse reference media with a 400. Character/style reference images, cited as 'image 1', 'image 2' in the prompt: up to 9 on seedance-2.0 / 2.0-fast / 2.0-mini, 30 on 2.5. Mutually exclusive with image_url / last_frame_url / real_face_asset_id."), - reference_videos: z.array(z.object({ url: z.string().url().max(2048) })).min(1).max(3).optional().describe("ACCOUNT RAIL ONLY. Motion reference clips (1-3) on seedance-2.0 / 2.0-fast / 2.0-mini — NOT 2.5. Each clip is BILLED AT THE 15.2s CEILING whatever its real length, so one clip makes a 5s 720p render cost about 4x (seedance-2.0-mini ~$0.40 -> ~$1.61), and more at 480p."), - reference_audios: z.array(z.object({ url: z.string().url().max(2048) })).min(1).max(3).optional().describe("ACCOUNT RAIL ONLY. Audio reference clips (1-3) on seedance-2.0 / 2.0-fast / 2.0-mini — NOT 2.5. Requires a reference image or video alongside. Billed at 0.3x the 15.2s video-clip rate."), + reference_videos: z.array(z.object({ url: z.string().url().max(2048) })).min(1).max(3).optional().describe("ACCOUNT RAIL ONLY. Motion reference clips (1-3) on the seedance-2.0 family and 2.5. Each clip is BILLED AT ITS MODEL'S CEILING (15.2s on the 2.0 family, 30.2s on 2.5) whatever its real length, so one clip makes a 5s 720p render cost about 4x (seedance-2.0-mini ~$0.40 -> ~$1.61), ~7x on 2.5, more at 480p."), + reference_audios: z.array(z.object({ url: z.string().url().max(2048) })).min(1).max(3).optional().describe("ACCOUNT RAIL ONLY. Audio reference clips (1-3), same models. Requires a reference image or video alongside. Billed at 0.3x the video-clip ceiling (15.2s on the 2.0 family, 30.2s on 2.5)."), bitrate_mode: z.enum(["standard", "high"]).optional().describe("Seedance 2.x only (2.0, 2.0-fast, 2.0-mini, 2.5): output bitrate. Defaults to standard."), output_format: z.enum(["mp4", "mov"]).optional().describe("Seedance 2.5 only: output container. Every other model returns MP4."), camera_fixed: z.boolean().optional().describe("Seedance 1.5-pro only: lock the camera so the shot does not drift."), @@ -581,7 +607,7 @@ Returns a permanent blockrun-hosted video URL (the gateway mirrors the asset to } } if (media && !REFERENCE_MEDIA_MODELS.has(selectedModel)) { - return reject(`Model ${selectedModel} does not accept reference video or audio clips. Supported: ${[...REFERENCE_MEDIA_MODELS].join(", ")}.${selectedModel === SEEDANCE_25 ? " 2.5 takes reference IMAGES (up to 30) but no reference clips." : ""}`); + return reject(`Model ${selectedModel} does not accept reference video or audio clips. Supported: ${[...REFERENCE_MEDIA_MODELS].join(", ")}.`); } if (reference_audios?.length && !reference_image_urls?.length && !reference_videos?.length) { return reject("Reference audio requires a reference image or video — combine reference_audios with reference_image_urls or reference_videos."); @@ -693,8 +719,9 @@ Returns a permanent blockrun-hosted video URL (the gateway mirrors the asset to // Human-in-the-loop (BLOCKRUN_CONFIRM_SPEND=on): ask before signing. A // decline returns here — nothing is sent, and the finally releases the // reservation. No-ops when off, sub-threshold, or unsupported by the client. - // Reference clips are billed at the 15.2s ceiling each and can put a - // 5s render at 4-24x its usual price; a human approving the charge + // Reference clips are billed at their model's ceiling each (15.2s on + // the 2.0 family, 30.2s on 2.5) and can put a 5s render at 4-24x its + // usual price; a human approving the charge // has to see why, not just a model and a duration. const referenceLabel = [ reference_image_urls?.length && `${reference_image_urls.length} ref image${reference_image_urls.length > 1 ? "s" : ""}`, diff --git a/test/video-models.test.ts b/test/video-models.test.ts index 588b140..83da678 100644 --- a/test/video-models.test.ts +++ b/test/video-models.test.ts @@ -484,7 +484,7 @@ test("reference media is refused on the wallet rail and points at the account ra } }); -test("a reference clip is reserved at the 15.2s ceiling, not at the output duration", async () => { +test("a reference clip on 2.0 is reserved at its 15.2s ceiling, not at the output duration", async () => { // The count-based term this replaced priced the clip as if it were as long as // the render, which under-reserved 2-3.2x. Assert the property, not a literal: // the surcharge does not move with the output length. diff --git a/test/video-reference-media.test.ts b/test/video-reference-media.test.ts index 0be8f15..249d82e 100644 --- a/test/video-reference-media.test.ts +++ b/test/video-reference-media.test.ts @@ -92,7 +92,7 @@ mock.module("../src/utils/confirm-spend.js", { }, }); -const { registerVideoTool, estimateVideoCost } = await import("../src/tools/video.js"); +const { registerVideoTool, estimateVideoCost, referenceCeilingSeconds } = await import("../src/tools/video.js"); function makeHarnessWithConfig() { let config: any; @@ -139,8 +139,8 @@ const AUD = "https://example.com/score.mp3"; // --------------------------------------------------------------------------- // The gateway's arithmetic, transcribed: tokens = output seconds x the model's -// per-second rate x the resolution factor, PLUS every reference clip at the -// 15.2s ceiling x 21,600 tokens/s (audio at 0.3x), with the reference term's +// per-second rate x the resolution factor, PLUS every reference clip at its +// model's ceiling (15.2s on the 2.0 family) x 21,600 tokens/s (audio at 0.3x), with the reference term's // resolution factor floored at 1. Reserve must never fall below it. const REFERENCE_CHARGE: Array<[string, number, number, number, string | undefined, number]> = [ // model, outputSeconds, videos, audios, resolution, gateway charge (no tx fee) @@ -170,6 +170,47 @@ test("the reserve covers the gateway's per-reference-second charge on every prob } }); +// The reference-clip ceiling is PER MODEL (the gateway's REFERENCE_CEILING_SECONDS, +// enterprise#297): "must be less than or equal to 15.2" on the 2.0 family and +// "… 30.2" on 2.5. A flat 15.2 would reserve a 2.5 clip at half its price, on +// the one rail where the reserve is the only budget control. +test("the reference-clip ceiling is per model: 15.2s on the 2.0 family, 30.2s on 2.5, none on 1.5-pro", () => { + for (const m of ["bytedance/seedance-2.0", "bytedance/seedance-2.0-fast", "bytedance/seedance-2.0-mini"]) { + assert.equal(referenceCeilingSeconds(m), 15.2, m); + } + assert.equal(referenceCeilingSeconds("bytedance/seedance-2.5"), 30.2); + for (const m of ["bytedance/seedance-1.5-pro", "xai/grok-imagine-video", "azure/sora-2", "constructor"]) { + assert.equal(referenceCeilingSeconds(m), undefined, m); + } +}); + +test("a seedance-2.5 reference clip is reserved at the 30.2s ceiling, not 2.0's 15.2s", () => { + const m = "bytedance/seedance-2.5"; + const plain = estimateVideoCost(m, 5, "720p"); + const oneVideo = estimateVideoCost(m, 5, "720p", { videos: 1 }); + // 30.2s x 21,600 tok/s x $13.8565/M x 1.05 margin = $9.4908 for the clip + // alone; (5s x 21,690 + 30.2s x 21,600) x the same = $11.0687 in all. + assert.ok(Math.abs(oneVideo - 11.068690) < 0.0021, `2.5 + one clip reserves ${oneVideo}, expected ~$11.07`); + assert.ok(oneVideo - plain > 9.49, `a 30.2s clip on 2.5 costs ~$9.49, got ${oneVideo - plain}`); + // The gateway quotes a 2.5 clip at its ceiling floored to whole seconds (30s, + // measured 2026-09-25): $11.0058. The reserve must never fall below it. + assert.ok(oneVideo >= 11.005837, `reserve ${oneVideo} below the gateway's 30s charge`); + // Audio is 0.3x video at the SAME per-model ceiling. + const oneAudio = estimateVideoCost(m, 5, "720p", { audios: 1 }) - plain; + assert.ok(Math.abs(oneAudio / (oneVideo - plain) - 0.3) < 1e-6, `audio factor on 2.5 is ${oneAudio / (oneVideo - plain)}`); + // Per reference second the two tiers differ only by their token price, so + // after dividing that out the 2.5 clip is exactly 30.2/15.2 of a 2.0 clip. + const clip20 = estimateVideoCost("bytedance/seedance-2.0", 5, "720p", { videos: 1 }) - estimateVideoCost("bytedance/seedance-2.0", 5, "720p"); + const secondsRatio = ((oneVideo - plain) / 13.8565) / (clip20 / 9.9715); + assert.ok(Math.abs(secondsRatio - 30.2 / 15.2) < 1e-4, `2.5 clip is ${secondsRatio}x a 2.0 clip's seconds, expected ${30.2 / 15.2}`); +}); + +test("references on a token-priced model with no clip ceiling (1.5-pro) throw, never borrow another model's", () => { + assert.throws(() => estimateVideoCost("bytedance/seedance-1.5-pro", 5, "720p", { videos: 1 }), /no reference-clip ceiling/); + assert.throws(() => estimateVideoCost("bytedance/seedance-1.5-pro", 5, "720p", { audios: 1 }), /no reference-clip ceiling/); + assert.ok(estimateVideoCost("bytedance/seedance-1.5-pro", 5, "720p") > 0, "1.5-pro without references still prices"); +}); + test("a reference clip costs the same whatever the OUTPUT length — the bug the count-based term had", () => { // The replaced formula multiplied the surcharge by the render duration, so a // 4s render with a 15.2s clip reserved a quarter of what it owed. The clip's @@ -234,11 +275,15 @@ test("reference media is refused BEFORE any network call on both wallet rails", { reference_videos: [{ url: VID }] }, { reference_audios: [{ url: AUD }], reference_image_urls: [IMG] }, ]) { - const text = await errorText({ prompt: "a cube", model: "bytedance/seedance-2.0", ...args }); - assert.match(text, /served only by the BlockRun account rail/, `${chain}: ${text}`); - assert.match(text, /api\.blockrun\.ai/, text); - // The refusal must never read as a money event: nothing was sent. - assert.match(text, /No payment was taken/, text); + // 2.5 takes clips on the account rail now; neither wallet gateway + // serves reference media for ANY model, so the rail rule holds for it too. + for (const model of ["bytedance/seedance-2.0", "bytedance/seedance-2.5"]) { + const text = await errorText({ prompt: "a cube", model, ...args }); + assert.match(text, /served only by the BlockRun account rail/, `${chain} ${model}: ${text}`); + assert.match(text, /api\.blockrun\.ai/, text); + // The refusal must never read as a money event: nothing was sent. + assert.match(text, /No payment was taken/, text); + } } } } finally { @@ -333,10 +378,13 @@ test("every capability guard fires for its off-model input, and none reaches the [{ model: "azure/sora-2", reference_image_urls: [IMG] }, /does not accept reference images/], [{ model: "bytedance/seedance-2.0", reference_image_urls: Array(10).fill(IMG) }, /at most 9 reference images — got 10/], [{ model: "bytedance/seedance-2.0-fast", reference_image_urls: Array(10).fill(IMG) }, /at most 9 reference images/], - // reference clips: 2.5 takes images but no clips - [{ model: "bytedance/seedance-2.5", reference_videos: [{ url: VID }] }, /does not accept reference video or audio/], - [{ model: "bytedance/seedance-2.5", reference_audios: [{ url: AUD }], reference_image_urls: [IMG] }, /2\.5 takes reference IMAGES/], + // reference clips: 1.5-pro and the non-Seedance models take none [{ model: "bytedance/seedance-1.5-pro", reference_videos: [{ url: VID }] }, /does not accept reference video or audio/], + [{ model: "bytedance/seedance-1.5-pro", reference_audios: [{ url: AUD }], reference_videos: [{ url: VID }] }, /does not accept reference video or audio/], + [{ model: "azure/sora-2", reference_videos: [{ url: VID }] }, /does not accept reference video or audio/], + // 2.5 still caps clips at 3 of each, and audio still needs visual conditioning + [{ model: "bytedance/seedance-2.5", reference_audios: [{ url: AUD }] }, /requires a reference image or video/], + [{ model: "bytedance/seedance-2.5", reference_videos: [{ url: VID }], image_url: IMG }, /cannot be combined with frame seeds/], [{ model: "xai/grok-imagine-video", reference_videos: [{ url: VID }] }, /does not accept reference video or audio/], // audio needs visual conditioning [{ model: "bytedance/seedance-2.0", reference_audios: [{ url: AUD }] }, /requires a reference image or video/], @@ -368,10 +416,20 @@ test("every capability guard fires for its off-model input, and none reaches the test("the accepted twins at each boundary still go through", async () => { assert.equal(((await bodySentFor({ prompt: "t", model: "bytedance/seedance-2.0", reference_image_urls: Array(9).fill(IMG) })).reference_image_urls as string[]).length, 9); assert.equal(((await bodySentFor({ prompt: "t", model: "bytedance/seedance-2.5", reference_image_urls: Array(30).fill(IMG) })).reference_image_urls as string[]).length, 30); - for (const model of ["bytedance/seedance-2.0", "bytedance/seedance-2.0-fast", "bytedance/seedance-2.0-mini"]) { + for (const model of ["bytedance/seedance-2.0", "bytedance/seedance-2.0-fast", "bytedance/seedance-2.0-mini", "bytedance/seedance-2.5"]) { const body = await bodySentFor({ prompt: "t", model, reference_videos: [{ url: VID }], reference_audios: [{ url: AUD }] }); assert.deepEqual(body.reference_videos, [{ url: VID }], model); + assert.deepEqual(body.reference_audios, [{ url: AUD }], model); } + // 2.5 at its limits: 3 clips of each type beside 30 reference images. + const full = await bodySentFor({ + prompt: "t", model: "bytedance/seedance-2.5", duration_seconds: 30, + reference_image_urls: Array(30).fill(IMG), + reference_videos: Array(3).fill({ url: VID }), reference_audios: Array(3).fill({ url: AUD }), + }); + assert.equal((full.reference_videos as unknown[]).length, 3); + assert.equal((full.reference_audios as unknown[]).length, 3); + assert.equal((full.reference_image_urls as unknown[]).length, 30); // Every shape that used to need an input_type declaration still goes // through; the gateway does the inference now. for (const args of [ @@ -517,6 +575,34 @@ test("the reference_videos description gives the real multiple, not the old form assert.match(d, /about 4x/); }); +test("the schema text names 2.5 for reference clips and gives each family its own ceiling", () => { + const { config } = makeHarnessWithConfig(); + for (const field of ["reference_videos", "reference_audios"] as const) { + const d = config.inputSchema[field].description as string; + assert.match(d, /2\.5/, field); + assert.doesNotMatch(d, /NOT 2\.5/, field); + assert.match(d, /15\.2s on the 2\.0 family, 30\.2s on 2\.5/, field); + } + const desc = config.description as string; + assert.doesNotMatch(desc, /no reference VIDEO\/AUDIO/); + assert.doesNotMatch(desc, /billed at the 15\.2s ceiling/); + assert.match(desc, /30\.2s on 2\.5/); +}); + +test("the account rail reserves a 2.5 reference job at the 30.2s ceiling before submitting", async () => { + const args = { prompt: "t", model: "bytedance/seedance-2.5", duration_seconds: 5, reference_videos: [{ url: VID }], reference_image_urls: [IMG] }; + const expected = estimateVideoCost("bytedance/seedance-2.5", 5, undefined, { videos: 1 }); + const h = makeHarness(); + h.budget.limit = expected - 0.01; + const res = await h.call(args); + const text = res.content.map((c: any) => c.text).join("\n"); + assert.equal(res.isError, true, "a budget below the 30.2s-ceiling price must refuse a 2.5 reference job"); + assert.doesNotMatch(text, /NETWORK_ESCAPE/); + const shown = /next call estimated \$([\d.]+)/.exec(text); + assert.ok(shown, `no estimate in budget message: ${text}`); + assert.ok(Math.abs(Number(shown[1]) - expected) < 0.01 && expected > 11, `reserved ${shown[1]}, expected ~${expected}`); +}); + test("2.5 first-and-last-frame is refused by name on the Solana wallet rail, and only there", async () => { // sol.blockrun.ai answers 400 "does not support first-and-last-frame video" // for 2.5 before quoting; Base quotes it (unsigned probes, 2026-09-29).