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
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,14 +163,30 @@ opt-in and uses **your** keys — no managed backend, no account binding. Config
| Capability | Providers | Env |
|---|---|---|
| Image (`ovs image`) | OpenAI-compatible · Gemini · Doubao Seedream | `OVS_IMAGE_PROVIDER` · `OVS_IMAGE_BASE_URL` · `OVS_IMAGE_API_KEY` · `OVS_IMAGE_MODEL` |
| Video (`ovs video`) | Doubao Seedance (image-to-video) | `OVS_VIDEO_PROVIDER` · `OVS_VIDEO_BASE_URL` · `OVS_VIDEO_API_KEY` · `OVS_VIDEO_MODEL` |
| Video (`ovs video`) | Doubao Seedance · Atlas Cloud · MuAPI | `OVS_VIDEO_PROVIDER` · `OVS_VIDEO_BASE_URL` · `OVS_VIDEO_API_KEY` · `OVS_VIDEO_MODEL` |
| TTS (`ovs speak`) | OpenAI-compatible (incl. ElevenLabs-style) | `OVS_TTS_BASE_URL` · `OVS_TTS_API_KEY` · `OVS_TTS_MODEL` · `OVS_TTS_VOICE` · `OVS_TTS_FORMAT` |

Use `ovs speech-capabilities` to resolve the exact configured narration profile without
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.

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
key when MuAPI is selected, and never selects MuAPI by itself. `video.model` /
`OVS_VIDEO_MODEL` currently supports these validated Kling v2.1 endpoint slugs:
`kling-v2.1-master-t2v`, `kling-v2.1-master-i2v`, `kling-v2.1-standard-i2v`, and
`kling-v2.1-pro-i2v`; unsupported slugs are rejected rather than sent with a mismatched body.
Text-to-video defaults to `kling-v2.1-master-t2v`, while a first-frame `image_url` defaults to
`kling-v2.1-master-i2v`. The default base URL includes `/api/v1`; custom MuAPI base URLs must
include that path. See the [MuAPI API reference](https://muapi.ai/docs/api-reference) for the
submit-and-poll contract and model-specific parameters. This adapter accepts the common
prompt, aspect-ratio, duration, and first-frame inputs; for the supported Kling endpoints,
duration is 5 or 10 seconds and ratio is `16:9`, `9:16`, or `1:1`. Provider-neutral `resolution`,
`generate_audio`, and `quality` inputs remain compatible with signed plans; resolution and audio
are ignored by Kling, while quality is validated but not sent. Edit and additional-reference
controls are rejected until a matching MuAPI schema is supported.

---

## How it compares
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,8 +639,8 @@ const videoCmd = defineCommand({
quality: { type: 'string', description: 'economy | balanced | quality (provider-neutral intent)' },
ratio: { type: 'string', default: '16:9', description: '16:9 | 9:16 | 1:1 | 4:3 | 3:4 | 21:9' },
duration: { type: 'string', default: '5', description: '4-15 seconds' },
resolution: { type: 'string', default: '720p', description: '480p | 720p | 1080p' },
'generate-audio': { type: 'boolean', default: true },
resolution: { type: 'string', description: '480p | 720p | 1080p' },
'generate-audio': { type: 'boolean', description: 'request provider-generated audio when supported' },
},
async run({ args }) {
const referenceImageUrls = args['image-urls']
Expand All @@ -660,8 +660,8 @@ const videoCmd = defineCommand({
quality: args.quality ? String(args.quality) as 'economy' | 'balanced' | 'quality' : undefined,
ratio: String(args.ratio) as '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9',
duration: num(args.duration, 'duration'),
resolution: String(args.resolution) as '480p' | '720p' | '1080p',
generate_audio: args['generate-audio'] !== false,
resolution: args.resolution ? String(args.resolution) as '480p' | '720p' | '1080p' : undefined,
generate_audio: args['generate-audio'] === undefined ? undefined : args['generate-audio'] !== false,
}));
},
});
Expand Down
32 changes: 29 additions & 3 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export interface ImageProviderConfig {
model?: string;
}
export interface VideoProviderConfig {
provider?: 'doubao' | 'atlas';
provider?: 'doubao' | 'atlas' | 'muapi';
base_url?: string;
api_key?: string;
model?: string;
Expand All @@ -32,6 +32,22 @@ 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 {
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;
}

/** Config file location: $OVS_CONFIG_DIR/config.json, else ~/.config/orkas-video-studio/config.json */
export function configPath(): string {
const dir =
Expand Down Expand Up @@ -71,11 +87,21 @@ export function loadConfig(): OvsConfig {
...(process.env.OVS_IMAGE_API_KEY ? { api_key: process.env.OVS_IMAGE_API_KEY } : {}),
...(process.env.OVS_IMAGE_MODEL ? { model: process.env.OVS_IMAGE_MODEL } : {}),
};
const configuredVideoProvider = normalizeVideoProvider(process.env.OVS_VIDEO_PROVIDER);
const fileVideoProvider = normalizeVideoProvider(fromFile.video?.provider);
const effectiveVideoProvider = configuredVideoProvider ?? fileVideoProvider;
// MUAPI_API_KEY is intentionally opt-in: an unrelated key in the shell must
// never change a provider-less config into a billable MuAPI request.
// When MuAPI is explicitly selected, its vendor-specific key wins over the
// generic key so a stale OVS_VIDEO_API_KEY cannot silently cause a 401.
const videoApiKey = effectiveVideoProvider === 'muapi'
? process.env.MUAPI_API_KEY || process.env.OVS_VIDEO_API_KEY
: process.env.OVS_VIDEO_API_KEY;
const video: VideoProviderConfig = {
...fromFile.video,
...(process.env.OVS_VIDEO_PROVIDER ? { provider: process.env.OVS_VIDEO_PROVIDER as VideoProviderConfig['provider'] } : {}),
...(effectiveVideoProvider ? { provider: effectiveVideoProvider } : {}),
...(process.env.OVS_VIDEO_BASE_URL ? { base_url: process.env.OVS_VIDEO_BASE_URL } : {}),
...(process.env.OVS_VIDEO_API_KEY ? { api_key: process.env.OVS_VIDEO_API_KEY } : {}),
...(videoApiKey ? { api_key: videoApiKey } : {}),
...(process.env.OVS_VIDEO_MODEL ? { model: process.env.OVS_VIDEO_MODEL } : {}),
};
const out: OvsConfig = { ...fromFile };
Expand Down
32 changes: 30 additions & 2 deletions packages/core/src/runtime/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,28 @@ export async function fetchWithTimeout(url: string, init: RequestInit & { timeou
}
}

function errorMessage(value: unknown): string | undefined {
if (typeof value === 'string') return value;
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 (message) return message;
}
return undefined;
}

/** Keep provider failures actionable without echoing headers, URLs, or secrets. */
function providerErrorDetail(body: string): string | undefined {
let detail: string | undefined;
try {
detail = errorMessage(JSON.parse(body));
} catch {
detail = body.replace(/\s+/g, ' ').trim() || undefined;
}
return detail?.slice(0, 500);
}

/** POST JSON and parse a JSON response, throwing a legible error on non-2xx. */
export async function postJson(url: string, body: unknown, headers: Record<string, string>, timeoutMs = 60_000): Promise<unknown> {
const res = await fetchWithTimeout(url, {
Expand All @@ -31,7 +53,10 @@ export async function postJson(url: string, body: unknown, headers: Record<strin
timeoutMs,
});
const text = await res.text();
if (!res.ok) throw new Error(`provider request failed with HTTP ${res.status}`);
if (!res.ok) {
const detail = providerErrorDetail(text);
throw new Error(`provider request failed with HTTP ${res.status}${detail ? `: ${detail}` : ''}`);
}
try {
return JSON.parse(text);
} catch {
Expand All @@ -43,7 +68,10 @@ export async function postJson(url: string, body: unknown, headers: Record<strin
export async function getJson(url: string, headers: Record<string, string>, timeoutMs = 30_000): Promise<unknown> {
const res = await fetchWithTimeout(url, { method: 'GET', headers, timeoutMs });
const text = await res.text();
if (!res.ok) throw new Error(`provider request failed with HTTP ${res.status}`);
if (!res.ok) {
const detail = providerErrorDetail(text);
throw new Error(`provider request failed with HTTP ${res.status}${detail ? `: ${detail}` : ''}`);
}
try {
return JSON.parse(text);
} catch {
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ server.tool(
);
server.tool(
'video',
'Generate a video clip via the configured BYO provider (Doubao Seedance), with exact Gate C settings.',
'Generate a video clip via the configured BYO provider (Doubao Seedance, Atlas Cloud, or MuAPI), with exact Gate C settings.',
{
prompt: z.string(),
output: z.string(),
Expand Down
Loading