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
18 changes: 18 additions & 0 deletions assets/agent/openchatcut-tool-schemas.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
{
"version": 1,
"edit": [
{
"name": "export_jianying_draft",
"description": "Export the current timeline as a CapCut/JianYing draft (via capcut-cli). The draft appears in the CapCut/JianYing project list; open it there to review and render. Only call this when the user explicitly confirms the export.",
"input_schema": {
"type": "object",
"properties": {
"draftName": {
"type": "string",
"description": "Draft name shown in CapCut/JianYing. Defaults to a timestamped name."
},
"draftsDir": {
"type": "string",
"description": "Optional draft store directory override (defaults to the CapCut store)."
}
},
"additionalProperties": false
}
},
{
"name": "read_timeline",
"description": "Read the current timeline: fps and every clip (id, track, name, startFrame, durationInFrames, props). Call this first to see current state before editing.",
Expand Down
Binary file added assets/branding/jianying-export-dialog.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
252 changes: 252 additions & 0 deletions server/external-agent/jianying-export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
// JianYing / CapCut draft export via the open-source capcut-cli (npm, MIT).
// The browser-side agent tool collects the timeline (clip sources, timing,
// captions) and POSTs it here; this module resolves media URLs to local files
// and drives capcut-cli to build a real draft in the CapCut/JianYing store.
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { basename, join } from 'node:path';
import { uploadReadDirs } from '../media-dir.ts';

/** dev / worktree upload root; isolated profiles read only their own store but
* dev media commonly lives here too. */
const WORKTREE_UPLOAD_DIR = join(process.cwd(), 'public', 'media', 'uploads');

export interface JianyingExportClip {
kind: string;
src: string;
startFrame: number;
durationInFrames: number;
volume?: number;
name?: string;
}

export interface JianyingExportCaption {
startMs: number;
endMs: number;
text: string;
}

export interface JianyingExportRequest {
draftName?: string;
fps: number;
items: JianyingExportClip[];
captions?: JianyingExportCaption[];
/** Override for the draft store directory (CapCut store by default). */
draftsDir?: string;
}

export interface JianyingExportResult {
ok: boolean;
draftName: string;
draftPath: string;
addedVideos: number;
addedAudios: number;
captions: number;
warnings: string[];
error?: string;
}

const DEFAULT_CAPCUT_STORE = join(
process.env.HOME ?? '',
'Movies',
'CapCut',
'User Data',
'Projects',
'com.lveditor.draft',
);

/** Resolve a clip src (/media/uploads/<name> or absolute path) to a local file. */
export function expandHomeDir(dir: string): string {
return dir.replace(/^~(?=\/|$)/, process.env.HOME ?? '');
}

export function resolveMediaPath(src: string): string | undefined {
const clean = String(src || '').trim();
if (!clean) return undefined;
if (clean.startsWith('/media/uploads/')) {
const name = clean.slice('/media/uploads/'.length);
if (!name || name.includes('/') || name.includes('\\')) return undefined;
const roots = [...new Set([...uploadReadDirs(), WORKTREE_UPLOAD_DIR])];
for (const dir of roots) {
const candidate = join(dir, name);
if (existsSync(candidate)) return candidate;
}
return undefined;
}
if (existsSync(clean)) return clean;
return undefined;
}

function capcutBin(): string {
return process.env.CAPCUT_CLI || 'capcut-cli';
}

function runCapcut(args: string[], timeoutMs = 120_000): Promise<unknown> {
const executable = capcutBin();
const prefix = executable.includes('/') || executable.includes('\\')
? [executable]
: ['npx', '--yes', executable];
return new Promise((resolve, reject) => {
const child = spawn(prefix[0], [...prefix.slice(1), ...args], {
env: { ...process.env, FORCE_COLOR: '0' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
const timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error(`capcut-cli timed out after ${timeoutMs / 1000}s: ${args[0] ?? ''}`));
}, timeoutMs);
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
child.on('error', (error) => {
clearTimeout(timer);
reject(new Error(`capcut-cli launch failed: ${error.message}`));
});
child.on('close', (code) => {
clearTimeout(timer);
const combined = `${stdout}\n${stderr}`.trim();
if (code === 0) {
try {
const firstJson = combined.split('\n').find((line) => line.trim().startsWith('{'));
if (firstJson) {
resolve(JSON.parse(firstJson));
return;
}
} catch {
/* fall through to raw output */
}
resolve({ raw: combined.slice(0, 400) });
return;
}
reject(new Error(`capcut-cli ${args[0] ?? ''} failed (exit ${code}): ${combined.slice(0, 500)}`));
});
});
}

function framesToSeconds(frames: number, fps: number): number {
if (!Number.isFinite(frames) || frames < 0) return 0;
return Math.round((frames / (fps || 30)) * 100) / 100;
}

function isVideoKind(kind: string): boolean {
return kind === 'video' || kind === 'image' || kind === 'gif';
}

function isAudioKind(kind: string): boolean {
return kind === 'audio';
}

async function writeSrtFile(captions: JianyingExportCaption[]): Promise<{ file: string; dir: string }> {
const dir = await mkdtemp(join(tmpdir(), 'occ-jianying-'));
const file = join(dir, 'captions.srt');
const lines: string[] = [];
captions.forEach((caption, index) => {
const format = (ms: number): string => {
const total = Math.max(0, Math.round(ms));
const h = Math.floor(total / 3_600_000);
const m = Math.floor((total % 3_600_000) / 60_000);
const s = Math.floor((total % 60_000) / 1000);
const milli = total % 1000;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')},${String(milli).padStart(3, '0')}`;
};
lines.push(`${index + 1}`);
lines.push(`${format(caption.startMs)} --> ${format(caption.endMs)}`);
lines.push(caption.text.replace(/\r?\n/g, ' ').trim());
lines.push('');
});
await writeFile(file, lines.join('\n'), 'utf8');
return { file, dir };
}

/**
* Build a CapCut/JianYing draft from an OpenChatCut timeline using capcut-cli.
* The first video clip seeds the draft (quickstart); remaining video clips are
* appended at their timeline positions; audio clips and captions follow.
*/
export async function exportJianyingDraft(raw: Partial<JianyingExportRequest>): Promise<JianyingExportResult> {
const request: JianyingExportRequest = {
fps: Number(raw.fps) || 30,
items: Array.isArray(raw.items) ? raw.items.filter((item) => item && typeof item === 'object') : [],
captions: Array.isArray(raw.captions) ? raw.captions.filter((caption) => caption && typeof caption === 'object') : [],
draftName: typeof raw.draftName === 'string' ? raw.draftName : undefined,
draftsDir: typeof raw.draftsDir === 'string' ? raw.draftsDir : undefined,
};
const warnings: string[] = [];
const fps = Number(request.fps) || 30;
const videos = request.items.filter((item) => isVideoKind(item.kind));
const audios = request.items.filter((item) => isAudioKind(item.kind));
if (videos.length === 0) {
return { ok: false, draftName: '', draftPath: '', addedVideos: 0, addedAudios: 0, captions: 0, warnings, error: 'timeline has no video clips to export' };
}
const resolved = videos.map((clip) => ({ clip, file: resolveMediaPath(clip.src) }));
const missing = resolved.filter((entry) => !entry.file).map((entry) => entry.clip.src);
if (missing.length > 0) {
return { ok: false, draftName: '', draftPath: '', addedVideos: 0, addedAudios: 0, captions: 0, warnings, error: `media files not found locally: ${missing.slice(0, 3).join(', ')}` };
}
const draftName = String(request.draftName || `OpenChatCut-${new Date().toISOString().slice(0, 16).replace(/[:T]/g, '')}`)
.replace(/[\\/\0]/g, '')
.slice(0, 60);
if (!draftName) {
return { ok: false, draftName: '', draftPath: '', addedVideos: 0, addedAudios: 0, captions: 0, warnings, error: 'invalid draft name' };
}
const draftsDir = expandHomeDir(String(request.draftsDir || '').trim())
|| DEFAULT_CAPCUT_STORE;
const first = resolved[0];
const firstStart = framesToSeconds(first.clip.startFrame, fps);
const firstDuration = framesToSeconds(first.clip.durationInFrames, fps);
const createArgs = [
'quickstart', draftName,
'--video', first.file as string,
'--jianying', '--force-write', '--drafts', draftsDir,
];
if (firstStart > 0) createArgs.push('--start', String(firstStart));
if (firstDuration > 0) createArgs.push('--duration', String(firstDuration));
const created = await runCapcut(createArgs) as { ok?: boolean; draft_path?: string; error?: string };
if (!created?.ok || !created.draft_path) {
return { ok: false, draftName, draftPath: '', addedVideos: 0, addedAudios: 0, captions: 0, warnings, error: created?.error || 'capcut-cli quickstart failed' };
}
const draftPath = created.draft_path;
let addedVideos = 1;
for (const entry of resolved.slice(1)) {
const start = framesToSeconds(entry.clip.startFrame, fps);
const duration = framesToSeconds(entry.clip.durationInFrames, fps);
const args = ['add-video', draftPath, entry.file as string, String(start)];
if (duration > 0) args.push(String(duration));
args.push('--jianying', '--force-write', '--drafts', draftsDir);
const result = await runCapcut(args) as { ok?: boolean; error?: string };
if (result?.ok) addedVideos += 1;
else warnings.push(`add-video ${basename(entry.file as string)}: ${result?.error || 'failed'}`);
}
let addedAudios = 0;
for (const clip of audios) {
const file = resolveMediaPath(clip.src);
if (!file) {
warnings.push(`audio not found locally: ${clip.src}`);
continue;
}
const start = framesToSeconds(clip.startFrame, fps);
const duration = framesToSeconds(clip.durationInFrames, fps);
const args = ['add-audio', draftPath, file, String(start)];
if (duration > 0) args.push(String(duration));
args.push('--jianying', '--force-write', '--drafts', draftsDir);
const result = await runCapcut(args) as { ok?: boolean; error?: string };
if (result?.ok) addedAudios += 1;
else warnings.push(`add-audio ${basename(file)}: ${result?.error || 'failed'}`);
}
let captions = 0;
const captionList = (request.captions ?? []).filter((caption) => caption.text.trim() && caption.endMs > caption.startMs);
if (captionList.length > 0) {
const { file, dir } = await writeSrtFile(captionList);
try {
const result = await runCapcut(['import-srt', draftPath, file, '--jianying', '--force-write', '--drafts', draftsDir]) as { ok?: boolean; error?: string };
if (result?.ok) captions = captionList.length;
else warnings.push(`import-srt: ${result?.error || 'failed'}`);
} finally {
void rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
}
return { ok: true, draftName, draftPath, addedVideos, addedAudios, captions, warnings };
}
23 changes: 23 additions & 0 deletions server/external-agent/jianying-export.verify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import { expandHomeDir, resolveMediaPath } from './jianying-export.ts';

assert.equal(expandHomeDir(''), '');
assert.equal(expandHomeDir('/plain/path'), '/plain/path');
assert.equal(expandHomeDir('~/Movies'), `${process.env.HOME}/Movies`);
assert.equal(expandHomeDir('~other/path'), '~other/path');
assert.equal(expandHomeDir('~/'), `${process.env.HOME}/`);
assert.equal(expandHomeDir('~'), process.env.HOME);

assert.equal(resolveMediaPath(''), undefined);
assert.equal(resolveMediaPath('/media/uploads/../etc/passwd'), undefined);
assert.equal(resolveMediaPath('/media/uploads/./x.mp4'), undefined);
assert.equal(resolveMediaPath('/definitely/not/a/file.mp4'), undefined);

const publicUpload = resolveMediaPath('/media/uploads/01c3ba22-961a-4d4b-aa70-f33727150f93.mp4');
if (publicUpload) {
assert.ok(publicUpload.endsWith('01c3ba22-961a-4d4b-aa70-f33727150f93.mp4'), 'resolves to a real media file');
} else {
console.warn('[skip] no media file present on this machine — path resolution fallback verified via negatives');
}

console.log('jianying-export media path resolution checks passed');
7 changes: 7 additions & 0 deletions server/plugins/external-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
unregisterEditor,
} from '../external-agent/broker.ts';
import { handleMcpRequest, mcpTools } from '../external-agent/mcp.ts';
import { exportJianyingDraft } from '../external-agent/jianying-export.ts';
import { claimBrowserProjectOwnership } from '../external-agent/project-edit-ownership.ts';
import {
EDITOR_BOOTSTRAP_HEADER,
Expand Down Expand Up @@ -103,6 +104,12 @@ export async function handleExternalAgentBridge(
sendBridgeJson(res, 415, { error: 'editor bridge writes require JSON' });
return;
}
if (write && url.pathname === '/jianying-export') {
const body = await readBridgeJson(req);
const result = await exportJianyingDraft(body);
sendBridgeJson(res, result.ok ? 200 : 400, result);
return;
}
await routeExternalAgentBridge(req, res, url, operations);
}

Expand Down
1 change: 1 addition & 0 deletions src/agent/external-tool-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const DRAFT_EDIT_TOOL_NAMES = new Set([
'edit_item', 'manage_effects', 'edit_captions', 'update_watermark',
'manage_markers', 'apply_caption_avoidance', 'place_graphics_in_safe_zone', 'auto_reframe',
'manage_design_style',
'export_jianying_draft',
]);

const SERVER_DIRECT_READ_TOOL_NAMES: Record<string, true> = {
Expand Down
3 changes: 3 additions & 0 deletions src/agent/tools/core-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { prepareTemplate } from '../../template-host';
import { generateAgentText } from '../client';
import { designStyleHint } from '../systemPrompt';
import { execCoreDataTool } from './core-data-tools';
import { execJianyingExport } from './jianying-export-tool';

type Args = Record<string, unknown>;

Expand Down Expand Up @@ -132,6 +133,8 @@ export async function execCoreTool(
if (name === 'ToolSearch') return searchTools(args, schemas);
const dataResult = execCoreDataTool(name, args, ctx);
if (dataResult !== undefined) return dataResult;
const jianyingResult = await execJianyingExport(name, args, ctx);
if (jianyingResult !== undefined) return jianyingResult;
if (name === 'list_templates' || name === 'search_templates' || name === 'add_motion_graphic') {
return execTemplateCatalog(name, args, ctx);
}
Expand Down
Loading