diff --git a/assets/agent/openchatcut-tool-schemas.json b/assets/agent/openchatcut-tool-schemas.json index 28c0b17d..6333c9c5 100644 --- a/assets/agent/openchatcut-tool-schemas.json +++ b/assets/agent/openchatcut-tool-schemas.json @@ -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.", diff --git a/assets/branding/jianying-export-dialog.png b/assets/branding/jianying-export-dialog.png new file mode 100644 index 00000000..2ce2f025 Binary files /dev/null and b/assets/branding/jianying-export-dialog.png differ diff --git a/server/external-agent/jianying-export.ts b/server/external-agent/jianying-export.ts new file mode 100644 index 00000000..357dc773 --- /dev/null +++ b/server/external-agent/jianying-export.ts @@ -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/ 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 { + 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): Promise { + 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 }; +} \ No newline at end of file diff --git a/server/external-agent/jianying-export.verify.ts b/server/external-agent/jianying-export.verify.ts new file mode 100644 index 00000000..d2e311a0 --- /dev/null +++ b/server/external-agent/jianying-export.verify.ts @@ -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'); \ No newline at end of file diff --git a/server/plugins/external-agent.ts b/server/plugins/external-agent.ts index e93bc3ae..876777f8 100644 --- a/server/plugins/external-agent.ts +++ b/server/plugins/external-agent.ts @@ -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, @@ -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); } diff --git a/src/agent/external-tool-policy.ts b/src/agent/external-tool-policy.ts index 53ef69b9..35b463e5 100644 --- a/src/agent/external-tool-policy.ts +++ b/src/agent/external-tool-policy.ts @@ -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 = { diff --git a/src/agent/tools/core-tools.ts b/src/agent/tools/core-tools.ts index acc5abc4..2b6c2f69 100644 --- a/src/agent/tools/core-tools.ts +++ b/src/agent/tools/core-tools.ts @@ -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; @@ -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); } diff --git a/src/agent/tools/jianying-export-tool.ts b/src/agent/tools/jianying-export-tool.ts new file mode 100644 index 00000000..f2ff0669 --- /dev/null +++ b/src/agent/tools/jianying-export-tool.ts @@ -0,0 +1,106 @@ +import type { AgentContext } from '../context'; +import type { AgentToolSchema } from '../tool-schema'; +import type { TimelineItem } from '../../editor/types'; + +type Args = Record; + +export const JIANYING_EXPORT_TOOL_NAME = 'export_jianying_draft'; + +export const jianyingExportToolSchema: AgentToolSchema = { + name: JIANYING_EXPORT_TOOL_NAME, + 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, + }, +}; + +export function mediaItems(items: TimelineItem[]): TimelineItem[] { + return items.filter((item) => item.kind === 'video' || item.kind === 'image' || item.kind === 'gif' || item.kind === 'audio'); +} + +/** Caption cues from the active captions overlay: merge transcript words into + * phrase cues (timeline ms). Falls back to the source item's transcript. */ +export function captionCues(state: { fps: number; items: TimelineItem[] }, captions: { enabled?: boolean; sourceItemId?: string | null; sourceMode?: 'item' | 'timeline'; sources?: string[] } | null | undefined): { startMs: number; endMs: number; text: string }[] { + if (!captions?.enabled) return []; + const cueWords = (item: TimelineItem | undefined): { start: number; end: number; text: string }[] | undefined => { + if (!item?.transcript || item.transcript.length === 0) return undefined; + return item.transcript; + }; + let words: { start: number; end: number; text: string }[] = []; + if (captions.sourceMode === 'timeline') { + for (const item of state.items) { + const candidate = cueWords(item); + if (candidate) words = [...words, ...candidate]; + } + } else if (captions.sourceItemId) { + words = cueWords(state.items.find((item) => item.id === captions.sourceItemId)) ?? []; + } + if (words.length === 0) return []; + words = [...words].sort((a, b) => a.start - b.start); + const cues: { startMs: number; endMs: number; text: string }[] = []; + let current: { startMs: number; endMs: number; text: string } | null = null; + for (const word of words) { + if (!word.text.trim()) continue; + if (!current) { + current = { startMs: word.start, endMs: word.end, text: word.text.trim() }; + continue; + } + const gap = word.start - current.endMs; + if (gap <= 450) { + current.endMs = Math.max(current.endMs, word.end); + current.text = `${current.text} ${word.text.trim()}`; + } else { + cues.push(current); + current = { startMs: word.start, endMs: word.end, text: word.text.trim() }; + } + } + if (current) cues.push(current); + return cues; +} + +export async function execJianyingExport(name: string, args: Args, ctx: AgentContext): Promise { + if (name !== JIANYING_EXPORT_TOOL_NAME) return undefined; + const state = ctx.getState(); + const items = mediaItems(state.items); + const body: Record = { + draftName: typeof args.draftName === 'string' && args.draftName.trim() ? String(args.draftName).trim().slice(0, 60) : '', + draftsDir: typeof args.draftsDir === 'string' && args.draftsDir.trim() ? String(args.draftsDir).trim() : '', + fps: state.fps, + items: items.map((item) => ({ + kind: item.kind, + src: item.src ?? '', + startFrame: item.startFrame, + durationInFrames: item.durationInFrames, + volume: item.volume, + name: item.name, + })), + captions: captionCues(state, state.captions), + }; + const response = await fetch('/api/external-agent/jianying-export', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = (await response.json().catch(() => null)) as { + ok?: boolean; error?: string; draftName?: string; draftPath?: string; + addedVideos?: number; addedAudios?: number; captions?: number; warnings?: string[]; + } | null; + if (!response.ok || !data?.ok) { + throw new Error(data?.error ?? `jianying export failed (${response.status})`); + } + return { + ok: true, + draftName: data.draftName, + draftPath: data.draftPath, + addedVideos: data.addedVideos, + addedAudios: data.addedAudios, + captions: data.captions, + warnings: data.warnings ?? [], + note: 'Draft written to the CapCut/JianYing store. Restart CapCut/JianYing if the project list does not refresh.', + }; +} \ No newline at end of file diff --git a/src/agent/tools/schemas/core-tools.ts b/src/agent/tools/schemas/core-tools.ts index d97e6033..c2aedab4 100644 --- a/src/agent/tools/schemas/core-tools.ts +++ b/src/agent/tools/schemas/core-tools.ts @@ -1,7 +1,9 @@ import type { AgentToolSchema } from '../../tool-schema'; +import { jianyingExportToolSchema } from '../jianying-export-tool'; /** Core schemas shared by the browser registry and the server-side data-only executor. */ export const CORE_TOOL_SCHEMAS: AgentToolSchema[] = [ + jianyingExportToolSchema, { 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.', diff --git a/src/export/ExportDialogFooter.tsx b/src/export/ExportDialogFooter.tsx index 6fa65c58..8f2ecced 100644 --- a/src/export/ExportDialogFooter.tsx +++ b/src/export/ExportDialogFooter.tsx @@ -89,11 +89,13 @@ export function ExportFooter({ tab, outputName, videoSummary, disabled, workflow {cancellable && ( )} - + {tab !== 'jianying' && ( + + )} ); } diff --git a/src/export/ExportDialogMain.tsx b/src/export/ExportDialogMain.tsx index d82ad7a5..97277cac 100644 --- a/src/export/ExportDialogMain.tsx +++ b/src/export/ExportDialogMain.tsx @@ -100,13 +100,15 @@ export function ExportDialogMain({ state, model }: { state: TimelineState; model qualityMode={model.qualityMode} setQualityMode={model.setQualityMode} onToggle={workflow.toggleAutoQa} nleFormat={model.nleFormat} setNleFormat={model.setNleFormat} includeMg={model.includeMg} - setIncludeMg={model.setIncludeMg} mgCount={model.mgItems.length} + setIncludeMg={model.setIncludeMg} mgCount={model.mgItems.length} base={model.base} /> - + {model.tab !== 'jianying' && ( + + )} diff --git a/src/export/ExportDialogTabs.tsx b/src/export/ExportDialogTabs.tsx index a8edc4d7..e61856a0 100644 --- a/src/export/ExportDialogTabs.tsx +++ b/src/export/ExportDialogTabs.tsx @@ -1,6 +1,8 @@ import type { TimelineState } from '../editor/types'; import { trackAlias } from '../editor/types'; +import { Icon } from '../components/icons'; import { useT } from '../i18n/locale'; +import { captionCues, mediaItems } from '../agent/tools/jianying-export-tool'; import { MAX_VIDEO_BITRATE_MBPS, MIN_VIDEO_BITRATE_MBPS, @@ -15,6 +17,12 @@ import { } from './useExportDialogModel'; import type { ExportQaUiState, ExportTab } from './useExportWorkflow'; import { fcpxmlBackgroundFillCount } from './fcpxml'; +import { loadJianYingDraftPreference, saveJianYingDraftPreference, type JianYingDraftStore } from './jianyingDraftPreference'; +import { useState } from 'react'; + +/** macOS default store for the Chinese JianYing (剪映专业版) app; drafts in 6.0+ + * are encrypted and capcut-cli cannot decrypt them, hence the ≤5.9 note. */ +const JIANYING_STORE = '~/Movies/JianyingPro/User Data/Projects/com.lveditor.draft'; const resolutionLabel = (value: string): string => value === '4k' ? '4K' : value; const clampBitrate = (value: number): number => Math.max( @@ -203,11 +211,136 @@ function XmlTab({ state, nleFormat, includeMg, mgCount, setNleFormat, setInclude ); } +interface JianyingExportOutcome { + draftName: string; + draftPath: string; + addedVideos: number; + addedAudios: number; + captions: number; + warnings: string[]; +} + +function JianyingTab({ state, base }: { state: TimelineState; base: string }) { + const t = useT(); + const initial = loadJianYingDraftPreference(); + const [draftName, setDraftName] = useState(initial.draftName || base); + const [store, setStore] = useState(initial.store); + const [customDir, setCustomDir] = useState(initial.customDir); + const [busy, setBusy] = useState(false); + const [outcome, setOutcome] = useState(null); + const [error, setError] = useState(null); + const draftsDir = store === 'jianying' ? JIANYING_STORE : store === 'custom' ? customDir.trim() : ''; + const updateStore = (next: JianYingDraftStore) => { + setStore(next); + saveJianYingDraftPreference({ store: next, customDir, draftName: draftName === base ? '' : draftName }); + }; + const run = async () => { + if (busy) return; + setBusy(true); + setError(null); + setOutcome(null); + try { + const body = { + draftName: draftName.trim(), + fps: state.fps, + items: mediaItems(state.items).map((item) => ({ + kind: item.kind, + src: item.src ?? '', + startFrame: item.startFrame, + durationInFrames: item.durationInFrames, + volume: item.volume, + name: item.name, + })), + captions: captionCues(state, state.captions), + draftsDir, + }; + const response = await fetch('/api/external-agent/jianying-export', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = (await response.json().catch(() => null)) as (JianyingExportOutcome & { ok?: boolean; error?: string }) | null; + if (!response.ok || !data?.ok) { + setError(data?.error ?? t('剪映草稿导出失败')); + return; + } + saveJianYingDraftPreference({ store, customDir, draftName: draftName.trim() }); + setOutcome(data); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + setBusy(false); + } + }; + return ( + <> + + + setDraftName(event.target.value)} disabled={busy} /> + + + + + {store === 'jianying' &&

{JIANYING_STORE}

} + {store === 'custom' && ( + + { + setCustomDir(event.target.value); + saveJianYingDraftPreference({ store, customDir: event.target.value, draftName: draftName === base ? '' : draftName }); + }} + disabled={busy} /> + + )} +

+ {t('剪映 6.0 起草稿文件已加密,本工具生成明文草稿,建议使用剪映 5.9.0 或更早版本打开;CapCut 国际版不受此限制。')} +

+ {error &&

{error}

} + {outcome && ( +
+ +
+ {t('草稿已生成')} · {outcome.draftName} +

+ {t('{videos} 个视频 · {audios} 个音轨 · {captions} 条字幕', { + videos: outcome.addedVideos, + audios: outcome.addedAudios, + captions: outcome.captions, + })} +
+ {outcome.draftPath} + {outcome.warnings.length > 0 && ( + <>
{outcome.warnings.join(';')} + )} +

+
+
+ )} + + + ); +} + export interface ExportTabContentProps extends VideoTabProps, XmlTabProps { tab: ExportTab; state: TimelineState; subtitles: ExportSubtitleSettings; mgCount: number; + base: string; } export function ExportTabContent(props: ExportTabContentProps) { @@ -215,5 +348,6 @@ export function ExportTabContent(props: ExportTabContentProps) { if (props.tab === 'audio') return ; if (props.tab === 'mg') return ; if (props.tab === 'subtitles') return ; + if (props.tab === 'jianying') return ; return ; } diff --git a/src/export/exportWorkflowTypes.ts b/src/export/exportWorkflowTypes.ts index 0d229630..869e6db7 100644 --- a/src/export/exportWorkflowTypes.ts +++ b/src/export/exportWorkflowTypes.ts @@ -5,7 +5,7 @@ import type { ExportQaReport } from './quality'; import type { ExportResolution } from './mediaSettings'; import type { ExportFailure } from './exportFailure'; -export type ExportTab = 'video' | 'audio' | 'mg' | 'subtitles' | 'xml'; +export type ExportTab = 'video' | 'audio' | 'mg' | 'subtitles' | 'xml' | 'jianying'; export type ExportPhase = 'queued' | 'preparing' | 'rendering' | 'finalizing' | 'verifying' | 'downloading' | 'completed' | 'failed' | 'cancelled'; export type RenderEngine = 'idle' | 'checking' | 'browser' | 'server'; export type Translate = (zh: string, params?: Record) => string; diff --git a/src/export/jianyingDraftPreference.ts b/src/export/jianyingDraftPreference.ts new file mode 100644 index 00000000..edc9ffad --- /dev/null +++ b/src/export/jianyingDraftPreference.ts @@ -0,0 +1,36 @@ +const STORAGE_KEY = 'cc.jianyingDraft.v1'; + +export type JianYingDraftStore = 'capcut' | 'jianying' | 'custom'; + +export interface JianYingDraftPreference { + store: JianYingDraftStore; + customDir: string; + draftName: string; +} + +export const DEFAULT_JIANYING_DRAFT_PREFERENCE: JianYingDraftPreference = { + store: 'capcut', + customDir: '', + draftName: '', +}; + +export function loadJianYingDraftPreference(): JianYingDraftPreference { + try { + const parsed = JSON.parse(globalThis.localStorage?.getItem(STORAGE_KEY) ?? 'null') as Partial | null; + return { + store: parsed?.store === 'jianying' || parsed?.store === 'custom' ? parsed.store : 'capcut', + customDir: typeof parsed?.customDir === 'string' ? parsed.customDir : '', + draftName: typeof parsed?.draftName === 'string' ? parsed.draftName : '', + }; + } catch { + return { ...DEFAULT_JIANYING_DRAFT_PREFERENCE }; + } +} + +export function saveJianYingDraftPreference(preference: JianYingDraftPreference): void { + try { + globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(preference)); + } catch { + // Export still works when storage is unavailable or full. + } +} \ No newline at end of file diff --git a/src/export/useExportDialogModel.ts b/src/export/useExportDialogModel.ts index 4574a01c..3492887c 100644 --- a/src/export/useExportDialogModel.ts +++ b/src/export/useExportDialogModel.ts @@ -49,6 +49,7 @@ export const EXPORT_TABS = [ { key: 'mg', label: '动态图层', summary: 'ProRes 4444', icon: 'sparkles' }, { key: 'subtitles', label: '字幕稿', summary: 'SRT / TXT', icon: 'captions' }, { key: 'xml', label: '剪辑工程', summary: 'FCPXML', icon: 'clipboard' }, + { key: 'jianying', label: '剪映草稿', summary: 'CapCut / 剪映', icon: 'video' }, ] as const satisfies ReadonlyArray<{ key: ExportTab; label: string; summary: string; icon: IconName }>; export const EXPORT_ACTION_LABELS: Record = { @@ -57,6 +58,7 @@ export const EXPORT_ACTION_LABELS: Record = { mg: '导出动态图层', subtitles: '下载字幕', xml: '生成剪辑工程', + jianying: '导出剪映草稿', }; export const EXPORT_FPS = [...EXPORT_FPS_OPTIONS]; @@ -186,6 +188,7 @@ function outputName(base: string, tab: ExportTab, video: ExportVideoSettings, su if (tab === 'audio') return `${base}.mp3`; if (tab === 'subtitles') return `${base}.${subtitles.format}`; if (tab === 'xml') return `${base}-${nleFormat === 'fcp_xml_resolve' ? 'resolve' : 'premiere'}.fcpxml`; + if (tab === 'jianying') return `${base}-jianying`; return mgOutput; } diff --git a/src/i18n/dict/en/exportPanel.ts b/src/i18n/dict/en/exportPanel.ts index e1d4655d..975cdddd 100644 --- a/src/i18n/dict/en/exportPanel.ts +++ b/src/i18n/dict/en/exportPanel.ts @@ -13,6 +13,8 @@ export default { '动态图层': 'Motion layers', '字幕稿': 'Caption file', '剪辑工程': 'Edit project', + '剪映草稿': 'JianYing Draft', + '导出剪映草稿': 'Export JianYing Draft', '输出类型': 'Output type', '本机渲染': 'Local render', '本机自适应': 'Adaptive local', @@ -180,4 +182,18 @@ export default { '导出素材未就绪:{n} 个素材仍是未完成的上传占位(可能因磁盘满或断网上传失败),请删除或重新导入这些素材:{list}': 'Export media not ready: {n} assets are still unfinished upload placeholders (upload may have failed due to disk full or network loss). Delete or re-import them: {list}', '正在恢复导出…': 'Resuming export…', '此导出正在由另一个窗口恢复,请稍后重试': 'This export is being recovered in another window. Please try again shortly.', + '剪映草稿导出失败': 'Failed to create the JianYing draft', + '生成剪映草稿': 'Create a JianYing draft', + '把时间线上的视频、音轨与字幕写入本地草稿库,用剪映或 CapCut 打开即可继续剪辑。': 'Writes the timeline video, audio and captions into a local draft; open it in JianYing or CapCut to continue editing.', + '草稿名称': 'Draft name', + '目标草稿库': 'Target draft store', + 'CapCut 草稿库': 'CapCut store', + '剪映草稿库': 'JianYing store', + '剪映 6.0 起草稿文件已加密,本工具生成明文草稿,建议使用剪映 5.9.0 或更早版本打开;CapCut 国际版不受此限制。': 'Draft files are encrypted since JianYing 6.0 and this tool writes plaintext drafts, so use JianYing 5.9.0 or earlier; the international CapCut is unaffected.', + '草稿已生成': 'Draft created', + '{videos} 个视频 · {audios} 个音轨 · {captions} 条字幕': '{videos} videos · {audios} audio tracks · {captions} captions', + '导出到剪映': 'Export to JianYing', + '正在生成草稿…': 'Creating draft…', + '自定义路径': 'Custom path', + '草稿库路径': 'Draft store path', } as Record;