import { spawn, execSync } from 'child_process'; import path from 'path'; import fs from 'fs'; import os from 'os'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..'); const PIPELINE_SCRIPT = path.join(PROJECT_ROOT, '.claude', 'skills', 'video-from-script', 'scripts', 'pipeline.js'); const ACCOUNTS_DIR = path.join(PROJECT_ROOT, 'accounts'); const OUTPUT_DIR = path.join(PROJECT_ROOT, 'output'); export interface ToolDefinition { name: string; description: string; input_schema: { type: 'object'; properties: Record; required?: string[]; }; execute: (params: Record) => Promise; } // ============================================================================ // Helpers // ============================================================================ function loadJSON(filePath: string): unknown { return JSON.parse(fs.readFileSync(filePath, 'utf-8')); } /** Write items JSON to a temp file and invoke pipeline init, returning the manifest path */ function runInit(params: { account: string; mode: string; items: unknown[]; imageModel?: string; videoModel?: string; format?: string; }): string { // Write items to a temp file to avoid shell escaping issues with JSON const tmpFile = path.join(os.tmpdir(), `pipeline-items-${Date.now()}.json`); fs.writeFileSync(tmpFile, JSON.stringify(params.items), 'utf-8'); try { const args = [ `"${PIPELINE_SCRIPT}"`, 'init', `--account "${params.account}"`, `--mode ${params.mode}`, `--items-file "${tmpFile}"`, params.imageModel ? `--image-model ${params.imageModel}` : '', params.videoModel ? `--video-model ${params.videoModel}` : '', params.format ? `--format ${params.format}` : '', ].filter(Boolean).join(' '); const output = execSync(`node ${args}`, { cwd: PROJECT_ROOT, encoding: 'utf-8' }); // Parse manifest path from init output: "Manifest 已创建: " const match = output.match(/Manifest 已创建: (.+)/); if (!match) throw new Error(`Failed to parse manifest path from init output: ${output}`); return match[1].trim(); } finally { // Clean up temp file (pipeline init also deletes it, but be safe) try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } } } // ============================================================================ // Tool Registry // ============================================================================ export const tools: ToolDefinition[] = [ // --------------------------------------------------------------------------- // Existing tools (kept exactly as before) // --------------------------------------------------------------------------- { name: 'list_accounts', description: '列出所有可用账号,返回每个账号的名称、描述、生图模型和视频模型', input_schema: { type: 'object', properties: {}, required: [], }, execute: async () => { if (!fs.existsSync(ACCOUNTS_DIR)) return '暂无账号'; const dirs = fs.readdirSync(ACCOUNTS_DIR, { withFileTypes: true }) .filter((d) => d.isDirectory() && !d.name.startsWith('_') && !d.name.startsWith('.')) .map((d) => { const configPath = path.join(ACCOUNTS_DIR, d.name, 'account.json'); if (fs.existsSync(configPath)) { const cfg = loadJSON(configPath) as Record; return `${d.name} - ${cfg.description || '无描述'} (生图:${cfg.imageModel} 视频:${cfg.videoModel} 画幅:${cfg.defaultFormat})`; } return d.name; }); return dirs.join('\n') || '暂无账号'; }, }, { name: 'create_account', description: '创建新的短视频账号。需要提供账号ID、名称和描述。创建后可在 accounts/ 目录下找到配置。', input_schema: { type: 'object', properties: { id: { type: 'string', description: '账号唯一标识,英文小写,如 military-account' }, name: { type: 'string', description: '账号显示名称,中文,如 军事账号' }, desc: { type: 'string', description: '账号描述,说明视频风格和主题' }, imageModel: { type: 'string', description: '生图模型: gemini, mj, gpt, kling' }, videoModel: { type: 'string', description: '视频模型: veo3-fast, veo3-fast-frames, kling, grok' }, format: { type: 'string', description: '画幅: 9:16 (竖屏), 16:9 (横屏), 1:1 (方形)' }, }, required: ['id', 'name'], }, execute: async (params) => { const { id, name, desc, imageModel, videoModel, format } = params as Record; const cmd = [ `node "${PIPELINE_SCRIPT}" create-account`, `--id "${id}"`, `--name "${name}"`, `--desc "${desc || ''}"`, `--video-model ${videoModel || 'veo3-fast'}`, imageModel ? `--image-model ${imageModel}` : '', format ? `--format ${format}` : '', ].filter(Boolean).join(' '); const result = execSync(cmd, { cwd: PROJECT_ROOT, encoding: 'utf-8' }); return `账号「${name}」创建成功。\n${result}`; }, }, { name: 'pipeline_status', description: '查看指定 manifest 的 pipeline 执行进度和各阶段状态', input_schema: { type: 'object', properties: { manifest: { type: 'string', description: 'manifest.json 的绝对路径' }, }, required: ['manifest'], }, execute: async (params) => { const { manifest } = params as { manifest: string }; const result = execSync(`node "${PIPELINE_SCRIPT}" status --manifest "${manifest}"`, { cwd: PROJECT_ROOT, encoding: 'utf-8', }); return result; }, }, { name: 'run_pipeline_phase', description: '执行视频创作 pipeline 的指定阶段。阶段顺序: images(生图) → upload(上传) → videos(生视频) → tts(配音) → assemble(成片组装)。执行前需确认 manifest.json 已存在。', input_schema: { type: 'object', properties: { manifest: { type: 'string', description: 'manifest.json 的绝对路径' }, phase: { type: 'string', description: '要执行的阶段: images, upload, videos, tts, assemble。多个阶段用逗号分隔如 images,upload' }, }, required: ['manifest', 'phase'], }, execute: async (params) => { const { manifest, phase } = params as { manifest: string; phase: string }; return new Promise((resolve, reject) => { const proc = spawn('node', [PIPELINE_SCRIPT, 'run', '--manifest', manifest, '--phase', phase], { cwd: PROJECT_ROOT, env: { ...process.env }, }); let output = ''; proc.stdout.on('data', (d: Buffer) => { output += d.toString(); }); proc.stderr.on('data', (d: Buffer) => { output += d.toString(); }); proc.on('close', (code) => { code === 0 ? resolve(output || '执行成功') : reject(new Error(`Pipeline exit code ${code}: ${output.slice(-500)}`)); }); }); }, }, { name: 'get_account_config', description: '获取指定账号的完整配置,包括模型选择、TTS语音、字幕风格等', input_schema: { type: 'object', properties: { accountId: { type: 'string', description: '账号ID,如 军事账号' }, }, required: ['accountId'], }, execute: async (params) => { const { accountId } = params as { accountId: string }; const configPath = path.join(ACCOUNTS_DIR, accountId, 'account.json'); if (!fs.existsSync(configPath)) return `账号「${accountId}」不存在`; const cfg = loadJSON(configPath); return JSON.stringify(cfg, null, 2); }, }, // --------------------------------------------------------------------------- // New tools: image/video generation, manifest management // --------------------------------------------------------------------------- { name: 'generate_images', description: '文生图:根据文本提示词生成图片。内部创建临时 manifest 并调用 pipeline images 阶段,支持批量生成。生成结果写入 output 目录,返回图片文件路径列表。', input_schema: { type: 'object', properties: { accountId: { type: 'string', description: '账号ID,用于继承模型、参考图等配置' }, prompt: { type: 'string', description: '图片提示词(imagePrompt),描述要生成的图片内容' }, count: { type: 'number', description: '生成图片数量,默认 1' }, imageModel: { type: 'string', description: '生图模型(可选,默认继承账号配置): gemini, mj, gpt-image, kling' }, }, required: ['accountId', 'prompt'], }, execute: async (params) => { const { accountId, prompt, count, imageModel } = params as Record; const num = Math.max(1, Number(count) || 1); // Build items: each item needs shotDesc + imagePrompt for the images phase const items = Array.from({ length: num }, (_, i) => ({ id: i + 1, shotDesc: prompt as string, script: '', imagePrompt: prompt as string, keyword: 'generated', })); // Init manifest (items passed as array, helper writes temp file) const manifestPath = runInit({ account: accountId as string, mode: 'single', items, imageModel: imageModel as string | undefined, }); // Run images phase execSync(`node "${PIPELINE_SCRIPT}" run --manifest "${manifestPath}" --phase images`, { cwd: PROJECT_ROOT, encoding: 'utf-8', }); // Read back results const manifest = loadJSON(manifestPath) as { items?: Array<{ id: number; file?: string; candidates?: string[]; status?: string }> }; const results = (manifest.items || []).map((item) => ({ id: item.id, file: item.file || null, candidates: item.candidates || [], status: item.status, })); return JSON.stringify({ manifestPath, images: results }, null, 2); }, }, { name: 'generate_videos', description: '图生视频:根据已有图片和提示词生成 AI 视频。内部创建临时 manifest,先上传图片再执行 videos 阶段。返回视频文件路径。', input_schema: { type: 'object', properties: { accountId: { type: 'string', description: '账号ID,用于继承视频模型等配置' }, imagePath: { type: 'string', description: '图片文件路径(本地绝对路径或相对 PROJECT_ROOT 的路径)' }, prompt: { type: 'string', description: '视频提示词(videoPrompt),描述图片如何动起来' }, videoModel: { type: 'string', description: '视频模型(可选,默认继承账号配置): veo3-fast, veo3-fast-frames, kling, grok' }, imagePrompt: { type: 'string', description: '图片提示词(可选),用于 manifest 记录' }, }, required: ['accountId', 'imagePath', 'prompt'], }, execute: async (params) => { const { accountId, imagePath, prompt, videoModel, imagePrompt } = params as Record; const resolvedImagePath = path.isAbsolute(imagePath) ? imagePath : path.resolve(PROJECT_ROOT, imagePath); if (!fs.existsSync(resolvedImagePath)) { return `错误: 图片文件不存在: ${resolvedImagePath}`; } // Determine relative path from output dir const baseName = path.basename(resolvedImagePath); const item = { id: 1, shotDesc: imagePrompt || prompt, script: '', imagePrompt: imagePrompt || prompt, videoPrompt: prompt, keyword: 'generated', file: `images/${baseName}`, }; // Init manifest (items passed as array, helper writes temp file) const manifestPath = runInit({ account: accountId, mode: 'single', items: [item], videoModel: videoModel, }); // Copy image into manifest's images dir const manifestDir = path.dirname(manifestPath); const imagesDir = path.join(manifestDir, 'images'); if (!fs.existsSync(imagesDir)) fs.mkdirSync(imagesDir, { recursive: true }); const targetPath = path.join(imagesDir, baseName); if (resolvedImagePath !== targetPath) { fs.copyFileSync(resolvedImagePath, targetPath); } // Run upload + videos phases execSync(`node "${PIPELINE_SCRIPT}" run --manifest "${manifestPath}" --phase upload,videos`, { cwd: PROJECT_ROOT, encoding: 'utf-8', }); // Read back results const manifest = loadJSON(manifestPath) as { items?: Array<{ id: number; video?: string; videoUrl?: string; videoDuration?: number; status?: string }>; }; const results = (manifest.items || []).map((it) => ({ id: it.id, video: it.video || null, videoUrl: it.videoUrl || null, videoDuration: it.videoDuration || null, status: it.status, })); return JSON.stringify({ manifestPath, videos: results }, null, 2); }, }, { name: 'get_account_prompts', description: '获取指定账号的三个提示词模板内容:分镜提示词、图片提示词、视频提示词。这些模板定义了账号的视频风格和创意方向。', input_schema: { type: 'object', properties: { accountId: { type: 'string', description: '账号ID' }, }, required: ['accountId'], }, execute: async (params) => { const { accountId } = params as { accountId: string }; const accountDir = path.join(ACCOUNTS_DIR, accountId); if (!fs.existsSync(accountDir)) return `账号「${accountId}」不存在`; const promptsDir = path.join(accountDir, 'prompts'); const promptFiles = ['分镜.md', '图片提示词.md', '视频提示词.md']; const result: Record = {}; for (const file of promptFiles) { const filePath = path.join(promptsDir, file); if (fs.existsSync(filePath)) { result[file] = fs.readFileSync(filePath, 'utf-8'); } else { result[file] = `(文件不存在: ${filePath})`; } } return JSON.stringify(result, null, 2); }, }, { name: 'list_outputs', description: '列出 output 目录下所有项目及其 manifest 状态,方便查看历史生成记录和进行中的任务。', input_schema: { type: 'object', properties: {}, required: [], }, execute: async () => { if (!fs.existsSync(OUTPUT_DIR)) return 'output 目录不存在'; const entries = fs.readdirSync(OUTPUT_DIR, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => { const manifestPath = path.join(OUTPUT_DIR, d.name, 'manifest.json'); if (!fs.existsSync(manifestPath)) return { name: d.name, status: 'no manifest' }; try { const manifest = loadJSON(manifestPath) as { account?: string; imageModel?: string; videoModel?: string; mode?: string; pipeline?: { phases?: Record }; items?: Array<{ status?: string }>; }; const phases = manifest.pipeline?.phases || {}; const itemCount = manifest.items?.length || 0; const doneCount = manifest.items?.filter((i) => i.status === 'done').length || 0; return { name: d.name, account: manifest.account || '?', mode: manifest.mode || '?', items: `${doneCount}/${itemCount} done`, phases: Object.entries(phases).map(([k, v]) => `${k}:${v}`).join(', ') || 'pending', }; } catch { return { name: d.name, status: 'manifest parse error' }; } }); return JSON.stringify(entries, null, 2); }, }, { name: 'get_manifest', description: '读取指定 manifest.json 的完整内容,返回 JSON 字符串。', input_schema: { type: 'object', properties: { manifestPath: { type: 'string', description: 'manifest.json 的绝对路径或相对路径' }, }, required: ['manifestPath'], }, execute: async (params) => { const { manifestPath } = params as { manifestPath: string }; const resolved = path.isAbsolute(manifestPath) ? manifestPath : path.resolve(PROJECT_ROOT, manifestPath); if (!fs.existsSync(resolved)) return `manifest 不存在: ${resolved}`; const manifest = loadJSON(resolved); return JSON.stringify(manifest, null, 2); }, }, { name: 'create_manifest', description: '初始化新的 manifest.json,调用 pipeline.js init 创建输出目录和项目骨架。这是视频创作 pipeline 的起点。', input_schema: { type: 'object', properties: { accountId: { type: 'string', description: '账号ID,决定模型、参考图等配置来源' }, mode: { type: 'string', description: '创作模式: single (单图模式) 或 framePair (首尾帧模式)' }, items: { type: 'string', description: '分镜数据 JSON 数组字符串。每个元素需包含 shotDesc (分镜描述) 和 script (旁白文案),可选 imagePrompt、videoPrompt、directorRef、keyword、duration' }, imageModel: { type: 'string', description: '生图模型(可选,默认继承账号配置): gemini, mj, gpt-image, kling' }, videoModel: { type: 'string', description: '视频模型(可选,默认继承账号配置): veo3-fast, veo3-fast-frames, kling, grok' }, format: { type: 'string', description: '画幅(可选,默认继承账号配置): 9:16, 16:9, 1:1' }, }, required: ['accountId', 'mode', 'items'], }, execute: async (params) => { const { accountId, mode, items, imageModel, videoModel, format } = params as Record; // Validate items JSON let parsed: unknown; try { parsed = JSON.parse(items); } catch { return '错误: items 不是合法的 JSON 字符串'; } if (!Array.isArray(parsed) || (parsed as unknown[]).length === 0) return '错误: items 必须是非空 JSON 数组'; // Write items to temp file to avoid shell escaping issues const tmpFile = path.join(os.tmpdir(), `pipeline-items-${Date.now()}.json`); fs.writeFileSync(tmpFile, items, 'utf-8'); try { const args = [ `"${PIPELINE_SCRIPT}"`, 'init', `--account "${accountId}"`, `--mode ${mode}`, `--items-file "${tmpFile}"`, imageModel ? `--image-model ${imageModel}` : '', videoModel ? `--video-model ${videoModel}` : '', format ? `--format ${format}` : '', ].filter(Boolean).join(' '); const output = execSync(`node ${args}`, { cwd: PROJECT_ROOT, encoding: 'utf-8' }); const match = output.match(/Manifest 已创建: (.+)/); const manifestPath = match ? match[1].trim() : '(unable to parse path)'; return `Manifest 已创建: ${manifestPath}\n\n${output}`; } finally { try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } } }, }, ];