Skip to content
Open
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ SEEDANCE_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
SEEDANCE_API_KEY=
SEEDANCE_VIDEO_MODEL=doubao-seedance-2-0-260128

# OFox multi-model gateway (LLM preset + video generation share one key)
LLM_OFOX_BASE_URL=https://api.ofox.ai/v1
LLM_OFOX_API_KEY=
OFOX_VIDEO_MODEL=bytedance/seedance-2.0-fast

KLING_BASE_URL=https://api-singapore.klingai.com
KLING_API_KEY=
KLING_VIDEO_MODEL=kling-v3-omni
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ Clients must send `Authorization: Bearer <token>`. The current bridge is designe
|---|---|
| Frontend | React 19, TypeScript 6, Vite 8 |
| Editing core | Immutable timeline state, command layer, and proposal-based application |
| Agent | Vercel AI SDK 7 (Anthropic, OpenAI, Gemini, Kimi, Qwen, GLM, DeepSeek, MiniMax, Xiaomi MiMo, Mistral, xAI Grok by API key or SuperGrok/X Premium+ subscription sign-in, and compatible APIs), Agent Skills, MCP SDK |
| Agent | Vercel AI SDK 7 (Anthropic, OpenAI, Gemini, Kimi, Qwen, GLM, DeepSeek, MiniMax, Xiaomi MiMo, Mistral, xAI Grok by API key or SuperGrok/X Premium+ subscription sign-in, OFox, and compatible APIs), Agent Skills, MCP SDK |
| Preview and visuals | Remotion Player, WebGL / GLSL |
| Server | Dual-host Vite / Electron plugins and a server-side keystore |
| Persistence | Shared local project store under `~/.openchatcut`, IndexedDB cache, configurable local media directory, optional Cloudflare R2 |
Expand Down
2 changes: 1 addition & 1 deletion README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ OPENCHATCUT_EDITOR_URL=https://your-editor.example.com
|---|---|
| 前端 | React 19、TypeScript 6、Vite 8 |
| 编辑核心 | 不可变时间线状态、命令层、提案式应用 |
| Agent | Vercel AI SDK 7(Anthropic、OpenAI、Gemini、Kimi、Qwen、GLM、DeepSeek、MiniMax、小米 MiMo、Mistral、xAI Grok(API Key 或 SuperGrok/X Premium+ 订阅登录)与兼容接口)、Agent Skills、MCP SDK |
| Agent | Vercel AI SDK 7(Anthropic、OpenAI、Gemini、Kimi、Qwen、GLM、DeepSeek、MiniMax、小米 MiMo、Mistral、xAI Grok(API Key 或 SuperGrok/X Premium+ 订阅登录)、OFox 与兼容接口)、Agent Skills、MCP SDK |
| 预览与视觉 | Remotion Player、WebGL / GLSL |
| 服务端 | Vite / Electron 双宿主插件、服务端密钥仓 |
| 持久化 | `~/.openchatcut` 下的本机共享工程库、IndexedDB 缓存、可配置本地素材目录、可选 Cloudflare R2 |
Expand Down
13 changes: 7 additions & 6 deletions assets/agent/openchatcut-tool-schemas.json
Original file line number Diff line number Diff line change
Expand Up @@ -1922,9 +1922,10 @@
"kling",
"hailuo",
"byteplus",
"grok-imagine-video"
"grok-imagine-video",
"ofox"
],
"description": "hailuo is MiniMax: 6 or 10s; firstFrame optional; lastFrame allowed with firstFrame; no multi-ref or multi-shot. 1080p is 6s only. byteplus is BytePlus ModelArk Seedance — same request shape/limits as seedance2. grok-imagine-video is xAI Grok Imagine: text-to-video only, 1–15s, audio track included, no references/frames."
"description": "hailuo is MiniMax: 6 or 10s; firstFrame optional; lastFrame allowed with firstFrame; no multi-ref or multi-shot. 1080p is 6s only. byteplus is BytePlus ModelArk Seedance — same request shape/limits as seedance2. grok-imagine-video is xAI Grok Imagine: text-to-video only, 1–15s, audio track included, no references/frames. ofox is the OFox multi-model gateway (Seedance/Wan and more behind one key): 2–30s with per-model limits enforced by the API; supports firstFrame (and optional lastFrame), or up to 9 refImages (frames and refImages are mutually exclusive); no refVideos/refAudios yet."
},
"prompt": {
"type": "string",
Expand All @@ -1942,11 +1943,11 @@
"type": "string"
}
],
"description": "Integer seconds, 2–15 for Seedance, 3–15 for Kling, exactly 6 or 10 for Hailuo (Hailuo 1080p → 6 only), 1–15 for grok-imagine-video."
"description": "Integer seconds, 2–15 for Seedance, 3–15 for Kling, exactly 6 or 10 for Hailuo (Hailuo 1080p → 6 only), 1–15 for grok-imagine-video, 2–30 for ofox (per-model limits enforced by the API)."
},
"ratio": {
"type": "string",
"description": "Seedance: 16:9, 4:3, 1:1, 3:4, 9:16, 21:9, adaptive. Kling: 16:9, 9:16, 1:1. grok-imagine-video: 16:9, 9:16, 1:1, 4:3, 3:4, 3:2, 2:3. Do not send for hailuo."
"description": "Seedance: 16:9, 4:3, 1:1, 3:4, 9:16, 21:9, adaptive. Kling: 16:9, 9:16, 1:1. grok-imagine-video: 16:9, 9:16, 1:1, 4:3, 3:4, 3:2, 2:3. ofox: 16:9, 9:16, 1:1, 4:3, 3:4, 3:2, 2:3, 21:9, 9:21. Do not send for hailuo."
},
"resolution": {
"type": "string",
Expand All @@ -1957,7 +1958,7 @@
"1080p",
"4k"
],
"description": "Seedance: 480p/720p(default)/1080p/4k. Hailuo: 512p (Hailuo-02), 720p→API 768P, 1080p (6s only). Kling: pair with mode std/pro. grok-imagine-video: 480p(default)/720p/1080p."
"description": "Seedance: 480p/720p(default)/1080p/4k. Hailuo: 512p (Hailuo-02), 720p→API 768P, 1080p (6s only). Kling: pair with mode std/pro. grok-imagine-video: 480p(default)/720p/1080p. ofox: 480p/720p(default)/1080p, per-model support enforced by the API."
},
"mode": {
"type": "string",
Expand All @@ -1973,7 +1974,7 @@
},
"lastFrame": {
"type": "string",
"description": "Project image asset reference; requires firstFrame. Supported on seedance2, kling, and hailuo (not with multi-ref on seedance2)."
"description": "Project image asset reference; requires firstFrame. Supported on seedance2, kling, hailuo, and ofox (not with multi-ref on seedance2/ofox)."
},
"refImages": {
"type": "array",
Expand Down
7 changes: 7 additions & 0 deletions server/key-probes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,13 @@ export const PROBES: Record<string, ProbeDef> = {
'video/hailuo': minimaxProbe,
'video/byteplus': byteplusProbe,
'video/xai': xaiMediaProbe,
'video/ofox': {
needs: [['LLM_OFOX_API_KEY']],
run: (get) => fetch(`${base(get, 'LLM_OFOX_BASE_URL', 'https://api.ofox.ai/v1')}/models`, {
signal: t(), headers: bearer(get('LLM_OFOX_API_KEY')),
}),
models: parseModelCatalog,
},
'music/mureka': {
needs: [['MUREKA_API_KEY']],
run: (get) => fetch(`${base(get, 'MUREKA_BASE_URL', 'https://api.mureka.ai')}/v1/account/billing`, {
Expand Down
2 changes: 1 addition & 1 deletion server/key-probes.verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const EXPECTED_PAGES = [
'image/openai', 'image/gemini', 'image/minimax', 'image/wavespeed', 'image/byteplus', 'image/xai',
'voice/elevenlabs', 'voice/openai', 'voice/gemini', 'voice/mistral', 'voice/cartesia',
'voice/doubao', 'voice/minimax', 'voice/inworld', 'voice/fishaudio', 'voice/speechify',
'video/seedance', 'video/kling', 'video/hailuo', 'video/byteplus', 'video/xai',
'video/seedance', 'video/kling', 'video/hailuo', 'video/byteplus', 'video/xai', 'video/ofox',
'music/mureka', 'music/minimax', 'music/atlas', 'music/sonilo',
'stock/pexels', 'stock/pixabay', 'stock/unsplash', 'stock/freesound',
'transcription/assemblyai', 'transcription/openai', 'transcription/mistral',
Expand Down
5 changes: 5 additions & 0 deletions server/keystore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ export const KEY_NAMES = [
"LLM_OPENROUTER_API_KEY",
"LLM_OPENROUTER_BASE_URL",
"LLM_OPENROUTER_MODEL",
"LLM_OFOX_API_KEY",
"LLM_OFOX_BASE_URL",
"LLM_OFOX_MODEL",
"OFOX_VIDEO_MODEL",
"LLM_OLLAMA_API_KEY",
"LLM_OLLAMA_BASE_URL",
"LLM_OLLAMA_MODEL",
Expand Down Expand Up @@ -237,6 +241,7 @@ export const NON_SECRET_NAMES: ReadonlySet<string> = new Set([
"BYTEPLUS_VIDEO_MODEL",
"XAI_IMAGE_MODEL",
"XAI_VIDEO_MODEL",
"OFOX_VIDEO_MODEL",
"INWORLD_TTS_MODEL",
"FISHAUDIO_TTS_MODEL",
"SPEECHIFY_TTS_MODEL",
Expand Down
2 changes: 1 addition & 1 deletion server/keystore.verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ const MODEL_ROUTING_NAMES = [
'MINIMAX_TTS_MODEL', 'MINIMAX_VIDEO_MODEL', 'MINIMAX_MUSIC_MODEL', 'MINIMAX_IMAGE_MODEL',
'ATLASCLOUD_API_BASE', 'ATLASCLOUD_MUSIC_MODEL',
'WAVESPEED_IMAGE_MODEL', 'BYTEPLUS_IMAGE_MODEL', 'BYTEPLUS_VIDEO_MODEL',
'XAI_IMAGE_MODEL', 'XAI_VIDEO_MODEL',
'XAI_IMAGE_MODEL', 'XAI_VIDEO_MODEL', 'OFOX_VIDEO_MODEL',
'INWORLD_TTS_MODEL', 'FISHAUDIO_TTS_MODEL', 'SPEECHIFY_TTS_MODEL',
'PREFERRED_IMAGE_VENDOR', 'PREFERRED_VOICE_VENDOR', 'PREFERRED_VIDEO_VENDOR', 'PREFERRED_MUSIC_VENDOR',
'PREFERRED_TRANSCRIPTION_PROVIDER', 'TRANSCRIPTION_LANGUAGE', 'TRANSCRIPTION_DIARIZATION', 'AUTO_TRANSCRIBE_INGEST', 'UI_SCALE',
Expand Down
9 changes: 9 additions & 0 deletions server/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,15 @@ export function serverPlugins(options: { projectStoreHttp?: boolean } = {}): Plu
get xaiVideoModel() {
return getKey("XAI_VIDEO_MODEL") || "grok-imagine-video-1.5";
},
get ofoxBaseUrl() {
return getKey("LLM_OFOX_BASE_URL") || "https://api.ofox.ai/v1";
},
get ofoxApiKey() {
return getKey("LLM_OFOX_API_KEY");
},
get ofoxVideoModel() {
return getKey("OFOX_VIDEO_MODEL") || "bytedance/seedance-2.0-fast";
},
get byteplusModel() {
return getKey("BYTEPLUS_VIDEO_MODEL") || "seedance-1-5-pro-251215";
},
Expand Down
131 changes: 131 additions & 0 deletions server/plugins/ofox-video-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { proxyDispatcher } from '../outbound-proxy.ts';
import type { RegisterGenerationProviderTask } from './generation-jobs.ts';
import { mediaDataUrl } from './video-media.ts';
import type { ValidVideoRequest } from './video-validation.ts';

type FetchInit = Parameters<typeof fetch>[1] & { dispatcher?: unknown };
const fetchWithProxy = (url: RequestInfo | URL, init?: FetchInit): Promise<Response> =>
fetch(url, { ...init, dispatcher: proxyDispatcher() } as RequestInit);
const FAILURES = new Set(['failed', 'error', 'cancelled', 'canceled', 'expired']);
const SUCCESSES = new Set(['completed', 'succeeded']);
const wait = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));

interface OfoxVideoOptions {
ofoxBaseUrl: string;
ofoxApiKey: string;
ofoxVideoModel: string;
}

async function providerError(response: Response): Promise<string> {
const text = await response.text();
try {
const data = JSON.parse(text) as { message?: string; error?: { message?: string } };
return data.error?.message ?? data.message ?? `video provider failed (${response.status})`;
} catch {
return text.slice(0, 300) || `video provider failed (${response.status})`;
}
}

interface OfoxMediaRef {
type: 'image_url';
image_url: { url: string };
frame_type?: 'first_frame' | 'last_frame';
}

/** Frame anchors and image references both ride as data URLs; the OFox
* gateway re-hosts data URIs on its own object storage before handing the
* request to the upstream model (verified live), so local project media
* needs no public hosting. frame_images and input_references are mutually
* exclusive at the API level; validation enforces that before submission. */
async function ofoxMediaInputs(input: ValidVideoRequest): Promise<{
frame_images?: OfoxMediaRef[];
input_references?: OfoxMediaRef[];
}> {
if (input.firstFramePath) {
const frames: OfoxMediaRef[] = [{
type: 'image_url',
image_url: { url: await mediaDataUrl(input.firstFramePath) },
frame_type: 'first_frame',
}];
if (input.lastFramePath) {
frames.push({
type: 'image_url',
image_url: { url: await mediaDataUrl(input.lastFramePath) },
frame_type: 'last_frame',
});
}
return { frame_images: frames };
}
if (input.refImagePaths.length) {
return {
input_references: await Promise.all(input.refImagePaths.map(async (path) => ({
type: 'image_url' as const,
image_url: { url: await mediaDataUrl(path) },
}))),
};
}
return {};
}

/** OFox multi-model video gateway: asynchronous task id plus polling.
* POST /videos -> { id } ; GET /videos/{id} -> { status, unsigned_urls }.
* Invalid duration/resolution/model values are rejected by the API with a
* clear 400 before any task is created, so no per-model limits are kept here. */
export async function generateOfoxVideo(
input: ValidVideoRequest,
options: OfoxVideoOptions,
registerProviderTask: RegisterGenerationProviderTask,
existingTaskId?: string,
): Promise<string> {
const baseUrl = options.ofoxBaseUrl.replace(/\/$/, '');
const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${options.ofoxApiKey}` };
let taskId = existingTaskId;
if (!taskId) {
const startedResponse = await fetchWithProxy(`${baseUrl}/videos`, {
method: 'POST', headers,
body: JSON.stringify({
model: options.ofoxVideoModel, prompt: input.prompt,
duration: input.durationSeconds, aspect_ratio: input.ratio,
resolution: input.resolution ?? '720p',
...(input.generateAudio !== undefined ? { generate_audio: input.generateAudio } : {}),
...(input.seed !== undefined ? { seed: input.seed } : {}),
...(await ofoxMediaInputs(input)),
}),
// Data-URL payloads carry whole reference images; give the submit
// request a proportionally larger timeout than the text-only case.
signal: AbortSignal.timeout(120_000),
});
if (!startedResponse.ok) throw new Error(await providerError(startedResponse));
const started = await startedResponse.json() as { id?: unknown };
taskId = String(started.id ?? '');
if (!taskId) throw new Error('ofox did not return a task id');
await registerProviderTask('ofox', taskId);
}
const deadline = Date.now() + 15 * 60_000;
while (Date.now() < deadline) {
const poll = await fetchWithProxy(`${baseUrl}/videos/${encodeURIComponent(taskId)}`, {
headers, signal: AbortSignal.timeout(20_000),
});
if (!poll.ok) throw new Error(await providerError(poll));
const current = await poll.json() as { status?: unknown; unsigned_urls?: unknown; error?: { message?: unknown } };
const status = String(current.status ?? '');
if (SUCCESSES.has(status)) {
// Prefer mirror_urls (persistent signed CDN addresses, present only when
// the upstream has mirroring enabled) and fall back to unsigned_urls
// (temporary upstream links that may expire within 24 hours).
const body = current as { mirror_urls?: unknown; unsigned_urls?: unknown };
for (const field of ['mirror_urls', 'unsigned_urls'] as const) {
const urls = Array.isArray(body[field]) ? body[field] as unknown[] : [];
const url = urls.find((item): item is string => typeof item === 'string' && /^https?:\/\//.test(item));
if (url) return url;
}
throw new Error('ofox generation succeeded without a video URL');
}
if (FAILURES.has(status)) {
const detail = typeof current.error?.message === 'string' ? `: ${current.error.message}` : '';
throw new Error(`ofox generation ${status}${detail}`);
}
await wait(3_000);
}
throw new Error('ofox generation timed out');
}
46 changes: 42 additions & 4 deletions server/plugins/video-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export type KlingVideoReferType = 'feature' | 'base';

export interface VideoRequest {
operationId?: string;
model?: 'seedance2' | 'kling' | 'hailuo' | 'byteplus' | 'grok-imagine-video';
model?: 'seedance2' | 'kling' | 'hailuo' | 'byteplus' | 'grok-imagine-video' | 'ofox';
prompt?: string;
name?: string;
durationSeconds?: number | string;
Expand Down Expand Up @@ -34,7 +34,7 @@ export interface VideoRequest {
}

export interface ValidVideoRequest extends Omit<VideoRequest, 'model' | 'prompt' | 'durationSeconds' | 'ratio' | 'refImagePaths' | 'refVideoPaths' | 'refAudioPaths'> {
model: 'seedance2' | 'kling' | 'hailuo' | 'byteplus' | 'grok-imagine-video';
model: 'seedance2' | 'kling' | 'hailuo' | 'byteplus' | 'grok-imagine-video' | 'ofox';
prompt: string;
durationSeconds: number;
durationSpecified: boolean;
Expand Down Expand Up @@ -192,14 +192,52 @@ function validateGrok(input: ValidVideoRequest): ValidVideoRequest {
return input;
}

const OFOX_UNSUPPORTED_ARK_KEYS = ['cameraFixed', 'watermark', 'returnLastFrame', 'executionExpiresAfter', 'priority'] as const;

/** OFox multi-model video gateway: the API validates duration, resolution and
* vendor per model with a clear 400 before any task is created, so only
* cross-provider limits and unsupported options are enforced here. Text,
* first/last-frame and image-reference modes are wired; frame anchors and
* references are mutually exclusive at the API level (400 references_conflict),
* enforced locally before any paid submission. */
function validateOfox(input: ValidVideoRequest): ValidVideoRequest {
if (!input.prompt || input.prompt.length > 4000) throw new Error('ofox prompt is required and must be at most 4000 characters');
if (input.durationSeconds < 2 || input.durationSeconds > 30) throw new Error('ofox durationSeconds must be between 2 and 30 (per-model limits are enforced by the API)');
if (!['16:9', '9:16', '1:1', '4:3', '3:4', '3:2', '2:3', '21:9', '9:21'].includes(input.ratio)) {
throw new Error(`ofox does not support ratio ${input.ratio}`);
}
if (input.resolution && !['480p', '720p', '1080p'].includes(input.resolution)) throw new Error('ofox resolution must be 480p, 720p, or 1080p (per-model support is enforced by the API)');
if (input.lastFramePath && !input.firstFramePath) throw new Error('lastFrame requires firstFrame');
if ((input.firstFramePath || input.lastFramePath) && input.refImagePaths.length) {
throw new Error('ofox frame anchors (firstFrame/lastFrame) cannot be combined with refImages');
}
if (input.refImagePaths.length > 9) throw new Error('ofox accepts at most 9 refImages');
if (input.refVideoPaths.length || input.refAudioPaths.length) {
throw new Error('ofox refVideos/refAudios are not wired in this integration yet; use refImages or firstFrame/lastFrame');
}
if (input.mode || input.shotType || input.multiPrompts?.length || input.refVideoMode) {
throw new Error('multi-shot and editing options are not supported by ofox');
}
for (const key of OFOX_UNSUPPORTED_ARK_KEYS) {
if (input[key] !== undefined) throw new Error(`${key} is supported by seedance2/byteplus only`);
}
if (input.generateAudio !== undefined && typeof input.generateAudio !== 'boolean') throw new Error('generateAudio must be a boolean');
if (input.seed !== undefined && !Number.isSafeInteger(input.seed)) throw new Error('seed must be a safe integer');
if (input.promptOptimizer !== undefined || input.fastPretreatment !== undefined) {
throw new Error('promptOptimizer/fastPretreatment are supported by hailuo only');
}
return input;
}

export function validateVideoRequest(input: VideoRequest): ValidVideoRequest {
if (input.model !== 'seedance2' && input.model !== 'kling' && input.model !== 'hailuo' && input.model !== 'byteplus' && input.model !== 'grok-imagine-video') {
throw new Error('model must be seedance2, kling, hailuo, byteplus, or grok-imagine-video');
if (input.model !== 'seedance2' && input.model !== 'kling' && input.model !== 'hailuo' && input.model !== 'byteplus' && input.model !== 'grok-imagine-video' && input.model !== 'ofox') {
throw new Error('model must be seedance2, kling, hailuo, byteplus, grok-imagine-video, or ofox');
}
if (input.model === 'hailuo' && input.ratio !== undefined) throw new Error('hailuo does not accept ratio; framing follows the first frame when present');
const normalized = common(input, input.model);
if (normalized.model === 'hailuo') return validateHailuo(normalized);
if (normalized.model === 'kling') return validateKling(normalized);
if (normalized.model === 'grok-imagine-video') return validateGrok(normalized);
if (normalized.model === 'ofox') return validateOfox(normalized);
return validateSeedance(normalized);
}
Loading