Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { readFileSync } from 'node:fs';
import { defineCommand, runMain } from 'citty';
import {
doctor as runDoctor,
validateEdl,
summarizeEdl,
assessDelivery,
rankTakes,
Expand All @@ -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';

Expand Down Expand Up @@ -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;
},
Expand Down
22 changes: 9 additions & 13 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/ir/edl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
29 changes: 24 additions & 5 deletions packages/core/src/runtime/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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;
Expand All @@ -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;
}
Expand Down
15 changes: 15 additions & 0 deletions packages/core/test/edl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
23 changes: 23 additions & 0 deletions packages/core/test/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
DEFAULT_HYPERFRAMES_SPEC,
resolveInside,
run,
providerErrorMessage,
} from '../src/runtime/index';

describe('binary resolution', () => {
Expand Down Expand Up @@ -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();
});
});
5 changes: 2 additions & 3 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod';
import {
doctor as runDoctor,
validateEdl,
summarizeEdl,
assessDelivery,
rankTakes,
Expand All @@ -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';

Expand Down Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion packages/skills/stage-plan/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions packages/tools/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
77 changes: 77 additions & 0 deletions packages/tools/src/plan-provider.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> =>
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<string, unknown>).spec as Record<string, unknown>;
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 };
}
Loading
Loading