@lingjingai/scriptctl 0.25.2 → 0.25.4

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.
@@ -0,0 +1,801 @@
1
+ import * as path from "node:path";
2
+ import { CliError, DEFAULT_CONCURRENCY, EXIT_NEEDS_AGENT, EXIT_OK, EXIT_RUNTIME, EXIT_USAGE, directDir, sha256Text, } from "../../common.js";
3
+ import { fileCheckpointer, llm, loom, } from "../../domain/loom/index.js";
4
+ import { asList, isDict, strOf, } from "../../domain/script/shared.js";
5
+ import { makeProvider } from "../../infra/providers.js";
6
+ import { loadScriptForEdit, saveScriptSession, syncValidationResult, validateSession, } from "./session.js";
7
+ import { summarizeIssues } from "../direct.js";
8
+ export const DEFAULT_SYNOPSIS_GENERATE_CONFIG = {
9
+ episodeInputMaxChars: 6000,
10
+ episodeChunkTargetChars: 4800,
11
+ arcEpisodeCount: 10,
12
+ finalMaxInputChars: 12000,
13
+ };
14
+ export const DEFAULT_SYNOPSIS_PROVIDER = "openai";
15
+ export const DEFAULT_SYNOPSIS_MODEL = "deepseek-v4-pro-packy";
16
+ const CHUNK_INSTRUCTION = "从当前剧本片段中提取剧情梗概和关键事件。只基于当前片段,不要推测后续。";
17
+ export const CHUNK_FORMAT = [
18
+ "输出格式:一个纯文本结构化文档。不要包裹代码块,不要在文档前后添加解释。",
19
+ "",
20
+ "## 片段梗概",
21
+ "<如果当前集只有一个片段,写 200-300 字;如果当前集被切成多个片段,写 80-150 字。按发生顺序概括,只写当前片段。>",
22
+ "",
23
+ "## 关键事件",
24
+ "- <事件1>",
25
+ "- <事件2>",
26
+ "",
27
+ "## 人物变化",
28
+ "- <人物名> | <变化>",
29
+ "",
30
+ "## 结尾钩子",
31
+ "<有则写一句;没有则留空>",
32
+ ].join("\n");
33
+ const EPISODE_INSTRUCTION = "把同一集的多个片段梗概合并为一集梗概。只使用输入片段,不要新增剧情。";
34
+ export const EPISODE_FORMAT = [
35
+ "输出格式:一个纯文本结构化文档。不要包裹代码块,不要在文档前后添加解释。",
36
+ "",
37
+ "## 分集梗概",
38
+ "<200-300 字,按剧情发生顺序写成连贯段落。>",
39
+ "",
40
+ "## 单句梗概",
41
+ "<30-50 字,概括本集主线。>",
42
+ "",
43
+ "## 关键事件",
44
+ "- <事件1>",
45
+ "- <事件2>",
46
+ ].join("\n");
47
+ const ARC_INSTRUCTION = "把一组连续分集梗概压缩为阶段梗概。只使用输入分集,不要新增剧情。";
48
+ export const ARC_FORMAT = [
49
+ "输出格式:一个纯文本结构化文档。不要包裹代码块,不要在文档前后添加解释。",
50
+ "",
51
+ "## 阶段梗概",
52
+ "<400-600 字,概括这一阶段的主线推进。>",
53
+ "",
54
+ "## 关键转折",
55
+ "- <转折1>",
56
+ "- <转折2>",
57
+ "",
58
+ "## 未解决悬念",
59
+ "- <悬念1>",
60
+ ].join("\n");
61
+ const FINAL_INSTRUCTION = "根据分集索引和阶段梗概生成全剧总览。只使用输入内容,不要新增剧情、人物或关系。";
62
+ export const FINAL_FORMAT = [
63
+ "输出格式:一个纯文本结构化文档。不要包裹代码块,不要在文档前后添加解释。",
64
+ "",
65
+ "## 全文梗概",
66
+ "<800-1200 字,覆盖全剧主线、关键关系、主要冲突和结局走向。>",
67
+ "",
68
+ "## 主题",
69
+ "<一句话或一个短语>",
70
+ "",
71
+ "## 一句话",
72
+ "<一句完整 logline>",
73
+ "",
74
+ "## 风格",
75
+ "<类型 / 气质 / 叙事风格>",
76
+ "",
77
+ "## 主要人物",
78
+ "- <名字> | <角色定位>",
79
+ ].join("\n");
80
+ export function renderEpisodeSource(script, episode, index) {
81
+ const episodeId = strOf(episode["episode_id"]).trim() || `ep_${String(index + 1).padStart(3, "0")}`;
82
+ const episodeNo = episodeNumber(episodeId, index);
83
+ const title = strOf(episode["title"]).trim();
84
+ const lines = [];
85
+ const scriptTitle = strOf(script["title"]).trim();
86
+ if (scriptTitle)
87
+ lines.push(`剧名:${scriptTitle}`);
88
+ lines.push(`集数:第${episodeNo}集`);
89
+ if (title)
90
+ lines.push(`标题:${title}`);
91
+ lines.push("");
92
+ for (const scene of asList(episode["scenes"])) {
93
+ lines.push(renderSceneHeader(script, scene));
94
+ for (const action of asList(scene["actions"])) {
95
+ const rendered = renderAction(script, action);
96
+ if (rendered)
97
+ lines.push(rendered);
98
+ }
99
+ lines.push("");
100
+ }
101
+ return {
102
+ episodeIndex: index,
103
+ episodeId,
104
+ episodeNo,
105
+ title,
106
+ existingSynopsis: strOf(episode["synopsis"]).trim(),
107
+ text: lines.join("\n").trim(),
108
+ };
109
+ }
110
+ export function splitEpisodeSource(source, config = DEFAULT_SYNOPSIS_GENERATE_CONFIG) {
111
+ const rawLines = source.text.split(/\r?\n/);
112
+ const lines = rawLines.flatMap((line) => splitLongLine(line, config.episodeChunkTargetChars));
113
+ if (source.text.length <= config.episodeInputMaxChars && lines.length === rawLines.length) {
114
+ return [{
115
+ episodeIndex: source.episodeIndex,
116
+ episodeId: source.episodeId,
117
+ episodeNo: source.episodeNo,
118
+ title: source.title,
119
+ part: 1,
120
+ partCount: 1,
121
+ text: source.text,
122
+ sourceHash: sha256Text(source.text),
123
+ }];
124
+ }
125
+ const chunks = [];
126
+ let current = [];
127
+ let currentChars = 0;
128
+ for (const line of lines) {
129
+ const lineChars = line.length + (current.length > 0 ? 1 : 0);
130
+ if (current.length > 0 && currentChars + lineChars > config.episodeChunkTargetChars) {
131
+ chunks.push(current.join("\n").trim());
132
+ current = [];
133
+ currentChars = 0;
134
+ }
135
+ const nextLineChars = line.length + (current.length > 0 ? 1 : 0);
136
+ current.push(line);
137
+ currentChars += nextLineChars;
138
+ }
139
+ if (current.length > 0)
140
+ chunks.push(current.join("\n").trim());
141
+ return chunks.map((text, idx) => ({
142
+ episodeIndex: source.episodeIndex,
143
+ episodeId: source.episodeId,
144
+ episodeNo: source.episodeNo,
145
+ title: source.title,
146
+ part: idx + 1,
147
+ partCount: chunks.length,
148
+ text,
149
+ sourceHash: sha256Text(text),
150
+ }));
151
+ }
152
+ export function parseChunkBrief(text, chunk) {
153
+ const sections = sectionMap(text);
154
+ return {
155
+ episodeIndex: chunk.episodeIndex,
156
+ episodeId: chunk.episodeId,
157
+ episodeNo: chunk.episodeNo,
158
+ title: chunk.title,
159
+ part: chunk.part,
160
+ partCount: chunk.partCount,
161
+ synopsis: requiredSection(sections, "片段梗概"),
162
+ events: parseBullets(sections.get("关键事件") ?? ""),
163
+ characterChanges: parseCharacterChanges(sections.get("人物变化") ?? ""),
164
+ hook: (sections.get("结尾钩子") ?? "").trim(),
165
+ };
166
+ }
167
+ export function reduceChunkBriefs(items, ctx) {
168
+ const source = requireScope(ctx.scope, "episode");
169
+ const ordered = [...items].sort((a, b) => a.part - b.part);
170
+ return {
171
+ episodeIndex: source.episodeIndex,
172
+ episodeId: source.episodeId,
173
+ episodeNo: source.episodeNo,
174
+ title: source.title,
175
+ synopsis: ordered.map((item) => item.synopsis).filter((s) => s.trim()).join("\n"),
176
+ oneLine: firstNonEmpty(ordered.map((item) => item.synopsis)),
177
+ events: uniqueStrings(ordered.flatMap((item) => item.events)),
178
+ };
179
+ }
180
+ export function parseEpisodeBrief(text, source) {
181
+ const sections = sectionMap(text);
182
+ return {
183
+ episodeIndex: source.episodeIndex,
184
+ episodeId: source.episodeId,
185
+ episodeNo: source.episodeNo,
186
+ title: source.title,
187
+ synopsis: requiredSection(sections, "分集梗概"),
188
+ oneLine: requiredSection(sections, "单句梗概"),
189
+ events: parseBullets(sections.get("关键事件") ?? ""),
190
+ };
191
+ }
192
+ export function buildArcParcels(episodes, arcEpisodeCount) {
193
+ const parcels = [];
194
+ for (let i = 0; i < episodes.length; i += arcEpisodeCount) {
195
+ const group = episodes.slice(i, i + arcEpisodeCount);
196
+ const first = group[0];
197
+ const last = group[group.length - 1];
198
+ parcels.push({
199
+ index: parcels.length + 1,
200
+ startEpisodeNo: first.episodeNo,
201
+ endEpisodeNo: last.episodeNo,
202
+ text: renderEpisodeBriefs(group),
203
+ });
204
+ }
205
+ return parcels;
206
+ }
207
+ export function buildArcParcelsFromArcs(arcs, fanout) {
208
+ const parcels = [];
209
+ for (let i = 0; i < arcs.length; i += fanout) {
210
+ const group = arcs.slice(i, i + fanout);
211
+ const first = group[0];
212
+ const last = group[group.length - 1];
213
+ parcels.push({
214
+ index: parcels.length + 1,
215
+ startEpisodeNo: first.startEpisodeNo,
216
+ endEpisodeNo: last.endEpisodeNo,
217
+ text: renderArcBriefs(group),
218
+ });
219
+ }
220
+ return parcels;
221
+ }
222
+ export function parseArcBrief(text, parcel) {
223
+ const sections = sectionMap(text);
224
+ return {
225
+ index: parcel.index,
226
+ startEpisodeNo: parcel.startEpisodeNo,
227
+ endEpisodeNo: parcel.endEpisodeNo,
228
+ synopsis: requiredSection(sections, "阶段梗概"),
229
+ turns: parseBullets(sections.get("关键转折") ?? ""),
230
+ openQuestions: parseBullets(sections.get("未解决悬念") ?? ""),
231
+ };
232
+ }
233
+ export function reduceArcBriefs(items) {
234
+ const ordered = [...items].sort((a, b) => a.startEpisodeNo - b.startEpisodeNo);
235
+ const first = ordered[0];
236
+ const last = ordered[ordered.length - 1];
237
+ if (!first || !last)
238
+ throw new Error("arc reduce requires at least one item");
239
+ return {
240
+ index: first.index,
241
+ startEpisodeNo: first.startEpisodeNo,
242
+ endEpisodeNo: last.endEpisodeNo,
243
+ synopsis: ordered.map((item) => `第${item.startEpisodeNo}-${item.endEpisodeNo}集:${item.synopsis}`).join("\n"),
244
+ turns: uniqueStrings(ordered.flatMap((item) => item.turns)),
245
+ openQuestions: uniqueStrings(ordered.flatMap((item) => item.openQuestions)),
246
+ };
247
+ }
248
+ export function renderFinalInput(episodes, arcs) {
249
+ const lines = ["分集索引:"];
250
+ for (const ep of [...episodes].sort((a, b) => a.episodeNo - b.episodeNo)) {
251
+ lines.push(`第${ep.episodeNo}集:${ep.oneLine || ep.synopsis}`);
252
+ }
253
+ lines.push("", "阶段梗概:");
254
+ for (const arc of [...arcs].sort((a, b) => a.startEpisodeNo - b.startEpisodeNo)) {
255
+ lines.push(`第${arc.startEpisodeNo}-${arc.endEpisodeNo}集:${arc.synopsis}`);
256
+ }
257
+ return lines.join("\n");
258
+ }
259
+ export function parseScriptOverview(text) {
260
+ const sections = sectionMap(text);
261
+ return {
262
+ synopsis: requiredSection(sections, "全文梗概"),
263
+ theme: requiredSection(sections, "主题"),
264
+ logline: requiredSection(sections, "一句话"),
265
+ style: requiredSection(sections, "风格"),
266
+ mainCharacters: parseMainCharacters(requiredSection(sections, "主要人物")),
267
+ };
268
+ }
269
+ export async function generateSynopsisFromScript(opts) {
270
+ const config = opts.config ?? DEFAULT_SYNOPSIS_GENERATE_CONFIG;
271
+ const episodes = asList(opts.script["episodes"]);
272
+ if (episodes.length === 0) {
273
+ throw new CliError("SYNOPSIS GENERATE BLOCKED: episodes missing", "No episodes found in script.", {
274
+ exitCode: EXIT_USAGE,
275
+ required: ["script.episodes[] with at least one episode"],
276
+ received: ["episodes: 0"],
277
+ nextSteps: ["Create or parse episodes before generating synopsis."],
278
+ errorCode: "SCRIPT_EPISODES_MISSING",
279
+ });
280
+ }
281
+ const allSources = episodes.map((episode, index) => renderEpisodeSource(opts.script, episode, index));
282
+ const sources = opts.overwrite ? allSources : allSources.filter((source) => !source.existingSynopsis);
283
+ const episodeRun = sources.length === 0
284
+ ? []
285
+ : await loom(opts.script)
286
+ .split({
287
+ scope: "episode",
288
+ partition: () => sources,
289
+ chunk: (source) => splitEpisodeSource(source, config),
290
+ })
291
+ .extract(llm({
292
+ instruction: CHUNK_INSTRUCTION,
293
+ format: CHUNK_FORMAT,
294
+ input: ({ parcel }) => renderChunkInput(parcel),
295
+ parse: ({ text, parcel }) => parseChunkBrief(text, parcel),
296
+ }))
297
+ .reduce("episode", reduceChunkBriefs)
298
+ .extract(llm({
299
+ instruction: EPISODE_INSTRUCTION,
300
+ format: EPISODE_FORMAT,
301
+ input: ({ parcel }) => renderEpisodeReduceInput(parcel),
302
+ parse: ({ text, parcel }) => parseEpisodeBrief(text, parcel),
303
+ }))
304
+ .reduce("root", (items) => [...items].sort((a, b) => a.episodeNo - b.episodeNo))
305
+ .run({
306
+ concurrency: opts.concurrency,
307
+ checkpoint: fileCheckpointer(path.join(opts.checkpointDir, "episode")),
308
+ checkpointSalt: opts.checkpointSalt,
309
+ model: opts.model,
310
+ });
311
+ const generatedEpisodeBriefs = Array.isArray(episodeRun) ? episodeRun : episodeRun.value;
312
+ const byIndex = new Map(generatedEpisodeBriefs.map((brief) => [brief.episodeIndex, brief]));
313
+ const episodeBriefs = allSources.map((source) => byIndex.get(source.episodeIndex) ?? {
314
+ episodeIndex: source.episodeIndex,
315
+ episodeId: source.episodeId,
316
+ episodeNo: source.episodeNo,
317
+ title: source.title,
318
+ synopsis: source.existingSynopsis,
319
+ oneLine: synopsisPreviewLine(source.existingSynopsis),
320
+ events: [],
321
+ });
322
+ const missing = episodeBriefs.filter((brief) => !brief.synopsis.trim()).map((brief) => brief.episodeId);
323
+ if (missing.length > 0) {
324
+ throw new CliError("SYNOPSIS GENERATE BLOCKED: episode synopsis missing", "Some episode synopses are still missing.", {
325
+ exitCode: EXIT_RUNTIME,
326
+ required: ["every episode has synopsis"],
327
+ received: missing,
328
+ nextSteps: ["Use --overwrite to regenerate, or manually set missing episode synopses."],
329
+ errorCode: "EPISODE_SYNOPSIS_MISSING",
330
+ });
331
+ }
332
+ const overviewNeeded = opts.overwrite || scriptOverviewMissing(opts.script);
333
+ const arcRun = overviewNeeded
334
+ ? await generateArcBriefs({
335
+ episodes: episodeBriefs,
336
+ model: opts.model,
337
+ checkpointDir: path.join(opts.checkpointDir, "arc"),
338
+ checkpointSalt: opts.checkpointSalt,
339
+ concurrency: opts.concurrency,
340
+ config,
341
+ })
342
+ : { value: [], report: { reused: 0, ran: 0 } };
343
+ const overviewRun = overviewNeeded
344
+ ? await generateScriptOverview({
345
+ episodes: episodeBriefs,
346
+ arcs: arcRun.value,
347
+ model: opts.model,
348
+ checkpointDir: path.join(opts.checkpointDir, "final"),
349
+ checkpointSalt: opts.checkpointSalt,
350
+ config,
351
+ })
352
+ : { value: existingScriptOverview(opts.script), report: { reused: 0, ran: 0 } };
353
+ return {
354
+ episodeBriefs,
355
+ overview: overviewRun.value,
356
+ overviewGenerated: overviewNeeded,
357
+ report: {
358
+ reused: (Array.isArray(episodeRun) ? 0 : episodeRun.report.reused) + arcRun.report.reused + overviewRun.report.reused,
359
+ ran: (Array.isArray(episodeRun) ? 0 : episodeRun.report.ran) + arcRun.report.ran + overviewRun.report.ran,
360
+ },
361
+ };
362
+ }
363
+ export async function commandSynopsisGenerate(opts) {
364
+ const providerName = strOf(opts["provider"] || DEFAULT_SYNOPSIS_PROVIDER);
365
+ const modelName = strOf(opts["model"] || DEFAULT_SYNOPSIS_MODEL);
366
+ const concurrency = parsePositiveInt(opts["concurrency"], DEFAULT_CONCURRENCY, "--concurrency");
367
+ const overwrite = Boolean(opts["overwrite"]);
368
+ const apply = Boolean(opts["apply"]);
369
+ const dryRun = Boolean(opts["dry_run"]);
370
+ if (apply && dryRun) {
371
+ throw new CliError("SYNOPSIS GENERATE BLOCKED: flags mutually exclusive", "--apply and --dry-run are mutually exclusive.", {
372
+ exitCode: EXIT_USAGE,
373
+ required: ["at most one of --apply / --dry-run"],
374
+ received: ["--apply", "--dry-run"],
375
+ nextSteps: ["Pick one mode and retry."],
376
+ errorCode: "SYNOPSIS_GENERATE_FLAG_CONFLICT",
377
+ });
378
+ }
379
+ const workspace = strOf(opts["workspace_path"] || "workspace");
380
+ const session = await loadScriptForEdit(opts);
381
+ const provider = makeProvider(providerName, modelName);
382
+ if (!provider.completeText) {
383
+ throw new CliError("SYNOPSIS GENERATE BLOCKED: provider lacks text completion", "Provider does not support text completion.", {
384
+ exitCode: EXIT_USAGE,
385
+ required: ["provider.completeText"],
386
+ received: [`--provider ${providerName}`],
387
+ nextSteps: ["Use --provider openai, --provider anthropic, or --provider mock."],
388
+ errorCode: "PROVIDER_TEXT_UNSUPPORTED",
389
+ });
390
+ }
391
+ const completeText = provider.completeText.bind(provider);
392
+ const checkpoint = strOf(opts["checkpoint"]).trim() || path.join(directDir(workspace), "loom", "synopsis-generate");
393
+ const result = await generateSynopsisFromScript({
394
+ script: session.script,
395
+ model: (request) => completeText(request),
396
+ checkpointDir: checkpoint,
397
+ checkpointSalt: { provider: providerName, model: modelName },
398
+ concurrency,
399
+ overwrite,
400
+ });
401
+ const changedEpisodeIds = new Set();
402
+ const scriptEpisodes = asList(session.script["episodes"]);
403
+ for (const brief of result.episodeBriefs) {
404
+ const episode = scriptEpisodes[brief.episodeIndex];
405
+ if (episode && (overwrite || !strOf(episode["synopsis"]).trim())) {
406
+ if (strOf(episode["synopsis"]) !== brief.synopsis) {
407
+ episode["synopsis"] = brief.synopsis;
408
+ changedEpisodeIds.add(brief.episodeId);
409
+ }
410
+ }
411
+ }
412
+ let overviewChanged = false;
413
+ overviewChanged = setTextField(session.script, "synopsis", result.overview.synopsis, overwrite) || overviewChanged;
414
+ overviewChanged = setTextField(session.script, "theme", result.overview.theme, overwrite) || overviewChanged;
415
+ overviewChanged = setTextField(session.script, "logline", result.overview.logline, overwrite) || overviewChanged;
416
+ overviewChanged = setTextField(session.script, "style", result.overview.style, overwrite) || overviewChanged;
417
+ if (overwrite || asList(session.script["main_characters"]).length === 0) {
418
+ const nextMainCharacters = result.overview.mainCharacters.map((item) => ({ name: item.name, role: item.role }));
419
+ if (JSON.stringify(session.script["main_characters"] ?? []) !== JSON.stringify(nextMainCharacters)) {
420
+ session.script["main_characters"] = nextMainCharacters;
421
+ overviewChanged = true;
422
+ }
423
+ }
424
+ const hasChanges = changedEpisodeIds.size > 0 || overviewChanged;
425
+ const validation = validateSession(session);
426
+ const passed = Boolean(validation["passed"]);
427
+ const resultLines = [
428
+ `episodes: ${result.episodeBriefs.length}`,
429
+ `episode_synopses: ${changedEpisodeIds.size}`,
430
+ `overview: ${result.overviewGenerated ? "generated" : "reused"}`,
431
+ `loom: ran=${result.report.ran}, reused=${result.report.reused}`,
432
+ `changed: ${String(hasChanges).toLowerCase()}`,
433
+ `validation: ${passed ? apply ? "passed" : "would pass" : apply ? "needs repair" : "would need repair"}`,
434
+ `dry-run: ${apply ? "false" : "true"}`,
435
+ ];
436
+ if (apply && hasChanges) {
437
+ const [newRevision, idempotent] = await saveScriptSession(session, opts, "synopsis.generate");
438
+ await syncValidationResult(session, validation, newRevision);
439
+ if (session.remote) {
440
+ resultLines.push(`revision: ${newRevision}`);
441
+ resultLines.push(`idempotent: ${String(idempotent).toLowerCase()}`);
442
+ }
443
+ }
444
+ const report = {
445
+ title: apply && !hasChanges
446
+ ? "SYNOPSIS GENERATE: No changes"
447
+ : apply
448
+ ? passed ? "SYNOPSIS GENERATED" : "SYNOPSIS GENERATED: Validation needs repair"
449
+ : "SYNOPSIS GENERATE DRY-RUN (no write)",
450
+ op: apply ? "synopsis.generate" : "synopsis.generate.dry-run",
451
+ changed: apply && hasChanges,
452
+ summary: `prepared ${result.episodeBriefs.length} episode synopsis record(s) and whole-script overview`,
453
+ result: resultLines,
454
+ warnings: passed ? [] : summarizeIssues(asList(validation["issues"])),
455
+ issues: summarizeIssues(asList(validation["issues"])),
456
+ artifacts: apply && hasChanges ? [session.artifactLabel, path.join(directDir(workspace), "validation.json")] : [session.artifactLabel],
457
+ next: [apply ? "Review the generated synopsis fields." : "Re-run with --apply to write the generated synopsis fields."],
458
+ episode_synopses: result.episodeBriefs.map((brief) => ({
459
+ episode_id: brief.episodeId,
460
+ title: brief.title,
461
+ synopsis: brief.synopsis,
462
+ one_line: brief.oneLine,
463
+ })),
464
+ };
465
+ return [report, passed ? EXIT_OK : EXIT_NEEDS_AGENT];
466
+ }
467
+ function splitLongLine(line, maxChars) {
468
+ if (line.length <= maxChars)
469
+ return [line];
470
+ const parts = [];
471
+ for (let i = 0; i < line.length; i += maxChars) {
472
+ parts.push(line.slice(i, i + maxChars));
473
+ }
474
+ return parts;
475
+ }
476
+ async function generateArcBriefs(opts) {
477
+ let current = buildArcParcels(opts.episodes, opts.config.arcEpisodeCount);
478
+ let level = 1;
479
+ const report = { reused: 0, ran: 0 };
480
+ while (true) {
481
+ const run = await loom(current)
482
+ .split({
483
+ scope: "arc",
484
+ partition: (parcels) => parcels,
485
+ chunk: (parcel) => [parcel],
486
+ })
487
+ .extract(llm({
488
+ instruction: ARC_INSTRUCTION,
489
+ format: ARC_FORMAT,
490
+ input: ({ parcel }) => renderArcInput(parcel),
491
+ parse: ({ text, parcel }) => parseArcBrief(text, parcel),
492
+ }))
493
+ .reduce("arc", (items) => requireSingle(items, "arc"))
494
+ .reduce("root", (items) => [...items].sort((a, b) => a.startEpisodeNo - b.startEpisodeNo))
495
+ .run({
496
+ concurrency: opts.concurrency,
497
+ checkpoint: fileCheckpointer(path.join(opts.checkpointDir, `level-${level}`)),
498
+ checkpointSalt: opts.checkpointSalt,
499
+ model: opts.model,
500
+ });
501
+ report.reused += run.report.reused;
502
+ report.ran += run.report.ran;
503
+ const briefs = run.value;
504
+ const finalInput = renderFinalInput(opts.episodes, briefs);
505
+ if (finalInput.length <= opts.config.finalMaxInputChars || briefs.length <= 1)
506
+ return { value: briefs, report };
507
+ current = buildArcParcelsFromArcs(briefs, opts.config.arcEpisodeCount);
508
+ level += 1;
509
+ }
510
+ }
511
+ async function generateScriptOverview(opts) {
512
+ let arcs = opts.arcs;
513
+ while (renderFinalInput(opts.episodes, arcs).length > opts.config.finalMaxInputChars && arcs.length > 1) {
514
+ arcs = [reduceArcBriefs(arcs)];
515
+ }
516
+ const run = await loom({ episodes: opts.episodes, arcs })
517
+ .extract(llm({
518
+ instruction: FINAL_INSTRUCTION,
519
+ format: FINAL_FORMAT,
520
+ input: ({ parcel }) => renderFinalInput(parcel.episodes, parcel.arcs),
521
+ parse: ({ text }) => parseScriptOverview(text),
522
+ }))
523
+ .run({
524
+ concurrency: 1,
525
+ checkpoint: fileCheckpointer(opts.checkpointDir),
526
+ checkpointSalt: opts.checkpointSalt,
527
+ model: opts.model,
528
+ });
529
+ return { value: run.value, report: run.report };
530
+ }
531
+ function renderSceneHeader(script, scene) {
532
+ const env = isDict(scene["environment"]) ? scene["environment"] : {};
533
+ const space = strOf(env["space"]).trim();
534
+ const time = strOf(env["time"]).trim();
535
+ const context = isDict(scene["context"]) ? scene["context"] : {};
536
+ const locations = asList(context["locations"]).map((ref) => assetName(script, "locations", "location_id", "location_name", strOf(ref["location_id"]))).filter((s) => s);
537
+ const actors = asList(context["actors"]).map((ref) => assetName(script, "actors", "actor_id", "actor_name", strOf(ref["actor_id"]))).filter((s) => s);
538
+ const location = locations.join("、") || "-";
539
+ const envText = [space, time].filter((s) => s).join(" ");
540
+ return `[场景] ${envText || "-"}|${location}|出场:${actors.join("、") || "-"}`;
541
+ }
542
+ function renderAction(script, action) {
543
+ const type = strOf(action["type"]).trim();
544
+ if (type === "dialogue") {
545
+ if (strOf(action["delivery"]) === "overlap" && asList(action["lines"]).length > 0) {
546
+ const lines = asList(action["lines"]).map((line) => {
547
+ const speaker = speakerName(script, strOf(line["speaker_id"])) || strOf(line["speaker_id"]);
548
+ const content = strOf(line["content"]).trim();
549
+ return speaker && content ? `${speaker}:${content}` : content;
550
+ }).filter((line) => line);
551
+ return lines.length > 0 ? `[对白] ${lines.join(" / ")}` : "";
552
+ }
553
+ const speaker = actionSpeakerName(script, action);
554
+ const content = strOf(action["content"]).trim();
555
+ return content ? `[对白] ${speaker ? `${speaker}:` : ""}${content}` : "";
556
+ }
557
+ if (type === "inner_thought") {
558
+ const speaker = actionSpeakerName(script, action);
559
+ const content = strOf(action["content"]).trim();
560
+ return content ? `[心声] ${speaker ? `${speaker}:` : ""}${content}` : "";
561
+ }
562
+ const content = strOf(action["content"]).trim();
563
+ return content ? `[动作] ${content}` : "";
564
+ }
565
+ function actionSpeakerName(script, action) {
566
+ const speakerId = strOf(action["speaker_id"]).trim();
567
+ if (speakerId)
568
+ return speakerName(script, speakerId) || speakerId;
569
+ const actorId = strOf(action["actor_id"]).trim();
570
+ if (actorId)
571
+ return assetName(script, "actors", "actor_id", "actor_name", actorId) || actorId;
572
+ const speakers = asList(action["speakers"]).map((ref) => speakerName(script, strOf(ref["speaker_id"])) || strOf(ref["speaker_id"])).filter((s) => s);
573
+ return speakers.join("/");
574
+ }
575
+ function speakerName(script, speakerId) {
576
+ const speaker = asList(script["speakers"]).find((item) => strOf(item["speaker_id"]) === speakerId);
577
+ return speaker ? strOf(speaker["display_name"]).trim() : "";
578
+ }
579
+ function assetName(script, collection, idKey, nameKey, id) {
580
+ const item = asList(script[collection]).find((entry) => strOf(entry[idKey]) === id);
581
+ return item ? strOf(item[nameKey]).trim() : "";
582
+ }
583
+ function episodeNumber(episodeId, index) {
584
+ const m = /^ep_(\d+)$/.exec(episodeId);
585
+ return m ? Number(m[1]) : index + 1;
586
+ }
587
+ function sectionMap(text) {
588
+ const sections = new Map();
589
+ let current = null;
590
+ let lines = [];
591
+ const flush = () => {
592
+ if (current)
593
+ sections.set(current, lines.join("\n").trim());
594
+ lines = [];
595
+ };
596
+ for (const rawLine of text.split(/\r?\n/)) {
597
+ const heading = parseSectionHeading(rawLine);
598
+ if (heading) {
599
+ if (current === heading.name && heading.inline) {
600
+ lines.push(rawLine);
601
+ continue;
602
+ }
603
+ flush();
604
+ current = heading.name;
605
+ if (heading.inline)
606
+ lines.push(heading.inline);
607
+ continue;
608
+ }
609
+ if (current)
610
+ lines.push(rawLine);
611
+ }
612
+ flush();
613
+ return sections;
614
+ }
615
+ const SYNOPSIS_SECTION_NAMES = new Set([
616
+ "片段梗概",
617
+ "关键事件",
618
+ "人物变化",
619
+ "结尾钩子",
620
+ "分集梗概",
621
+ "单句梗概",
622
+ "阶段梗概",
623
+ "关键转折",
624
+ "未解决悬念",
625
+ "全文梗概",
626
+ "主题",
627
+ "一句话",
628
+ "风格",
629
+ "主要人物",
630
+ ]);
631
+ function parseSectionHeading(rawLine) {
632
+ const line = rawLine.trim();
633
+ const md = /^#{1,6}\s+(.+?)\s*$/.exec(line);
634
+ if (md) {
635
+ const name = md[1].trim();
636
+ return SYNOPSIS_SECTION_NAMES.has(name) ? { name, inline: "" } : null;
637
+ }
638
+ const colon = line.search(/[::]/);
639
+ const name = (colon >= 0 ? line.slice(0, colon) : line).trim();
640
+ if (!SYNOPSIS_SECTION_NAMES.has(name))
641
+ return null;
642
+ return { name, inline: colon >= 0 ? line.slice(colon + 1).trim() : "" };
643
+ }
644
+ function requiredSection(sections, name) {
645
+ const value = (sections.get(name) ?? "").trim();
646
+ if (!value)
647
+ throw new Error(`synopsis output missing section: ${name}`);
648
+ return value;
649
+ }
650
+ function parseBullets(text) {
651
+ return text.split(/\r?\n/)
652
+ .map((line) => line.trim())
653
+ .map((line) => line.replace(/^[-*]\s*/, "").trim())
654
+ .filter((line) => line.length > 0);
655
+ }
656
+ function parseCharacterChanges(text) {
657
+ return parseBullets(text).map((line) => {
658
+ const [name, ...rest] = line.split("|");
659
+ return { name: strOf(name).trim(), change: rest.join("|").trim() };
660
+ }).filter((item) => item.name && item.change);
661
+ }
662
+ function parseMainCharacters(text) {
663
+ return parseBullets(text).map((line) => {
664
+ const [name, ...rest] = line.split("|");
665
+ return { name: strOf(name).trim(), role: rest.join("|").trim() };
666
+ }).filter((item) => item.name && item.role);
667
+ }
668
+ function renderChunkInput(chunk) {
669
+ return [
670
+ `当前分集:第${chunk.episodeNo}集 ${chunk.title || ""}`.trim(),
671
+ `当前片段:${chunk.part}/${chunk.partCount}`,
672
+ "",
673
+ "这是当前任务的原文:",
674
+ chunk.text,
675
+ ].join("\n");
676
+ }
677
+ function renderEpisodeReduceInput(episode) {
678
+ return [
679
+ `当前分集:第${episode.episodeNo}集 ${episode.title || ""}`.trim(),
680
+ "",
681
+ "片段梗概:",
682
+ episode.synopsis,
683
+ "",
684
+ "片段关键事件:",
685
+ ...episode.events.map((event) => `- ${event}`),
686
+ ].join("\n");
687
+ }
688
+ function renderEpisodeBriefs(episodes) {
689
+ const lines = [];
690
+ for (const ep of episodes) {
691
+ lines.push(`第${ep.episodeNo}集 ${ep.title || ""}`.trim());
692
+ lines.push(`单句:${ep.oneLine}`);
693
+ lines.push(`梗概:${ep.synopsis}`);
694
+ if (ep.events.length > 0) {
695
+ lines.push("关键事件:");
696
+ for (const event of ep.events)
697
+ lines.push(`- ${event}`);
698
+ }
699
+ lines.push("");
700
+ }
701
+ return lines.join("\n").trim();
702
+ }
703
+ function renderArcBriefs(arcs) {
704
+ const lines = [];
705
+ for (const arc of arcs) {
706
+ lines.push(`第${arc.startEpisodeNo}-${arc.endEpisodeNo}集`);
707
+ lines.push(`阶段梗概:${arc.synopsis}`);
708
+ if (arc.turns.length > 0) {
709
+ lines.push("关键转折:");
710
+ for (const turn of arc.turns)
711
+ lines.push(`- ${turn}`);
712
+ }
713
+ if (arc.openQuestions.length > 0) {
714
+ lines.push("未解决悬念:");
715
+ for (const q of arc.openQuestions)
716
+ lines.push(`- ${q}`);
717
+ }
718
+ lines.push("");
719
+ }
720
+ return lines.join("\n").trim();
721
+ }
722
+ function renderArcInput(parcel) {
723
+ return [
724
+ `当前阶段:第${parcel.startEpisodeNo}-${parcel.endEpisodeNo}集`,
725
+ "",
726
+ "分集梗概:",
727
+ parcel.text,
728
+ ].join("\n");
729
+ }
730
+ function requireScope(scope, name) {
731
+ if (scope === null)
732
+ throw new Error(`${name} scope missing`);
733
+ return scope;
734
+ }
735
+ function requireSingle(items, name) {
736
+ const item = items[0];
737
+ if (!item || items.length !== 1)
738
+ throw new Error(`${name} reduce requires exactly one item`);
739
+ return item;
740
+ }
741
+ function firstNonEmpty(values) {
742
+ return values.map((value) => value.trim()).find((value) => value.length > 0) ?? "";
743
+ }
744
+ function uniqueStrings(values) {
745
+ const seen = new Set();
746
+ const out = [];
747
+ for (const value of values.map((v) => v.trim()).filter((v) => v.length > 0)) {
748
+ if (seen.has(value))
749
+ continue;
750
+ seen.add(value);
751
+ out.push(value);
752
+ }
753
+ return out;
754
+ }
755
+ function synopsisPreviewLine(text) {
756
+ const clean = text.replace(/\s+/g, " ").trim();
757
+ return clean.length > 50 ? clean.slice(0, 50) : clean;
758
+ }
759
+ function parsePositiveInt(value, fallback, label) {
760
+ if (value === undefined || value === null || value === "")
761
+ return fallback;
762
+ const n = Number(value);
763
+ if (!Number.isFinite(n) || n <= 0) {
764
+ throw new CliError("SYNOPSIS GENERATE BLOCKED: numeric option invalid", `${label} must be a positive integer.`, {
765
+ exitCode: EXIT_USAGE,
766
+ required: [`${label}: positive integer`],
767
+ received: [strOf(value)],
768
+ nextSteps: [`Pass ${label} ${fallback}.`],
769
+ errorCode: "NUMERIC_OPTION_INVALID",
770
+ });
771
+ }
772
+ return Math.floor(n);
773
+ }
774
+ function setTextField(script, key, value, overwrite) {
775
+ if (!overwrite && strOf(script[key]).trim())
776
+ return false;
777
+ if (strOf(script[key]) === value)
778
+ return false;
779
+ script[key] = value;
780
+ return true;
781
+ }
782
+ function scriptOverviewMissing(script) {
783
+ return !strOf(script["synopsis"]).trim()
784
+ || !strOf(script["theme"]).trim()
785
+ || !strOf(script["logline"]).trim()
786
+ || !strOf(script["style"]).trim()
787
+ || asList(script["main_characters"]).length === 0;
788
+ }
789
+ function existingScriptOverview(script) {
790
+ return {
791
+ synopsis: strOf(script["synopsis"]).trim(),
792
+ theme: strOf(script["theme"]).trim(),
793
+ logline: strOf(script["logline"]).trim(),
794
+ style: strOf(script["style"]).trim(),
795
+ mainCharacters: asList(script["main_characters"]).map((item) => ({
796
+ name: strOf(item["name"]).trim(),
797
+ role: strOf(item["role"]).trim(),
798
+ })).filter((item) => item.name && item.role),
799
+ };
800
+ }
801
+ //# sourceMappingURL=synopsis-generate.js.map