diff --git a/README.md b/README.md index 61a0091..9093b70 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,8 @@ Use `ovs speech-capabilities` to resolve the exact configured narration profile printing credentials, then `ovs narration fit` before and after synthesis to keep each line inside its plan window. Video generation accepts explicit reference images, ratio, duration, resolution, and audio generation flags so the provider call matches the approved plan. +`ovs plan validate` also checks each generate segment's ratio, duration, and operation against +the configured `video.provider`, so Gate C never approves a plan the provider will reject. For [MuAPI](https://muapi.ai), explicitly set `video.provider` to `"muapi"` and provide `MUAPI_API_KEY` (or use `OVS_VIDEO_API_KEY`). `MUAPI_API_KEY` takes precedence over the generic diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ad3c2c0..a13d8e4 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,7 +3,6 @@ import { readFileSync } from 'node:fs'; import { defineCommand, runMain } from 'citty'; import { doctor as runDoctor, - validateEdl, summarizeEdl, assessDelivery, rankTakes, @@ -14,7 +13,7 @@ import { resolveGateTransition, } from '@orkas/video-studio-core'; import type { VideoEdl, Take, QualityThresholds, GateTransitionInput } from '@orkas/video-studio-core'; -import { edit, render as renderTool, composition as compositionTool, analyze, speech, image, video, collectProducedSec } from '@orkas/video-studio-tools'; +import { edit, render as renderTool, composition as compositionTool, analyze, speech, image, video, collectProducedSec, validatePlanWithProvider } from '@orkas/video-studio-tools'; import type { EditProgressEvent } from '@orkas/video-studio-tools'; import { listSkills, readSkill, installSkills, type InstallTarget, type InstallScope } from './skills.js'; @@ -401,10 +400,10 @@ const plan = defineCommand({ meta: { name: 'plan', description: 'Work with the plan.json video IR.' }, subCommands: { validate: defineCommand({ - meta: { name: 'validate', description: 'Validate a plan.json; exit 1 on errors.' }, + meta: { name: 'validate', description: 'Validate a plan.json (structure, promise, and the configured video provider); exit 1 on errors.' }, args: { file: { type: 'positional', required: true } }, run({ args }) { - const r = validateEdl(readPlan(String(args.file))); + const r = validatePlanWithProvider(readPlan(String(args.file))); printJson(r); if (!r.ok) process.exitCode = 1; }, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index eeeee6b..47f1c32 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -32,20 +32,16 @@ export interface OvsConfig { video?: VideoProviderConfig; } -const VIDEO_PROVIDERS = ['doubao', 'atlas', 'muapi'] as const; -type VideoProvider = (typeof VIDEO_PROVIDERS)[number]; - -function normalizeVideoProvider(value: unknown): VideoProvider | undefined { +/** + * Lower-case and trim a provider name so `MuAPI` / ` muapi ` select the same + * adapter. Unknown names are passed through, not rejected: the video adapter + * reports them when a video is actually requested, so a typo in + * `video.provider` cannot break the image / TTS commands that share this config. + */ +function normalizeVideoProvider(value: unknown): VideoProviderConfig['provider'] | undefined { if (value === undefined || value === null) return undefined; - if (typeof value !== 'string') { - throw new Error('video.provider must be doubao, atlas, or muapi'); - } - const normalized = value.trim().toLowerCase(); - if (!normalized) return undefined; - if (!(VIDEO_PROVIDERS as readonly string[]).includes(normalized)) { - throw new Error(`Unsupported video provider "${value}". Expected doubao, atlas, or muapi.`); - } - return normalized as VideoProvider; + const normalized = String(value).trim().toLowerCase(); + return normalized ? (normalized as VideoProviderConfig['provider']) : undefined; } /** Config file location: $OVS_CONFIG_DIR/config.json, else ~/.config/orkas-video-studio/config.json */ diff --git a/packages/core/src/ir/edl.ts b/packages/core/src/ir/edl.ts index be3dff9..cd79c27 100644 --- a/packages/core/src/ir/edl.ts +++ b/packages/core/src/ir/edl.ts @@ -775,7 +775,7 @@ function validateSpec( err(`${at}.spec.generation_duration_sec`, 'E_SPEC_GENERATE_SETTINGS', 'video generation_duration_sec must be between 4 and 15'); } if (spec.ratio !== undefined && !['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'].includes(String(spec.ratio))) { - err(`${at}.spec.ratio`, 'E_SPEC_GENERATE_SETTINGS', 'video ratio is not supported by the BYO Seedance adapter'); + err(`${at}.spec.ratio`, 'E_SPEC_GENERATE_SETTINGS', 'video ratio must be 16:9, 9:16, 1:1, 4:3, 3:4, or 21:9 (the configured provider may accept fewer; `ovs plan validate` checks it)'); } if (spec.resolution !== undefined && !['480p', '720p', '1080p'].includes(String(spec.resolution))) { err(`${at}.spec.resolution`, 'E_SPEC_GENERATE_SETTINGS', 'video resolution must be 480p, 720p, or 1080p'); diff --git a/packages/core/src/runtime/fetch.ts b/packages/core/src/runtime/fetch.ts index 1fca152..1a3c6fa 100644 --- a/packages/core/src/runtime/fetch.ts +++ b/packages/core/src/runtime/fetch.ts @@ -22,12 +22,31 @@ export async function fetchWithTimeout(url: string, init: RequestInit & { timeou } } -function errorMessage(value: unknown): string | undefined { - if (typeof value === 'string') return value; +/** + * Best-effort human-readable message from a provider error payload: a string, + * an object carrying message / msg / error / detail(s) / code (searched + * recursively, first hit wins), or an array of those — FastAPI-style + * validation bodies are `{detail: [{loc, msg, type}]}`, where the field name + * is prefixed so "Input should be 5 or 10" says which input. Never echoes the + * whole body, so a payload that reflects headers or keys stays private. + */ +export function providerErrorMessage(value: unknown): string | undefined { + if (typeof value === 'string') return value.trim() || undefined; + if (Array.isArray(value)) { + for (const item of value) { + const message = providerErrorMessage(item); + if (message) return message; + } + return undefined; + } if (!value || typeof value !== 'object') return undefined; const record = value as Record; - for (const key of ['message', 'error', 'detail', 'details', 'code']) { - const message = errorMessage(record[key]); + if (typeof record.msg === 'string' && Array.isArray(record.loc)) { + const field = record.loc.filter((part): part is string => typeof part === 'string' && part !== 'body').join('.'); + return field ? `${field}: ${record.msg}` : record.msg; + } + for (const key of ['message', 'msg', 'error', 'detail', 'details', 'code']) { + const message = providerErrorMessage(record[key]); if (message) return message; } return undefined; @@ -37,7 +56,7 @@ function errorMessage(value: unknown): string | undefined { function providerErrorDetail(body: string): string | undefined { let detail: string | undefined; try { - detail = errorMessage(JSON.parse(body)); + detail = providerErrorMessage(JSON.parse(body)); } catch { detail = body.replace(/\s+/g, ' ').trim() || undefined; } diff --git a/packages/core/test/edl.test.ts b/packages/core/test/edl.test.ts index d6881ec..58d5717 100644 --- a/packages/core/test/edl.test.ts +++ b/packages/core/test/edl.test.ts @@ -193,6 +193,21 @@ describe('validateEdl — promise consistency', () => { cost_estimate: { billable_generations: 1 }, })); expect(codes(aliased.errors)).toContain('E_SPEC_GENERATE_SETTINGS_ALIAS'); + + // The neutral contract accepts every plan ratio; provider-specific subsets + // are the tools layer's job, so the message must not name one adapter. + const wide = validateEdl(plan({ + segments: [seg({ id: 's1', order: 1, source: 'generate', target_sec: 5, spec: { prompt: 'city', media_kind: 'video', ratio: '4:3' } })], + cost_estimate: { billable_generations: 1 }, + })); + expect(wide.ok).toBe(true); + const bad = validateEdl(plan({ + segments: [seg({ id: 's1', order: 1, source: 'generate', target_sec: 5, spec: { prompt: 'city', media_kind: 'video', ratio: '2:1' } })], + cost_estimate: { billable_generations: 1 }, + })); + const ratioIssue = bad.errors.find((e) => e.path === 'segments[0].spec.ratio'); + expect(ratioIssue?.message).toMatch(/16:9, 9:16, 1:1, 4:3, 3:4, or 21:9/); + expect(ratioIssue?.message).not.toMatch(/Seedance/); }); it('requires provided media to declare video versus image', () => { diff --git a/packages/core/test/runtime.test.ts b/packages/core/test/runtime.test.ts index 8ffe226..5d8b3d3 100644 --- a/packages/core/test/runtime.test.ts +++ b/packages/core/test/runtime.test.ts @@ -11,6 +11,7 @@ import { DEFAULT_HYPERFRAMES_SPEC, resolveInside, run, + providerErrorMessage, } from '../src/runtime/index'; describe('binary resolution', () => { @@ -102,3 +103,25 @@ describe('subprocess boundaries', () => { } }); }); + +describe('providerErrorMessage', () => { + it('finds the human-readable message in the common provider error shapes', () => { + expect(providerErrorMessage('plain text')).toBe('plain text'); + expect(providerErrorMessage({ error: { code: 'FORBIDDEN', message: 'Not authorized' } })).toBe('Not authorized'); + expect(providerErrorMessage({ detail: 'Not authorized' })).toBe('Not authorized'); + expect(providerErrorMessage({ error: 'quota exceeded' })).toBe('quota exceeded'); + }); + + it('reads FastAPI-style validation arrays and names the offending field', () => { + const body = { detail: [{ type: 'enum', loc: ['body', 'duration'], msg: 'Input should be 5 or 10', input: 8 }] }; + expect(providerErrorMessage(body)).toBe('duration: Input should be 5 or 10'); + expect(providerErrorMessage([{ msg: 'field required', loc: ['body'] }])).toBe('field required'); + }); + + it('returns undefined instead of echoing an unrecognized payload', () => { + expect(providerErrorMessage({ api_key: 'must not be shown' })).toBeUndefined(); + expect(providerErrorMessage([])).toBeUndefined(); + expect(providerErrorMessage(42)).toBeUndefined(); + expect(providerErrorMessage(' ')).toBeUndefined(); + }); +}); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 3848e8c..409b76c 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -5,7 +5,6 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod'; import { doctor as runDoctor, - validateEdl, summarizeEdl, assessDelivery, rankTakes, @@ -16,7 +15,7 @@ import { resolveGateTransition, } from '@orkas/video-studio-core'; import type { VideoEdl } from '@orkas/video-studio-core'; -import { edit, render as renderTool, composition as compositionTool, analyze, speech, image, video, collectProducedSec } from '@orkas/video-studio-tools'; +import { edit, render as renderTool, composition as compositionTool, analyze, speech, image, video, collectProducedSec, validatePlanWithProvider } from '@orkas/video-studio-tools'; import type { EditProgressEvent } from '@orkas/video-studio-tools'; import { listSkills, readSkill } from './skills.js'; @@ -156,7 +155,7 @@ server.tool( ); // --- plan IR --------------------------------------------------------------- -server.tool('plan_validate', 'Validate a plan.json (structural + promise consistency).', { file: z.string() }, ({ file }) => format(validateEdl(readPlan(file)))); +server.tool('plan_validate', 'Validate a plan.json (structure, promise consistency, and the configured video provider\'s limits).', { file: z.string() }, ({ file }) => format(validatePlanWithProvider(readPlan(file)))); server.tool('plan_summarize', 'Render a human-readable timeline of a plan.json.', { file: z.string() }, ({ file }) => format(summarizeEdl(readPlan(file) as VideoEdl))); server.tool( 'plan_promise_check', diff --git a/packages/skills/stage-plan/SKILL.md b/packages/skills/stage-plan/SKILL.md index 04239dd..2ace178 100644 --- a/packages/skills/stage-plan/SKILL.md +++ b/packages/skills/stage-plan/SKILL.md @@ -99,7 +99,7 @@ Plan to the craft bar (`video-craft`): a hook in the first seconds, one idea per ## Step 4 — Validate, then gate B -1. `ovs plan validate` on `project/plan.json`. Fix EVERY error before going further — errors mean the plan cannot be executed or it breaks its own promise (e.g. `source_required` but no source segment). Reconsider warnings. +1. `ovs plan validate` on `project/plan.json`. Fix EVERY error before going further — errors mean the plan cannot be executed or it breaks its own promise (e.g. `source_required` but no source segment). It also checks every generate video segment against the configured `video.provider` (MuAPI's Kling endpoints accept only `16:9`/`9:16`/`1:1` and 5 or 10 s); fix the plan or switch provider now rather than letting an approved generation fail. Reconsider warnings. 2. `ovs plan promise-check` on the PLAN, before producing anything. It computes the planned motion ratio vs. the promise — a fail means the plan is already a slideshow / breaks its promise. Fixing the plan now is free; re-assembling later is not. Rebalance durations or convert a static beat to footage until it passes (gate D re-checks against the real cut). 3. `ovs plan summarize` → present that timeline for **production plan confirmation**, including the exact narrator and generation settings. After the user's reply, run `ovs gate transition`; do not infer approval or request it again for an unchanged already-approved plan. diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 9ffc336..50b656d 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -12,5 +12,6 @@ export { editVideo, probeMedia } from './edit/index.js'; export type { EditOp, ProbeResult } from './edit/index.js'; export type { EditProgressEvent, OnEditProgress, EditRunOptions } from './progress.js'; export { collectProducedSec, resolveProducedPath } from './plan-produced.js'; +export { checkPlanVideoProvider, validatePlanWithProvider } from './plan-provider.js'; export * from './hyperframes/index.js'; export * from './composition/index.js'; diff --git a/packages/tools/src/plan-provider.ts b/packages/tools/src/plan-provider.ts new file mode 100644 index 0000000..ca57149 --- /dev/null +++ b/packages/tools/src/plan-provider.ts @@ -0,0 +1,77 @@ +/** + * Provider-aware half of plan validation. `validateEdl` enforces the + * provider-neutral plan contract (six ratios, 4–15 s, generate | edit); the + * configured BYO video provider may accept only a subset of it (MuAPI's Kling + * endpoints: 16:9 / 9:16 / 1:1, 5 or 10 s, generate only). Without this check + * a plan can be approved at Gate C and then fail on every billable segment at + * generation time. Shared by `ovs plan validate` and the MCP plan_validate tool. + */ + +import { loadConfig, validateEdl } from '@orkas/video-studio-core'; +import type { EdlIssue, EdlValidation, OvsConfig } from '@orkas/video-studio-core'; +import { videoProviderLimits, type VideoProviderLimits } from './video/video.js'; + +const isObject = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v); + +function acceptsDuration(limits: VideoProviderLimits['durations'], seconds: number): boolean { + return 'allowed' in limits ? limits.allowed.includes(seconds) : seconds >= limits.min && seconds <= limits.max; +} + +function describeDurations(limits: VideoProviderLimits['durations']): string { + return 'allowed' in limits ? `${limits.allowed.join(' or ')} seconds` : `${limits.min}-${limits.max} seconds`; +} + +/** + * Error issues for every generate VIDEO segment whose ratio, duration, or + * operation the configured video provider (default doubao) would reject. + * Image generation and plans without video generation are never affected, so + * a compose-only project does not need a video provider configured at all. + */ +export function checkPlanVideoProvider(plan: unknown, config: OvsConfig = loadConfig()): EdlIssue[] { + if (!isObject(plan) || !Array.isArray(plan.segments)) return []; + const targets = plan.segments + .map((segment, index) => ({ segment, index })) + .filter(({ segment }) => isObject(segment) && segment.source === 'generate' && isObject(segment.spec) && segment.spec.media_kind !== 'image'); + if (!targets.length) return []; + + let limits: VideoProviderLimits; + try { + limits = videoProviderLimits(config.video); + } catch (error) { + return [{ level: 'error', path: '$', code: 'E_VIDEO_PROVIDER', message: (error as Error).message }]; + } + + const issues: EdlIssue[] = []; + const err = (path: string, message: string) => issues.push({ level: 'error', path, code: 'E_SPEC_GENERATE_PROVIDER', message }); + const fix = 'change the plan or switch video.provider before Gate C'; + for (const { segment, index } of targets) { + const spec = (segment as Record).spec as Record; + const at = `segments[${index}].spec`; + if (typeof spec.ratio === 'string' && !limits.ratios.includes(spec.ratio)) { + err(`${at}.ratio`, `video provider "${limits.provider}" supports ratios ${limits.ratios.join(', ')}, not ${spec.ratio}; ${fix}`); + } + const seconds = spec.generation_duration_sec; + if (typeof seconds === 'number' && Number.isFinite(seconds) && !acceptsDuration(limits.durations, seconds)) { + err(`${at}.generation_duration_sec`, `video provider "${limits.provider}" supports durations of ${describeDurations(limits.durations)}, not ${seconds}; ${fix}`); + } + const operation = spec.operation ?? 'generate'; + if (typeof operation === 'string' && !(limits.operations as readonly string[]).includes(operation)) { + err(`${at}.operation`, `video provider "${limits.provider}" supports the ${limits.operations.join(' and ')} operation only, not "${operation}"; ${fix}`); + } + } + return issues; +} + +/** + * `validateEdl` plus the configured-provider check. A field the neutral + * validator already rejected is not reported twice; `ok` is false when either + * layer rejects the plan. + */ +export function validatePlanWithProvider(plan: unknown, config?: OvsConfig): EdlValidation { + const base = validateEdl(plan); + const rejected = new Set(base.errors.map((issue) => issue.path)); + const providerIssues = checkPlanVideoProvider(plan, config).filter((issue) => !rejected.has(issue.path)); + if (!providerIssues.length) return base; + return { ok: false, errors: [...base.errors, ...providerIssues], warnings: base.warnings }; +} diff --git a/packages/tools/src/video/video.ts b/packages/tools/src/video/video.ts index d984920..51c3c93 100644 --- a/packages/tools/src/video/video.ts +++ b/packages/tools/src/video/video.ts @@ -1,6 +1,6 @@ import { writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { loadConfig, ensureParentDir, fetchWithTimeout, postJson, getJson } from '@orkas/video-studio-core'; +import { loadConfig, ensureParentDir, fetchWithTimeout, postJson, getJson, providerErrorMessage } from '@orkas/video-studio-core'; import type { OvsConfig, VideoProviderConfig } from '@orkas/video-studio-core'; const ARK_DEFAULT_BASE = 'https://ark.cn-beijing.volces.com/api/v3'; @@ -23,6 +23,11 @@ const MUAPI_MODEL_KINDS = { const MUAPI_SUPPORTED_MODELS = Object.keys(MUAPI_MODEL_KINDS).join(', '); const MUAPI_SUPPORTED_RATIOS = ['16:9', '9:16', '1:1'] as const; const MUAPI_SUPPORTED_DURATIONS = [5, 10] as const; +// The provider-neutral plan contract enforced by `validateEdl`; every adapter +// accepts a subset of it and declares that subset in `limits` so Gate C can +// reject a plan the configured provider would refuse at generation time. +const PLAN_RATIOS = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'] as const; +const SEEDANCE_DURATION = { min: 4, max: 15 } as const; const POLL_INTERVAL_MS = 10_000; const POLL_TIMEOUT_MS = 30_000; // per-poll request timeout — one slow poll must not fail the task const TASK_TIMEOUT_MS = 60 * 60 * 1000; @@ -52,16 +57,9 @@ export interface ProviderRequest { body: Record; } -function arkBase(cfg: VideoProviderConfig): string { - return (cfg.base_url ?? ARK_DEFAULT_BASE).replace(/\/+$/, ''); -} - -function atlasBase(cfg: VideoProviderConfig): string { - return (cfg.base_url ?? ATLAS_DEFAULT_BASE).replace(/\/+$/, ''); -} - -function muapiBase(cfg: VideoProviderConfig): string { - return (cfg.base_url ?? MUAPI_DEFAULT_BASE).replace(/\/+$/, ''); +/** Provider API root: the configured base_url (trailing slashes stripped) or the adapter default. */ +function providerBase(cfg: VideoProviderConfig, defaultBase: string): string { + return (cfg.base_url ?? defaultBase).replace(/\/+$/, ''); } /** Build an Atlas Cloud media task request (`POST {base}/model/generateVideo`). */ @@ -74,8 +72,8 @@ export function buildAtlasCreateRequest(cfg: VideoProviderConfig, p: VideoParams throw new Error('video: Atlas Cloud accepts a single first-frame image_url; additional references are not supported'); } const duration = p.duration ?? 5; - if (!Number.isFinite(duration) || duration < 4 || duration > 15) { - throw new Error('video: duration must be between 4 and 15 seconds'); + if (!Number.isFinite(duration) || duration < SEEDANCE_DURATION.min || duration > SEEDANCE_DURATION.max) { + throw new Error(`video: duration must be between ${SEEDANCE_DURATION.min} and ${SEEDANCE_DURATION.max} seconds`); } // Default the model by task type, and fail closed on an explicit mismatch: // a text-to-video model given a first frame would silently produce a video @@ -88,7 +86,7 @@ export function buildAtlasCreateRequest(cfg: VideoProviderConfig, p: VideoParams throw new Error(`video: model "${model}" requires a first-frame image_url; pass one, or use a text-to-video model (e.g. ${ATLAS_DEFAULT_MODEL})`); } return { - url: `${atlasBase(cfg)}/model/generateVideo`, + url: `${providerBase(cfg, ATLAS_DEFAULT_BASE)}/model/generateVideo`, headers: { authorization: `Bearer ${cfg.api_key}`, 'content-type': 'application/json' }, body: { model, @@ -141,7 +139,7 @@ export function buildMuapiCreateRequest(cfg: VideoProviderConfig, p: VideoParams throw new Error(`video: MuAPI model "${model}" supports 16:9, 9:16, and 1:1 aspect ratios`); } return { - url: `${muapiBase(cfg)}/${model}`, + url: `${providerBase(cfg, MUAPI_DEFAULT_BASE)}/${model}`, headers: { 'x-api-key': cfg.api_key, 'content-type': 'application/json' }, body: { prompt: p.prompt, @@ -176,11 +174,11 @@ export function buildSeedanceCreateRequest(cfg: VideoProviderConfig, p: VideoPar content.push({ type: 'video_url', role: 'reference_video', video_url: { url } }); } const duration = p.duration ?? 5; - if (!Number.isFinite(duration) || duration < 4 || duration > 15) { - throw new Error('video: duration must be between 4 and 15 seconds'); + if (!Number.isFinite(duration) || duration < SEEDANCE_DURATION.min || duration > SEEDANCE_DURATION.max) { + throw new Error(`video: duration must be between ${SEEDANCE_DURATION.min} and ${SEEDANCE_DURATION.max} seconds`); } return { - url: `${arkBase(cfg)}/contents/generations/tasks`, + url: `${providerBase(cfg, ARK_DEFAULT_BASE)}/contents/generations/tasks`, headers: { authorization: `Bearer ${cfg.api_key}`, 'content-type': 'application/json' }, body: { model: p.model ?? cfg.model ?? DEFAULT_MODEL, @@ -227,15 +225,25 @@ interface ProviderPollState { error?: string; } -type VideoProvider = 'doubao' | 'atlas' | 'muapi'; +export type VideoProvider = 'doubao' | 'atlas' | 'muapi'; + +/** What the adapter forwards to its provider; a subset of the plan contract. */ +export interface VideoProviderLimits { + provider: VideoProvider; + ratios: readonly string[]; + /** Generation durations in seconds: an explicit list, or an inclusive range. */ + durations: { allowed: readonly number[] } | { min: number; max: number }; + operations: readonly ('generate' | 'edit')[]; +} interface VideoProviderAdapter { buildRequest: (cfg: VideoProviderConfig, params: VideoParams) => ProviderRequest; taskId: (response: unknown) => string | undefined; - base: (cfg: VideoProviderConfig) => string; + defaultBase: string; pollUrl: (base: string, id: string) => string; authHeaders: (cfg: VideoProviderConfig) => Record; parsePoll: (response: unknown) => ProviderPollState; + limits: VideoProviderLimits; } function firstString(value: unknown): string | undefined { @@ -244,13 +252,6 @@ function firstString(value: unknown): string | undefined { return undefined; } -function errorMessage(value: unknown): string | undefined { - if (typeof value === 'string') return value; - if (!value || typeof value !== 'object') return undefined; - const record = value as Record; - return typeof record.message === 'string' ? record.message : undefined; -} - function bearerHeaders(cfg: VideoProviderConfig): Record { return { authorization: `Bearer ${cfg.api_key}` }; } @@ -259,7 +260,7 @@ const VIDEO_PROVIDER_ADAPTERS: Record = { doubao: { buildRequest: buildSeedanceCreateRequest, taskId: (response) => (response as CreateResp).id, - base: arkBase, + defaultBase: ARK_DEFAULT_BASE, pollUrl: (base, id) => `${base}/contents/generations/tasks/${id}`, authHeaders: bearerHeaders, parsePoll: (response) => { @@ -267,14 +268,15 @@ const VIDEO_PROVIDER_ADAPTERS: Record = { return { status: poll.status, outputUrl: firstString(poll.content?.video_url), - error: errorMessage(poll.error), + error: providerErrorMessage(poll.error), }; }, + limits: { provider: 'doubao', ratios: PLAN_RATIOS, durations: SEEDANCE_DURATION, operations: ['generate', 'edit'] }, }, atlas: { buildRequest: buildAtlasCreateRequest, taskId: (response) => (response as AtlasResp).data?.id, - base: atlasBase, + defaultBase: ATLAS_DEFAULT_BASE, pollUrl: (base, id) => `${base}/model/prediction/${id}`, authHeaders: bearerHeaders, parsePoll: (response) => { @@ -282,14 +284,15 @@ const VIDEO_PROVIDER_ADAPTERS: Record = { return { status: data?.status, outputUrl: firstString(data?.output) ?? firstString(data?.outputs), - error: errorMessage(data?.error), + error: providerErrorMessage(data?.error), }; }, + limits: { provider: 'atlas', ratios: PLAN_RATIOS, durations: SEEDANCE_DURATION, operations: ['generate'] }, }, muapi: { buildRequest: buildMuapiCreateRequest, taskId: (response) => (response as MuapiCreateResp).request_id, - base: muapiBase, + defaultBase: MUAPI_DEFAULT_BASE, pollUrl: (base, id) => `${base}/predictions/${id}/result`, authHeaders: (cfg) => ({ 'x-api-key': cfg.api_key! }), parsePoll: (response) => { @@ -297,9 +300,10 @@ const VIDEO_PROVIDER_ADAPTERS: Record = { return { status: poll.status, outputUrl: firstString(poll.outputs), - error: errorMessage(poll.error), + error: providerErrorMessage(poll.error), }; }, + limits: { provider: 'muapi', ratios: MUAPI_SUPPORTED_RATIOS, durations: { allowed: MUAPI_SUPPORTED_DURATIONS }, operations: ['generate'] }, }, }; @@ -309,6 +313,14 @@ function resolveVideoProvider(provider: VideoProviderConfig['provider']): VideoP throw new Error(`video: unsupported provider "${String(provider)}"; expected doubao, atlas, or muapi`); } +/** + * Ratios / durations / operations the configured provider (default doubao) + * will accept, for provider-aware plan checks. Throws on an unknown provider. + */ +export function videoProviderLimits(cfg?: VideoProviderConfig): VideoProviderLimits { + return VIDEO_PROVIDER_ADAPTERS[resolveVideoProvider(cfg?.provider)].limits; +} + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); export interface VideoResult { @@ -368,7 +380,7 @@ export async function generateVideo(params: VideoParams, config: OvsConfig = loa const id = adapter.taskId(created); if (!id) throw new Error('video: task create returned no id'); - const base = adapter.base(cfg); + const base = providerBase(cfg, adapter.defaultBase); const authHeaders = adapter.authHeaders(cfg); const start = now(); diff --git a/packages/tools/test/gen.test.ts b/packages/tools/test/gen.test.ts index fb0c05b..928a0af 100644 --- a/packages/tools/test/gen.test.ts +++ b/packages/tools/test/gen.test.ts @@ -563,6 +563,29 @@ describe('generateVideo (MuAPI task + poll)', () => { } }); + it('names the offending field from a FastAPI-style MuAPI validation error', async () => { + const srv = await startServer((req, res) => { + if (req.method === 'POST') { + res.writeHead(422, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ detail: [{ type: 'enum', loc: ['body', 'duration'], msg: 'Input should be 5 or 10', input: 8 }] })); + } else { + res.writeHead(404); + res.end(); + } + }); + try { + await expect( + generateVideo( + { prompt: 'rejected', output: join(dir, 'muapi-422.mp4') }, + { video: { provider: 'muapi', base_url: srv.baseUrl, api_key: 'mu-key', model: 'kling-v2.1-master-t2v' } }, + { pollIntervalMs: 1 }, + ), + ).rejects.toThrow(/HTTP 422: duration: Input should be 5 or 10/); + } finally { + await srv.close(); + } + }); + it('includes a safe provider error detail for a rejected MuAPI request', async () => { const srv = await startServer((req, res) => { if (req.method === 'POST') { @@ -668,6 +691,35 @@ describe('config env overlay', () => { } }); + it('keeps an unknown video.provider for the video adapter to reject instead of failing loadConfig', async () => { + const prev = { ...process.env }; + const configDir = mkdtempSync(join(tmpdir(), 'ovs-badprovider-config-')); + process.env.OVS_CONFIG_DIR = configDir; + delete process.env.OVS_VIDEO_PROVIDER; + delete process.env.OVS_VIDEO_API_KEY; + delete process.env.MUAPI_API_KEY; + try { + writeFileSync(join(configDir, 'config.json'), JSON.stringify({ + video: { provider: ' Seedance ', api_key: 'file-key' }, + image: { provider: 'openai', api_key: 'image-key' }, + })); + // A typo in video.provider must not take image / TTS down with it. + const c = loadConfig(); + expect(c.image).toMatchObject({ provider: 'openai', api_key: 'image-key' }); + expect(c.video).toMatchObject({ provider: 'seedance', api_key: 'file-key' }); + // MUAPI_API_KEY still only applies to an explicit muapi selection. + process.env.MUAPI_API_KEY = 'mu-key'; + expect(loadConfig().video?.api_key).toBe('file-key'); + await expect(generateVideo({ prompt: 'p', output: join(dir, 'bad-provider.mp4') }, c)).rejects.toThrow(/unsupported provider "seedance"/); + } finally { + rmSync(configDir, { recursive: true, force: true }); + for (const k of ['OVS_CONFIG_DIR', 'OVS_VIDEO_PROVIDER', 'OVS_VIDEO_API_KEY', 'OVS_VIDEO_BASE_URL', 'OVS_VIDEO_MODEL', 'MUAPI_API_KEY']) { + if (prev[k] === undefined) delete process.env[k]; + else process.env[k] = prev[k]; + } + } + }); + it('loads an explicitly selected MuAPI key from a config file', () => { const prev = { ...process.env }; const configDir = mkdtempSync(join(tmpdir(), 'ovs-muapi-config-')); diff --git a/packages/tools/test/plan-provider.test.ts b/packages/tools/test/plan-provider.test.ts new file mode 100644 index 0000000..13b3811 --- /dev/null +++ b/packages/tools/test/plan-provider.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import type { OvsConfig } from '@orkas/video-studio-core'; +import { checkPlanVideoProvider, validatePlanWithProvider } from '../src/plan-provider'; + +const MUAPI: OvsConfig = { video: { provider: 'muapi', api_key: 'mu-key' } }; +const ATLAS: OvsConfig = { video: { provider: 'atlas', api_key: 'k' } }; +const NONE: OvsConfig = {}; + +function generatePlan(spec: Record, segmentOverrides: Record = {}) { + return { + aspect: '16:9', + total_target_sec: 5, + language: 'en', + delivery_promise: { type: 'compose_led', source_required: false, motion_min_ratio: 0 }, + segments: [{ + id: 's1', order: 1, role: 'body', layer: 'primary', source: 'generate', target_sec: 5, + spec: { prompt: 'a wide shot of a city at dawn', media_kind: 'video', ...spec }, + ...segmentOverrides, + }], + tracks: {}, + cost_estimate: { billable_generations: 1 }, + }; +} + +const paths = (issues: { path: string }[]) => issues.map((i) => i.path); + +describe('checkPlanVideoProvider', () => { + it('rejects ratio, duration, and operation the configured MuAPI endpoints cannot run', () => { + const issues = checkPlanVideoProvider(generatePlan({ ratio: '4:3', generation_duration_sec: 8, operation: 'edit' }), MUAPI); + expect(paths(issues)).toEqual([ + 'segments[0].spec.ratio', + 'segments[0].spec.generation_duration_sec', + 'segments[0].spec.operation', + ]); + expect(issues.every((i) => i.level === 'error' && i.code === 'E_SPEC_GENERATE_PROVIDER')).toBe(true); + expect(issues[0].message).toMatch(/"muapi" supports ratios 16:9, 9:16, 1:1, not 4:3/); + expect(issues[1].message).toMatch(/5 or 10 seconds, not 8/); + expect(issues[2].message).toMatch(/generate operation only/); + }); + + it('accepts a plan inside the MuAPI limits and leaves unset fields to the adapter defaults', () => { + expect(checkPlanVideoProvider(generatePlan({ ratio: '9:16', generation_duration_sec: 10 }), MUAPI)).toEqual([]); + expect(checkPlanVideoProvider(generatePlan({}), MUAPI)).toEqual([]); + }); + + it('matches the neutral plan contract for Doubao (default) and Atlas, except Atlas has no edit', () => { + const wide = generatePlan({ ratio: '4:3', generation_duration_sec: 8 }); + expect(checkPlanVideoProvider(wide, NONE)).toEqual([]); + expect(checkPlanVideoProvider(wide, ATLAS)).toEqual([]); + expect(paths(checkPlanVideoProvider(generatePlan({ operation: 'edit' }), ATLAS))).toEqual(['segments[0].spec.operation']); + expect(checkPlanVideoProvider(generatePlan({ operation: 'edit' }), NONE)).toEqual([]); + }); + + it('ignores image generation and plans without video generation', () => { + expect(checkPlanVideoProvider(generatePlan({ media_kind: 'image', ratio: '4:3' }), MUAPI)).toEqual([]); + const compose = { segments: [{ id: 'c', order: 1, layer: 'primary', source: 'compose', spec: { kind: 'title-card' } }] }; + expect(checkPlanVideoProvider(compose, { video: { provider: 'nope' as never } })).toEqual([]); + expect(checkPlanVideoProvider(null, MUAPI)).toEqual([]); + }); + + it('reports an unsupported configured provider once, only when the plan generates video', () => { + const issues = checkPlanVideoProvider(generatePlan({}), { video: { provider: 'seedance' as never, api_key: 'k' } }); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ level: 'error', path: '$', code: 'E_VIDEO_PROVIDER' }); + expect(issues[0].message).toMatch(/unsupported provider "seedance"/); + }); +}); + +describe('validatePlanWithProvider', () => { + it('fails a structurally valid plan that the configured provider cannot run', () => { + const plan = generatePlan({ ratio: '4:3', generation_duration_sec: 5 }); + expect(validatePlanWithProvider(plan, NONE).ok).toBe(true); + const r = validatePlanWithProvider(plan, MUAPI); + expect(r.ok).toBe(false); + expect(r.errors.map((e) => e.code)).toEqual(['E_SPEC_GENERATE_PROVIDER']); + }); + + it('does not report a field twice when the neutral validator already rejected it', () => { + const r = validatePlanWithProvider(generatePlan({ ratio: '2:1' }), MUAPI); + expect(r.ok).toBe(false); + expect(r.errors.filter((e) => e.path === 'segments[0].spec.ratio').map((e) => e.code)).toEqual(['E_SPEC_GENERATE_SETTINGS']); + }); +});