yuanflow-cli 0.1.44 → 0.1.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -23
- package/package.json +1 -1
- package/skills/yuanflow-skill/HTML/346/212/245/345/221/212/347/224/237/346/210/220/SKILL.md +4 -0
- package/skills/yuanflow-skill/IP/350/277/220/350/220/245/SKILL.md +4 -0
- package/skills/yuanflow-skill/README.md +3 -0
- package/skills/yuanflow-skill//344/270/252/344/272/272/345/210/233/344/275/234/345/272/223/SKILL.md +2 -2
- package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/SKILL.md +2 -2
- package/skills/yuanflow-skill//345/210/233/344/275/234/346/200/273/347/233/221/SKILL.md +4 -0
- package/skills/yuanflow-skill//345/243/260/351/237/263/345/205/213/351/232/206/SKILL.md +146 -0
- package/skills/yuanflow-skill//345/243/260/351/237/263/345/244/215/345/210/273/SKILL.md +103 -0
- package/skills/yuanflow-skill//345/260/217/347/272/242/344/271/246/350/277/220/350/220/245/SKILL.md +1 -1
- package/skills/yuanflow-skill//345/260/217/347/272/242/344/271/246/350/277/220/350/220/245/references/commands.md +1 -2
- package/skills/yuanflow-skill//345/270/220/345/217/267/345/256/232/344/275/215/SKILL.md +4 -0
- package/skills/yuanflow-skill//345/270/220/345/217/267/347/233/221/346/216/247/SKILL.md +1 -1
- package/skills/yuanflow-skill//346/226/207/346/241/210/345/210/233/344/275/234/SKILL.md +4 -0
- package/skills/yuanflow-skill//346/234/254/345/234/260/351/237/263/350/247/206/351/242/221/350/275/254/346/226/207/345/255/227/SKILL.md +15 -13
- package/skills/yuanflow-skill//347/224/237/345/233/276/346/212/200/350/203/275/SKILL.md +2 -2
- package/skills/yuanflow-skill//347/233/264/346/222/255/346/212/225/346/265/201/347/255/226/347/225/245/SKILL.md +4 -0
- package/skills/yuanflow-skill//347/233/264/346/222/255/350/257/235/346/234/257/347/224/237/346/210/220/SKILL.md +4 -0
- package/skills/yuanflow-skill//350/207/252/345/252/222/344/275/223/346/265/217/350/247/210/345/231/250/350/207/252/345/212/250/345/214/226/SKILL.md +4 -4
- package/skills/yuanflow-skill//350/247/206/350/247/211/347/220/206/350/247/243/SKILL.md +4 -4
- package/skills/yuanflow-skill//350/247/206/351/242/221/346/212/225/346/265/201/347/255/226/347/225/245/SKILL.md +1 -1
- package/skills/yuanflow-skill//350/247/206/351/242/221/346/213/206/350/247/243/SKILL.md +6 -6
- package/skills/yuanflow-skill//350/247/206/351/242/221/346/231/272/350/203/275/345/211/252/350/276/221/SKILL.md +22 -22
- package/skills/yuanflow-skill//351/200/211/351/242/230/347/255/226/345/210/222/SKILL.md +4 -0
- package/skills/yuanflow-skill//351/237/263/350/247/206/351/242/221/345/234/250/347/272/277/350/275/254/346/226/207/345/255/227/SKILL.md +2 -2
- package/src/agent-protocol.js +3 -3
- package/src/cli.js +24 -22
- package/src/voice-tools.js +471 -0
- package/src/video-tools.js +0 -969
package/src/video-tools.js
DELETED
|
@@ -1,969 +0,0 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
|
-
import fs from 'node:fs/promises';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
|
|
6
|
-
const VIDEO_EXTS = new Set(['.mp4', '.mov', '.m4v', '.mkv', '.webm', '.avi']);
|
|
7
|
-
const AUDIO_EXTS = new Set(['.mp3', '.wav', '.m4a', '.aac', '.flac', '.ogg']);
|
|
8
|
-
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
|
|
9
|
-
const PROJECT_FILE = 'project.json';
|
|
10
|
-
const ASSETS_FILE = 'assets.json';
|
|
11
|
-
const BEATS_FILE = 'beats.json';
|
|
12
|
-
const AUDIO_ALIGNMENT_FILE = 'audio_alignment.json';
|
|
13
|
-
const VISUAL_SEGMENTS_FILE = 'visual_segments.json';
|
|
14
|
-
const VISUAL_UNDERSTANDING_FILE = 'visual_understanding.json';
|
|
15
|
-
const EDL_FILE = 'edl.json';
|
|
16
|
-
const STRATEGY_FILE = 'strategy_snapshot.json';
|
|
17
|
-
const TIMELINE_PLAN_FILE = 'timeline_plan.json';
|
|
18
|
-
|
|
19
|
-
export function listVideoCommands() {
|
|
20
|
-
return [
|
|
21
|
-
videoCommand('init', '初始化视频剪辑项目,创建独立工作目录和 project.json。', [
|
|
22
|
-
option('--input', 'input', true, '素材输入目录。'),
|
|
23
|
-
option('--output', 'output', false, '剪辑项目输出目录。'),
|
|
24
|
-
option('--primary-audio', 'primaryAudio', false, '主口播、旁白或主音频文件。'),
|
|
25
|
-
option('--broll', 'broll', false, 'B-roll 视频或图片素材路径,多个用英文逗号分隔。'),
|
|
26
|
-
option('--target-duration', 'targetDuration', false, '目标成片时长,单位秒。'),
|
|
27
|
-
option('--aspect', 'aspect', false, '目标画幅,例如 9:16。'),
|
|
28
|
-
]),
|
|
29
|
-
videoCommand('strategy', '导入知识库规则查询结果,生成 strategy_snapshot.json。', [
|
|
30
|
-
option('--project', 'project', true, '剪辑项目目录。'),
|
|
31
|
-
option('--template-type', 'templateType', true, '模板类型:talking_head 或 short_video_sales。'),
|
|
32
|
-
option('--rules-file', 'rulesFile', true, '知识库 rules/rule-detail 查询结果 JSON,多个用英文逗号分隔。'),
|
|
33
|
-
]),
|
|
34
|
-
videoCommand('inspect', '扫描素材并生成 assets.json。', [
|
|
35
|
-
option('--project', 'project', true, '剪辑项目目录。'),
|
|
36
|
-
option('--dry-run', 'dryRun', false, '不调用 ffprobe,用假探测数据验证流程。'),
|
|
37
|
-
]),
|
|
38
|
-
videoCommand('transcribe', '导入文案或既有转写结果,生成 beats.json。', [
|
|
39
|
-
option('--project', 'project', true, '剪辑项目目录。'),
|
|
40
|
-
option('--script', 'script', false, '直接传入文案文本。'),
|
|
41
|
-
option('--script-file', 'scriptFile', false, '读取文案文件。'),
|
|
42
|
-
option('--transcript-file', 'transcriptFile', false, '读取转写 JSON 或文本文件。'),
|
|
43
|
-
]),
|
|
44
|
-
videoCommand('align', '导入 ASR 或 forced alignment 时间戳结果,生成 audio_alignment.json 和带时间戳 beats.json。', [
|
|
45
|
-
option('--project', 'project', true, '剪辑项目目录。'),
|
|
46
|
-
option('--asr-file', 'asrFile', true, '音视频在线转文字或 forced alignment 输出的 JSON 文件。'),
|
|
47
|
-
]),
|
|
48
|
-
videoCommand('timeline', '按 1 秒 1 帧抽帧并生成 visual_segments.json。', [
|
|
49
|
-
option('--project', 'project', true, '剪辑项目目录。'),
|
|
50
|
-
option('--fps', 'fps', false, '抽帧频率,基础版建议 1。'),
|
|
51
|
-
option('--dry-run', 'dryRun', false, '不调用 ffmpeg,只生成占位帧清单。'),
|
|
52
|
-
]),
|
|
53
|
-
videoCommand('visual-review', '导入 Agent/人工对抽帧结果的视觉理解,回写 visual_segments.json。', [
|
|
54
|
-
option('--project', 'project', true, '剪辑项目目录。'),
|
|
55
|
-
option('--review-file', 'reviewFile', true, 'Agent 或人工生成的 visual_review.agent.json。'),
|
|
56
|
-
]),
|
|
57
|
-
videoCommand('plan', '校验 Agent 生成的 timeline_plan 和 EDL,写入标准 timeline_plan.json / edl.json。', [
|
|
58
|
-
option('--project', 'project', true, '剪辑项目目录。'),
|
|
59
|
-
option('--timeline-plan', 'timelinePlan', false, 'Agent 生成的 timeline_plan.agent.json。'),
|
|
60
|
-
option('--edl', 'edl', false, 'Agent 生成的 edl.agent.json;如果 timeline_plan 内已包含 edl,可不传。'),
|
|
61
|
-
]),
|
|
62
|
-
videoCommand('render-preview', '根据 edl.json 渲染预览视频。', [
|
|
63
|
-
option('--project', 'project', true, '剪辑项目目录。'),
|
|
64
|
-
option('--output', 'output', false, '预览视频输出路径。'),
|
|
65
|
-
option('--dry-run', 'dryRun', false, '只返回 ffmpeg 命令,不实际渲染。'),
|
|
66
|
-
]),
|
|
67
|
-
videoCommand('evaluate', '检查预览或成片并生成 eval_report.json。', [
|
|
68
|
-
option('--project', 'project', true, '剪辑项目目录。'),
|
|
69
|
-
option('--file', 'file', false, '需要检查的视频文件。'),
|
|
70
|
-
option('--dry-run', 'dryRun', false, '只检查文件存在性,不调用 ffprobe。'),
|
|
71
|
-
]),
|
|
72
|
-
videoCommand('render-final', '根据 edl.json 导出最终视频。', [
|
|
73
|
-
option('--project', 'project', true, '剪辑项目目录。'),
|
|
74
|
-
option('--output', 'output', false, '最终视频输出路径。'),
|
|
75
|
-
option('--dry-run', 'dryRun', false, '只返回 ffmpeg 命令,不实际渲染。'),
|
|
76
|
-
]),
|
|
77
|
-
];
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export async function runVideoCommand({ action, options }) {
|
|
81
|
-
if (action === 'init') return initProject(options);
|
|
82
|
-
if (action === 'inspect') return inspectProject(options);
|
|
83
|
-
if (action === 'strategy') return saveStrategySnapshot(options);
|
|
84
|
-
if (action === 'transcribe') return importTranscript(options);
|
|
85
|
-
if (action === 'align') return importAudioAlignment(options);
|
|
86
|
-
if (action === 'timeline') return buildTimeline(options);
|
|
87
|
-
if (action === 'visual-review') return importVisualReview(options);
|
|
88
|
-
if (action === 'plan') return planFromAgentEdl(options);
|
|
89
|
-
if (action === 'render-preview') return renderVideo(options, { final: false });
|
|
90
|
-
if (action === 'render-final') return renderVideo(options, { final: true });
|
|
91
|
-
if (action === 'evaluate') return evaluateVideo(options);
|
|
92
|
-
throw new Error('未知 video 命令。用法:yuanflow-cli video init|inspect|strategy|transcribe|align|timeline|visual-review|plan|render-preview|render-final|evaluate');
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function videoCommand(action, description, options) {
|
|
96
|
-
return {
|
|
97
|
-
key: `video.${action}`,
|
|
98
|
-
command: `video ${action}`,
|
|
99
|
-
kind: 'video-editing',
|
|
100
|
-
description,
|
|
101
|
-
method: 'LOCAL',
|
|
102
|
-
apiPath: '',
|
|
103
|
-
socialPath: '',
|
|
104
|
-
positionals: [],
|
|
105
|
-
options: [
|
|
106
|
-
...options,
|
|
107
|
-
option('--format', 'format', false, 'Agent 调用时建议使用 agent-json。'),
|
|
108
|
-
],
|
|
109
|
-
requestBody: null,
|
|
110
|
-
returns: '返回本地视频剪辑项目产物路径、校验结果或渲染命令。',
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function option(flag, name, required, label) {
|
|
115
|
-
return { flag, name, required, label };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
async function initProject(options) {
|
|
119
|
-
const input = requiredPath(options.named?.input, '缺少 --input。');
|
|
120
|
-
const output = path.resolve(options.output || options.named?.output || path.join(input, 'yuanflow-video-edit'));
|
|
121
|
-
await fs.mkdir(output, { recursive: true });
|
|
122
|
-
await fs.mkdir(path.join(output, 'frames'), { recursive: true });
|
|
123
|
-
await fs.mkdir(path.join(output, 'renders'), { recursive: true });
|
|
124
|
-
|
|
125
|
-
const project = {
|
|
126
|
-
project_id: `vf_${timestampId()}`,
|
|
127
|
-
input_dir: input,
|
|
128
|
-
work_dir: output,
|
|
129
|
-
created_at: new Date().toISOString(),
|
|
130
|
-
target: {
|
|
131
|
-
duration_s: numberOrNull(options.named?.['target-duration']),
|
|
132
|
-
aspect: stringOrDefault(options.named?.aspect, '9:16'),
|
|
133
|
-
platform: stringOrDefault(options.named?.platform, ''),
|
|
134
|
-
style: stringOrDefault(options.named?.style, ''),
|
|
135
|
-
},
|
|
136
|
-
asset_hints: {
|
|
137
|
-
primary_audio: normalizeOptionalPath(options.named?.['primary-audio']),
|
|
138
|
-
broll: parsePathList(options.named?.broll),
|
|
139
|
-
primary_video: normalizeOptionalPath(options.named?.['primary-video']),
|
|
140
|
-
background_music: normalizeOptionalPath(options.named?.['background-music']),
|
|
141
|
-
},
|
|
142
|
-
workflow: {
|
|
143
|
-
visual_sampling_fps: 1,
|
|
144
|
-
visual_understanding: 'agent_frame_review',
|
|
145
|
-
planning_owner: 'agent',
|
|
146
|
-
cli_responsibility: 'validate_edl_and_render',
|
|
147
|
-
},
|
|
148
|
-
};
|
|
149
|
-
await writeJson(path.join(output, PROJECT_FILE), project);
|
|
150
|
-
return { ok: true, action: 'video.init', project };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
async function inspectProject(options) {
|
|
154
|
-
const project = await readProject(options);
|
|
155
|
-
const files = await collectInputFiles(project.input_dir);
|
|
156
|
-
const hintedPaths = new Set([
|
|
157
|
-
...Object.values(project.asset_hints || {}).flat().filter(Boolean),
|
|
158
|
-
].map((item) => path.resolve(item)));
|
|
159
|
-
const scanPaths = [...new Set([...files, ...hintedPaths])];
|
|
160
|
-
const assets = [];
|
|
161
|
-
|
|
162
|
-
for (const filePath of scanPaths) {
|
|
163
|
-
const stat = await safeStat(filePath);
|
|
164
|
-
if (!stat?.isFile()) continue;
|
|
165
|
-
const mediaType = detectMediaType(filePath);
|
|
166
|
-
if (!mediaType) continue;
|
|
167
|
-
const probe = options.dryRun ? dryProbe(filePath, mediaType) : await probeMedia(filePath, mediaType);
|
|
168
|
-
const asset = {
|
|
169
|
-
asset_id: `asset_${String(assets.length + 1).padStart(3, '0')}`,
|
|
170
|
-
path: path.resolve(filePath),
|
|
171
|
-
name: path.basename(filePath),
|
|
172
|
-
media_type: mediaType,
|
|
173
|
-
role: inferRole(filePath, mediaType, project, assets),
|
|
174
|
-
duration_s: probe.duration_s,
|
|
175
|
-
width: probe.width,
|
|
176
|
-
height: probe.height,
|
|
177
|
-
hash: await hashFileStat(filePath),
|
|
178
|
-
probe,
|
|
179
|
-
};
|
|
180
|
-
assets.push(asset);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
await writeJson(path.join(project.work_dir, ASSETS_FILE), assets);
|
|
184
|
-
return {
|
|
185
|
-
ok: true,
|
|
186
|
-
action: 'video.inspect',
|
|
187
|
-
project: projectSummary(project),
|
|
188
|
-
assets,
|
|
189
|
-
dry_run: Boolean(options.dryRun),
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
async function importTranscript(options) {
|
|
194
|
-
const project = await readProject(options);
|
|
195
|
-
const script = await readScriptInput(options);
|
|
196
|
-
if (!script) {
|
|
197
|
-
return {
|
|
198
|
-
ok: true,
|
|
199
|
-
action: 'video.transcribe',
|
|
200
|
-
project: projectSummary(project),
|
|
201
|
-
requires_transcription: true,
|
|
202
|
-
next_action: '请先使用“音视频在线转文字”或“本地音视频转文字”获得文案,再用 --script-file 导入。',
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
const beats = splitScriptToBeats(script).map((text, index) => ({
|
|
206
|
-
beat_id: `beat_${String(index + 1).padStart(3, '0')}`,
|
|
207
|
-
text,
|
|
208
|
-
start_s: null,
|
|
209
|
-
end_s: null,
|
|
210
|
-
intent: '',
|
|
211
|
-
visual_need: '',
|
|
212
|
-
emotion: '',
|
|
213
|
-
}));
|
|
214
|
-
const payload = {
|
|
215
|
-
source: options.named?.['script-file'] ? path.resolve(options.named['script-file']) : 'inline',
|
|
216
|
-
imported_at: new Date().toISOString(),
|
|
217
|
-
beats,
|
|
218
|
-
};
|
|
219
|
-
await writeJson(path.join(project.work_dir, BEATS_FILE), payload);
|
|
220
|
-
return { ok: true, action: 'video.transcribe', project: projectSummary(project), beats };
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
async function importAudioAlignment(options) {
|
|
224
|
-
const project = await readProject(options);
|
|
225
|
-
const asrPath = requiredPath(options.named?.['asr-file'], '缺少 --asr-file。');
|
|
226
|
-
const parsed = JSON.parse(await fs.readFile(asrPath, 'utf8'));
|
|
227
|
-
const segments = extractAlignmentSegments(parsed);
|
|
228
|
-
if (segments.length === 0) {
|
|
229
|
-
return {
|
|
230
|
-
ok: false,
|
|
231
|
-
action: 'video.align',
|
|
232
|
-
project: projectSummary(project),
|
|
233
|
-
alignment_validation: {
|
|
234
|
-
ok: false,
|
|
235
|
-
errors: ['ASR/对齐结果里没有可用的 segments,无法生成带时间戳 beats。'],
|
|
236
|
-
},
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const alignment = {
|
|
241
|
-
version: 1,
|
|
242
|
-
source: path.resolve(asrPath),
|
|
243
|
-
imported_at: new Date().toISOString(),
|
|
244
|
-
text: stringOrDefault(parsed.text || parsed.data?.text || parsed.result?.text, segments.map((item) => item.text).join('')),
|
|
245
|
-
segments,
|
|
246
|
-
};
|
|
247
|
-
const beats = segments.map((segment, index) => ({
|
|
248
|
-
beat_id: `beat_${String(index + 1).padStart(3, '0')}`,
|
|
249
|
-
text: segment.text,
|
|
250
|
-
start_s: segment.start_s,
|
|
251
|
-
end_s: segment.end_s,
|
|
252
|
-
intent: '',
|
|
253
|
-
visual_need: '',
|
|
254
|
-
emotion: '',
|
|
255
|
-
alignment_source: 'audio_alignment',
|
|
256
|
-
}));
|
|
257
|
-
await writeJson(path.join(project.work_dir, AUDIO_ALIGNMENT_FILE), alignment);
|
|
258
|
-
await writeJson(path.join(project.work_dir, BEATS_FILE), {
|
|
259
|
-
source: path.resolve(asrPath),
|
|
260
|
-
imported_at: new Date().toISOString(),
|
|
261
|
-
alignment_file: path.join(project.work_dir, AUDIO_ALIGNMENT_FILE),
|
|
262
|
-
beats,
|
|
263
|
-
});
|
|
264
|
-
return {
|
|
265
|
-
ok: true,
|
|
266
|
-
action: 'video.align',
|
|
267
|
-
project: projectSummary(project),
|
|
268
|
-
alignment,
|
|
269
|
-
beats,
|
|
270
|
-
alignment_path: path.join(project.work_dir, AUDIO_ALIGNMENT_FILE),
|
|
271
|
-
beats_path: path.join(project.work_dir, BEATS_FILE),
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
async function saveStrategySnapshot(options) {
|
|
276
|
-
const project = await readProject(options);
|
|
277
|
-
const templateType = stringOrDefault(options.named?.['template-type'], 'custom');
|
|
278
|
-
const ruleFiles = parsePathList(options.named?.['rules-file']);
|
|
279
|
-
if (ruleFiles.length === 0) {
|
|
280
|
-
return {
|
|
281
|
-
ok: true,
|
|
282
|
-
action: 'video.strategy',
|
|
283
|
-
project: projectSummary(project),
|
|
284
|
-
agent_action_required: true,
|
|
285
|
-
expected_output: path.join(project.work_dir, STRATEGY_FILE),
|
|
286
|
-
message: '请先通过 knowledge rules / rule-detail 查询视频剪辑策略规则,再用 --rules-file 导入查询结果。',
|
|
287
|
-
};
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const ruleSources = [];
|
|
291
|
-
const rules = [];
|
|
292
|
-
for (const filePath of ruleFiles) {
|
|
293
|
-
const parsed = JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
294
|
-
const extracted = extractRulesFromKnowledgePayload(parsed);
|
|
295
|
-
ruleSources.push({
|
|
296
|
-
path: path.resolve(filePath),
|
|
297
|
-
count: extracted.length,
|
|
298
|
-
});
|
|
299
|
-
rules.push(...extracted);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
const strategy = {
|
|
303
|
-
version: 1,
|
|
304
|
-
template_type: templateType,
|
|
305
|
-
created_at: new Date().toISOString(),
|
|
306
|
-
source: 'knowledge_rules_manual_import',
|
|
307
|
-
required_packs: requiredPacksForTemplate(templateType),
|
|
308
|
-
rule_sources: ruleSources,
|
|
309
|
-
rules: uniqueRules(rules),
|
|
310
|
-
execution_policy: {
|
|
311
|
-
planning_owner: 'agent',
|
|
312
|
-
rule_source: 'agent_rule_library',
|
|
313
|
-
cli_responsibility: 'validate_timeline_plan_edl_and_render',
|
|
314
|
-
manual_test_first: true,
|
|
315
|
-
},
|
|
316
|
-
};
|
|
317
|
-
await writeJson(path.join(project.work_dir, STRATEGY_FILE), strategy);
|
|
318
|
-
return {
|
|
319
|
-
ok: true,
|
|
320
|
-
action: 'video.strategy',
|
|
321
|
-
project: projectSummary(project),
|
|
322
|
-
strategy,
|
|
323
|
-
strategy_path: path.join(project.work_dir, STRATEGY_FILE),
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
async function buildTimeline(options) {
|
|
328
|
-
const project = await readProject(options);
|
|
329
|
-
const assets = await readAssets(project);
|
|
330
|
-
const fps = Number(options.named?.fps || 1);
|
|
331
|
-
if (!Number.isFinite(fps) || fps <= 0) {
|
|
332
|
-
throw new Error('--fps 必须是大于 0 的数字。基础版建议固定为 1。');
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
const frameRoot = path.join(project.work_dir, 'frames');
|
|
336
|
-
const frameManifests = [];
|
|
337
|
-
const visualSegments = [];
|
|
338
|
-
for (const asset of assets.filter((item) => ['video', 'image'].includes(item.media_type))) {
|
|
339
|
-
const assetFrameDir = path.join(frameRoot, asset.asset_id);
|
|
340
|
-
await fs.mkdir(assetFrameDir, { recursive: true });
|
|
341
|
-
let frames;
|
|
342
|
-
if (options.dryRun) {
|
|
343
|
-
frames = buildDryFrames(asset, assetFrameDir, fps);
|
|
344
|
-
} else {
|
|
345
|
-
frames = await extractFrames(asset, assetFrameDir, fps);
|
|
346
|
-
}
|
|
347
|
-
frameManifests.push({ asset_id: asset.asset_id, path: asset.path, fps, frames });
|
|
348
|
-
const segmentCount = Math.max(1, frames.length || Math.ceil((asset.duration_s || 1) * fps));
|
|
349
|
-
for (let index = 0; index < segmentCount; index += 1) {
|
|
350
|
-
visualSegments.push({
|
|
351
|
-
segment_id: `vis_${String(visualSegments.length + 1).padStart(4, '0')}`,
|
|
352
|
-
asset_id: asset.asset_id,
|
|
353
|
-
start_s: index / fps,
|
|
354
|
-
end_s: (index + 1) / fps,
|
|
355
|
-
frame_path: frames[index]?.path || path.join(assetFrameDir, `frame_${String(index + 1).padStart(6, '0')}.jpg`),
|
|
356
|
-
description: '',
|
|
357
|
-
subjects: [],
|
|
358
|
-
scene: '',
|
|
359
|
-
motion: '',
|
|
360
|
-
agent_review_required: true,
|
|
361
|
-
});
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
await writeJson(path.join(project.work_dir, 'frames_manifest.json'), frameManifests);
|
|
365
|
-
await writeJson(path.join(project.work_dir, VISUAL_SEGMENTS_FILE), visualSegments);
|
|
366
|
-
return {
|
|
367
|
-
ok: true,
|
|
368
|
-
action: 'video.timeline',
|
|
369
|
-
project: projectSummary(project),
|
|
370
|
-
fps,
|
|
371
|
-
visual_segments: visualSegments,
|
|
372
|
-
frame_manifests: frameManifests,
|
|
373
|
-
dry_run: Boolean(options.dryRun),
|
|
374
|
-
};
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
async function importVisualReview(options) {
|
|
378
|
-
const project = await readProject(options);
|
|
379
|
-
const reviewPath = requiredPath(options.named?.['review-file'], '缺少 --review-file。');
|
|
380
|
-
const review = JSON.parse(await fs.readFile(reviewPath, 'utf8'));
|
|
381
|
-
const visualSegments = await readOptionalJson(path.join(project.work_dir, VISUAL_SEGMENTS_FILE));
|
|
382
|
-
if (!Array.isArray(visualSegments) || visualSegments.length === 0) {
|
|
383
|
-
return {
|
|
384
|
-
ok: false,
|
|
385
|
-
action: 'video.visual-review',
|
|
386
|
-
project: projectSummary(project),
|
|
387
|
-
review_validation: { ok: false, errors: ['请先执行 video timeline 生成 visual_segments.json。'], warnings: [] },
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
const reviewValidation = validateVisualReview(review, visualSegments);
|
|
392
|
-
if (!reviewValidation.ok) {
|
|
393
|
-
return { ok: false, action: 'video.visual-review', project: projectSummary(project), review_validation: reviewValidation };
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
const byId = new Map((review.reviews || []).map((item) => [item.segment_id, item]));
|
|
397
|
-
const reviewedSegments = visualSegments.map((segment) => {
|
|
398
|
-
const item = byId.get(segment.segment_id);
|
|
399
|
-
if (!item) return segment;
|
|
400
|
-
return {
|
|
401
|
-
...segment,
|
|
402
|
-
description: item.description,
|
|
403
|
-
subjects: Array.isArray(item.subjects) ? item.subjects : [],
|
|
404
|
-
scene: stringOrDefault(item.scene, ''),
|
|
405
|
-
motion: stringOrDefault(item.motion, ''),
|
|
406
|
-
semantic_tags: Array.isArray(item.semantic_tags) ? item.semantic_tags : [],
|
|
407
|
-
quality_score: isFiniteNumber(item.quality_score) ? Number(item.quality_score) : null,
|
|
408
|
-
visual_review_source: path.resolve(reviewPath),
|
|
409
|
-
agent_review_required: false,
|
|
410
|
-
};
|
|
411
|
-
});
|
|
412
|
-
const payload = {
|
|
413
|
-
version: 1,
|
|
414
|
-
source: path.resolve(reviewPath),
|
|
415
|
-
imported_at: new Date().toISOString(),
|
|
416
|
-
reviewed_count: review.reviews.length,
|
|
417
|
-
reviews: review.reviews,
|
|
418
|
-
};
|
|
419
|
-
await writeJson(path.join(project.work_dir, VISUAL_UNDERSTANDING_FILE), payload);
|
|
420
|
-
await writeJson(path.join(project.work_dir, VISUAL_SEGMENTS_FILE), reviewedSegments);
|
|
421
|
-
return {
|
|
422
|
-
ok: true,
|
|
423
|
-
action: 'video.visual-review',
|
|
424
|
-
project: projectSummary(project),
|
|
425
|
-
review_validation: reviewValidation,
|
|
426
|
-
visual_understanding_path: path.join(project.work_dir, VISUAL_UNDERSTANDING_FILE),
|
|
427
|
-
visual_segments_path: path.join(project.work_dir, VISUAL_SEGMENTS_FILE),
|
|
428
|
-
reviewed_segments: reviewedSegments.filter((segment) => byId.has(segment.segment_id)),
|
|
429
|
-
};
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
async function planFromAgentEdl(options) {
|
|
433
|
-
const project = await readProject(options);
|
|
434
|
-
const timelinePlanSource = normalizeOptionalPath(options.named?.['timeline-plan']);
|
|
435
|
-
const edlSource = normalizeOptionalPath(options.named?.edl);
|
|
436
|
-
let timelinePlan = null;
|
|
437
|
-
let timelinePlanValidation = null;
|
|
438
|
-
|
|
439
|
-
if (timelinePlanSource) {
|
|
440
|
-
timelinePlan = JSON.parse(await fs.readFile(timelinePlanSource, 'utf8'));
|
|
441
|
-
const beats = await readOptionalJson(path.join(project.work_dir, BEATS_FILE));
|
|
442
|
-
const visualSegments = await readOptionalJson(path.join(project.work_dir, VISUAL_SEGMENTS_FILE));
|
|
443
|
-
timelinePlanValidation = validateTimelinePlan(timelinePlan, beats, visualSegments);
|
|
444
|
-
if (!timelinePlanValidation.ok) {
|
|
445
|
-
return { ok: false, action: 'video.plan', project: projectSummary(project), timeline_plan_validation: timelinePlanValidation };
|
|
446
|
-
}
|
|
447
|
-
await writeJson(path.join(project.work_dir, TIMELINE_PLAN_FILE), timelinePlan);
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
if (!edlSource && !timelinePlan?.edl) {
|
|
451
|
-
return {
|
|
452
|
-
ok: true,
|
|
453
|
-
action: 'video.plan',
|
|
454
|
-
project: projectSummary(project),
|
|
455
|
-
agent_action_required: true,
|
|
456
|
-
required_inputs: ['strategy_snapshot.json', 'audio_alignment.json 或 beats.json', 'visual_segments.json', 'visual_understanding.json', '用户剪辑 brief'],
|
|
457
|
-
expected_outputs: [path.join(project.work_dir, TIMELINE_PLAN_FILE), path.join(project.work_dir, EDL_FILE)],
|
|
458
|
-
message: '由 Agent 读取规则库策略快照、音频对齐结果、beats、视觉理解结果和 1秒1帧抽帧结果后生成 timeline_plan;timeline_plan 内可包含 edl,或再用 --edl 单独传入。',
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
const edl = edlSource ? JSON.parse(await fs.readFile(edlSource, 'utf8')) : timelinePlan.edl;
|
|
462
|
-
const assets = await readAssets(project);
|
|
463
|
-
const validation = validateEdl(edl, assets);
|
|
464
|
-
if (!validation.ok) {
|
|
465
|
-
return { ok: false, action: 'video.plan', timeline_plan_validation: timelinePlanValidation, validation };
|
|
466
|
-
}
|
|
467
|
-
await writeJson(path.join(project.work_dir, EDL_FILE), edl);
|
|
468
|
-
return {
|
|
469
|
-
ok: true,
|
|
470
|
-
action: 'video.plan',
|
|
471
|
-
project: projectSummary(project),
|
|
472
|
-
timeline_plan_validation: timelinePlanValidation,
|
|
473
|
-
validation,
|
|
474
|
-
timeline_plan_path: timelinePlan ? path.join(project.work_dir, TIMELINE_PLAN_FILE) : '',
|
|
475
|
-
edl_path: path.join(project.work_dir, EDL_FILE),
|
|
476
|
-
};
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
async function renderVideo(options, { final }) {
|
|
480
|
-
const project = await readProject(options);
|
|
481
|
-
const edl = await readEdl(project);
|
|
482
|
-
const assets = await readAssets(project);
|
|
483
|
-
const validation = validateEdl(edl, assets);
|
|
484
|
-
if (!validation.ok) return { ok: false, action: final ? 'video.render-final' : 'video.render-preview', validation };
|
|
485
|
-
|
|
486
|
-
const output = path.resolve(options.output || options.named?.output || path.join(project.work_dir, final ? 'final.mp4' : 'preview.mp4'));
|
|
487
|
-
const commands = buildRenderCommands(project, edl, assets, output, { final });
|
|
488
|
-
if (!options.dryRun) {
|
|
489
|
-
await runRenderCommands(commands);
|
|
490
|
-
}
|
|
491
|
-
return {
|
|
492
|
-
ok: true,
|
|
493
|
-
action: final ? 'video.render-final' : 'video.render-preview',
|
|
494
|
-
project: projectSummary(project),
|
|
495
|
-
output,
|
|
496
|
-
validation,
|
|
497
|
-
commands: commands.map((item) => item.args),
|
|
498
|
-
dry_run: Boolean(options.dryRun),
|
|
499
|
-
};
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
async function evaluateVideo(options) {
|
|
503
|
-
const project = await readProject(options);
|
|
504
|
-
const target = path.resolve(options.named?.file || options.file || options.output || path.join(project.work_dir, 'preview.mp4'));
|
|
505
|
-
const exists = Boolean(await safeStat(target));
|
|
506
|
-
const checks = [{ type: 'file_exists', status: exists ? 'pass' : 'fail', message: exists ? '输出文件存在。' : '输出文件不存在。' }];
|
|
507
|
-
if (exists && !options.dryRun) {
|
|
508
|
-
const probe = await probeMedia(target, 'video');
|
|
509
|
-
checks.push({ type: 'duration', status: probe.duration_s > 0 ? 'pass' : 'warn', message: `检测到时长 ${probe.duration_s || 0} 秒。` });
|
|
510
|
-
}
|
|
511
|
-
const report = { ok: checks.every((item) => item.status !== 'fail'), file: target, checks, warnings: [] };
|
|
512
|
-
await writeJson(path.join(project.work_dir, 'eval_report.json'), report);
|
|
513
|
-
return { ok: true, action: 'video.evaluate', project: projectSummary(project), report, dry_run: Boolean(options.dryRun) };
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
function validateEdl(edl, assets) {
|
|
517
|
-
const errors = [];
|
|
518
|
-
const warnings = [];
|
|
519
|
-
const byId = new Map(assets.map((asset) => [asset.asset_id, asset]));
|
|
520
|
-
if (!edl || typeof edl !== 'object') errors.push('EDL 必须是 JSON 对象。');
|
|
521
|
-
if (!Array.isArray(edl?.ranges) || edl.ranges.length === 0) errors.push('EDL 必须包含非空 ranges。');
|
|
522
|
-
if (edl?.audio?.source && !byId.has(edl.audio.source)) errors.push(`audio.source 不存在: ${edl.audio.source}`);
|
|
523
|
-
if (edl?.audio?.source) {
|
|
524
|
-
const audio = byId.get(edl.audio.source);
|
|
525
|
-
if (audio && audio.media_type !== 'audio') errors.push(`audio.source 必须指向音频素材: ${edl.audio.source}`);
|
|
526
|
-
}
|
|
527
|
-
for (const [index, range] of (edl?.ranges || []).entries()) {
|
|
528
|
-
const label = `ranges[${index}]`;
|
|
529
|
-
const asset = byId.get(range.source);
|
|
530
|
-
if (!asset) {
|
|
531
|
-
errors.push(`${label}.source 不存在: ${range.source}`);
|
|
532
|
-
continue;
|
|
533
|
-
}
|
|
534
|
-
if (!['video', 'image'].includes(asset.media_type)) {
|
|
535
|
-
errors.push(`${label}.source 必须指向视频或图片素材。`);
|
|
536
|
-
}
|
|
537
|
-
if (!isFiniteNumber(range.start) || !isFiniteNumber(range.end) || range.end <= range.start) {
|
|
538
|
-
errors.push(`${label} start/end 必须是有效数字,且 end > start。`);
|
|
539
|
-
}
|
|
540
|
-
if (!range.reason) warnings.push(`${label} 缺少 reason,建议 Agent 写明保留理由。`);
|
|
541
|
-
if (asset.duration_s && range.end > asset.duration_s + 0.05) {
|
|
542
|
-
warnings.push(`${label} 结束时间超过素材探测时长,渲染时可能失败。`);
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
return { ok: errors.length === 0, errors, warnings };
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
function validateTimelinePlan(plan, beatsPayload, visualSegments) {
|
|
549
|
-
const errors = [];
|
|
550
|
-
const warnings = [];
|
|
551
|
-
if (!plan || typeof plan !== 'object' || Array.isArray(plan)) {
|
|
552
|
-
return { ok: false, errors: ['timeline_plan 必须是 JSON 对象。'], warnings };
|
|
553
|
-
}
|
|
554
|
-
if (!Array.isArray(plan.beats) || plan.beats.length === 0) {
|
|
555
|
-
errors.push('timeline_plan.beats 必须是非空数组。');
|
|
556
|
-
}
|
|
557
|
-
if (!Array.isArray(plan.matches) || plan.matches.length === 0) {
|
|
558
|
-
errors.push('timeline_plan.matches 必须是非空数组。');
|
|
559
|
-
}
|
|
560
|
-
if (!plan.template_type) {
|
|
561
|
-
warnings.push('timeline_plan 缺少 template_type,建议写明 talking_head 或 short_video_sales。');
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
const knownBeatIds = new Set((beatsPayload?.beats || []).map((beat) => beat.beat_id));
|
|
565
|
-
const planBeatIds = new Set((plan.beats || []).map((beat) => beat.beat_id).filter(Boolean));
|
|
566
|
-
const knownSegmentIds = new Set((visualSegments || []).map((segment) => segment.segment_id));
|
|
567
|
-
for (const [index, beat] of (plan.beats || []).entries()) {
|
|
568
|
-
const label = `beats[${index}]`;
|
|
569
|
-
if (!beat.beat_id) errors.push(`${label}.beat_id 不能为空。`);
|
|
570
|
-
if (!beat.text) warnings.push(`${label}.text 为空,后续复核会缺少文案依据。`);
|
|
571
|
-
if (knownBeatIds.size > 0 && beat.beat_id && !knownBeatIds.has(beat.beat_id)) {
|
|
572
|
-
warnings.push(`${label}.beat_id 未出现在 beats.json 中: ${beat.beat_id}`);
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
for (const [index, match] of (plan.matches || []).entries()) {
|
|
576
|
-
const label = `matches[${index}]`;
|
|
577
|
-
if (!match.beat_id) errors.push(`${label}.beat_id 不能为空。`);
|
|
578
|
-
if (match.beat_id && !planBeatIds.has(match.beat_id)) {
|
|
579
|
-
errors.push(`${label}.beat_id 未出现在 timeline_plan.beats 中: ${match.beat_id}`);
|
|
580
|
-
}
|
|
581
|
-
if (!match.segment_id) errors.push(`${label}.segment_id 不能为空。`);
|
|
582
|
-
if (knownSegmentIds.size > 0 && match.segment_id && !knownSegmentIds.has(match.segment_id)) {
|
|
583
|
-
errors.push(`${label}.segment_id 未出现在 visual_segments.json 中: ${match.segment_id}`);
|
|
584
|
-
}
|
|
585
|
-
if (!isFiniteNumber(match.score) || Number(match.score) < 0 || Number(match.score) > 1) {
|
|
586
|
-
errors.push(`${label}.score 必须是 0 到 1 之间的数字。`);
|
|
587
|
-
}
|
|
588
|
-
if (!match.reason) {
|
|
589
|
-
errors.push(`${label}.reason 不能为空,必须说明画面为什么匹配当前 beat。`);
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
const blockers = (plan.material_gaps || []).filter((gap) => ['blocker', 'fatal'].includes(String(gap.severity || '').toLowerCase()));
|
|
593
|
-
if (blockers.length > 0) {
|
|
594
|
-
errors.push(`timeline_plan 存在未处理的阻塞性缺素材: ${blockers.map((gap) => gap.description || gap.type || 'unknown').join(';')}`);
|
|
595
|
-
}
|
|
596
|
-
if (!plan.edl) {
|
|
597
|
-
warnings.push('timeline_plan 未内嵌 edl;需要另传 --edl。');
|
|
598
|
-
}
|
|
599
|
-
return { ok: errors.length === 0, errors, warnings };
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
function validateVisualReview(review, visualSegments) {
|
|
603
|
-
const errors = [];
|
|
604
|
-
const warnings = [];
|
|
605
|
-
if (!review || typeof review !== 'object' || Array.isArray(review)) {
|
|
606
|
-
return { ok: false, errors: ['visual_review 必须是 JSON 对象。'], warnings };
|
|
607
|
-
}
|
|
608
|
-
if (!Array.isArray(review.reviews) || review.reviews.length === 0) {
|
|
609
|
-
errors.push('visual_review.reviews 必须是非空数组。');
|
|
610
|
-
}
|
|
611
|
-
const knownSegmentIds = new Set((visualSegments || []).map((segment) => segment.segment_id));
|
|
612
|
-
const seen = new Set();
|
|
613
|
-
for (const [index, item] of (review.reviews || []).entries()) {
|
|
614
|
-
const label = `reviews[${index}]`;
|
|
615
|
-
if (!item.segment_id) errors.push(`${label}.segment_id 不能为空。`);
|
|
616
|
-
if (item.segment_id && !knownSegmentIds.has(item.segment_id)) {
|
|
617
|
-
errors.push(`${label}.segment_id 未出现在 visual_segments.json 中: ${item.segment_id}`);
|
|
618
|
-
}
|
|
619
|
-
if (item.segment_id && seen.has(item.segment_id)) {
|
|
620
|
-
errors.push(`${label}.segment_id 重复: ${item.segment_id}`);
|
|
621
|
-
}
|
|
622
|
-
if (item.segment_id) seen.add(item.segment_id);
|
|
623
|
-
if (!item.description) errors.push(`${label}.description 不能为空,必须说明画面内容。`);
|
|
624
|
-
if (!Array.isArray(item.subjects)) warnings.push(`${label}.subjects 建议使用数组。`);
|
|
625
|
-
if (item.quality_score !== undefined && (!isFiniteNumber(item.quality_score) || Number(item.quality_score) < 0 || Number(item.quality_score) > 1)) {
|
|
626
|
-
errors.push(`${label}.quality_score 必须是 0 到 1 之间的数字。`);
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
return { ok: errors.length === 0, errors, warnings };
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
function buildRenderCommands(project, edl, assets, output, { final }) {
|
|
633
|
-
const byId = new Map(assets.map((asset) => [asset.asset_id, asset]));
|
|
634
|
-
const renderDir = path.join(project.work_dir, 'renders');
|
|
635
|
-
const clipsDir = path.join(renderDir, final ? 'clips_final' : 'clips_preview');
|
|
636
|
-
const concatFile = path.join(renderDir, final ? 'concat_final.txt' : 'concat_preview.txt');
|
|
637
|
-
const baseVideo = path.join(renderDir, final ? 'base_final.mp4' : 'base_preview.mp4');
|
|
638
|
-
const commands = [
|
|
639
|
-
{ tool: 'mkdir', args: ['mkdir', clipsDir] },
|
|
640
|
-
];
|
|
641
|
-
const clipPaths = [];
|
|
642
|
-
const scale = final ? 'scale=-2:1920' : 'scale=-2:1280';
|
|
643
|
-
|
|
644
|
-
for (const [index, range] of edl.ranges.entries()) {
|
|
645
|
-
const asset = byId.get(range.source);
|
|
646
|
-
const clipPath = path.join(clipsDir, `seg_${String(index + 1).padStart(3, '0')}.mp4`);
|
|
647
|
-
clipPaths.push(clipPath);
|
|
648
|
-
const duration = Number(range.end) - Number(range.start);
|
|
649
|
-
if (asset.media_type === 'image') {
|
|
650
|
-
commands.push({
|
|
651
|
-
tool: 'ffmpeg',
|
|
652
|
-
args: ['ffmpeg', '-y', '-loop', '1', '-i', asset.path, '-t', String(duration), '-vf', scale, '-an', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', clipPath],
|
|
653
|
-
});
|
|
654
|
-
} else {
|
|
655
|
-
commands.push({
|
|
656
|
-
tool: 'ffmpeg',
|
|
657
|
-
args: ['ffmpeg', '-y', '-ss', String(range.start), '-i', asset.path, '-t', String(duration), '-vf', scale, '-an', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', clipPath],
|
|
658
|
-
});
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
commands.push({ tool: 'write-concat', args: ['write-concat', concatFile, ...clipPaths] });
|
|
662
|
-
commands.push({ tool: 'ffmpeg', args: ['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concatFile, '-c', 'copy', baseVideo] });
|
|
663
|
-
const audioAsset = edl.audio?.source ? byId.get(edl.audio.source) : null;
|
|
664
|
-
if (audioAsset) {
|
|
665
|
-
commands.push({ tool: 'ffmpeg', args: ['ffmpeg', '-y', '-i', baseVideo, '-i', audioAsset.path, '-map', '0:v:0', '-map', '1:a:0', '-shortest', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', output] });
|
|
666
|
-
} else {
|
|
667
|
-
commands.push({ tool: 'ffmpeg', args: ['ffmpeg', '-y', '-i', baseVideo, '-c', 'copy', output] });
|
|
668
|
-
}
|
|
669
|
-
return commands;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
async function runRenderCommands(commands) {
|
|
673
|
-
for (const command of commands) {
|
|
674
|
-
if (command.tool === 'mkdir') {
|
|
675
|
-
await fs.mkdir(command.args[1], { recursive: true });
|
|
676
|
-
} else if (command.tool === 'write-concat') {
|
|
677
|
-
const [, concatFile, ...clipPaths] = command.args;
|
|
678
|
-
await fs.mkdir(path.dirname(concatFile), { recursive: true });
|
|
679
|
-
await fs.writeFile(concatFile, clipPaths.map((item) => `file '${toFfmpegConcatPath(item)}'`).join('\n'), 'utf8');
|
|
680
|
-
} else if (command.tool === 'ffmpeg') {
|
|
681
|
-
await exec(command.args[0], command.args.slice(1));
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
async function extractFrames(asset, destDir, fps) {
|
|
687
|
-
const pattern = path.join(destDir, 'frame_%06d.jpg');
|
|
688
|
-
if (asset.media_type === 'image') {
|
|
689
|
-
const out = path.join(destDir, 'frame_000001.jpg');
|
|
690
|
-
await fs.copyFile(asset.path, out);
|
|
691
|
-
return [{ index: 1, time_s: 0, path: out }];
|
|
692
|
-
}
|
|
693
|
-
await exec('ffmpeg', ['-y', '-i', asset.path, '-vf', `fps=${fps}`, '-q:v', '3', pattern]);
|
|
694
|
-
const files = (await fs.readdir(destDir)).filter((item) => item.endsWith('.jpg')).sort();
|
|
695
|
-
return files.map((file, index) => ({ index: index + 1, time_s: index / fps, path: path.join(destDir, file) }));
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
function buildDryFrames(asset, assetFrameDir, fps) {
|
|
699
|
-
const count = asset.media_type === 'image' ? 1 : Math.max(1, Math.ceil((asset.duration_s || 6) * fps));
|
|
700
|
-
return Array.from({ length: count }, (_, index) => ({
|
|
701
|
-
index: index + 1,
|
|
702
|
-
time_s: index / fps,
|
|
703
|
-
path: path.join(assetFrameDir, `frame_${String(index + 1).padStart(6, '0')}.jpg`),
|
|
704
|
-
}));
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
async function readProject(options) {
|
|
708
|
-
const projectDir = normalizeOptionalPath(options.named?.project || options.output);
|
|
709
|
-
if (!projectDir) throw new Error('缺少 --project。');
|
|
710
|
-
return JSON.parse(await fs.readFile(path.join(projectDir, PROJECT_FILE), 'utf8'));
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
async function readAssets(project) {
|
|
714
|
-
return JSON.parse(await fs.readFile(path.join(project.work_dir, ASSETS_FILE), 'utf8'));
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
async function readEdl(project) {
|
|
718
|
-
return JSON.parse(await fs.readFile(path.join(project.work_dir, EDL_FILE), 'utf8'));
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
async function readOptionalJson(filePath) {
|
|
722
|
-
const stat = await safeStat(filePath);
|
|
723
|
-
if (!stat?.isFile()) return null;
|
|
724
|
-
return JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
async function readScriptInput(options) {
|
|
728
|
-
if (typeof options.named?.script === 'string') return options.named.script;
|
|
729
|
-
if (typeof options.named?.['script-file'] === 'string') {
|
|
730
|
-
return fs.readFile(path.resolve(options.named['script-file']), 'utf8');
|
|
731
|
-
}
|
|
732
|
-
if (typeof options.named?.['transcript-file'] === 'string') {
|
|
733
|
-
const raw = await fs.readFile(path.resolve(options.named['transcript-file']), 'utf8');
|
|
734
|
-
try {
|
|
735
|
-
const parsed = JSON.parse(raw);
|
|
736
|
-
if (Array.isArray(parsed.beats)) return parsed.beats.map((item) => item.text || '').join('\n');
|
|
737
|
-
if (Array.isArray(parsed.words)) return parsed.words.map((item) => item.text || '').join('');
|
|
738
|
-
if (typeof parsed.text === 'string') return parsed.text;
|
|
739
|
-
} catch {
|
|
740
|
-
return raw;
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
return '';
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
function extractAlignmentSegments(payload) {
|
|
747
|
-
const candidates = [
|
|
748
|
-
payload?.segments,
|
|
749
|
-
payload?.data?.segments,
|
|
750
|
-
payload?.result?.segments,
|
|
751
|
-
payload?.data?.result?.segments,
|
|
752
|
-
payload?.response?.segments,
|
|
753
|
-
payload?.words,
|
|
754
|
-
payload?.data?.words,
|
|
755
|
-
payload?.result?.words,
|
|
756
|
-
];
|
|
757
|
-
const found = candidates.find((item) => Array.isArray(item));
|
|
758
|
-
return (found || [])
|
|
759
|
-
.map((item) => {
|
|
760
|
-
const text = stringOrDefault(item.text || item.word || item.sentence, '').trim();
|
|
761
|
-
const start = firstFiniteNumber(item.start_s, item.start, item.begin_s, item.begin, item.from);
|
|
762
|
-
const end = firstFiniteNumber(item.end_s, item.end, item.stop_s, item.stop, item.to);
|
|
763
|
-
return { text, start_s: start, end_s: end };
|
|
764
|
-
})
|
|
765
|
-
.filter((item) => item.text && isFiniteNumber(item.start_s) && isFiniteNumber(item.end_s) && Number(item.end_s) > Number(item.start_s))
|
|
766
|
-
.map((item) => ({ text: item.text, start_s: Number(item.start_s), end_s: Number(item.end_s) }));
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
function splitScriptToBeats(script) {
|
|
770
|
-
return String(script)
|
|
771
|
-
.replace(/\r/g, '\n')
|
|
772
|
-
.split(/(?<=[。!?!?;;])\s*|\n+/u)
|
|
773
|
-
.map((item) => item.trim())
|
|
774
|
-
.filter(Boolean);
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
async function collectInputFiles(inputDir) {
|
|
778
|
-
const out = [];
|
|
779
|
-
async function walk(dir) {
|
|
780
|
-
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
781
|
-
for (const entry of entries) {
|
|
782
|
-
const p = path.join(dir, entry.name);
|
|
783
|
-
if (entry.isDirectory()) await walk(p);
|
|
784
|
-
else out.push(path.resolve(p));
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
await walk(inputDir);
|
|
788
|
-
return out;
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
function inferRole(filePath, mediaType, project, existingAssets) {
|
|
792
|
-
const resolved = path.resolve(filePath);
|
|
793
|
-
const hints = project.asset_hints || {};
|
|
794
|
-
if (hints.primary_audio && path.resolve(hints.primary_audio) === resolved) return 'primary_audio';
|
|
795
|
-
if (hints.primary_video && path.resolve(hints.primary_video) === resolved) return 'primary_video';
|
|
796
|
-
if (hints.background_music && path.resolve(hints.background_music) === resolved) return 'background_music';
|
|
797
|
-
if ((hints.broll || []).map((item) => path.resolve(item)).includes(resolved)) {
|
|
798
|
-
return mediaType === 'image' ? 'broll_image' : 'broll_video';
|
|
799
|
-
}
|
|
800
|
-
if (mediaType === 'audio') {
|
|
801
|
-
return existingAssets.some((item) => item.role === 'primary_audio') ? 'background_music' : 'primary_audio';
|
|
802
|
-
}
|
|
803
|
-
if (mediaType === 'image') return 'broll_image';
|
|
804
|
-
return existingAssets.some((item) => item.role === 'primary_audio') ? 'broll_video' : 'primary_video';
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
async function probeMedia(filePath, mediaType) {
|
|
808
|
-
const output = await exec('ffprobe', ['-v', 'error', '-show_entries', 'format=duration:stream=width,height', '-of', 'json', filePath]);
|
|
809
|
-
const parsed = JSON.parse(output.stdout || '{}');
|
|
810
|
-
const videoStream = (parsed.streams || []).find((item) => item.width && item.height);
|
|
811
|
-
return {
|
|
812
|
-
duration_s: Number(parsed.format?.duration || 0),
|
|
813
|
-
width: mediaType === 'image' ? videoStream?.width || null : videoStream?.width || null,
|
|
814
|
-
height: mediaType === 'image' ? videoStream?.height || null : videoStream?.height || null,
|
|
815
|
-
};
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
function dryProbe(filePath, mediaType) {
|
|
819
|
-
return {
|
|
820
|
-
duration_s: mediaType === 'image' ? 1 : 6,
|
|
821
|
-
width: mediaType === 'audio' ? null : 1080,
|
|
822
|
-
height: mediaType === 'audio' ? null : 1920,
|
|
823
|
-
dry_run: true,
|
|
824
|
-
file: filePath,
|
|
825
|
-
};
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
function detectMediaType(filePath) {
|
|
829
|
-
const ext = path.extname(filePath).toLowerCase();
|
|
830
|
-
if (VIDEO_EXTS.has(ext)) return 'video';
|
|
831
|
-
if (AUDIO_EXTS.has(ext)) return 'audio';
|
|
832
|
-
if (IMAGE_EXTS.has(ext)) return 'image';
|
|
833
|
-
return '';
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
async function hashFileStat(filePath) {
|
|
837
|
-
const stat = await fs.stat(filePath);
|
|
838
|
-
const digest = createHash('sha256');
|
|
839
|
-
digest.update(path.resolve(filePath));
|
|
840
|
-
digest.update(String(stat.size));
|
|
841
|
-
digest.update(String(stat.mtimeMs));
|
|
842
|
-
return digest.digest('hex');
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
async function safeStat(filePath) {
|
|
846
|
-
try {
|
|
847
|
-
return await fs.stat(filePath);
|
|
848
|
-
} catch {
|
|
849
|
-
return null;
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
function requiredPath(value, message) {
|
|
854
|
-
const p = normalizeOptionalPath(value);
|
|
855
|
-
if (!p) throw new Error(message);
|
|
856
|
-
return p;
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
function normalizeOptionalPath(value) {
|
|
860
|
-
if (!value || value === true) return '';
|
|
861
|
-
return path.resolve(String(value));
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
function parsePathList(value) {
|
|
865
|
-
if (!value || value === true) return [];
|
|
866
|
-
return String(value)
|
|
867
|
-
.split(',')
|
|
868
|
-
.map((item) => item.trim())
|
|
869
|
-
.filter(Boolean)
|
|
870
|
-
.map((item) => path.resolve(item));
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
function extractRulesFromKnowledgePayload(payload) {
|
|
874
|
-
const candidates = [
|
|
875
|
-
payload?.data?.response?.data,
|
|
876
|
-
payload?.response?.data,
|
|
877
|
-
payload?.data?.data,
|
|
878
|
-
payload?.data,
|
|
879
|
-
payload,
|
|
880
|
-
];
|
|
881
|
-
const found = candidates.find((item) => Array.isArray(item));
|
|
882
|
-
return (found || [])
|
|
883
|
-
.filter((item) => item && typeof item === 'object')
|
|
884
|
-
.map((item) => ({
|
|
885
|
-
rule_code: stringOrDefault(item.rule_code, ''),
|
|
886
|
-
rule_name: stringOrDefault(item.rule_name, ''),
|
|
887
|
-
rule_type: stringOrDefault(item.rule_type, ''),
|
|
888
|
-
summary: stringOrDefault(item.summary || item.public_summary, ''),
|
|
889
|
-
instruction: stringOrDefault(item.instruction || item.execution_instruction, ''),
|
|
890
|
-
avoid: Array.isArray(item.avoid) ? item.avoid : [],
|
|
891
|
-
example_pattern: Array.isArray(item.example_pattern) ? item.example_pattern : [],
|
|
892
|
-
}))
|
|
893
|
-
.filter((item) => item.rule_code || item.rule_name || item.instruction);
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
function uniqueRules(rules) {
|
|
897
|
-
const seen = new Set();
|
|
898
|
-
const out = [];
|
|
899
|
-
for (const rule of rules) {
|
|
900
|
-
const key = rule.rule_code || `${rule.rule_name}:${rule.instruction}`;
|
|
901
|
-
if (seen.has(key)) continue;
|
|
902
|
-
seen.add(key);
|
|
903
|
-
out.push(rule);
|
|
904
|
-
}
|
|
905
|
-
return out;
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
function requiredPacksForTemplate(templateType) {
|
|
909
|
-
const base = ['video_edit_logic_layer_pack', 'video_cli_validation_render_pack'];
|
|
910
|
-
if (templateType === 'talking_head') return [base[0], 'talking_head_edit_template_pack', base[1]];
|
|
911
|
-
if (templateType === 'short_video_sales') return [base[0], 'short_video_sales_edit_template_pack', base[1]];
|
|
912
|
-
return base;
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
function numberOrNull(value) {
|
|
916
|
-
if (value === undefined || value === null || value === true || value === '') return null;
|
|
917
|
-
const n = Number(value);
|
|
918
|
-
return Number.isFinite(n) ? n : null;
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
function stringOrDefault(value, fallback) {
|
|
922
|
-
if (value === undefined || value === null || value === true || value === '') return fallback;
|
|
923
|
-
return String(value);
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
function isFiniteNumber(value) {
|
|
927
|
-
return Number.isFinite(Number(value));
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
function firstFiniteNumber(...values) {
|
|
931
|
-
for (const value of values) {
|
|
932
|
-
if (isFiniteNumber(value)) return Number(value);
|
|
933
|
-
}
|
|
934
|
-
return null;
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
function projectSummary(project) {
|
|
938
|
-
return {
|
|
939
|
-
project_id: project.project_id,
|
|
940
|
-
work_dir: project.work_dir,
|
|
941
|
-
input_dir: project.input_dir,
|
|
942
|
-
target: project.target,
|
|
943
|
-
};
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
async function writeJson(filePath, payload) {
|
|
947
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
948
|
-
await fs.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
function exec(file, args) {
|
|
952
|
-
return new Promise((resolve, reject) => {
|
|
953
|
-
execFile(file, args, { windowsHide: true }, (error, stdout, stderr) => {
|
|
954
|
-
if (error) {
|
|
955
|
-
reject(new Error(stderr || error.message));
|
|
956
|
-
return;
|
|
957
|
-
}
|
|
958
|
-
resolve({ stdout, stderr });
|
|
959
|
-
});
|
|
960
|
-
});
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
function toFfmpegConcatPath(filePath) {
|
|
964
|
-
return path.resolve(filePath).replace(/\\/g, '/').replace(/'/g, "'\\''");
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
function timestampId() {
|
|
968
|
-
return new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
|
|
969
|
-
}
|