import type { ProviderRouteId } from './types.js'; /** * Declarative, single-sourced capability envelope for one provider route. * * The numeric/enum values here are NOT re-derived heuristics — they mirror the * limits the transports already enforce: * - dreamina-useapi: `src/video/providers/dreamina-useapi.ts` * (DREAMINA_MAX_IMAGE_REFS / _VIDEO / _AUDIO, resolutionFor(), clampDuration()). * - runway-useapi: `src/video/providers/runway-useapi.ts` * (RUNWAY_MAX_IMAGE_REFS / _VIDEO, clampDuration(), default mode 'explore'). * - seedance-direct: `src/video/native-seedance.ts` * (REFERENCE_BUDGET images/videos/audios via assertReferenceBudget). * - veo-useapi: `src/video/native-veo.ts` (veoRatio landscape/portrait/square; * conservative defaults — UseAPI enforces the rest server-side). * * This matrix is the read-only contract used by `assertRouteRequestValid` to * fail fast (or warn under VCLAW_ALLOW_UNSAFE_MODELS) BEFORE a provider submit. * Keep it in sync with the transport limits above — do not let it drift. */ export interface RouteCapabilities { /** Submission modes the route understands (e.g. 'explore' | 'credits'). */ modes: string[]; /** Output resolutions the route can deliver (e.g. '720p', '1080p'). */ resolutions: string[]; /** * Discrete clip durations (seconds) the route accepts/clamps to. An empty * array means the transport enforces no client-side duration set (the * provider applies model-specific limits server-side), so duration is not * checked for this route. */ durationsSec: number[]; /** Maximum number of image reference inputs per submission. */ maxImageRefs: number; /** Maximum number of video reference inputs per submission. */ maxVideoRefs: number; /** Maximum number of audio reference inputs per submission. */ maxAudioRefs: number; /** Whether the route can attach/generate audio. */ audioCapable: boolean; /** When a capability is gated to a region (e.g. '1080p is CA-only'). */ regionGated?: string; /** Free-form operator notes carried into warnings/diagnostics. */ notes?: string[]; } /** * Capability matrix for the four live provider routes. `veo-direct` is * intentionally absent — it was an adapter-only phantom route with no native * transport and has been removed from `ProviderRouteId`. */ export const ROUTE_CAPABILITIES: Record = { 'veo-useapi': { // These describe the ROUTE's capability envelope, not the current // native-veo.ts transport's completeness. The Bun/Flow transport today // forwards only referencePaths[0] and silently ignores extra references // (it never rejects them), so the matrix must NOT hard-fail payloads the // transport tolerates — doing so would change existing behavior: // - first-frame + last-frame image-to-video and ingredients-to-video // legitimately carry up to ~3 image references (cf. the former veo // descriptor's ingredients-to-video maxReferenceImages: 3). // - scene chaining carries the previous scene's video output as a single // seed input → maxVideoRefs: 1. // - no audio reference path exists, and no flow submits audio refs to this // route → audioCapable:false, maxAudioRefs:0. // - durationSeconds is passed straight through; UseAPI enforces // model-specific limits server-side → durationsSec left empty (= // unconstrained) so valid durations are never rejected here. // veoRatio maps aspect → landscape/portrait/square (no resolution clamp). modes: ['default'], resolutions: ['720p', '1080p'], durationsSec: [], maxImageRefs: 3, maxVideoRefs: 1, maxAudioRefs: 0, audioCapable: false, notes: [ 'Aggregator Veo route (native-veo.ts, Google Flow via useapi.net). Image refs cover first-frame/last-frame/ingredients (≤3); a single video ref is the scene-chaining seed. The transport currently forwards only the first image and ignores extras, so these caps describe route capability, not transport completeness. UseAPI enforces model-specific duration limits server-side.', ], }, 'runway-useapi': { // runway-useapi.ts: RUNWAY_MAX_IMAGE_REFS=11, RUNWAY_MAX_VIDEO_REFS=3, // clampDuration -> 5/8/10/15, default mode 'explore' (free, low-res). modes: ['explore', 'credits'], resolutions: ['720p', '1080p'], durationsSec: [5, 8, 10, 15], maxImageRefs: 11, maxVideoRefs: 3, maxAudioRefs: 0, audioCapable: false, notes: [ "Default mode 'explore' is free, queued, and low-res; 'credits' is the paid faster path.", ], }, 'dreamina-useapi': { // dreamina-useapi.ts: 9 image / 3 video / 3 audio refs; resolutionFor -> // 1080p (CA-only) else 720p; clampDuration -> 4/5/8/10/12/15. modes: ['default'], resolutions: ['720p', '1080p'], durationsSec: [4, 5, 8, 10, 12, 15], maxImageRefs: 9, maxVideoRefs: 3, maxAudioRefs: 3, audioCapable: true, regionGated: '1080p is CA-only; 720p works on both US and CA regions.', notes: [ 'Seedance 2.0 via Dreamina (useapi.net). Real human faces are rejected by content moderation.', ], }, 'seedance-direct': { // native-seedance.ts REFERENCE_BUDGET = { images: 9, videos: 3, audios: 3 }. // 15s max per generation; longer videos use segmented stitching. modes: ['fast', 'quality'], resolutions: ['720p', '1080p'], durationsSec: [5, 8, 10, 15], maxImageRefs: 9, maxVideoRefs: 3, maxAudioRefs: 3, audioCapable: true, notes: [ 'Direct Seedance 2.0 via xskill.ai. 15s max per generation. Photoreal face references are rejected — describe characters by visual descriptor.', ], }, 'magnific-rest': { // Magnific/Freepik proxied video catalog (native-magnific.ts). seedance-pro-1080p // is image-to-video; durations enforced server-side. Cost-aware: defaults to the // cheapest variant; premium models (e.g. seedance-pro-1080p) are opt-in. modes: ['default'], resolutions: ['720p', '1080p'], durationsSec: [], maxImageRefs: 1, maxVideoRefs: 0, maxAudioRefs: 0, audioCapable: false, notes: [ 'Magnific REST video generation. Cheap-by-default: the default is the cheapest catalog model that fits the requested operation (NOT pinned to Seedance, whose only Magnific model is premium). Premium models like seedance-pro-1080p (image-to-video) are opt-in via VCLAW_MAGNIFIC_MODEL. Requires MAGNIFIC_API_KEY.', ], }, }; /** * A request to validate against a route's capability envelope. Every field is * optional: an absent field is simply not checked (so callers that only know a * subset of the request — e.g. just resolution + aspect ratio at payload-build * time — never trip a false violation for a value they did not supply). */ export interface RouteRequest { resolution?: string; durationSec?: number; aspectRatio?: string; imageRefs?: number; videoRefs?: number; audioRefs?: number; mode?: string; /** * Resolved Veo model for a `veo-useapi` request (`fast | quality | omni-flash`). * Only consulted by the veo-useapi-specific gate below. Other routes ignore it. */ veoModel?: string; /** * True when this request carries a voice-narration preset (`flow.ts --voice`). * Provider-side this is `omni-flash`-only on the veo-useapi route. */ hasVoice?: boolean; /** * True when this request carries a V2V edit video reference * (`flow.ts --ref-video`). NOTE: this is NOT the scene-chaining seed * (`inputKind: 'video'`), which the route supports on any model — it is the * dedicated omni-flash V2V edit source. omni-flash-only on the veo-useapi route. */ hasVideoRef?: boolean; /** * True when this request carries an omni-flash First-Frame (startImage / I2V) * seed — the gated build-ahead identity-lock path (see VideoExecutionTask. * firstFrame). A start image satisfies the omni-flash "voice needs a visual * anchor" precondition just like an R2V image or V2V video. Only ever true on * omni-flash once the `VCLAW_OMNI_FIRST_FRAME` gate is enabled; absent → * byte-identical legacy behavior on the veo-useapi route. */ hasStartImage?: boolean; } /** Options for {@link assertRouteRequestValid}. */ export interface AssertRouteRequestValidOptions { /** Defaults to `process.env`. Pure callers can inject a fixed map. */ env?: NodeJS.ProcessEnv; } /** Result of a capability check. `ok` is always true when no Error is thrown. */ export interface RouteRequestValidation { ok: boolean; warnings: string[]; } function isTruthyEnv(value: string | undefined): boolean { if (!value) return false; const v = value.trim().toLowerCase(); return v !== '' && v !== '0' && v !== 'false' && v !== 'no' && v !== 'off'; } /** * Gate for the omni-flash First-Frame (startImage / I2V) path, shared by the * execution-runtime payload builder and the veo-useapi transport so a single env * flag controls both layers. * * As of 2026-06-06 omni-flash startImage is LIVE on useapi.net (live-verified: * the request routes to abra_i2v_8s / IMAGE_TO_VIDEO), so this is now ENABLED BY * DEFAULT. `VCLAW_OMNI_FIRST_FRAME` is a kill-switch — only 0/false/no/off * disables it (e.g. if the provider regresses). See VideoExecutionTask.firstFrame. */ export function isOmniFirstFrameEnabled(env: NodeJS.ProcessEnv = process.env): boolean { const v = (env.VCLAW_OMNI_FIRST_FRAME ?? '').trim().toLowerCase(); return !(v === '0' || v === 'false' || v === 'no' || v === 'off'); } /** * Validate a {@link RouteRequest} against {@link ROUTE_CAPABILITIES} for the * given route. Pure and deterministic (no fs/network); the only side input is * `env` (default `process.env`), read once for the `VCLAW_ALLOW_UNSAFE_MODELS` * escape hatch. * * Behavior: * - On a hard violation (resolution/duration/mode not supported, or refs over * budget), throws a clear {@link Error} — UNLESS `VCLAW_ALLOW_UNSAFE_MODELS` * is truthy, in which case each violation is downgraded to a `warnings[]` * entry and `{ ok: true }` is returned. * - A fully-valid (or empty) request returns `{ ok: true, warnings: [] }`. * - veo-useapi only: a `hasVoice` or `hasVideoRef` request whose `veoModel` * is not `omni-flash` is a violation (voice/V2V are omni-flash-only), * pushed through the same downgradable path. * * Only fields that are present on the request are checked, so partial requests * never produce spurious violations for fields the caller did not supply. */ export function assertRouteRequestValid( routeId: ProviderRouteId, req: RouteRequest, opts: AssertRouteRequestValidOptions = {}, ): RouteRequestValidation { const env = opts.env ?? process.env; const allowUnsafe = isTruthyEnv(env.VCLAW_ALLOW_UNSAFE_MODELS); const caps = ROUTE_CAPABILITIES[routeId]; const violations: string[] = []; const warnings: string[] = []; if (!caps) { // Unknown route — treat as a hard violation (caller passed a non-live id). violations.push(`Unknown provider route: ${routeId}`); } else { if (req.resolution !== undefined && !caps.resolutions.includes(req.resolution)) { violations.push( `Route ${routeId} does not support resolution ${req.resolution} (supported: ${caps.resolutions.join(', ')}).`, ); } if ( req.durationSec !== undefined && caps.durationsSec.length > 0 && !caps.durationsSec.includes(req.durationSec) ) { violations.push( `Route ${routeId} does not support duration ${req.durationSec}s (supported: ${caps.durationsSec.join(', ')}s).`, ); } if (req.mode !== undefined && !caps.modes.includes(req.mode)) { violations.push( `Route ${routeId} does not support mode '${req.mode}' (supported: ${caps.modes.join(', ')}).`, ); } if (req.imageRefs !== undefined && req.imageRefs > caps.maxImageRefs) { violations.push( `Route ${routeId} accepts at most ${caps.maxImageRefs} image references (got ${req.imageRefs}).`, ); } if (req.videoRefs !== undefined && req.videoRefs > caps.maxVideoRefs) { violations.push( `Route ${routeId} accepts at most ${caps.maxVideoRefs} video references (got ${req.videoRefs}).`, ); } if (req.audioRefs !== undefined && req.audioRefs > caps.maxAudioRefs) { violations.push( `Route ${routeId} accepts at most ${caps.maxAudioRefs} audio references (got ${req.audioRefs}).`, ); } if ( req.audioRefs !== undefined && req.audioRefs > 0 && caps.maxAudioRefs > 0 && !caps.audioCapable ) { violations.push(`Route ${routeId} is not audio-capable but ${req.audioRefs} audio reference(s) were supplied.`); } // veo-useapi model gate: voice narration and V2V edits are unlocked only by // the omni-flash Flow v1 model (mirrors the Bun sidecar's validateFlowVideo // throws). Fires only when the carrier field is actually present, so every // legacy payload (voice/V2V absent) is a no-op and stays byte-identical. if (routeId === 'veo-useapi') { if (req.hasVideoRef && req.veoModel !== 'omni-flash') { violations.push( "Route veo-useapi: video-to-video (referenceVideoMediaId) requires veoModel 'omni-flash'.", ); } if (req.hasVoice && req.veoModel !== 'omni-flash') { violations.push( "Route veo-useapi: voice narration (voicePreset) requires veoModel 'omni-flash'.", ); } // Provider rule: referenceAudio requires an image (R2V), a video (V2V), // or a first-frame startImage (I2V) reference — pure text-to-video + voice // is rejected by Google. Fail fast in-process rather than burning a submit // on a guaranteed rejection. (`hasStartImage` only goes true on omni-flash // once the VCLAW_OMNI_FIRST_FRAME gate is enabled, so legacy stays exact.) if ( req.hasVoice && req.veoModel === 'omni-flash' && !(req.imageRefs && req.imageRefs > 0) && !req.hasVideoRef && !req.hasStartImage ) { violations.push( "Route veo-useapi: voice narration requires a reference image (R2V) or referenceVideoMediaId (V2V); omni-flash text-to-video + voice is rejected by the provider.", ); } } } if (violations.length === 0) { return { ok: true, warnings }; } if (allowUnsafe) { for (const v of violations) { warnings.push(`VCLAW_ALLOW_UNSAFE_MODELS: ${v}`); } return { ok: true, warnings }; } throw new Error( `Route capability check failed for ${routeId}: ${violations.join(' ')} ` + `Set VCLAW_ALLOW_UNSAFE_MODELS=1 to downgrade these to warnings.`, ); }