feat(agent): 添加视频创作工作流技能系统和流程工具

新增基于 SKILL.md 的视频创作工作流系统,Agent 可通过 skills 目录加载结构化的导演指令;实现 validate_storyboard、update_manifest_items、confirm_images 三个流程工具支撑分镜校验、提示词更新和图片确认。
This commit is contained in:
2026-05-08 01:54:04 +08:00
parent 3a124f0310
commit e16305840b
7 changed files with 316 additions and 13 deletions

View File

@@ -0,0 +1,26 @@
import { execSync } from 'child_process';
import { PIPELINE_SCRIPT, PROJECT_ROOT } from './shared';
import type { ToolDefinition } from './types';
export const confirmImages: ToolDefinition = {
name: 'confirm_images',
description: '确认分镜图质量,将 manifest 中所有图片标记为 confirmed=true。也可以跳过确认直接批量确认。',
input_schema: {
type: 'object',
properties: {
manifestPath: { type: 'string', description: 'manifest.json 路径' },
skip: { type: 'boolean', description: '跳过人工确认,直接全部确认(默认 false' },
},
required: ['manifestPath'],
},
execute: async (params) => {
const { manifestPath, skip = false } = params as { manifestPath: string; skip?: boolean };
try {
const cmd = `node "${PIPELINE_SCRIPT}" confirm --manifest "${manifestPath}"${skip ? ' --all' : ''}`;
const output = execSync(cmd, { cwd: PROJECT_ROOT, encoding: 'utf-8' });
return `图片确认完成:\n${output}`;
} catch (err: any) {
return `确认失败: ${err.message}`;
}
},
};

View File

@@ -12,6 +12,9 @@ import { generateVideos } from './generate-videos';
import { listOutputs } from './list-outputs';
import { getManifest } from './get-manifest';
import { createManifest } from './create-manifest';
import { validateStoryboard } from './validate-storyboard';
import { confirmImages } from './confirm-images';
import { updateManifestItems } from './update-manifest-items';
export const tools: ToolDefinition[] = [
listAccounts,
@@ -25,4 +28,7 @@ export const tools: ToolDefinition[] = [
listOutputs,
getManifest,
createManifest,
validateStoryboard,
confirmImages,
updateManifestItems,
];

View File

@@ -0,0 +1,45 @@
import path from 'path';
import fs from 'fs';
import { PROJECT_ROOT, loadJSON } from './shared';
import type { ToolDefinition } from './types';
export const updateManifestItems: ToolDefinition = {
name: 'update_manifest_items',
description: '更新 manifest.json 中指定 items 的字段(如 imagePrompt、videoPrompt。只更新提供的字段不覆盖其他字段。',
input_schema: {
type: 'object',
properties: {
manifestPath: { type: 'string', description: 'manifest.json 路径' },
updates: { type: 'string', description: 'JSON 数组,每个元素需包含 idshot 序号)和要更新的字段,如 [{id:1,imagePrompt:"..."},{id:2,imagePrompt:"..."}]' },
},
required: ['manifestPath', 'updates'],
},
execute: async (params) => {
const { manifestPath, updates } = params as { manifestPath: string; updates: string };
const resolved = path.isAbsolute(manifestPath)
? manifestPath
: path.resolve(PROJECT_ROOT, manifestPath);
if (!fs.existsSync(resolved)) return `manifest 不存在: ${resolved}`;
let updateList: any[];
try { updateList = JSON.parse(updates); } catch { return '错误: updates 不是合法 JSON'; }
if (!Array.isArray(updateList)) return '错误: updates 必须是数组';
const manifest = loadJSON(resolved) as { items: any[] };
if (!manifest.items) return '错误: manifest 无 items 数组';
let updated = 0;
for (const upd of updateList) {
const idx = manifest.items.findIndex((item: any) => item.id === upd.id);
if (idx === -1) return `错误: 找不到 id=${upd.id} 的 item`;
const { id, ...fields } = upd;
Object.assign(manifest.items[idx], fields);
updated++;
}
fs.writeFileSync(resolved, JSON.stringify(manifest, null, 2), 'utf-8');
return `已更新 ${updated}/${manifest.items.length} 个 item`;
},
};

View File

@@ -0,0 +1,70 @@
import type { ToolDefinition } from './types';
export const validateStoryboard: ToolDefinition = {
name: 'validate_storyboard',
description: '校验分镜脚本质量TTS 估算 ≤ 6s、ratio 预检、script 拼接校验。返回校验结果和问题列表。',
input_schema: {
type: 'object',
properties: {
items: { type: 'string', description: '分镜 JSON 数组字符串,每个元素需包含 shotDesc、script 字段,可选 duration、directorRef' },
videoModelDuration: { type: 'number', description: '视频模型固定时长(秒),默认 6' },
},
required: ['items'],
},
execute: async (params) => {
const { items, videoModelDuration = 6 } = params as { items: string; videoModelDuration?: number };
let parsed: any[];
try { parsed = JSON.parse(items); } catch { return '错误: items 不是合法 JSON'; }
if (!Array.isArray(parsed) || parsed.length === 0) return '错误: items 必须是非空数组';
const errors: string[] = [];
const warnings: string[] = [];
const TTS_SPEED = 1.15;
const CHARS_PER_SEC = 5;
for (const item of parsed) {
const idx = item.id ?? parsed.indexOf(item) + 1;
const script: string = item.script || '';
const charCount = script.length;
const ttsEstimate = charCount / CHARS_PER_SEC;
const audioDur = ttsEstimate * TTS_SPEED;
const ratio = videoModelDuration / audioDur;
if (!item.shotDesc) errors.push(`Shot ${idx}: 缺少 shotDesc`);
if (!script) errors.push(`Shot ${idx}: 缺少 script`);
// TTS 估算检查
if (ttsEstimate > 6) {
errors.push(`Shot ${idx}: TTS 估算 ${ttsEstimate.toFixed(1)}s > 6s必须拆分 (script: ${script.slice(0, 30)}...)`);
}
// ratio 预检
if (ratio < 0.9) {
errors.push(`Shot ${idx}: ratio ${ratio.toFixed(2)} < 0.9,音频太长需拆分 (audio=${audioDur.toFixed(1)}s, video=${videoModelDuration}s)`);
}
if (!item.directorRef) {
warnings.push(`Shot ${idx}: 建议填写 directorRef`);
}
}
// script 拼接校验 - 返回统计而非原文比对(原文由用户提供)
const totalChars = parsed.reduce((sum: number, i: any) => sum + (i.script?.length || 0), 0);
const totalAudio = (totalChars / CHARS_PER_SEC) * TTS_SPEED;
const result = {
valid: errors.length === 0,
shotCount: parsed.length,
totalChars,
estimatedTotalAudio: `${totalAudio.toFixed(1)}s`,
errors,
warnings,
};
if (errors.length > 0) {
return `❌ 分镜校验未通过 (${errors.length} 个问题):\n\n${errors.map((e, i) => `${i + 1}. ${e}`).join('\n')}${warnings.length ? `\n\n⚠ 警告:\n${warnings.map((w, i) => `${i + 1}. ${w}`).join('\n')}` : ''}\n\n统计: ${parsed.length} 个镜头, 总字数 ${totalChars}, 音频估算 ${totalAudio.toFixed(1)}s`;
}
return `✅ 分镜校验通过\n\n统计: ${parsed.length} 个镜头, 总字数 ${totalChars}, 音频估算 ${totalAudio.toFixed(1)}s${warnings.length ? `\n\n⚠ 警告:\n${warnings.map((w, i) => `${i + 1}. ${w}`).join('\n')}` : ''}`;
},
};