Files
video-create/web/server/agent/tools.ts
2026-05-07 04:17:54 +08:00

439 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, { type: string; description: string }>;
required?: string[];
};
execute: (params: Record<string, unknown>) => Promise<string>;
}
// ============================================================================
// 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 已创建: <path>"
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<string, string>;
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<string, string>;
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<string, unknown>;
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<string, string>;
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<string, string> = {};
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<string, string> };
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<string, string>;
// 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 */ }
}
},
},
];