chainlesschain 0.47.6 → 0.47.7

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.
Files changed (107) hide show
  1. package/package.json +2 -2
  2. package/src/assets/web-panel/.build-hash +1 -1
  3. package/src/assets/web-panel/assets/Analytics-BFI7jbwM.css +1 -0
  4. package/src/assets/web-panel/assets/Analytics-DQ135mAd.js +3 -0
  5. package/src/assets/web-panel/assets/AppLayout-6SPt_8Y_.js +1 -0
  6. package/src/assets/web-panel/assets/AppLayout-BFJ-Fofn.css +1 -0
  7. package/src/assets/web-panel/assets/{Backup-Ba9UybpT.js → Backup-DbVRG5vE.js} +1 -1
  8. package/src/assets/web-panel/assets/{Chat-BwXskT21.js → Chat-wVhrFK9C.js} +1 -1
  9. package/src/assets/web-panel/assets/{Cowork-UmOe7qvE.js → Cowork-lOC25IW2.js} +1 -1
  10. package/src/assets/web-panel/assets/{Cron-JHS-rc-4.js → Cron-3P0eVLTV.js} +1 -1
  11. package/src/assets/web-panel/assets/{Dashboard-B95cMCO7.js → Dashboard-Br7kCwKJ.js} +1 -1
  12. package/src/assets/web-panel/assets/{Git-CSYO0_zk.js → Git-CrDCcBig.js} +2 -2
  13. package/src/assets/web-panel/assets/{Logs-Hxw_K0km.js → Logs-BfTE8urP.js} +1 -1
  14. package/src/assets/web-panel/assets/{McpTools-DIE75TrB.js → McpTools-CsGIijNe.js} +1 -1
  15. package/src/assets/web-panel/assets/{Memory-C4KVnLlp.js → Memory-BXX_yMKJ.js} +1 -1
  16. package/src/assets/web-panel/assets/{Notes-DuzrHMAk.js → Notes-DU6Vf2cL.js} +1 -1
  17. package/src/assets/web-panel/assets/{Organization-DTq6uF82.js → Organization-Bny6yOPV.js} +4 -4
  18. package/src/assets/web-panel/assets/{P2P-C0hjlhsR.js → P2P-BxFZ1Bit.js} +2 -2
  19. package/src/assets/web-panel/assets/{Permissions-Ec0NH-xC.js → Permissions-B1j3Mtms.js} +3 -3
  20. package/src/assets/web-panel/assets/{Projects-U8D0asCS.js → Projects-D-CGscDu.js} +1 -1
  21. package/src/assets/web-panel/assets/{Providers-BngtTLvJ.js → Providers-r6NaBYMf.js} +1 -1
  22. package/src/assets/web-panel/assets/{RssFeed-B9NbwCKM.js → RssFeed-D7b68C5q.js} +1 -1
  23. package/src/assets/web-panel/assets/{Security-BL5Rkr1T.js → Security-MJfKv0EJ.js} +3 -3
  24. package/src/assets/web-panel/assets/{Services-D4MJzLld.js → Services-Yb_Q1V3d.js} +1 -1
  25. package/src/assets/web-panel/assets/{Skills-CQTOMDwF.js → Skills-DLTHcH5T.js} +1 -1
  26. package/src/assets/web-panel/assets/{Tasks-DepbJMnL.js → Tasks-CqycpPjS.js} +1 -1
  27. package/src/assets/web-panel/assets/{Templates-C24PVZPu.js → Templates-y01u2Zis.js} +1 -1
  28. package/src/assets/web-panel/assets/VideoEditing-BA1N-5kq.css +1 -0
  29. package/src/assets/web-panel/assets/VideoEditing-B_nPKw6B.js +1 -0
  30. package/src/assets/web-panel/assets/{Wallet-PQoSpN_P.js → Wallet-CsRgnjJY.js} +1 -1
  31. package/src/assets/web-panel/assets/{WebAuthn-BcuyQ4Lr.js → WebAuthn-DWoR5ADp.js} +1 -1
  32. package/src/assets/web-panel/assets/{WorkflowEditor-C-SvXbHW.js → WorkflowEditor-DBJhFPMN.js} +1 -1
  33. package/src/assets/web-panel/assets/{antd-DEjZPGMj.js → antd-Dh2t0vGq.js} +84 -84
  34. package/src/assets/web-panel/assets/index-tN-8TosE.js +2 -0
  35. package/src/assets/web-panel/assets/{markdown-CusdXFxb.js → markdown-CBnGGMzE.js} +1 -1
  36. package/src/assets/web-panel/index.html +2 -2
  37. package/src/commands/agent.js +20 -0
  38. package/src/commands/mcp.js +86 -4
  39. package/src/commands/memory.js +85 -4
  40. package/src/commands/sandbox.js +80 -6
  41. package/src/commands/serve.js +10 -0
  42. package/src/commands/session.js +250 -0
  43. package/src/commands/stream.js +75 -0
  44. package/src/commands/video.js +363 -0
  45. package/src/gateways/http/envelope-http-server.js +194 -0
  46. package/src/gateways/ws/message-dispatcher.js +123 -0
  47. package/src/gateways/ws/session-core-protocol.js +427 -0
  48. package/src/gateways/ws/session-protocol.js +42 -1
  49. package/src/gateways/ws/video-protocol.js +230 -0
  50. package/src/gateways/ws/ws-server.js +72 -0
  51. package/src/gateways/ws/ws-session-gateway.js +7 -3
  52. package/src/harness/jsonl-session-store.js +17 -9
  53. package/src/index.js +8 -0
  54. package/src/lib/agent-stream.js +63 -0
  55. package/src/lib/chat-core.js +183 -6
  56. package/src/lib/cowork/ab-comparator-cli.js +44 -23
  57. package/src/lib/cowork/agent-group-runner.js +145 -0
  58. package/src/lib/cowork/debate-review-cli.js +47 -25
  59. package/src/lib/cowork/project-style-analyzer-cli.js +34 -7
  60. package/src/lib/interaction-adapter.js +59 -1
  61. package/src/lib/jsonl-session-store.js +2 -0
  62. package/src/lib/memory-injection.js +90 -0
  63. package/src/lib/provider-stream.js +120 -0
  64. package/src/lib/sandbox-v2.js +198 -3
  65. package/src/lib/session-consolidator.js +125 -0
  66. package/src/lib/session-core-singletons.js +56 -0
  67. package/src/lib/session-tail.js +128 -0
  68. package/src/lib/session-usage.js +166 -0
  69. package/src/lib/shell-approval.js +96 -0
  70. package/src/lib/ws-chat-handler.js +3 -0
  71. package/src/repl/agent-repl.js +271 -6
  72. package/src/repl/chat-repl.js +87 -100
  73. package/src/runtime/agent-core.js +98 -15
  74. package/src/runtime/agent-runtime.js +105 -3
  75. package/src/runtime/policies/agent-policy.js +10 -0
  76. package/src/skills/video-editing/SKILL.md +46 -0
  77. package/src/skills/video-editing/beat-snap.js +127 -0
  78. package/src/skills/video-editing/extractors/audio-extractor.js +212 -0
  79. package/src/skills/video-editing/extractors/subtitle-extractor.js +90 -0
  80. package/src/skills/video-editing/extractors/video-extractor.js +137 -0
  81. package/src/skills/video-editing/parallel-orchestrator.js +212 -0
  82. package/src/skills/video-editing/pipeline.js +480 -0
  83. package/src/skills/video-editing/prompts/aesthetic-analysis.md +21 -0
  84. package/src/skills/video-editing/prompts/audio-segment.md +15 -0
  85. package/src/skills/video-editing/prompts/character-identify.md +19 -0
  86. package/src/skills/video-editing/prompts/dense-caption.md +20 -0
  87. package/src/skills/video-editing/prompts/editor-system.md +29 -0
  88. package/src/skills/video-editing/prompts/hook-dialogue.md +17 -0
  89. package/src/skills/video-editing/prompts/protagonist-detect.md +20 -0
  90. package/src/skills/video-editing/prompts/scene-caption.md +16 -0
  91. package/src/skills/video-editing/prompts/shot-caption.md +25 -0
  92. package/src/skills/video-editing/prompts/shot-plan.md +28 -0
  93. package/src/skills/video-editing/prompts/structure-proposal.md +16 -0
  94. package/src/skills/video-editing/prompts/vlog-scene-caption.md +18 -0
  95. package/src/skills/video-editing/render/audio-mix.js +128 -0
  96. package/src/skills/video-editing/render/ffmpeg-concat.js +45 -0
  97. package/src/skills/video-editing/render/ffmpeg-extract.js +67 -0
  98. package/src/skills/video-editing/reviewer.js +161 -0
  99. package/src/skills/video-editing/tools/commit.js +108 -0
  100. package/src/skills/video-editing/tools/review-clip.js +46 -0
  101. package/src/skills/video-editing/tools/semantic-retrieval.js +56 -0
  102. package/src/skills/video-editing/tools/shot-trimming.js +73 -0
  103. package/src/assets/web-panel/assets/Analytics-B4OM8S8X.css +0 -1
  104. package/src/assets/web-panel/assets/Analytics-DgypYeUB.js +0 -3
  105. package/src/assets/web-panel/assets/AppLayout-Bzf3mSZI.js +0 -1
  106. package/src/assets/web-panel/assets/AppLayout-DQyDwGut.css +0 -1
  107. package/src/assets/web-panel/assets/index-CwvzTTw_.js +0 -2
@@ -0,0 +1,212 @@
1
+ /**
2
+ * audio-extractor.js — 音频解构:ASR + beat/能量分析
3
+ *
4
+ * ASR 后端:
5
+ * - litellm (通过 LLM provider 的 ASR 能力)
6
+ * - whisper_cpp (本地)
7
+ * - ffmpeg-subtitle (如果用户已有 .srt 文件直接跳过)
8
+ *
9
+ * Beat 分析:
10
+ * - madmom Python sidecar (Phase 4)
11
+ * - 简化模式:ffmpeg volumedetect + 等间距伪 beat (MVP)
12
+ */
13
+
14
+ import { spawn } from "child_process";
15
+ import { promises as fs } from "fs";
16
+ import path from "path";
17
+
18
+ export async function extractSubtitle(
19
+ audioPath,
20
+ outputDir,
21
+ { existingSrt, llmCall } = {},
22
+ ) {
23
+ const srtPath = path.join(outputDir, "subtitle.srt");
24
+
25
+ if (existingSrt) {
26
+ await fs.copyFile(existingSrt, srtPath);
27
+ return parseSrt(await fs.readFile(srtPath, "utf-8"));
28
+ }
29
+
30
+ if (llmCall) {
31
+ const result = await llmCall({ type: "asr", audioPath });
32
+ if (result && result.srt) {
33
+ await fs.writeFile(srtPath, result.srt);
34
+ return parseSrt(result.srt);
35
+ }
36
+ }
37
+
38
+ return [];
39
+ }
40
+
41
+ export function parseSrt(content) {
42
+ const blocks = content.trim().split(/\n\n+/);
43
+ return blocks
44
+ .map((block) => {
45
+ const lines = block.split("\n");
46
+ if (lines.length < 3) return null;
47
+ const timeMatch = lines[1].match(
48
+ /(\d{2}:\d{2}:\d{2}[,.]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,.]\d{3})/,
49
+ );
50
+ if (!timeMatch) return null;
51
+ return {
52
+ start: srtTimeToSeconds(timeMatch[1]),
53
+ end: srtTimeToSeconds(timeMatch[2]),
54
+ text: lines.slice(2).join(" "),
55
+ };
56
+ })
57
+ .filter(Boolean);
58
+ }
59
+
60
+ function srtTimeToSeconds(t) {
61
+ const [h, m, rest] = t.split(":");
62
+ const [s, ms] = rest.replace(",", ".").split(".");
63
+ return (
64
+ parseInt(h) * 3600 +
65
+ parseInt(m) * 60 +
66
+ parseInt(s) +
67
+ parseInt(ms || 0) / 1000
68
+ );
69
+ }
70
+
71
+ export async function analyzeBeats(
72
+ audioPath,
73
+ outputDir,
74
+ { useMadmom = false } = {},
75
+ ) {
76
+ if (useMadmom) {
77
+ return analyzeBeatsMadmom(audioPath, outputDir);
78
+ }
79
+ return analyzeBeatsSimple(audioPath, outputDir);
80
+ }
81
+
82
+ async function analyzeBeatsSimple(audioPath, outputDir) {
83
+ const duration = await getAudioDuration(audioPath);
84
+ const minSegment = 3.0;
85
+ const segments = [];
86
+ let t = 0;
87
+ let idx = 0;
88
+ while (t < duration) {
89
+ const segEnd = Math.min(t + minSegment + Math.random() * 2, duration);
90
+ segments.push({
91
+ idx: idx++,
92
+ start: parseFloat(t.toFixed(3)),
93
+ end: parseFloat(segEnd.toFixed(3)),
94
+ label: classifySegment(t, duration),
95
+ });
96
+ t = segEnd;
97
+ }
98
+ const beatPath = path.join(outputDir, "audio_beats.json");
99
+ await fs.writeFile(beatPath, JSON.stringify({ duration, segments }, null, 2));
100
+ return { duration, segments };
101
+ }
102
+
103
+ function classifySegment(t, total) {
104
+ const pct = t / total;
105
+ if (pct < 0.1) return "intro";
106
+ if (pct < 0.3) return "build-up";
107
+ if (pct < 0.7) return "chorus";
108
+ if (pct < 0.9) return "bridge";
109
+ return "outro";
110
+ }
111
+
112
+ async function analyzeBeatsMadmom(audioPath, outputDir) {
113
+ return new Promise((resolve, reject) => {
114
+ const thisDir = path.dirname(
115
+ new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1"),
116
+ );
117
+ const script = path.join(
118
+ thisDir,
119
+ "..",
120
+ "..",
121
+ "..",
122
+ "..",
123
+ "scripts",
124
+ "madmom-beats.py",
125
+ );
126
+ const proc = spawn("python", [script, audioPath], {
127
+ stdio: ["ignore", "pipe", "pipe"],
128
+ });
129
+ let stdout = "";
130
+ let stderr = "";
131
+ proc.stdout.on("data", (d) => (stdout += d.toString("utf8")));
132
+ proc.stderr.on("data", (d) => (stderr += d.toString("utf8")));
133
+ proc.on("close", async (code) => {
134
+ if (code !== 0)
135
+ return reject(
136
+ new Error(`madmom sidecar exit ${code}: ${stderr.slice(-300)}`),
137
+ );
138
+ try {
139
+ const data = JSON.parse(stdout);
140
+ if (data.error) return reject(new Error(data.error));
141
+ const beatPath = path.join(outputDir, "audio_beats.json");
142
+ await fs.writeFile(beatPath, JSON.stringify(data, null, 2));
143
+ resolve(data);
144
+ } catch (e) {
145
+ reject(new Error(`madmom output parse failed: ${e.message}`));
146
+ }
147
+ });
148
+ proc.on("error", reject);
149
+ });
150
+ }
151
+
152
+ export async function getAudioDuration(audioPath) {
153
+ return new Promise((resolve, reject) => {
154
+ const args = [
155
+ "-i",
156
+ audioPath,
157
+ "-show_entries",
158
+ "format=duration",
159
+ "-v",
160
+ "quiet",
161
+ "-of",
162
+ "csv=p=0",
163
+ ];
164
+ const proc = spawn("ffprobe", args, { stdio: ["ignore", "pipe", "pipe"] });
165
+ let stdout = "";
166
+ proc.stdout.on("data", (d) => (stdout += d));
167
+ proc.on("close", (code) => {
168
+ if (code !== 0) return reject(new Error("ffprobe failed"));
169
+ resolve(parseFloat(stdout.trim()) || 0);
170
+ });
171
+ proc.on("error", reject);
172
+ });
173
+ }
174
+
175
+ export async function captionAudioSegments(segments, { llmCall } = {}) {
176
+ if (!llmCall) return segments;
177
+ const result = await llmCall({ type: "audio-caption", segments });
178
+ return result || segments;
179
+ }
180
+
181
+ export async function runAudioExtractor(audioPath, outputDir, options = {}) {
182
+ await fs.mkdir(outputDir, { recursive: true });
183
+
184
+ const subtitles = await extractSubtitle(audioPath, outputDir, {
185
+ existingSrt: options.existingSrt,
186
+ llmCall: options.llmCall,
187
+ });
188
+
189
+ const beats = await analyzeBeats(audioPath, outputDir, {
190
+ useMadmom: options.useMadmom,
191
+ });
192
+
193
+ const captionedSegments = await captionAudioSegments(beats.segments, {
194
+ llmCall: options.llmCall,
195
+ });
196
+
197
+ const captionPath = path.join(outputDir, "audio_caption.json");
198
+ await fs.writeFile(
199
+ captionPath,
200
+ JSON.stringify(
201
+ {
202
+ duration: beats.duration,
203
+ segments: captionedSegments,
204
+ subtitles,
205
+ },
206
+ null,
207
+ 2,
208
+ ),
209
+ );
210
+
211
+ return { subtitles, beats, captionPath };
212
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * subtitle-extractor.js — 独立 SRT/VTT 字幕提取 + 角色识别
3
+ */
4
+
5
+ import { promises as fs } from "fs";
6
+ import path from "path";
7
+ import { parseSrt } from "./audio-extractor.js";
8
+
9
+ export async function loadSubtitle(filePath) {
10
+ const content = await fs.readFile(filePath, "utf-8");
11
+ const ext = path.extname(filePath).toLowerCase();
12
+
13
+ if (ext === ".srt") return parseSrt(content);
14
+ if (ext === ".vtt") return parseVtt(content);
15
+
16
+ throw new Error(`Unsupported subtitle format: ${ext}`);
17
+ }
18
+
19
+ function parseVtt(content) {
20
+ const lines = content.replace(/\r\n/g, "\n").split("\n");
21
+ const entries = [];
22
+ let i = 0;
23
+ while (i < lines.length) {
24
+ if (lines[i].includes("-->")) {
25
+ const timeMatch = lines[i].match(
26
+ /(\d{2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}\.\d{3})/,
27
+ );
28
+ if (timeMatch) {
29
+ const textLines = [];
30
+ i++;
31
+ while (i < lines.length && lines[i].trim() !== "") {
32
+ textLines.push(lines[i].trim());
33
+ i++;
34
+ }
35
+ entries.push({
36
+ start: vttTimeToSeconds(timeMatch[1]),
37
+ end: vttTimeToSeconds(timeMatch[2]),
38
+ text: textLines.join(" "),
39
+ });
40
+ }
41
+ }
42
+ i++;
43
+ }
44
+ return entries;
45
+ }
46
+
47
+ function vttTimeToSeconds(t) {
48
+ const [h, m, s] = t.split(":");
49
+ return parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s);
50
+ }
51
+
52
+ export async function identifyCharacters(subtitles, { llmCall } = {}) {
53
+ if (!llmCall || subtitles.length === 0) {
54
+ return { speakers: {} };
55
+ }
56
+
57
+ const grouped = {};
58
+ for (const entry of subtitles) {
59
+ const speakerMatch = entry.text.match(/^\[([^\]]+)\]\s*/);
60
+ const speaker = speakerMatch ? speakerMatch[1] : "UNKNOWN";
61
+ if (!grouped[speaker]) grouped[speaker] = [];
62
+ grouped[speaker].push(entry.text.replace(/^\[[^\]]+\]\s*/, ""));
63
+ }
64
+
65
+ const result = await llmCall({
66
+ type: "character-identify",
67
+ speakers: grouped,
68
+ });
69
+
70
+ return result || { speakers: {} };
71
+ }
72
+
73
+ export async function runSubtitleExtractor(
74
+ subtitlePath,
75
+ outputDir,
76
+ options = {},
77
+ ) {
78
+ const subtitles = await loadSubtitle(subtitlePath);
79
+ const characters = await identifyCharacters(subtitles, {
80
+ llmCall: options.llmCall,
81
+ });
82
+
83
+ const outputPath = path.join(outputDir, "subtitle_analysis.json");
84
+ await fs.writeFile(
85
+ outputPath,
86
+ JSON.stringify({ subtitles, characters }, null, 2),
87
+ );
88
+
89
+ return { subtitles, characters, outputPath };
90
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * video-extractor.js — 视频解构:ffmpeg 抽帧 + VLM caption
3
+ *
4
+ * 输出:
5
+ * - frames/ — 抽帧 png (按 VIDEO_FPS)
6
+ * - scene.json — 场景切分 + caption
7
+ */
8
+
9
+ import { spawn } from "child_process";
10
+ import { promises as fs } from "fs";
11
+ import path from "path";
12
+
13
+ const DEFAULT_FPS = 2;
14
+
15
+ export async function extractFrames(
16
+ videoPath,
17
+ outputDir,
18
+ { fps = DEFAULT_FPS } = {},
19
+ ) {
20
+ const framesDir = path.join(outputDir, "frames");
21
+ await fs.mkdir(framesDir, { recursive: true });
22
+
23
+ return new Promise((resolve, reject) => {
24
+ const args = [
25
+ "-i",
26
+ videoPath,
27
+ "-vf",
28
+ `fps=${fps}`,
29
+ "-q:v",
30
+ "2",
31
+ path.join(framesDir, "frame_%06d.png"),
32
+ ];
33
+ const proc = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
34
+ let stderr = "";
35
+ proc.stderr.on("data", (d) => (stderr += d));
36
+ proc.on("close", (code) => {
37
+ if (code !== 0)
38
+ return reject(new Error(`ffmpeg exit ${code}: ${stderr.slice(-500)}`));
39
+ resolve(framesDir);
40
+ });
41
+ proc.on("error", reject);
42
+ });
43
+ }
44
+
45
+ export async function detectScenes(
46
+ videoPath,
47
+ outputDir,
48
+ { threshold = 0.3 } = {},
49
+ ) {
50
+ return new Promise((resolve, reject) => {
51
+ const args = [
52
+ "-i",
53
+ videoPath,
54
+ "-vf",
55
+ `select='gt(scene,${threshold})',showinfo`,
56
+ "-vsync",
57
+ "vfr",
58
+ "-f",
59
+ "null",
60
+ "-",
61
+ ];
62
+ const proc = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
63
+ let stderr = "";
64
+ proc.stderr.on("data", (d) => (stderr += d));
65
+ proc.on("close", async (code) => {
66
+ if (code !== 0) return reject(new Error(`scene detect exit ${code}`));
67
+ const timestamps = parseSceneTimestamps(stderr);
68
+ const scenePath = path.join(outputDir, "scene.json");
69
+ await fs.writeFile(
70
+ scenePath,
71
+ JSON.stringify({ scenes: timestamps }, null, 2),
72
+ );
73
+ resolve(timestamps);
74
+ });
75
+ proc.on("error", reject);
76
+ });
77
+ }
78
+
79
+ function parseSceneTimestamps(ffmpegStderr) {
80
+ const scenes = [];
81
+ const regex = /pts_time:(\d+\.?\d*)/g;
82
+ let m;
83
+ while ((m = regex.exec(ffmpegStderr)) !== null) {
84
+ scenes.push({ time: parseFloat(m[1]) });
85
+ }
86
+ return scenes;
87
+ }
88
+
89
+ export async function captionFrames(
90
+ framesDir,
91
+ { llmCall, batchSize = 32 } = {},
92
+ ) {
93
+ const files = (await fs.readdir(framesDir))
94
+ .filter((f) => f.endsWith(".png"))
95
+ .sort();
96
+
97
+ const captions = [];
98
+ for (let i = 0; i < files.length; i += batchSize) {
99
+ const batch = files.slice(i, i + batchSize);
100
+ const framePaths = batch.map((f) => path.join(framesDir, f));
101
+
102
+ if (llmCall) {
103
+ const result = await llmCall({
104
+ type: "scene-caption",
105
+ frames: framePaths,
106
+ frameIndices: batch.map((_, j) => i + j),
107
+ });
108
+ captions.push(...(Array.isArray(result) ? result : [result]));
109
+ } else {
110
+ for (const f of batch) {
111
+ captions.push({ frame: f, caption: null });
112
+ }
113
+ }
114
+ }
115
+ return captions;
116
+ }
117
+
118
+ export async function runVideoExtractor(videoPath, outputDir, options = {}) {
119
+ await fs.mkdir(outputDir, { recursive: true });
120
+
121
+ const fps = options.fps || DEFAULT_FPS;
122
+ const framesDir = await extractFrames(videoPath, outputDir, { fps });
123
+
124
+ const scenes = await detectScenes(videoPath, outputDir, {
125
+ threshold: options.sceneThreshold,
126
+ });
127
+
128
+ const captions = await captionFrames(framesDir, {
129
+ llmCall: options.llmCall,
130
+ batchSize: options.batchSize,
131
+ });
132
+
133
+ const captionPath = path.join(outputDir, "video_caption.json");
134
+ await fs.writeFile(captionPath, JSON.stringify({ fps, captions }, null, 2));
135
+
136
+ return { framesDir, scenes, captions, captionPath };
137
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * parallel-orchestrator.js — Section 级并行 Editor + 冲突检测 + 重跑
3
+ *
4
+ * 移植自 CutClaw ParallelShotOrchestrator:
5
+ * 1. 每个 section 并行 spawn editor 循环
6
+ * 2. 合并后检测时间区间冲突
7
+ * 3. 质量打分: 0.6 * protagonist_ratio + 0.4 * duration_accuracy
8
+ * 4. 输者重跑,注入 forbidden_time_ranges
9
+ */
10
+
11
+ import { EventEmitter } from "events";
12
+
13
+ export class ParallelShotOrchestrator extends EventEmitter {
14
+ constructor(options = {}) {
15
+ super();
16
+ this.maxConcurrency = options.maxConcurrency || 4;
17
+ this.maxReruns = options.maxReruns || 3;
18
+ this.qualityWeights = options.qualityWeights || {
19
+ protagonist: 0.6,
20
+ duration: 0.4,
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Run editor loops in parallel by section, resolve conflicts, rerun losers.
26
+ *
27
+ * @param {object[]} sections - shot_plan.sections
28
+ * @param {Function} runShotFn - async (section, shot, context) => result
29
+ * @param {object} context - shared context (assetDir, llmCall, etc.)
30
+ * @returns {object[]} merged shot_point entries
31
+ */
32
+ async run(sections, runShotFn, context) {
33
+ const allResults = [];
34
+
35
+ for (let i = 0; i < sections.length; i += this.maxConcurrency) {
36
+ const batch = sections.slice(i, i + this.maxConcurrency);
37
+ this.emit("event", {
38
+ type: "parallel.batch_start",
39
+ batchIdx: Math.floor(i / this.maxConcurrency),
40
+ sectionCount: batch.length,
41
+ ts: Date.now(),
42
+ });
43
+
44
+ const batchResults = await Promise.all(
45
+ batch.map((section) => this._runSection(section, runShotFn, context)),
46
+ );
47
+
48
+ allResults.push(...batchResults.flat());
49
+ }
50
+
51
+ const resolved = await this._resolveConflicts(
52
+ allResults,
53
+ runShotFn,
54
+ context,
55
+ );
56
+ return resolved;
57
+ }
58
+
59
+ async _runSection(section, runShotFn, context) {
60
+ const results = [];
61
+ for (const shot of section.shots || []) {
62
+ try {
63
+ const result = await runShotFn(section, shot, {
64
+ ...context,
65
+ forbiddenTimeRanges: [],
66
+ });
67
+ if (result) {
68
+ results.push({
69
+ ...result,
70
+ section_idx: section.section_idx,
71
+ shot_idx: shot.shot_idx,
72
+ target_duration: shot.target_duration || 3,
73
+ });
74
+ }
75
+ } catch (err) {
76
+ this.emit("event", {
77
+ type: "parallel.shot_error",
78
+ section_idx: section.section_idx,
79
+ shot_idx: shot.shot_idx,
80
+ error: err.message,
81
+ ts: Date.now(),
82
+ });
83
+ }
84
+ }
85
+ return results;
86
+ }
87
+
88
+ async _resolveConflicts(results, runShotFn, context) {
89
+ let resolved = [...results];
90
+
91
+ for (let round = 0; round < this.maxReruns; round++) {
92
+ const conflicts = detectConflicts(resolved);
93
+ if (conflicts.length === 0) break;
94
+
95
+ this.emit("event", {
96
+ type: "parallel.conflict_detected",
97
+ round,
98
+ conflictCount: conflicts.length,
99
+ ts: Date.now(),
100
+ });
101
+
102
+ const toRerun = [];
103
+ for (const [a, b] of conflicts) {
104
+ const scoreA = this._qualityScore(a);
105
+ const scoreB = this._qualityScore(b);
106
+
107
+ const loser = scoreA >= scoreB ? b : a;
108
+ const winner = scoreA >= scoreB ? a : b;
109
+
110
+ this.emit("event", {
111
+ type: "parallel.conflict_resolved",
112
+ winner: {
113
+ section: winner.section_idx,
114
+ shot: winner.shot_idx,
115
+ score: Math.max(scoreA, scoreB),
116
+ },
117
+ loser: {
118
+ section: loser.section_idx,
119
+ shot: loser.shot_idx,
120
+ score: Math.min(scoreA, scoreB),
121
+ },
122
+ ts: Date.now(),
123
+ });
124
+
125
+ toRerun.push({
126
+ entry: loser,
127
+ forbiddenTimeRanges: this._extractTimeRanges(winner),
128
+ });
129
+
130
+ resolved = resolved.filter((r) => r !== loser);
131
+ }
132
+
133
+ for (const { entry, forbiddenTimeRanges } of toRerun) {
134
+ const section = {
135
+ section_idx: entry.section_idx,
136
+ shots: [
137
+ {
138
+ shot_idx: entry.shot_idx,
139
+ target_duration: entry.target_duration,
140
+ },
141
+ ],
142
+ music_segment: entry.music_segment,
143
+ };
144
+ try {
145
+ const rerunResult = await this._runSection(section, runShotFn, {
146
+ ...context,
147
+ forbiddenTimeRanges,
148
+ });
149
+ resolved.push(...rerunResult);
150
+ } catch {
151
+ // skip failed reruns
152
+ }
153
+ }
154
+ }
155
+
156
+ return resolved.sort(
157
+ (a, b) => a.section_idx - b.section_idx || a.shot_idx - b.shot_idx,
158
+ );
159
+ }
160
+
161
+ _qualityScore(entry) {
162
+ const protagonist = entry.protagonist_ratio ?? 0.5;
163
+ const targetDur = entry.target_duration || 3;
164
+ const actualDur = entry.total_duration || 0;
165
+ const durAccuracy =
166
+ targetDur > 0
167
+ ? 1 - Math.min(Math.abs(actualDur - targetDur) / targetDur, 1)
168
+ : 0.5;
169
+
170
+ return (
171
+ this.qualityWeights.protagonist * protagonist +
172
+ this.qualityWeights.duration * durAccuracy
173
+ );
174
+ }
175
+
176
+ _extractTimeRanges(entry) {
177
+ return (entry.clips || []).map((c) => ({
178
+ start: c.start,
179
+ end: c.end,
180
+ }));
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Detect time-range overlaps between shot_point entries.
186
+ * Returns pairs [a, b] where a and b have overlapping clips.
187
+ */
188
+ export function detectConflicts(results) {
189
+ const conflicts = [];
190
+ const allClips = results.map((r) => ({
191
+ entry: r,
192
+ ranges: (r.clips || []).map((c) => [c.start, c.end]),
193
+ }));
194
+
195
+ for (let i = 0; i < allClips.length; i++) {
196
+ for (let j = i + 1; j < allClips.length; j++) {
197
+ if (rangesOverlap(allClips[i].ranges, allClips[j].ranges)) {
198
+ conflicts.push([allClips[i].entry, allClips[j].entry]);
199
+ }
200
+ }
201
+ }
202
+ return conflicts;
203
+ }
204
+
205
+ function rangesOverlap(rangesA, rangesB) {
206
+ for (const [aStart, aEnd] of rangesA) {
207
+ for (const [bStart, bEnd] of rangesB) {
208
+ if (aStart < bEnd && aEnd > bStart) return true;
209
+ }
210
+ }
211
+ return false;
212
+ }