diff --git a/CHANGELOG.md b/CHANGELOG.md
index 66249aa..42a0666 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,50 @@
# Changelog
+## 0.53.0
+
+### Added — Seedance reference media, 2.5 first/last frame, output controls
+
+`blockrun_video` gains the Seedance capabilities the gateway already serves.
+Full matrix, per rail and per model, in
+[docs/seedance-capabilities.md](docs/seedance-capabilities.md).
+
+- **Reference media — account rail only (`BLOCKRUN_API_KEY`).**
+ `reference_image_urls` (up to 9 on seedance-2.0 / 2.0-fast / 2.0-mini, up
+ to 30 on 2.5), `reference_videos` and `reference_audios` (1-3 each, 2.0
+ family only; audio needs an image or video beside it). The Base and Solana
+ gateways refuse reference media with a 400 before quoting, so the tool
+ refuses it on those rails first, naming the rail that serves it. No
+ payment is taken. References and frame seeds (`image_url`,
+ `last_frame_url`, `real_face_asset_id`) are mutually exclusive.
+- **Reference clips are expensive, and the reserve says so.** The gateway
+ bills per reference second, and the caller sends a URL, never a duration,
+ so every clip is reserved at the model's 15.2s ceiling. One clip makes a 5s
+ 720p render cost about 4x (seedance-2.0-mini ~$0.40 to ~$1.61). A 4K job
+ on seedance-2.0 with three videos and three audios reserves ~$130.88. The
+ spend confirmation now lists the reference clips, so the price makes sense.
+ Reference images carry no surcharge.
+- **First-and-last-frame on seedance-2.5** (`last_frame_url` with
+ `image_url`), joining 1.5-pro and the 2.0 family.
+- **Output controls:** `bitrate_mode` (2.x), `output_format` mp4/mov (2.5),
+ `return_last_frame`, `watermark` and `safety_identifier` (Seedance), and
+ `seed` / `camera_fixed` (1.5-pro). A control declared for the wrong model is
+ refused by name before payment.
+- Every in-memory guard now runs before the SSRF DNS resolution, so a
+ request the tool will refuse anyway costs no lookups.
+
+### Fixed
+
+- **Guard order.** With references present, the conflict with frame seeds is
+ answered before the `last_frame_url` / RealFace guards. Before, a reference
+ request carrying `last_frame_url` was told to add `image_url`, and only on
+ the resubmit that seeds and references never mix.
+- **A Solana image give-up named the wrong place to check.** Since 0.52.2 the
+ Solana image path can end in a settled-at-submit give-up, and the message
+ was written for the account rail: "the account is charged", "check
+ user.blockrun.ai/dashboard/activity". That dashboard has no record of an
+ on-chain transfer. The message now names the Solana wallet and
+ `blockrun_wallet action:"report"`.
+
## 0.52.3
### Fixed — 0.52.2's Solana image fix cut the paid submit off at 30s
diff --git a/README.md b/README.md
index d0db2be..b62032e 100644
--- a/README.md
+++ b/README.md
@@ -44,7 +44,7 @@ claude mcp add blockrun -s user -- npx -y @blockrun/mcp@latest
-
+
@@ -252,13 +252,13 @@ Package managers have shown install size for decades. Almost no MCP server shows
| Profile | Tools | Context |
|---------|-------|---------|
-| `full` *(default)* | 19 | 13,044 |
+| `full` *(default)* | 19 | 14,000 |
| `trading` | 8 | 5,411 |
-| `media` | 7 | 5,828 |
+| `media` | 7 | 6,784 |
| `research` | 5 | 2,752 |
| `chat` | 3 | 2,079 |
-Running `--profile trading` instead of the default costs **59% less context** for the same trading
+Running `--profile trading` instead of the default costs **61% less context** for the same trading
workflow. If you only ever ask about markets, that is the single cheapest change you can make.
Measure it yourself — against us, or against any other stdio MCP server:
@@ -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 | $0.053–0.32/sec charged |
+| `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_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/VERSION b/VERSION
index e095205..7f422a1 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.52.3
+0.53.0
diff --git a/assets/context-cost-dark.svg b/assets/context-cost-dark.svg
index aa771f2..e326291 100644
--- a/assets/context-cost-dark.svg
+++ b/assets/context-cost-dark.svg
@@ -1,10 +1,10 @@
-
+
CONTEXT COST
- 13.0K tokens
+ 14.0K tokens
7% of a 200K context window · every turn, whether or not you call a tool
- 5.4K with --profile trading — 59% less
+ 5.4K with --profile trading — 61% less
measured, not estimated
diff --git a/assets/context-cost.svg b/assets/context-cost.svg
index 585212b..8020d37 100644
--- a/assets/context-cost.svg
+++ b/assets/context-cost.svg
@@ -1,10 +1,10 @@
-
+
CONTEXT COST
- 13.0K tokens
+ 14.0K tokens
7% of a 200K context window · every turn, whether or not you call a tool
- 5.4K with --profile trading — 59% less
+ 5.4K with --profile trading — 61% less
measured, not estimated
diff --git a/docs/mcp-schema-overhead.md b/docs/mcp-schema-overhead.md
index 64e34a5..86b3cf2 100644
--- a/docs/mcp-schema-overhead.md
+++ b/docs/mcp-schema-overhead.md
@@ -10,19 +10,19 @@ Harness: [`scripts/measure-tool-schema.mjs`](../scripts/measure-tool-schema.mjs)
which fails the build when the README card disagrees with a live measurement.
Written 2026-09-01, verified against `@modelcontextprotocol/sdk` 1.29.0. Numbers re-measured
-2026-09-09 at 0.49.0.
+2026-09-26 for the Seedance video parameter expansion and its review pass.
## Our number
| Profile | Tools | Context |
|---------|-------|---------|
-| `full` *(default)* | 19 | 13,044 |
+| `full` *(default)* | 19 | 14,000 |
| `trading` | 8 | 5,411 |
-| `media` | 7 | 5,828 |
+| `media` | 7 | 6,784 |
| `research` | 5 | 2,752 |
| `chat` | 3 | 2,079 |
-Descriptions are ~55% of it, input schemas ~40%. `--profile trading` costs 59% less than the
+Descriptions are ~54% of it, input schemas ~42%. `--profile trading` costs 61% less than the
default for the same workflow.
These figures move with every description edit, so they are not the source of truth — the README
diff --git a/docs/seedance-capabilities.md b/docs/seedance-capabilities.md
new file mode 100644
index 0000000..1141716
--- /dev/null
+++ b/docs/seedance-capabilities.md
@@ -0,0 +1,139 @@
+# Seedance input and output capabilities
+
+What `blockrun_video` accepts, which models take it, which RAIL serves it, and
+what it costs. Written for this MCP server; the gateway's own request shapes,
+the SDK field names and the deployment switches live in their own repos.
+
+## Reference media is account-rail only
+
+Reference inputs (`reference_image_urls`, `reference_videos`,
+`reference_audios`) are served **only** by `api.blockrun.ai` — the account rail,
+reached by setting `BLOCKRUN_API_KEY`.
+
+Both wallet gateways refuse them with a `400` **before** issuing a quote
+(blockrun#728, blockrun-sol#374; live-probed on `blockrun.ai` and
+`sol.blockrun.ai` on 2026-09-26):
+
+```
+{"error":"reference media is not available on this gateway",
+ "message":"Seedance reference media (reference_image_urls) is served only by
+ api.blockrun.ai. ..."}
+```
+
+`blockrun_video` refuses them client-side on the wallet rails rather than
+forwarding a request that cannot succeed, so no payment is taken and no DNS
+lookup is spent on the reference URLs. Frame seeding (`image_url`,
+`last_frame_url`) is unaffected and works on every rail.
+
+## Supported combinations
+
+| Model | First + last frame | Reference images | Reference video/audio |
+| --- | --- | --- | --- |
+| 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 | 1–30 | No |
+
+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.
+
+`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
+content constraints still apply; accepting a URL does not verify the remote
+file.
+
+```json
+{
+ "model": "bytedance/seedance-2.0-mini",
+ "prompt": "Use image 1 for the character and video 1 for the motion",
+ "duration_seconds": 5,
+ "reference_image_urls": ["https://example.com/character.png"],
+ "reference_videos": [{"url": "https://example.com/motion.mp4"}],
+ "return_last_frame": true
+}
+```
+
+`input_type` is not a parameter of this tool. The gateway infers it from the
+fields above, so there is nothing to declare; a clip `role` is likewise omitted,
+since `"reference"` is the only value upstream honours.
+
+## What reference media costs
+
+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.
+
+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:
+
+| Request | Reserved |
+| --- | --- |
+| seedance-2.0-mini, 5s output, no references | ~$0.40 |
+| seedance-2.0-mini, 5s output, 1 reference video | ~$1.61 |
+| seedance-2.0-mini, 4s output, 3 videos + 3 audios | ~$5.03 |
+| 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 |
+
+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
+audios at 480p, because the resolution discount reaches the render but not the
+clip. The 4K row is not a typo: the clip term scales with output resolution
+exactly as the gateway's does, so a 4K reference job genuinely reserves and
+bills over a hundred dollars.
+`bytedance/seedance-2.0-mini` takes the same reference inputs as
+`bytedance/seedance-2.0` at roughly a third of the rate — prefer it unless you
+need 4K.
+
+Reference **images** carry no surcharge: the gateway's price formula takes
+reference video and audio seconds only, and on Seedance the image-to-video and
+text-to-video per-second rates are equal.
+
+The account rail bills when the gateway **accepts** the job, and issues no 402,
+so the reserve above is the only pre-payment control. Check
+`blockrun_wallet action:"report"` before a large reference job.
+
+## Additional output controls
+
+| Field | Models | Values |
+| --- | --- | --- |
+| `bitrate_mode` | Seedance 2.x | `standard`, `high` |
+| `output_format` | Seedance 2.5 | `mp4`, `mov` |
+| `camera_fixed` | Seedance 1.5-pro | Boolean |
+| `seed` | Seedance 1.5-pro | 0 – 2147483647 |
+| `safety_identifier` | Seedance | Opaque end-user id, ≤128 chars — not a content filter, and not a place for personal data |
+| `watermark` | Seedance | Boolean, default off |
+| `return_last_frame` | Seedance | Boolean |
+
+With `return_last_frame`, a completed response carries `last_frame_url` in both
+the text output and `structuredContent`, plus `last_frame_backed_up` when the
+gateway reports it. The frame uses the same storage backup semantics as the
+video. An upstream that omits the frame produces no invented URL.
+
+## Not in scope
+
+Automatic duration (`-1`), 2.5 editing/extension task modes, 2.5 1080p,
+draft/flex service tiers, callbacks, and task-list/cancel APIs are not exposed.
+The first group needs verified cost/output bounds; the lifecycle features need
+a separate ownership and settlement design.
+
+A control this tool DOES declare but the chosen model does not support is
+rejected before payment, naming the model and the field. A field the tool does
+not declare at all — `callback_url`, `draft`, `service_tier` and the rest — is
+stripped by schema validation before the handler sees it, so the job proceeds
+without it rather than failing. Do not rely on one of those reaching the
+gateway.
+
+Reference-video/audio jobs are additionally subject to the gateway's own
+`R2V_ENABLED` operational switch, which answers `503` when off. That is
+deployment state this repo neither reads nor changes. Image-only references are
+not subject to it.
+
+New behaviour is covered by `test/video-reference-media.test.ts` (rail
+availability, per-guard refusals, reserve arithmetic, SSRF across the arrays).
+No paid upstream renders were performed; run a small paid smoke test before
+relying on a new combination in production.
diff --git a/package.json b/package.json
index 53d86a7..23d178d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@blockrun/mcp",
- "version": "0.52.3",
+ "version": "0.53.0",
"mcpName": "io.github.BlockRunAI/blockrun-mcp",
"description": "BlockRun MCP Server - Give your AI agent web search, deep research, prediction markets, and crypto data. Pay per call from a USDC wallet (Solana or Base) or a BlockRun API key.",
"type": "module",
diff --git a/scripts/verify-prices.ts b/scripts/verify-prices.ts
index f7dfa8c..93bdd2c 100644
--- a/scripts/verify-prices.ts
+++ b/scripts/verify-prices.ts
@@ -241,6 +241,15 @@ const PROBES: Probe[] = [
["bytedance/seedance-2.0-fast", undefined, "480p"],
["bytedance/seedance-2.5", undefined, "480p"],
["bytedance/seedance-2.0", undefined, "480p"],
+ // NOT probed here, and not probeable here: the reference-media surcharge.
+ // Both wallet gateways refuse reference_image_urls / reference_videos /
+ // reference_audios with a 400 before quoting (blockrun#728, sol#374), so
+ // this script — which only speaks to blockrun.ai and sol.blockrun.ai — can
+ // never see a 402 that carries it. The reference term in estimateVideoCost
+ // is pinned instead against the gateway's own arithmetic in
+ // test/video-reference-media.test.ts, whose constants come from the
+ // token360 measurement in blockrun#730. A green run here does NOT mean the
+ // surcharge was verified.
] as Array<[string, number | undefined, string | undefined, boolean?]>).map(([model, seconds, resolution, expectRefused]) => ({
label: `video ${model.split("/")[1]}${seconds ? ` ${seconds}s` : ""}${resolution ? ` ${resolution}` : ""}`,
path: "videos/generations",
diff --git a/src/tools/image.ts b/src/tools/image.ts
index ba8808d..1e6fe3a 100644
--- a/src/tools/image.ts
+++ b/src/tools/image.ts
@@ -703,11 +703,19 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil
// "signature" for a rail that has none.
if (err instanceof BilledJobError) {
recordActualSpend(budget, err.paidUsd, estimatedCostForCatch, agent_id);
+ // Both rails throw this since the Solana image path moved onto the
+ // async helper (0.52.2): the account rail, and a Solana route that
+ // settled at submit. A USDC transfer already on-chain is not "billed
+ // to the account", and the account dashboard has no record of it.
+ const account = isApiKeyMode();
const what = err.billing === "billed"
- ? `Image generation did not return an image, but the gateway accepted the render${err.jobId ? ` (job ${err.jobId})` : ""} and the account is charged when it completes.`
- : `Image generation got no answer to its submit, so the render MAY have been accepted and billed to the BlockRun account.`;
+ ? account
+ ? `Image generation did not return an image, but the gateway accepted the render${err.jobId ? ` (job ${err.jobId})` : ""} and the account is charged when it completes.`
+ : `Image generation did not return an image, but the Solana wallet was charged when the gateway accepted the render${err.jobId ? ` (job ${err.jobId})` : ""}.`
+ : `Image generation got no answer to its submit, so the render MAY have been accepted and billed to ${account ? "the BlockRun account" : "the Solana wallet"}.`;
+ const where = account ? "https://user.blockrun.ai/dashboard/activity" : `blockrun_wallet action:"report" or the wallet's recent transactions`;
return {
- content: [{ type: "text", text: `${what} $${(err.paidUsd ?? estimatedCostForCatch).toFixed(4)} has been booked against your budget; check https://user.blockrun.ai/dashboard/activity before doing anything else — a new blockrun_image call starts and bills a second render.\nError: ${errMsg}` }],
+ content: [{ type: "text", text: `${what} $${(err.paidUsd ?? estimatedCostForCatch).toFixed(4)} has been booked against your budget; check ${where} before doing anything else — a new blockrun_image call starts and bills a second render.\nError: ${errMsg}` }],
isError: true,
};
}
diff --git a/src/tools/video.ts b/src/tools/video.ts
index 59ff62b..a8f1763 100644
--- a/src/tools/video.ts
+++ b/src/tools/video.ts
@@ -139,14 +139,64 @@ const REALFACE_MODELS = new Set([
]);
// Models that accept first-and-last-frame interpolation (last_frame_url).
-// Same reasoning as RealFace for 2.5.
+// The upstream schema includes frame interpolation on Seedance 2.5.
const FIRST_LAST_FRAME_MODELS = new Set([
"bytedance/seedance-1.5-pro",
"bytedance/seedance-2.0-fast",
"bytedance/seedance-2.0",
"bytedance/seedance-2.0-mini",
+ "bytedance/seedance-2.5",
]);
+// Models that accept reference IMAGES (reference_image_urls), and the per-model
+// count ceiling the gateway enforces. Mirrors supportsReferenceImages +
+// `seedanceId === "seedance-2.5" ? 30 : 9` in the gateway's video-input.ts.
+const REFERENCE_IMAGE_LIMIT: Record = {
+ "bytedance/seedance-2.0": 9,
+ "bytedance/seedance-2.0-fast": 9,
+ "bytedance/seedance-2.0-mini": 9,
+ "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 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 SEEDANCE_15_PRO = "bytedance/seedance-1.5-pro";
+const SEEDANCE_25 = "bytedance/seedance-2.5";
+
+/**
+ * Reference media is billed per reference SECOND, not per clip.
+ *
+ * Measured against token360 on 2026-09-23 (blockrun#730): identical 5s renders
+ * differing only in the reference clip cost +108,000 tokens for a 5s reference
+ * and +324,000 for a 15s one — exactly 3x for exactly 3x the length, so a
+ * reference second costs ~21,600 tokens against 21,780 for an output second,
+ * with no per-clip component at all. The count-based `1 + videos + 0.3*audios`
+ * term this replaces was right only when the clip happened to be as long as the
+ * render, and under-reserved 2-3.2x otherwise.
+ *
+ * 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.
+ */
+const REFERENCE_TOKENS_PER_SECOND = 21_600;
+const REFERENCE_AUDIO_SECOND_FACTOR = 0.3;
+const MAX_REFERENCE_SECONDS = 15.2;
+const MAX_REFERENCE_CLIPS = 3;
+
const VIDEO_DEFAULT_DURATION: Record = {
"xai/grok-imagine-video": 8,
"bytedance/seedance-1.5-pro": 5,
@@ -239,9 +289,26 @@ const GROK_RESOLUTIONS: Record; note: string
* is the priciest call this server can issue and was the last paid estimator the
* script did not cover, which is how a 30-40% stale Seedance table and a missing
* margin both survived. The handler still re-reserves from the 402 before
- * paying — reference media (r2v) adds upstream input tokens nothing here can see.
+ * paying on the wallet rails — but reference media is served ONLY by the
+ * 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.
*/
-export function estimateVideoCost(model: string, durationSeconds?: number, resolution?: string): number {
+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.
+ // Sitting inside it made the check fail OPEN for every other model: grok and
+ // sora fall through to their own tables, so a caller passing references there
+ // got them silently dropped — the same "priced as something it is not" shape
+ // the throw-never-default rule above exists to prevent.
+ const videos = references.videos ?? 0;
+ const audios = references.audios ?? 0;
+ if (![videos, audios].every(n => Number.isInteger(n) && n >= 0 && n <= MAX_REFERENCE_CLIPS)) {
+ throw new Error(`Invalid reference counts (videos=${videos}, audios=${audios}) — each must be a whole number from 0 to ${MAX_REFERENCE_CLIPS}.`);
+ }
+ 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.`);
+ }
// 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
@@ -260,7 +327,15 @@ 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.`);
}
- const tokens = seconds * SEEDANCE_TOKENS_PER_SECOND * RESOLUTION_TOKEN_FACTOR[res];
+ // 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);
+ // 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
+ // the one rate actually measured.
+ const tokens = seconds * SEEDANCE_TOKENS_PER_SECOND * RESOLUTION_TOKEN_FACTOR[res]
+ + referenceSeconds * REFERENCE_TOKENS_PER_SECOND * Math.max(RESOLUTION_TOKEN_FACTOR[res], 1);
return withTxFee((tokens * SEEDANCE_PRICE_PER_MTOKENS[model] / 1_000_000) * VIDEO_MARGIN);
}
@@ -326,22 +401,24 @@ export function registerVideoTool(server: McpServer, budget: BudgetState): void
{
description: `Generate short AI videos via BlockRun x402 on the active Base or Solana chain (async, client-polled).
-Turns a text prompt (and optional seed image) into a short MP4 clip. The tool submits the job, then polls until the video is ready (typical total wall-time 60-180s; 9 min Base / 15 min Solana hard cap). On the wallet rails payment settles only when upstream returns a finished video — if the job fails you are not charged; if this client gives up while a paid poll is still in flight the gateway may still settle, and the error text says so. On the account rail the job is billed when the gateway accepts it, so a job that fails or outlives the poll budget is still charged — the error names it.
+Turns a text prompt (and optional seed image) into a short video clip (MP4, or MOV on Seedance 2.5). The tool submits the job, then polls until the video is ready (typical total wall-time 60-180s; 9 min Base / 15 min Solana hard cap). On the wallet rails payment settles only when upstream returns a finished video — if the job fails you are not charged; if this client gives up while a paid poll is still in flight the gateway may still settle, and the error text says so. On the account rail the job is billed when the gateway accepts it, so a job that fails or outlives the poll budget is still charged — the error names it.
Models. Every rate below is what you are CHARGED (margin and transaction fee included), at the 720p baseline Seedance renders by default with synced audio:
- azure/sora-2 (~$0.105/sec, 720p + synced audio, text- or image-to-video) — OpenAI Sora 2 via Azure AI Foundry. duration_seconds must be 4, 8, or 12 (4s default -> ~$0.42/clip). image_url takes a NON-HUMAN reference image (faces are rejected upstream by moderation — use Seedance + RealFace for real people); same price as text-to-video. No RealFace, no last_frame_url. Base only for now: the Solana gateway quotes it as Seedance 2.0 at $1.135 and the tool refuses that quote unsigned.
- xai/grok-imagine-video ($0.05/sec at 480p default, $0.07/sec at 720p; 8s default -> $0.401/clip, 1-15s) — stylized, fast. 480p/720p only.
- bytedance/seedance-1.5-pro (~$0.071/sec, 4-12s, 5s default -> ~$0.35/clip) — cheapest Seedance, token-priced upstream
-- 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 and first/last-frame
-- bytedance/seedance-2.0-fast (~$0.165/sec, 4-15s, ~60-80s gen) — sweet-spot price/quality; supports BytePlus RealFace assets
-- 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 reference media
-- 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 does NOT support RealFace or first/last-frame. Use 2.0 for 1080p/4K or real-person video.
+- 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.
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.
+
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.
-Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GCS so URLs don't expire).`,
+Returns a permanent blockrun-hosted video URL (the gateway mirrors the asset to GCS so URLs don't expire).`,
annotations: TOOL_ANNOTATIONS.generative,
inputSchema: {
prompt: z.string().describe("Text description of the video to generate. E.g. 'a red apple slowly spinning on a wooden table', 'a hummingbird hovering near a red flower, ultra slow motion'"),
@@ -351,12 +428,22 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
generate_audio: z.boolean().optional().describe("Seedance only: whether to generate a synced audio track. Defaults ON for text-to-video and OFF for image/RealFace-conditioned. The auto-generated audio is occasionally rejected by upstream moderation ('output audio may contain sensitive information') even for benign prompts — pass false to skip audio and avoid that failure. Ignored by xAI/Sora."),
resolution: z.enum(["480p", "720p", "1080p", "4K"]).optional().describe("Output resolution. Seedance defaults to 720p and is token-priced (~2.25x at 1080p, ~9x at 4K); per-model sets from token360's published schema: seedance-2.0 480p/720p/1080p/4K · 1.5-pro 480p/720p/1080p · 2.0-fast, 2.0-mini and 2.5 480p/720p only. grok-imagine-video honours 480p (default, $0.05/sec) and 720p ($0.07/sec) and rejects anything higher. Ignored by Sora only (dropped from the request)."),
aspect_ratio: z.enum(["adaptive", "16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]).optional().describe("Output aspect ratio. Seedance honors the full set; Sora uses it only to pick portrait vs landscape (9:16 / 3:4 -> portrait); Grok ignores it (the gateway never forwards it to xAI). Defaults to the model's own default. (9:21 removed 2026-08-07 — no Seedance model offers it; use 9:16 for vertical.)"),
- last_frame_url: z.string().url().optional().describe("Seedance 1.5-pro / 2.0 / 2.0-fast / 2.0-mini only (NOT 2.5): 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."),
+ last_frame_url: z.string().url().optional().describe("Seedance 1.5-pro / 2.0 / 2.0-fast / 2.0-mini / 2.5: 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."),
+ 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."),
+ safety_identifier: z.string().max(128).optional().describe("Seedance only: an opaque, stable end-user id forwarded upstream for abuse attribution. Not a content filter — do NOT put personal data in it."),
+ seed: z.number().int().min(0).max(2_147_483_647).optional().describe("Seedance 1.5-pro only: reproducibility seed. Same seed + same prompt re-renders the same clip."),
+ watermark: z.boolean().optional().describe("Seedance only: burn the provider watermark into the output. Defaults off."),
+ return_last_frame: z.boolean().optional().describe("Seedance only: also return the final frame as an image URL, to seed the next clip in a chain."),
agent_id: z.string().optional().describe("Agent identifier for budget tracking and enforcement."),
},
},
- async ({ prompt, image_url, real_face_asset_id, duration_seconds, generate_audio, resolution, aspect_ratio, last_frame_url, model, agent_id }) => {
+ async ({ prompt, image_url, real_face_asset_id, duration_seconds, generate_audio, resolution, aspect_ratio, last_frame_url, reference_image_urls, reference_videos, reference_audios, bitrate_mode, output_format, camera_fixed, safety_identifier, seed, watermark, return_last_frame, model, agent_id }) => {
// Reserve the estimate up front so concurrent calls can't each pass a
// stale budget; release in finally once the call settles or fails.
let gate: ReturnType | undefined;
@@ -391,11 +478,36 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
try {
const selectedModel = model || "xai/grok-imagine-video";
+ const reject = (text: string) => ({ content: [{ type: "text" as const, text: formatError(text) }], isError: true });
+ const refs = Boolean(reference_image_urls?.length || reference_videos?.length || reference_audios?.length);
+
+ // Answered BEFORE every other guard, because the rail fact dominates
+ // them. Reference media is an api.blockrun.ai capability: BOTH wallet
+ // gateways answer any reference_* field with a 400 before quoting
+ // (blockrun#728, blockrun-sol#374; live-probed 2026-09-26 on
+ // blockrun.ai and sol.blockrun.ai). Ranked below the frame-seed guards
+ // it cost three round trips to learn the one thing that mattered — a
+ // 2.5 reference request with last_frame_url was told to add image_url,
+ // then that frame seeds and references do not mix, and only then that
+ // the rail cannot serve it at all.
+ if (refs && !isApiKeyMode()) {
+ return reject(`Reference media (reference_image_urls / reference_videos / reference_audios) is served only by the BlockRun account rail (api.blockrun.ai). The Base and Solana gateways refuse it with a 400 before quoting. Set BLOCKRUN_API_KEY to use the account rail, or drop the reference fields — image_url / last_frame_url frame seeding works on every rail. No payment was taken.`);
+ }
+
+ // Second for the same reason: with references present, every
+ // frame-seed guard below answers the wrong question. A reference
+ // request carrying last_frame_url and no image_url was told to add
+ // image_url, and only on the resubmit that frame seeds and references
+ // do not mix at all.
+ if (refs && (image_url || last_frame_url || real_face_asset_id)) {
+ return reject(`Reference inputs cannot be combined with frame seeds — reference mode and first-frame seeding are mutually exclusive. Drop ${[image_url && "image_url", last_frame_url && "last_frame_url", real_face_asset_id && "real_face_asset_id"].filter(Boolean).join(" / ")}, and pass character or style images as reference_image_urls instead.`);
+ }
+
// RealFace guardrails — fail fast client-side instead of round-tripping a 400.
if (real_face_asset_id) {
if (!REALFACE_MODELS.has(selectedModel)) {
return {
- content: [{ type: "text", text: formatError(`Model ${selectedModel} does not support RealFace assets. Use bytedance/seedance-2.0, bytedance/seedance-2.0-fast or bytedance/seedance-2.0-mini.`) }],
+ content: [{ type: "text", text: formatError(`Model ${selectedModel} does not support RealFace assets. Use ${[...REALFACE_MODELS].join(", ")}.`) }],
isError: true,
};
}
@@ -412,7 +524,9 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
if (last_frame_url) {
if (!FIRST_LAST_FRAME_MODELS.has(selectedModel)) {
return {
- content: [{ type: "text", text: formatError(`Model ${selectedModel} does not support first-and-last-frame interpolation (last_frame_url). Use bytedance/seedance-2.0, bytedance/seedance-2.0-fast, bytedance/seedance-2.0-mini or bytedance/seedance-1.5-pro.`) }],
+ // Model list read off the SET, not retyped: 2.5 joined it in
+ // 0.53.0 and the hand-maintained sentence did not follow.
+ content: [{ type: "text", text: formatError(`Model ${selectedModel} does not support first-and-last-frame interpolation (last_frame_url). Use ${[...FIRST_LAST_FRAME_MODELS].join(", ")}.`) }],
isError: true,
};
}
@@ -430,29 +544,53 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
}
}
- // SSRF guard on caller-supplied URLs, mirroring blockrun_image
- // (src/tools/image.ts). This process never fetches these URLs — the
- // GATEWAY's fetcher does — so this is defense-in-depth plus a saved
- // round trip: a URL pointing at localhost / the metadata endpoint /
- // the private network was previously forwarded, quoted, and PAID for
- // before failing (or worse, succeeding) server-side. Resolved, not
- // literal: wildcard-DNS names like 127.0.0.1.nip.io are public strings
- // that map to private addresses. zod's .url() accepts any scheme, so
- // file:// etc. are rejected here too.
- for (const [name, value] of [["image_url", image_url], ["last_frame_url", last_frame_url]] as const) {
- if (!value) continue;
- const parsed = new URL(value);
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
- return {
- content: [{ type: "text", text: formatError(`${name} must be an http(s) URL — got scheme "${parsed.protocol}"`) }],
- isError: true,
- };
+ // Reference media and the new output controls, checked BEFORE the SSRF
+ // loop below: those are up to 36 sequential DNS resolutions, and a
+ // combination this tool will refuse anyway must not pay for them. Same
+ // shape as the RealFace / last_frame_url guards above — a returned
+ // formatError, never a throw: a throw lands in the catch's money
+ // classifier and comes back as "Video generation failed", which reads
+ // like a render died when in fact nothing left the machine.
+ const media = Boolean(reference_videos?.length || reference_audios?.length);
+ const isSeedance = selectedModel.startsWith("bytedance/seedance-");
+ // hasOwn, not `!== undefined`: a prototype key ("constructor") would
+ // otherwise read as a function here, clear the support check below and
+ // make `length > ` a NaN-false that bypasses the count cap.
+ // Unreachable behind the zod enum today; the estimator's tables are
+ // guarded the same way for the same reason.
+ const referenceImageLimit = Object.hasOwn(REFERENCE_IMAGE_LIMIT, selectedModel) ? REFERENCE_IMAGE_LIMIT[selectedModel] : undefined;
+
+ if (reference_image_urls?.length) {
+ // Two remedies, two messages: "wrong model" and "too many for this
+ // model" are not the same fix, and one shared string left the caller
+ // unable to tell whether to drop an image or switch model.
+ if (referenceImageLimit === undefined) {
+ return reject(`Model ${selectedModel} does not accept reference images (reference_image_urls). Supported: ${Object.keys(REFERENCE_IMAGE_LIMIT).join(", ")}.`);
}
- if (await isBlockedFetchHostResolved(parsed.hostname)) {
- return {
- content: [{ type: "text", text: formatError(`${name} resolves to a private/loopback/link-local address (${parsed.hostname}) — refusing to forward it to the gateway.`) }],
- isError: true,
- };
+ if (reference_image_urls.length > referenceImageLimit) {
+ return reject(`${selectedModel} accepts at most ${referenceImageLimit} reference images — got ${reference_image_urls.length}.`);
+ }
+ }
+ 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." : ""}`);
+ }
+ 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.");
+ }
+ if (bitrate_mode !== undefined && !BITRATE_MODE_MODELS.has(selectedModel)) {
+ return reject(`bitrate_mode requires a Seedance 2.x model — got ${selectedModel}. Supported: ${[...BITRATE_MODE_MODELS].join(", ")}.`);
+ }
+ if (output_format !== undefined && selectedModel !== SEEDANCE_25) {
+ return reject(`output_format requires ${SEEDANCE_25} — got ${selectedModel}. Every other model returns MP4.`);
+ }
+ for (const [field, value] of [["seed", seed], ["camera_fixed", camera_fixed]] as const) {
+ if (value !== undefined && selectedModel !== SEEDANCE_15_PRO) {
+ return reject(`${field} requires ${SEEDANCE_15_PRO} on this tool — got ${selectedModel}.`);
+ }
+ }
+ for (const [field, value] of [["safety_identifier", safety_identifier], ["watermark", watermark], ["return_last_frame", return_last_frame]] as const) {
+ if (value !== undefined && !isSeedance) {
+ return reject(`${field} requires a Seedance model — got ${selectedModel}.`);
}
}
@@ -490,9 +628,52 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
}
}
+ // SSRF guard on caller-supplied URLs, mirroring blockrun_image
+ // (src/tools/image.ts). This process never fetches these URLs — the
+ // GATEWAY's fetcher does — so this is defense-in-depth plus a saved
+ // round trip: a URL pointing at localhost / the metadata endpoint /
+ // the private network was previously forwarded, quoted, and PAID for
+ // before failing (or worse, succeeding) server-side. Resolved, not
+ // literal: wildcard-DNS names like 127.0.0.1.nip.io are public strings
+ // that map to private addresses. zod's .url() accepts any scheme, so
+ // file:// etc. are rejected here too.
+ // Typed explicitly, not inferred: without the annotation a future
+ // `.map` callback returning a 1-element array still compiles, yields
+ // `value === undefined`, and is skipped by the falsy-continue below —
+ // silently dropping the SSRF check for that entire field.
+ const urlInputs: Array = [
+ ["image_url", image_url],
+ ["last_frame_url", last_frame_url],
+ ...(reference_image_urls ?? []).map(url => ["reference_image_urls", url] as const),
+ ...(reference_videos ?? []).map(clip => ["reference_videos", clip.url] as const),
+ ...(reference_audios ?? []).map(clip => ["reference_audios", clip.url] as const),
+ ];
+ // One resolution per HOST, not per URL: 30 reference images on one CDN
+ // used to be 30 identical getaddrinfo calls on libuv's 4-thread pool,
+ // awaited one at a time, before this call had earned anything.
+ const resolvedHosts = new Set();
+ for (const [name, value] of urlInputs) {
+ if (!value) continue;
+ const parsed = new URL(value);
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ return {
+ content: [{ type: "text", text: formatError(`${name} must be an http(s) URL — got scheme "${parsed.protocol}"`) }],
+ isError: true,
+ };
+ }
+ if (resolvedHosts.has(parsed.hostname)) continue;
+ resolvedHosts.add(parsed.hostname);
+ if (await isBlockedFetchHostResolved(parsed.hostname)) {
+ return {
+ content: [{ type: "text", text: formatError(`${name} resolves to a private/loopback/link-local address (${parsed.hostname}) — refusing to forward it to the gateway.`) }],
+ isError: true,
+ };
+ }
+ }
+
// Image input is NOT discounted upstream on Seedance (only video-to-video
// is), so text-to-video and image-to-video share one per-second rate.
- estimatedCost = estimateVideoCost(selectedModel, billedSeconds, resolution);
+ estimatedCost = estimateVideoCost(selectedModel, billedSeconds, resolution, { videos: reference_videos?.length, audios: reference_audios?.length });
gate = reserveBudget(budget, agent_id, estimatedCost);
if (!gate.allowed) {
return {
@@ -503,7 +684,15 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
// 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.
- const confirm = await confirmSpend(server, { usd: estimatedCost, label: `video · ${selectedModel} · ${billedSeconds}s` });
+ // 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
+ // 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" : ""}`,
+ reference_videos?.length && `${reference_videos.length} ref video${reference_videos.length > 1 ? "s" : ""}`,
+ reference_audios?.length && `${reference_audios.length} ref audio${reference_audios.length > 1 ? "s" : ""}`,
+ ].filter(Boolean).map((x) => ` · ${x}`).join("");
+ const confirm = await confirmSpend(server, { usd: estimatedCost, label: `video · ${selectedModel} · ${billedSeconds}s${referenceLabel}` });
if (!confirm.ok) return { content: [{ type: "text", text: confirm.reason ?? "Charge cancelled." }] };
const body: Record = { model: selectedModel, prompt };
@@ -518,6 +707,19 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
if (resolution !== undefined && seedanceRes) body.resolution = resolution;
if (aspect_ratio !== undefined) body.aspect_ratio = aspect_ratio;
if (last_frame_url) body.last_frame_url = last_frame_url;
+ // Pass-throughs, forwarded under their own names when the caller set
+ // them. `!== undefined` and not truthiness: seed 0, camera_fixed false
+ // and watermark false are all meaningful values a truthy test drops.
+ // `input_type` is deliberately NOT sent — the gateway infers it from
+ // the same fields, and asserting our own guess could only ever turn a
+ // correct request into a mismatch error.
+ for (const [key, value] of Object.entries({
+ reference_image_urls, reference_videos, reference_audios,
+ bitrate_mode, output_format, camera_fixed,
+ safety_identifier, seed, watermark, return_last_frame,
+ })) {
+ if (value !== undefined) body[key] = value;
+ }
// ---- Rail 1: account API key. No quote, no signature, no expiry. ----
if (isApiKeyMode()) {
@@ -533,7 +735,7 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
pollTimeoutMs: VIDEO_POLL_TIMEOUT_MS,
});
book(paidUsd);
- const clip = (data as { data?: Array<{ url?: string; source_url?: string; duration_seconds?: number; request_id?: string; backed_up?: boolean }> }).data?.[0];
+ const clip = (data as { data?: Array<{ url?: string; source_url?: string; duration_seconds?: number; request_id?: string; backed_up?: boolean; last_frame_url?: string; last_frame_backed_up?: boolean }> }).data?.[0];
if (!clip?.url) throw new Error("Completed video response missing video URL");
const modelOut = (data as { model?: string }).model || selectedModel;
const lines = [
@@ -546,6 +748,7 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
? `Cost: ~$${estimatedCost.toFixed(4)} (estimated — billed at exact usage; see https://user.blockrun.ai/dashboard/activity)`
: `Cost: $${paidUsd.toFixed(6)}`,
...(clip.backed_up ? ["Backed up to BlockRun storage (URL is permanent)"] : clip.source_url ? [`Source URL: ${clip.source_url}`] : []),
+ ...(clip.last_frame_url ? [`Last frame: ${clip.last_frame_url}${clip.last_frame_backed_up === false ? " (not mirrored — this URL expires)" : ""}`] : []),
...(clip.request_id ? [`Request ID: ${clip.request_id}`] : []),
...(txHash ? [`Receipt: ${txHash}`] : []),
];
@@ -561,6 +764,8 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
billing: "account",
...(clip.request_id ? { request_id: clip.request_id } : {}),
...(clip.backed_up !== undefined ? { backed_up: clip.backed_up } : {}),
+ ...(clip.last_frame_url ? { last_frame_url: clip.last_frame_url } : {}),
+ ...(clip.last_frame_backed_up !== undefined ? { last_frame_backed_up: clip.last_frame_backed_up } : {}),
...(txHash ? { txHash } : {}),
},
};
@@ -622,7 +827,7 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
// before validating the payload so a malformed completed body cannot
// make a real Solana charge disappear from the local ledger.
book(paidUsd);
- const clip = (data as { data?: Array<{ url?: string; source_url?: string; duration_seconds?: number; request_id?: string; backed_up?: boolean }>; model?: string }).data?.[0];
+ const clip = (data as { data?: Array<{ url?: string; source_url?: string; duration_seconds?: number; request_id?: string; backed_up?: boolean; last_frame_url?: string; last_frame_backed_up?: boolean }>; model?: string }).data?.[0];
if (!clip?.url) throw new Error("Completed Solana video response missing video URL");
const billedUsd = paidUsd ?? estimatedCost;
const lines = [
@@ -633,6 +838,7 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
"Chain: Solana",
`Cost: $${billedUsd.toFixed(4)}`,
...(clip.backed_up ? ["Backed up to BlockRun storage (URL is permanent)"] : clip.source_url ? [`Source URL: ${clip.source_url}`] : []),
+ ...(clip.last_frame_url ? [`Last frame: ${clip.last_frame_url}${clip.last_frame_backed_up === false ? " (not mirrored — this URL expires)" : ""}`] : []),
...(clip.request_id ? [`Request ID: ${clip.request_id}`] : []),
...(txHash ? [`Tx: ${txHash}`] : []),
];
@@ -647,6 +853,8 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
chain: "solana",
...(clip.request_id ? { request_id: clip.request_id } : {}),
...(clip.backed_up !== undefined ? { backed_up: clip.backed_up } : {}),
+ ...(clip.last_frame_url ? { last_frame_url: clip.last_frame_url } : {}),
+ ...(clip.last_frame_backed_up !== undefined ? { last_frame_backed_up: clip.last_frame_backed_up } : {}),
...(txHash ? { txHash } : {}),
},
};
@@ -787,6 +995,8 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
duration_seconds?: number;
request_id?: string;
backed_up?: boolean;
+ last_frame_url?: string;
+ last_frame_backed_up?: boolean;
modelReturned?: string;
txHash?: string;
} | null = null;
@@ -828,6 +1038,8 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
duration_seconds?: number;
request_id?: string;
backed_up?: boolean;
+ last_frame_url?: string;
+ last_frame_backed_up?: boolean;
}>;
error?: string;
model?: string;
@@ -868,6 +1080,8 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
duration_seconds: clip.duration_seconds,
request_id: clip.request_id,
backed_up: clip.backed_up,
+ last_frame_url: clip.last_frame_url,
+ last_frame_backed_up: clip.last_frame_backed_up,
modelReturned: pollData.model,
txHash: pollResp.headers.get("X-Payment-Receipt") ||
pollResp.headers.get("x-payment-receipt") || undefined,
@@ -898,6 +1112,7 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
`Model: ${completed.modelReturned || selectedModel}`,
`Cost: $${billedUsd.toFixed(4)}`,
...(completed.backed_up ? [`Backed up to BlockRun storage (URL is permanent)`] : completed.source_url ? [`Source URL: ${completed.source_url}`] : []),
+ ...(completed.last_frame_url ? [`Last frame: ${completed.last_frame_url}${completed.last_frame_backed_up === false ? " (not mirrored — this URL expires)" : ""}`] : []),
...(completed.request_id ? [`Request ID: ${completed.request_id}`] : []),
...(completed.txHash ? [`Tx: ${completed.txHash}`] : []),
];
@@ -915,6 +1130,8 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
cost_usd: billedUsd,
...(completed.request_id ? { request_id: completed.request_id } : {}),
...(completed.backed_up !== undefined ? { backed_up: completed.backed_up } : {}),
+ ...(completed.last_frame_url ? { last_frame_url: completed.last_frame_url } : {}),
+ ...(completed.last_frame_backed_up !== undefined ? { last_frame_backed_up: completed.last_frame_backed_up } : {}),
...(completed.txHash ? { txHash: completed.txHash } : {}),
},
};
diff --git a/test/solana-rail-parity.test.ts b/test/solana-rail-parity.test.ts
index e1383c0..afc3c30 100644
--- a/test/solana-rail-parity.test.ts
+++ b/test/solana-rail-parity.test.ts
@@ -29,6 +29,8 @@ let paidPostsIssued = 0;
let happyBody: unknown = {};
// The options the async helper was last called with, for the per-route knobs.
let asyncOpts: Record | null = null;
+// When set, the async double throws this after the paid submit (a settled-at-submit give-up).
+let asyncThrow: Error | null = null;
const abortError = () => { const e = new Error("This operation was aborted"); e.name = "AbortError"; return e; };
mock.module("../src/utils/wallet.js", {
@@ -79,6 +81,7 @@ mock.module("../src/utils/solana-402.js", {
opts.onQuote?.(quoteUsd, { resource: { description: "Seedance 2.0 Pro video generation (5s)" } });
opts.onPaidRequest?.();
paidPostsIssued++;
+ if (asyncThrow) throw asyncThrow;
if (giveUp) {
throw new Error(
"Generation did not complete within 900s (last status: processing). No settlement receipt was " +
@@ -159,7 +162,7 @@ const TOOLS: Array<{ name: string; register: Register; args: Record { quoteUsd = 0.5; giveUp = false; onQuoteWasFunction = null; paidPostsIssued = 0; happyBody = {}; asyncOpts = null; });
+beforeEach(() => { quoteUsd = 0.5; giveUp = false; onQuoteWasFunction = null; paidPostsIssued = 0; happyBody = {}; asyncOpts = null; asyncThrow = null; });
for (const t of TOOLS) {
test(`${t.name} on Solana hands the helper an onQuote — the guard cannot fire against nobody`, async () => {
@@ -245,3 +248,23 @@ for (const t of TOOLS.filter((x) => x.name in ROUTE_KNOBS)) {
assert.equal(asyncOpts.submitMaySettle, ROUTE_KNOBS[t.name].submitMaySettle, `${t.name}: submitMaySettle`);
});
}
+
+test("image: a Solana settled-at-submit give-up names the wallet, not the account dashboard", async () => {
+ // Since 0.52.2 the Solana image path can throw BilledJobError (the async
+ // helper's give-up on a route that settled at submit). The catch was written
+ // for the account rail and told a wallet user to check an account dashboard
+ // that has no record of an on-chain transfer.
+ const { BilledJobError } = await import("../src/utils/api-key-call.js");
+ const image = TOOLS.find((t) => t.name === "image")!;
+ quoteUsd = image.estimate;
+ asyncThrow = new BilledJobError("Image generation did not complete within 300s.", { paidUsd: quoteUsd, jobId: "img_1", billing: "billed" });
+ const { call, budget } = makeHarness(image.register);
+ const res = await call(image.args);
+ assert.equal(res.isError, true);
+ const t = text(res);
+ assert.match(t, /Solana wallet was charged/);
+ assert.match(t, /job img_1/);
+ assert.match(t, /blockrun_wallet action:"report"/);
+ assert.doesNotMatch(t, /dashboard\/activity|BlockRun account|account is charged/);
+ assert.ok(near(budget.spent, quoteUsd), `spent=${budget.spent}`);
+});
diff --git a/test/video-models.test.ts b/test/video-models.test.ts
index d6c5d9a..588b140 100644
--- a/test/video-models.test.ts
+++ b/test/video-models.test.ts
@@ -17,6 +17,9 @@ import assert from "node:assert/strict";
import type { BudgetState } from "../src/types.js";
import { pollTimeoutFor } from "../src/utils/poll.js";
+// Model/parameter tests are offline; DNS policy is exercised in video-money-path.
+mock.module("../src/utils/ssrf.js", { namedExports: { isBlockedFetchHostResolved: async () => false } });
+
const TEST_KEY = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
// fetch is mocked to a SENTINEL throw rather than left unmocked. Every case here
@@ -185,17 +188,10 @@ test("seedance-2.5 rejects RealFace assets before paying", async () => {
assert.match(text, /seedance-2\.0/);
});
-test("seedance-2.5 rejects first-and-last-frame interpolation", async () => {
- const text = await errorText({
- prompt: "a cube",
- model: "bytedance/seedance-2.5",
- image_url: "https://example.com/a.png",
- last_frame_url: "https://example.com/b.png",
- });
- // Match the capability guard's own wording — /last_frame_url/ alone also
- // matches the two sibling guards, so it stayed green if the wrong one fired.
- assert.match(text, /does not support first-and-last-frame interpolation/);
- assert.match(text, /seedance-1\.5-pro/);
+test("seedance-2.5 accepts first-and-last-frame interpolation", async () => {
+ const body = await bodySentFor({ prompt: "a cube", model: "bytedance/seedance-2.5", image_url: "https://example.com/a.png", last_frame_url: "https://example.com/b.png", output_format: "mov" });
+ assert.equal(body.last_frame_url, "https://example.com/b.png");
+ assert.equal(body.output_format, "mov");
});
test("first-and-last-frame rejects its two invalid combinations", async () => {
@@ -469,3 +465,56 @@ test("grok's resolution reaches the request body — the whole point of acceptin
const sora = await bodySentFor({ prompt: "a cube", model: "azure/sora-2", resolution: "720p" });
assert.equal(sora.resolution, undefined);
});
+
+
+// This suite runs on the WALLET rail, which is where reference media is refused
+// — both gateways answer any reference_* field with a 400 before quoting, so
+// the tool refuses it first and names the rail that serves it. The forwarding,
+// pricing and per-guard behaviour all live on the account rail and are pinned
+// in video-reference-media.test.ts.
+test("reference media is refused on the wallet rail and points at the account rail", async () => {
+ for (const args of [
+ { reference_image_urls: ["https://example.com/person.png"] },
+ { reference_videos: [{ url: "https://example.com/motion.mp4" }] },
+ { reference_audios: [{ url: "https://example.com/music.mp3" }] },
+ ]) {
+ const text = await errorText({ prompt: "test", model: "bytedance/seedance-2.0", ...args });
+ assert.match(text, /account rail/);
+ assert.match(text, /api\.blockrun\.ai/);
+ }
+});
+
+test("a reference clip is reserved at the 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.
+ const m = "bytedance/seedance-2.0";
+ const plain = estimateVideoCost(m, 5, "720p");
+ const withClip = estimateVideoCost(m, 5, "720p", { videos: 1 });
+ assert.ok(withClip - plain > 3.4, `one 15.2s clip on 2.0 costs ~$3.44, got ${withClip - plain}`);
+ assert.ok(
+ Math.abs((estimateVideoCost(m, 15, "720p", { videos: 1 }) - estimateVideoCost(m, 15, "720p")) - (withClip - plain)) < 1e-6,
+ "the reference surcharge must not scale with the render duration",
+ );
+});
+
+
+test("1.5 camera and seed controls preserve false and zero", async () => {
+ const body = await bodySentFor({ prompt: "test", model: "bytedance/seedance-1.5-pro", seed: 0, camera_fixed: false, watermark: false, safety_identifier: "end-user-42" });
+ assert.equal(body.seed, 0);
+ assert.equal(body.camera_fixed, false);
+ assert.equal(body.watermark, false);
+ // Sent by the old test but never asserted, so a dropped passthrough was free.
+ assert.equal(body.safety_identifier, "end-user-42");
+});
+
+test("first-and-last-frame names every model that supports it, 2.5 included", async () => {
+ // The rejection text is what steers the retry, and it was hand-maintained
+ // separately from FIRST_LAST_FRAME_MODELS — so when 2.5 joined the set, the
+ // message kept sending callers to the four older models.
+ for (const model of ["xai/grok-imagine-video", "azure/sora-2"]) {
+ const text = await errorText({ prompt: "a cube", model, image_url: "https://example.com/a.png", last_frame_url: "https://example.com/b.png" });
+ assert.match(text, /does not support first-and-last-frame interpolation/);
+ assert.match(text, /seedance-2\.5/, `${model}: the message must list 2.5 now that it is in the set`);
+ }
+});
diff --git a/test/video-money-path.test.ts b/test/video-money-path.test.ts
index 857b9ae..977c1b8 100644
--- a/test/video-money-path.test.ts
+++ b/test/video-money-path.test.ts
@@ -110,6 +110,10 @@ const respPoll = (body: unknown) => ({ status: 200, ok: true, headers: headers({
test("SSRF: non-http(s) schemes and private-resolving hosts are refused before ANY network call", async () => {
for (const args of [
+ // The reference_* fields are NOT exercised here: this suite is the wallet
+ // rail, where they are refused as account-rail-only before the SSRF loop
+ // ever runs. Their SSRF coverage — including a private host hiding behind
+ // a public first element — lives in video-reference-media.test.ts.
{ image_url: "file:///etc/passwd" },
{ image_url: "http://169.254.169.254/latest/meta-data/" },
{ image_url: "https://127.0.0.1.nip.io/a.png" },
@@ -166,12 +170,13 @@ test("a malformed completed poll still BOOKS the settled spend (the money alread
});
test("the happy path books the settled amount exactly once", async () => {
- script = [resp402, respSubmit, () => respPoll({ status: "completed", data: [{ url: "https://blockrun.ai/media/vid_1.mp4", duration_seconds: 8 }] })];
+ script = [resp402, respSubmit, () => respPoll({ status: "completed", data: [{ url: "https://blockrun.ai/media/vid_1.mp4", duration_seconds: 8, last_frame_url: "https://blockrun.ai/media/last.png", last_frame_backed_up: true }] })];
quotedAmount = "400000";
const { call, budget } = makeHarness();
const res = await call({ prompt: "a cube", model: "xai/grok-imagine-video" });
assert.notEqual(res.isError, true, res.content?.[0]?.text);
assert.equal(res.structuredContent.cost_usd, 0.4);
+ assert.equal(res.structuredContent.last_frame_url, "https://blockrun.ai/media/last.png");
assert.ok(Math.abs(budget.spent - 0.4) < 1e-9, `booked once, not twice: spent=${budget.spent}`);
});
@@ -204,7 +209,7 @@ test("a 402 far above the published rate is refused BEFORE signing — nothing s
});
test("a quote inside the tolerance still re-reserves and pays (4K renders exceed the estimate by design)", async () => {
- script = [resp402, respSubmit, () => respPoll({ status: "completed", data: [{ url: "https://blockrun.ai/media/vid_1.mp4", duration_seconds: 8 }] })];
+ script = [resp402, respSubmit, () => respPoll({ status: "completed", data: [{ url: "https://blockrun.ai/media/vid_1.mp4", duration_seconds: 8, last_frame_url: "https://blockrun.ai/media/last.png", last_frame_backed_up: true }] })];
quotedAmount = "450000"; // $0.45 against a $0.40 estimate: 1.125x
const { call, budget } = makeHarness();
const res = await call({ prompt: "a cube", model: "xai/grok-imagine-video" });
@@ -225,7 +230,7 @@ test("a transient poll rejection is retried inside the deadline, not fatal", asy
// One ECONNRESET used to throw out of the loop after (potentially) eight
// minutes of render, with no job id and no charge statement.
clockOffset = 0;
- script = [resp402, respSubmit, () => { throw new TypeError("fetch failed"); }, () => respPoll({ status: "completed", data: [{ url: "https://blockrun.ai/media/vid_1.mp4", duration_seconds: 8 }] })];
+ script = [resp402, respSubmit, () => { throw new TypeError("fetch failed"); }, () => respPoll({ status: "completed", data: [{ url: "https://blockrun.ai/media/vid_1.mp4", duration_seconds: 8, last_frame_url: "https://blockrun.ai/media/last.png", last_frame_backed_up: true }] })];
quotedAmount = "400000";
const { call, budget } = makeHarness();
const res = await call({ prompt: "a cube", model: "xai/grok-imagine-video" });
diff --git a/test/video-reference-media.test.ts b/test/video-reference-media.test.ts
new file mode 100644
index 0000000..d7f899b
--- /dev/null
+++ b/test/video-reference-media.test.ts
@@ -0,0 +1,518 @@
+// Seedance reference media: the ACCOUNT rail, where it is the only rail that
+// serves it and the only rail with no 402 to correct a bad estimate.
+//
+// Why this file exists separately from video-models.test.ts: that suite is
+// pinned to the Base rail (getChain: () => "base", no auth mock), and every
+// reference request is refused there by design. The interesting surface — the
+// body that is forwarded, and the reserve that is held before an unquoted,
+// billed-at-submit POST — only exists behind BLOCKRUN_API_KEY.
+//
+// Three properties are pinned here, each of which was wrong before:
+// 1. The reserve prices reference clips per reference SECOND at the model's
+// ceiling, the way the gateway does (blockrun#730). The count-based term
+// it replaces under-reserved 2-3.2x, and on this rail a reserve that is
+// too low means the budget cap admits a job it cannot pay for.
+// 2. Reference media is REFUSED before any network call on both wallet rails.
+// 3. Every capability guard fires for its off-model input, still with nothing
+// on the wire.
+import { test, mock } from "node:test";
+import assert from "node:assert/strict";
+import type { BudgetState } from "../src/types.js";
+
+const TEST_KEY = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
+
+// The SSRF resolver, driven by a hostname set rather than stubbed flat: the
+// reference fields are the only place 36 caller URLs reach it, and the wallet
+// rail refuses them before the loop, so this file is the only place their DNS
+// policy can be tested at all. `resolved` records the order and multiplicity of
+// lookups, which is how host dedup is asserted.
+const blockedHosts = new Set();
+const resolved: string[] = [];
+mock.module("../src/utils/ssrf.js", {
+ namedExports: {
+ isBlockedFetchHostResolved: async (host: string) => {
+ resolved.push(host);
+ return blockedHosts.has(host);
+ },
+ },
+});
+
+// Rail switches. Flipped per test, read through the mocked accessors below.
+let apiKeyMode = true;
+let activeChain: "base" | "solana" = "base";
+
+// As in video-models: a SENTINEL throw, so a guard that regresses shows up as
+// the sentinel in the error text instead of a real POST to a paid endpoint.
+const sent: Array> = [];
+mock.module("../src/utils/http.js", {
+ namedExports: {
+ fetchWithTimeout: async (_url: string, init?: { body?: string }) => {
+ if (init?.body) sent.push(JSON.parse(init.body));
+ throw new Error("NETWORK_ESCAPE");
+ },
+ isTimeoutError: () => false,
+ },
+});
+mock.module("../src/utils/auth.js", {
+ namedExports: {
+ isApiKeyMode: () => apiKeyMode,
+ getAuthMode: () => (apiKeyMode ? "api-key" : "wallet"),
+ getApiKey: () => (apiKeyMode ? "br_test" : undefined),
+ getApiKeyBase: () => "https://api.blockrun.ai",
+ apiAuthHeaders: () => (apiKeyMode ? { Authorization: "Bearer br_test" } : {}),
+ requireWalletMode: (c: string) => `${c} needs wallet mode`,
+ resetAuthCache: () => {},
+ DEFAULT_API_KEY_BASE: "https://api.blockrun.ai",
+ PORTAL_URL: "https://user.blockrun.ai",
+ PORTAL_KEYS_URL: "https://user.blockrun.ai/dashboard/keys",
+ PORTAL_CREDITS_URL: "https://user.blockrun.ai/dashboard/credits",
+ PORTAL_ACTIVITY_URL: "https://user.blockrun.ai/dashboard/activity",
+ },
+});
+mock.module("../src/utils/onramp.js", {
+ namedExports: { launchTopUp: async () => ({ opened: false, url: "", note: "" }) },
+});
+mock.module("../src/utils/wallet.js", {
+ namedExports: {
+ getApiBase: () => "https://api.blockrun.ai",
+ resolveGatewayUrl: (u: string) => (u.startsWith("http") ? u : `https://blockrun.ai/api${u.startsWith("/api/") ? u.slice(4) : u}`),
+ getChain: () => activeChain,
+ getOrCreateWalletKey: () => TEST_KEY,
+ getWalletInfo: async () => ({ address: "0xTEST" }),
+ resolveSolanaKey: () => undefined,
+ },
+});
+
+// The label a human approving the charge would read, captured per call.
+const confirmLabels: string[] = [];
+mock.module("../src/utils/confirm-spend.js", {
+ namedExports: {
+ confirmSpend: async (_s: unknown, o: { label: string }) => { confirmLabels.push(o.label); return { ok: true }; },
+ resetSpendApproval: () => {},
+ },
+});
+
+const { registerVideoTool, estimateVideoCost } = await import("../src/tools/video.js");
+
+function makeHarnessWithConfig() {
+ let config: any;
+ const server = {
+ registerTool: (_n: string, c: unknown) => { config = c; },
+ server: { getClientCapabilities: () => ({}) },
+ } as any;
+ registerVideoTool(server, { limit: null, spent: 0, calls: 0, agents: new Map() });
+ return { config };
+}
+
+function makeHarness() {
+ let handler: ((args: Record) => Promise) | undefined;
+ const server = {
+ registerTool: (_n: string, _c: unknown, h: any) => { handler = h; },
+ server: { getClientCapabilities: () => ({}) },
+ } as any;
+ const budget: BudgetState = { limit: null, spent: 0, calls: 0, agents: new Map() };
+ registerVideoTool(server, budget);
+ return { call: (args: Record) => handler!(args), budget };
+}
+
+async function errorText(args: Record) {
+ const res = await makeHarness().call(args);
+ assert.equal(res.isError, true, `expected a rejection for ${JSON.stringify(args)}`);
+ const text = res.content.map((c: any) => c.text).join("\n");
+ assert.doesNotMatch(text, /NETWORK_ESCAPE/, `gate regressed — ${JSON.stringify(args)} reached the network`);
+ return text;
+}
+
+async function bodySentFor(args: Record) {
+ sent.length = 0;
+ await makeHarness().call(args);
+ assert.equal(sent.length, 1, `expected exactly one request for ${JSON.stringify(args)}`);
+ return sent[0];
+}
+
+const IMG = "https://example.com/character.png";
+const VID = "https://example.com/motion.mp4";
+const AUD = "https://example.com/score.mp3";
+
+// ---------------------------------------------------------------------------
+// 1. The reserve
+// ---------------------------------------------------------------------------
+
+// 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
+// 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)
+ ["bytedance/seedance-2.0", 5, 1, 0, "720p", 4.573015],
+ ["bytedance/seedance-2.0", 5, 0, 1, "720p", 2.166740],
+ ["bytedance/seedance-2.0", 5, 3, 3, "720p", 14.541866],
+ ["bytedance/seedance-2.0-mini", 5, 1, 0, "720p", 1.605130],
+ ["bytedance/seedance-2.0-mini", 4, 3, 3, "720p", 5.024489],
+ // 480p: the reference term does NOT scale down — 21,600 was measured at 720p
+ // and scaling below it would bill under the only rate ever probed. Honest
+ // label: unlike the rows above, this figure is what the FLOOR produces, not
+ // an independently observed charge. blockrun#730 measured at 720p only, and
+ // neither wallet gateway will quote reference media, so no probe from this
+ // repo can confirm it. It pins the floor against removal, not against
+ // upstream. The gateway's referenceTokens() applies the same Math.max(rf, 1),
+ // so the two agree by construction.
+ ["bytedance/seedance-2.0-mini", 5, 1, 0, "480p", 1.405853],
+];
+
+test("the reserve covers the gateway's per-reference-second charge on every probed combination", () => {
+ for (const [model, seconds, videos, audios, resolution, charged] of REFERENCE_CHARGE) {
+ const reserved = estimateVideoCost(model, seconds, resolution, { videos, audios });
+ assert.ok(reserved >= charged, `${model} ${seconds}s v${videos}/a${audios} ${resolution}: reserved ${reserved} < charged ${charged}`);
+ // ...and only by the known transaction-fee gap, so the ceiling assumption
+ // cannot hide a stale rate behind a generous cushion.
+ assert.ok(reserved - charged <= 0.0021, `${model} ${seconds}s v${videos}/a${audios}: over-reserves by ${reserved - charged}`);
+ }
+});
+
+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
+ // price depends on the CLIP, and the caller never declares its length, so it
+ // is quoted at the ceiling and is flat across output durations.
+ const m = "bytedance/seedance-2.0";
+ const deltas = [4, 5, 10, 15].map(sec =>
+ estimateVideoCost(m, sec, "720p", { videos: 1 }) - estimateVideoCost(m, sec, "720p"));
+ for (const d of deltas) {
+ assert.ok(Math.abs(d - deltas[0]) < 1e-6, `reference surcharge moved with output duration: ${deltas.join(", ")}`);
+ }
+ assert.ok(deltas[0] > 3.4, `a 15.2s reference clip on 2.0 costs ~$3.44, got ${deltas[0]}`);
+});
+
+test("reference counts scale linearly and audio is exactly 0.3 of video", () => {
+ const m = "bytedance/seedance-2.0";
+ const plain = estimateVideoCost(m, 5, "720p");
+ const oneVideo = estimateVideoCost(m, 5, "720p", { videos: 1 }) - plain;
+ const oneAudio = estimateVideoCost(m, 5, "720p", { audios: 1 }) - plain;
+ // Tolerance is a millionth of a dollar, not an epsilon: these are USD deltas
+ // that have been through withTxFee's rounding, so exact equality is the wrong
+ // question — "the same to well under a cent" is the one that matters.
+ assert.ok(Math.abs(oneAudio / oneVideo - 0.3) < 1e-6, `audio factor is ${oneAudio / oneVideo}, not 0.3`);
+ for (const n of [2, 3]) {
+ const many = estimateVideoCost(m, 5, "720p", { videos: n }) - plain;
+ assert.ok(Math.abs(many - n * oneVideo) < 1e-6, `${n} clips is not ${n}x one clip`);
+ }
+ assert.equal(estimateVideoCost(m, 5, "720p", {}), plain, "an empty references object must price as no references");
+ assert.equal(estimateVideoCost(m, 5, "720p", { videos: 0, audios: 0 }), plain, "explicit zeros must price as no references");
+});
+
+test("an out-of-range or non-integer reference count throws rather than reserving a guessed rate", () => {
+ const m = "bytedance/seedance-2.0";
+ for (const bad of [{ videos: 4 }, { audios: 4 }, { videos: -1 }, { audios: -1 }, { videos: 1.5 }, { audios: NaN }]) {
+ assert.throws(() => estimateVideoCost(m, 5, "720p", bad), /Invalid reference counts/, JSON.stringify(bad));
+ }
+});
+
+test("references against a model with no token price throw instead of being silently dropped", () => {
+ // The validation used to live INSIDE the Seedance branch, so grok and sora
+ // fell through to their own per-second tables and the references vanished
+ // from the reserve without a word — the exact fail-open the estimator's
+ // throw-never-default rule exists to prevent.
+ for (const model of ["xai/grok-imagine-video", "azure/sora-2"]) {
+ assert.throws(() => estimateVideoCost(model, 8, undefined, { videos: 1 }), /not token-priced/, model);
+ }
+ // With no references those models still price normally.
+ assert.ok(estimateVideoCost("xai/grok-imagine-video", 8) > 0);
+});
+
+// ---------------------------------------------------------------------------
+// 2. Rail availability
+// ---------------------------------------------------------------------------
+
+test("reference media is refused BEFORE any network call on both wallet rails", async () => {
+ apiKeyMode = false;
+ try {
+ for (const chain of ["base", "solana"] as const) {
+ activeChain = chain;
+ for (const args of [
+ { reference_image_urls: [IMG] },
+ { 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);
+ }
+ }
+ } finally {
+ apiKeyMode = true;
+ activeChain = "base";
+ }
+});
+
+test("frame seeding still works on the wallet rails — only reference media is account-only", async () => {
+ apiKeyMode = false;
+ try {
+ const body = await bodySentFor({ prompt: "a cube", model: "bytedance/seedance-2.5", image_url: IMG, last_frame_url: "https://example.com/b.png" });
+ assert.equal(body.last_frame_url, "https://example.com/b.png");
+ } finally {
+ apiKeyMode = true;
+ }
+});
+
+// ---------------------------------------------------------------------------
+// 3. The account rail: what is forwarded, and what is reserved
+// ---------------------------------------------------------------------------
+
+test("the account rail forwards every reference field and output control verbatim", async () => {
+ const args = {
+ prompt: "Use image 1 for the character and video 1 for the motion",
+ model: "bytedance/seedance-2.0-mini",
+ duration_seconds: 5,
+ reference_image_urls: [IMG],
+ reference_videos: [{ url: VID }],
+ reference_audios: [{ url: AUD }],
+ bitrate_mode: "high",
+ return_last_frame: true,
+ safety_identifier: "end-user-42",
+ };
+ const body = await bodySentFor(args);
+ for (const field of ["reference_image_urls", "reference_videos", "reference_audios", "bitrate_mode", "return_last_frame", "safety_identifier"] as const) {
+ assert.deepEqual(body[field], args[field], field);
+ }
+ // input_type is never sent: the gateway infers it from these same fields and
+ // treats a supplied value as an assertion to reject on mismatch, so our own
+ // guess could only ever turn a correct request into an error.
+ assert.equal(body.input_type, undefined);
+ // Nor is a clip `role` — "reference" is its only legal value upstream and the
+ // gateway's own message says to omit it.
+ assert.deepEqual(body.reference_videos, [{ url: VID }]);
+});
+
+test("the account rail reserves the ceiling-priced surcharge before submitting", async () => {
+ // This rail bills when the gateway ACCEPTS the job and offers no quote, so
+ // the gate below is the only thing standing between a reference job and a
+ // budget cap. A limit just under the true price must refuse it.
+ const args = {
+ prompt: "t", model: "bytedance/seedance-2.0-mini", duration_seconds: 5,
+ reference_videos: [{ url: VID }], reference_image_urls: [IMG],
+ };
+ const expected = estimateVideoCost("bytedance/seedance-2.0-mini", 5, undefined, { videos: 1 });
+ assert.ok(expected > 1.6, `the ceiling-priced reserve should be ~$1.61, got ${expected}`);
+
+ const h = makeHarness();
+ h.budget.limit = expected - 0.01;
+ const res = await h.call({ ...args, agent_id: undefined });
+ const text = res.content.map((c: any) => c.text).join("\n");
+ assert.equal(res.isError, true, "a budget below the true reference price must refuse");
+ assert.doesNotMatch(text, /NETWORK_ESCAPE/, "refused calls must not reach the network");
+ assert.match(text, /budget/i, text);
+ 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, `reserved ${shown[1]}, expected ~${expected}`);
+});
+
+test("reference IMAGES carry no surcharge — the gateway prices only clips", async () => {
+ // calculateVideoPrice upstream takes { videoSeconds, audioSeconds } and
+ // nothing else; reference images only flip the i2v classification, which on
+ // Seedance is the same per-second rate. Reserving extra for them would
+ // refuse jobs the gateway would have served.
+ const withImages = estimateVideoCost("bytedance/seedance-2.5", 5, "720p", {});
+ const plain = estimateVideoCost("bytedance/seedance-2.5", 5, "720p");
+ assert.equal(withImages, plain);
+ const body = await bodySentFor({ prompt: "t", model: "bytedance/seedance-2.5", duration_seconds: 5, reference_image_urls: Array(30).fill(IMG) });
+ assert.equal((body.reference_image_urls as string[]).length, 30);
+});
+
+// ---------------------------------------------------------------------------
+// 4. Every guard, every branch
+// ---------------------------------------------------------------------------
+
+test("every capability guard fires for its off-model input, and none reaches the network", async () => {
+ const cases: Array<[Record, RegExp]> = [
+ // reference images: wrong model, and over the per-model count
+ [{ model: "bytedance/seedance-1.5-pro", reference_image_urls: [IMG] }, /does not accept reference images/],
+ [{ model: "xai/grok-imagine-video", reference_image_urls: [IMG] }, /does not accept reference images/],
+ [{ 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/],
+ [{ model: "bytedance/seedance-1.5-pro", reference_videos: [{ url: VID }] }, /does not accept reference video or audio/],
+ [{ 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/],
+ // references never combine with frame seeds
+ [{ model: "bytedance/seedance-2.0", reference_image_urls: [IMG], image_url: IMG }, /cannot be combined with frame seeds/],
+ [{ model: "bytedance/seedance-2.0", reference_image_urls: [IMG], real_face_asset_id: "ta_abc123" }, /cannot be combined with frame seeds/],
+ [{ model: "bytedance/seedance-2.0", reference_image_urls: [IMG], image_url: IMG, last_frame_url: IMG }, /cannot be combined with frame seeds/],
+ // output controls, each named in its own refusal
+ [{ model: "bytedance/seedance-1.5-pro", bitrate_mode: "high" }, /bitrate_mode requires a Seedance 2\.x model/],
+ [{ model: "xai/grok-imagine-video", bitrate_mode: "high" }, /bitrate_mode requires a Seedance 2\.x model/],
+ [{ model: "bytedance/seedance-2.0", output_format: "mov" }, /output_format requires bytedance\/seedance-2\.5/],
+ [{ model: "bytedance/seedance-2.0", seed: 1 }, /seed requires bytedance\/seedance-1\.5-pro/],
+ [{ model: "bytedance/seedance-2.5", camera_fixed: true }, /camera_fixed requires bytedance\/seedance-1\.5-pro/],
+ [{ model: "xai/grok-imagine-video", watermark: false }, /watermark requires a Seedance model/],
+ [{ model: "azure/sora-2", return_last_frame: true }, /return_last_frame requires a Seedance model/],
+ [{ model: "xai/grok-imagine-video", safety_identifier: "x" }, /safety_identifier requires a Seedance model/],
+ ];
+ for (const [args, re] of cases) {
+ const text = await errorText({ prompt: "t", ...args });
+ assert.match(text, re, JSON.stringify(args));
+ // A pre-payment refusal must never be dressed as a failed render: that is
+ // what the catch-all's "Video generation failed" prefix (and its "try
+ // seedance-2.0 or sora-2" advice) would have said for a request that never
+ // left the machine.
+ assert.doesNotMatch(text, /Video generation failed/, JSON.stringify(args));
+ }
+});
+
+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"]) {
+ const body = await bodySentFor({ prompt: "t", model, reference_videos: [{ url: VID }], reference_audios: [{ url: AUD }] });
+ assert.deepEqual(body.reference_videos, [{ url: VID }], model);
+ }
+ // Every shape that used to need an input_type declaration still goes
+ // through; the gateway does the inference now.
+ for (const args of [
+ {},
+ { image_url: IMG },
+ { image_url: IMG, last_frame_url: IMG },
+ { reference_image_urls: [IMG] },
+ ]) {
+ const body = await bodySentFor({ prompt: "t", model: "bytedance/seedance-2.0", ...args });
+ assert.equal(body.input_type, undefined, JSON.stringify(args));
+ }
+});
+
+test("input_type and a clip role are no longer part of the tool's surface", async () => {
+ // Both were redundant with something the gateway already does: it infers
+ // input_type from the same fields, and "reference" is the only clip role it
+ // honours. A caller that still sends either gets it dropped by schema
+ // validation rather than forwarded.
+ const { config } = makeHarnessWithConfig();
+ assert.equal(config.inputSchema.input_type, undefined, "input_type must be off the schema");
+ const clip = config.inputSchema.reference_videos.parse([{ url: VID, role: "reference" }]);
+ assert.deepEqual(clip, [{ url: VID }], "a stray role must be stripped, not forwarded");
+});
+
+// ---------------------------------------------------------------------------
+// 5. SSRF across the new URL arrays
+// ---------------------------------------------------------------------------
+
+test("a private host anywhere in a reference array is caught, and the message names the field", async () => {
+ blockedHosts.add("169.254.169.254");
+ try {
+ for (const [args, field] of [
+ [{ reference_image_urls: [IMG, IMG, "http://169.254.169.254/portrait.png"] }, "reference_image_urls"],
+ [{ reference_videos: [{ url: VID }, { url: "https://169.254.169.254/motion.mp4" }] }, "reference_videos"],
+ [{ reference_image_urls: [IMG], reference_audios: [{ url: AUD }, { url: "https://169.254.169.254/x.mp3" }] }, "reference_audios"],
+ ] as const) {
+ // A public first element must not shield the rest: a regression to
+ // checking only [0] passes every single-element case.
+ const text = await errorText({ prompt: "t", model: "bytedance/seedance-2.0", ...args });
+ assert.match(text, /private\/loopback\/link-local/, JSON.stringify(args));
+ assert.match(text, new RegExp(field), `the refusal must name the offending field, got: ${text}`);
+ }
+ } finally {
+ blockedHosts.delete("169.254.169.254");
+ }
+});
+
+test("a non-http(s) scheme in a reference array is refused and names its field", async () => {
+ const text = await errorText({ prompt: "t", model: "bytedance/seedance-2.0", reference_image_urls: [IMG], reference_audios: [{ url: "file:///etc/music.mp3" }] });
+ assert.match(text, /must be an http\(s\) URL/);
+ assert.match(text, /reference_audios/);
+});
+
+test("one DNS resolution per HOST, not per URL", async () => {
+ // 30 images on one CDN used to be 30 identical getaddrinfo calls on libuv's
+ // 4-thread pool, awaited one at a time, before the call had earned anything.
+ resolved.length = 0;
+ await bodySentFor({
+ prompt: "t", model: "bytedance/seedance-2.5",
+ reference_image_urls: Array(30).fill("https://cdn.example.com/a.png"),
+ });
+ assert.deepEqual(resolved, ["cdn.example.com"], `resolved ${resolved.length} times for one host`);
+});
+
+test("a bad resolution or duration is also refused before any DNS work", async () => {
+ // Same principle as the guard block above: every purely in-memory check now
+ // sits ahead of the resolver, so no caller-chosen hostname is looked up for
+ // a request the tool was always going to refuse.
+ resolved.length = 0;
+ await errorText({ prompt: "t", model: "bytedance/seedance-2.5", resolution: "4K", image_url: "https://attacker.example.com/a.png" });
+ assert.deepEqual(resolved, [], "a rejected resolution still resolved a hostname");
+ await errorText({ prompt: "t", model: "bytedance/seedance-2.0", duration_seconds: 99, image_url: "https://attacker.example.com/a.png" });
+ assert.deepEqual(resolved, [], "a rejected duration still resolved a hostname");
+});
+
+test("an unsupported combination is refused BEFORE any DNS work", async () => {
+ // The guards used to sit below the resolver, so a model that rejects
+ // reference images outright still paid for every lookup first — a free,
+ // unbilled, ledger-invisible outbound DNS query per caller-chosen hostname.
+ resolved.length = 0;
+ await errorText({
+ prompt: "t", model: "xai/grok-imagine-video",
+ reference_image_urls: Array(30).fill("https://attacker.example.com/a.png"),
+ });
+ assert.deepEqual(resolved, [], `${resolved.length} hostnames were resolved for a request the tool always refuses`);
+});
+
+test("the rail refusal is answered before every other guard", async () => {
+ // Ranked below the frame-seed guards, a wallet-rail reference request that
+ // also carried last_frame_url cost three round trips: "add image_url", then
+ // "frame seeds and references do not mix", then finally the rail fact that
+ // made all of it moot.
+ apiKeyMode = false;
+ try {
+ for (const args of [
+ { model: "bytedance/seedance-2.5", reference_image_urls: [IMG], last_frame_url: IMG },
+ { model: "bytedance/seedance-2.0", reference_image_urls: [IMG], real_face_asset_id: "ta_abc123" },
+ { model: "xai/grok-imagine-video", reference_image_urls: [IMG], image_url: IMG },
+ // Even a combination that is invalid for other reasons answers the rail
+ // first, because no rail change can make the rest matter.
+ { model: "bytedance/seedance-2.0", reference_image_urls: Array(99).fill(IMG), output_format: "mov" },
+ ]) {
+ const text = await errorText({ prompt: "t", ...args });
+ assert.match(text, /served only by the BlockRun account rail/, JSON.stringify(args));
+ }
+ } finally {
+ apiKeyMode = true;
+ }
+});
+
+test("on the account rail, the frame-seed conflict is answered before the frame-seed guards", async () => {
+ // With references present every last_frame / RealFace guard answers the
+ // wrong question: "last_frame_url requires image_url" sent the caller to
+ // add image_url, only to hear on the resubmit that seeds and references
+ // never mix.
+ for (const args of [
+ { model: "bytedance/seedance-2.5", reference_image_urls: [IMG], last_frame_url: IMG },
+ { model: "bytedance/seedance-2.0", reference_videos: [{ url: VID }], last_frame_url: IMG },
+ { model: "bytedance/seedance-2.0", reference_image_urls: [IMG], real_face_asset_id: "ta_abc123" },
+ ]) {
+ const text = await errorText({ prompt: "t", ...args });
+ assert.match(text, /cannot be combined with frame seeds/, JSON.stringify(args));
+ assert.doesNotMatch(text, /requires image_url|does not support RealFace/, JSON.stringify(args));
+ }
+});
+
+test("the spend confirmation names the reference clips that multiply the price", async () => {
+ confirmLabels.length = 0;
+ await bodySentFor({ prompt: "t", model: "bytedance/seedance-2.0", reference_image_urls: [IMG], reference_videos: [{ url: VID }, { url: VID }], reference_audios: [{ url: AUD }] });
+ assert.equal(confirmLabels.length, 1);
+ assert.match(confirmLabels[0], /1 ref image\b/);
+ assert.match(confirmLabels[0], /2 ref videos/);
+ assert.match(confirmLabels[0], /1 ref audio\b/);
+ confirmLabels.length = 0;
+ await bodySentFor({ prompt: "t", model: "bytedance/seedance-2.0", duration_seconds: 5 });
+ assert.doesNotMatch(confirmLabels[0], /ref/);
+});
+
+test("the reference_videos description gives the real multiple, not the old formula's", () => {
+ const { config } = makeHarnessWithConfig();
+ const d = config.inputSchema.reference_videos.description as string;
+ assert.doesNotMatch(d, /triples/);
+ assert.match(d, /about 4x/);
+});