@lingjingai/scriptctl 0.15.0 → 0.19.0
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 +1 -1
- package/dist/cli.js +7 -0
- package/dist/cli.js.map +1 -1
- package/dist/common.d.ts +10 -3
- package/dist/common.js +82 -60
- package/dist/common.js.map +1 -1
- package/dist/domain/direct-core.d.ts +27 -4
- package/dist/domain/direct-core.js +281 -55
- package/dist/domain/direct-core.js.map +1 -1
- package/dist/help-text.js +15 -1
- package/dist/help-text.js.map +1 -1
- package/dist/infra/providers.d.ts +12 -2
- package/dist/infra/providers.js +147 -98
- package/dist/infra/providers.js.map +1 -1
- package/dist/usecases/direct.js +342 -60
- package/dist/usecases/direct.js.map +1 -1
- package/dist/usecases/parse.js +104 -14
- package/dist/usecases/parse.js.map +1 -1
- package/dist/usecases/script.d.ts +2 -2
- package/dist/usecases/script.js +15 -11
- package/dist/usecases/script.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ACTION_TYPE_VALUES, ACTOR_REUSE_SCENE_LIMIT, CliError, DEFAULT_BATCH_MAX_CHARS, DEFAULT_BATCH_MIN_LINES, DEFAULT_BATCH_MODE, DEFAULT_BATCH_TARGET_LINES, DEFAULT_PROVIDER_ATTEMPTS, EXIT_NEEDS_AGENT, EXIT_RUNTIME, PROP_REUSE_SCENE_LIMIT, SCRIPT_SCHEMA_VERSION, SCRIPT_TARGET_KINDS, WORLDVIEW_VALUES, fmtId, } from "../common.js";
|
|
1
|
+
import { ACTION_TYPE_VALUES, ACTOR_REUSE_SCENE_LIMIT, CliError, DEFAULT_BATCH_MAX_CHARS, DEFAULT_BATCH_MIN_LINES, DEFAULT_BATCH_MODE, DEFAULT_BATCH_TARGET_LINES, DEFAULT_PROVIDER_ATTEMPTS, EXIT_NEEDS_AGENT, EXIT_RUNTIME, PROP_REUSE_SCENE_LIMIT, ROLE_TYPE_VALUES, SCRIPT_SCHEMA_VERSION, SCRIPT_TARGET_KINDS, WORLDVIEW_VALUES, fmtId, } from "../common.js";
|
|
2
2
|
// ---------------------------------------------------------------------------
|
|
3
3
|
// Regexes & lookup tables (shared with infra/converters.ts via re-export)
|
|
4
4
|
// ---------------------------------------------------------------------------
|
|
@@ -1133,13 +1133,6 @@ export async function providerExtractBatch(provider, sourceText, batch) {
|
|
|
1133
1133
|
return await provider.extractEpisode(sourceText, batch);
|
|
1134
1134
|
throw new Error("Provider has neither extractBatch nor extractEpisode");
|
|
1135
1135
|
}
|
|
1136
|
-
export async function providerExtractAssetCuration(provider, sourceText, script) {
|
|
1137
|
-
if (provider.extractAssetCuration) {
|
|
1138
|
-
const payload = await provider.extractAssetCuration(sourceText, script);
|
|
1139
|
-
return isDict(payload) ? payload : {};
|
|
1140
|
-
}
|
|
1141
|
-
return {};
|
|
1142
|
-
}
|
|
1143
1136
|
function sleepMs(ms) {
|
|
1144
1137
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1145
1138
|
}
|
|
@@ -1749,6 +1742,104 @@ export function parseAssetDoc(text, kind) {
|
|
|
1749
1742
|
return { actors, locations, props, speakers, state_definitions: stateDefs };
|
|
1750
1743
|
}
|
|
1751
1744
|
// ---------------------------------------------------------------------------
|
|
1745
|
+
// Asset doc serialization (inverse of parseAssetDoc / parseMetaDoc)
|
|
1746
|
+
//
|
|
1747
|
+
// Turn an assembled script back into the per-kind 资产 md so `parse
|
|
1748
|
+
// --extract-assets` can seed reviewable 人物/场景/道具/发声源.md from an
|
|
1749
|
+
// LLM-enriched script. Output must round-trip through parseAssetDoc: every
|
|
1750
|
+
// asset becomes a top-level `- 名: 描述` bullet, each state a ` - 状态名:`
|
|
1751
|
+
// sub-bullet. State appearance text is not stored on the script (states are
|
|
1752
|
+
// {state_id, state_name}); we emit the state name only and leave the appearance
|
|
1753
|
+
// blank for the human to fill — the file is a review seed, not the final word.
|
|
1754
|
+
// ---------------------------------------------------------------------------
|
|
1755
|
+
const _ASSET_DOC_HEADER = {
|
|
1756
|
+
actors: "人物",
|
|
1757
|
+
locations: "场景",
|
|
1758
|
+
props: "道具",
|
|
1759
|
+
speakers: "发声源",
|
|
1760
|
+
};
|
|
1761
|
+
const _ASSET_DOC_NAME_KEY = {
|
|
1762
|
+
actors: "actor_name",
|
|
1763
|
+
locations: "location_name",
|
|
1764
|
+
props: "prop_name",
|
|
1765
|
+
};
|
|
1766
|
+
// Collapse any whitespace run (incl. newlines) to a single space. A description
|
|
1767
|
+
// or single-value field spanning physical lines would break the line-oriented
|
|
1768
|
+
// grammar (continuation lines don't start with `-` / a known key → dropped).
|
|
1769
|
+
function _oneLine(s) {
|
|
1770
|
+
return s.replace(/\s+/g, " ").trim();
|
|
1771
|
+
}
|
|
1772
|
+
// Sanitize a name for emission. The md grammar can't represent a name containing
|
|
1773
|
+
// its own structural delimiters: `:`/`:` start a description (truncating the
|
|
1774
|
+
// name), `|` flags a flat `名|状态` state def (the asset disappears), and the meta
|
|
1775
|
+
// `主角:` value is re-split on `,`/`,`/`、`. None of these belong in a canonical
|
|
1776
|
+
// entity name, so replace them with spaces — the doc is a review seed and the
|
|
1777
|
+
// human fixes oddities; we just never emit a name that re-parses to something else.
|
|
1778
|
+
function _safeName(name) {
|
|
1779
|
+
return name.replace(/[\r\n::|,,、]+/g, " ").replace(/\s+/g, " ").trim();
|
|
1780
|
+
}
|
|
1781
|
+
function _assetBullet(name, description) {
|
|
1782
|
+
const desc = _oneLine(description);
|
|
1783
|
+
return desc ? `- ${name}: ${desc}` : `- ${name}`;
|
|
1784
|
+
}
|
|
1785
|
+
export function serializeAssetDoc(script, kind) {
|
|
1786
|
+
const lines = [`# ${_ASSET_DOC_HEADER[kind]}`, ""];
|
|
1787
|
+
if (kind === "speakers") {
|
|
1788
|
+
for (const sp of asList(script["speakers"])) {
|
|
1789
|
+
if (!isDict(sp))
|
|
1790
|
+
continue;
|
|
1791
|
+
const name = _safeName(strOf(sp["display_name"]));
|
|
1792
|
+
if (!name)
|
|
1793
|
+
continue;
|
|
1794
|
+
const sourceKind = strOf(sp["source_kind"]).trim();
|
|
1795
|
+
// 发声源.md is for NON-人物 sources only; actor-kind speakers are real
|
|
1796
|
+
// characters already registered in 人物.md. Every non-actor kind round-trips
|
|
1797
|
+
// as `名(kind)` (parseSpeakerAsset reads the (...) annotation).
|
|
1798
|
+
if (!sourceKind || sourceKind === "actor")
|
|
1799
|
+
continue;
|
|
1800
|
+
lines.push(_assetBullet(`${name}(${sourceKind})`, strOf(sp["description"])));
|
|
1801
|
+
}
|
|
1802
|
+
return lines.join("\n") + "\n";
|
|
1803
|
+
}
|
|
1804
|
+
const nameKey = _ASSET_DOC_NAME_KEY[kind];
|
|
1805
|
+
for (const asset of asList(script[kind])) {
|
|
1806
|
+
if (!isDict(asset))
|
|
1807
|
+
continue;
|
|
1808
|
+
const name = _safeName(strOf(asset[nameKey]));
|
|
1809
|
+
if (!name)
|
|
1810
|
+
continue;
|
|
1811
|
+
lines.push(_assetBullet(name, strOf(asset["description"])));
|
|
1812
|
+
for (const stateName of compactStateNames(asset)) {
|
|
1813
|
+
const safeState = _safeName(stateName);
|
|
1814
|
+
if (!safeState)
|
|
1815
|
+
continue;
|
|
1816
|
+
// Bare sub-bullet (no trailing colon): the script stores no appearance text
|
|
1817
|
+
// for a state, and `- 名:` with an empty description fails _MD_ASSET_ENTRY_RE.
|
|
1818
|
+
lines.push(` - ${safeState}`);
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
return lines.join("\n") + "\n";
|
|
1822
|
+
}
|
|
1823
|
+
export function serializeMetaDoc(script) {
|
|
1824
|
+
const lines = ["# 元信息", ""];
|
|
1825
|
+
const worldview = _oneLine(strOf(script["worldview"]));
|
|
1826
|
+
const style = _oneLine(strOf(script["style"]));
|
|
1827
|
+
const title = _oneLine(strOf(script["title"]));
|
|
1828
|
+
if (title)
|
|
1829
|
+
lines.push(`title: ${title}`);
|
|
1830
|
+
if (worldview)
|
|
1831
|
+
lines.push(`worldview: ${worldview}`);
|
|
1832
|
+
if (style)
|
|
1833
|
+
lines.push(`style: ${style}`);
|
|
1834
|
+
const leads = asList(script["actors"])
|
|
1835
|
+
.filter((a) => isDict(a) && strOf(a["role_type"]).trim() === "主角")
|
|
1836
|
+
.map((a) => _safeName(strOf(a["actor_name"])))
|
|
1837
|
+
.filter(Boolean);
|
|
1838
|
+
if (leads.length > 0)
|
|
1839
|
+
lines.push(`主角: ${leads.join(", ")}`);
|
|
1840
|
+
return lines.join("\n") + "\n";
|
|
1841
|
+
}
|
|
1842
|
+
// ---------------------------------------------------------------------------
|
|
1752
1843
|
// Compact / expand transforms
|
|
1753
1844
|
// ---------------------------------------------------------------------------
|
|
1754
1845
|
export function expandCompactEpisodeResult(result, episodePlan) {
|
|
@@ -1881,6 +1972,11 @@ export function expandCompactEpisodeResult(result, episodePlan) {
|
|
|
1881
1972
|
expanded["speakers"] = result["speakers"];
|
|
1882
1973
|
if ("state_definitions" in result)
|
|
1883
1974
|
expanded["state_definitions"] = result["state_definitions"];
|
|
1975
|
+
// Restore the synopsis carried through compaction (short key `syn`, or a
|
|
1976
|
+
// plain `synopsis` if the on-disk form was never compacted).
|
|
1977
|
+
const synopsis = readCarriedSynopsis(result);
|
|
1978
|
+
if (synopsis)
|
|
1979
|
+
expanded["synopsis"] = synopsis;
|
|
1884
1980
|
return expanded;
|
|
1885
1981
|
}
|
|
1886
1982
|
export function compactEpisodeResult(result) {
|
|
@@ -1967,7 +2063,14 @@ export function compactEpisodeResult(result) {
|
|
|
1967
2063
|
compactScene["a"] = compactActions;
|
|
1968
2064
|
compactScenes.push(compactScene);
|
|
1969
2065
|
}
|
|
1970
|
-
|
|
2066
|
+
const compacted = { sc: compactScenes };
|
|
2067
|
+
// Carry the synopsis through the compact round-trip (short key `syn`). Without
|
|
2068
|
+
// this, any synopsis written upstream (## 梗概 → batch result, or the episode
|
|
2069
|
+
// reduce) would silently evaporate the moment the result is persisted.
|
|
2070
|
+
const synopsis = strOf(result["synopsis"]).trim();
|
|
2071
|
+
if (synopsis)
|
|
2072
|
+
compacted["syn"] = synopsis;
|
|
2073
|
+
return compacted;
|
|
1971
2074
|
}
|
|
1972
2075
|
export function compactBatchResult(result) {
|
|
1973
2076
|
const compact = compactEpisodeResult(result);
|
|
@@ -2002,6 +2105,79 @@ export function compactBatchResult(result) {
|
|
|
2002
2105
|
}
|
|
2003
2106
|
return compact;
|
|
2004
2107
|
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Plan ONE level of the whole-script synopsis reduce.
|
|
2110
|
+
*
|
|
2111
|
+
* We can only char-group the items we actually have (their lengths are known);
|
|
2112
|
+
* a merged group's output length isn't known until the LLM produces it. So this
|
|
2113
|
+
* stays single-level and the orchestrator loops — call it on the episode
|
|
2114
|
+
* synopses, merge each group, then call it again on the merged outputs until it
|
|
2115
|
+
* returns fitsInOnePass, then run the final extractScriptSynopsis on whatever
|
|
2116
|
+
* survives. Pure: it never calls the provider.
|
|
2117
|
+
*
|
|
2118
|
+
* Boundaries: total ≤ threshold (or ≤1 item) → one pass; total ≫ threshold →
|
|
2119
|
+
* multiple bounded groups; every item alone already over threshold → no
|
|
2120
|
+
* grouping can make progress, so degrade to one pass rather than loop forever.
|
|
2121
|
+
*/
|
|
2122
|
+
export function planSynopsisReduction(synopses, thresholdChars, groupSize) {
|
|
2123
|
+
const items = asList(synopses).map((s) => strOf(s));
|
|
2124
|
+
const onePass = () => ({
|
|
2125
|
+
fitsInOnePass: true,
|
|
2126
|
+
groups: items.map((_, i) => [i]),
|
|
2127
|
+
});
|
|
2128
|
+
const total = items.reduce((sum, s) => sum + s.length, 0);
|
|
2129
|
+
if (items.length <= 1 || total <= thresholdChars)
|
|
2130
|
+
return onePass();
|
|
2131
|
+
const cap = Math.max(1, groupSize);
|
|
2132
|
+
const groups = [];
|
|
2133
|
+
let current = [];
|
|
2134
|
+
let currentChars = 0;
|
|
2135
|
+
for (let i = 0; i < items.length; i++) {
|
|
2136
|
+
const len = items[i].length;
|
|
2137
|
+
if (current.length > 0 && (current.length >= cap || currentChars + len > thresholdChars)) {
|
|
2138
|
+
groups.push(current);
|
|
2139
|
+
current = [];
|
|
2140
|
+
currentChars = 0;
|
|
2141
|
+
}
|
|
2142
|
+
current.push(i);
|
|
2143
|
+
currentChars += len;
|
|
2144
|
+
}
|
|
2145
|
+
if (current.length > 0)
|
|
2146
|
+
groups.push(current);
|
|
2147
|
+
// No progress possible (every item alone exceeds threshold → all singletons).
|
|
2148
|
+
if (groups.length >= items.length)
|
|
2149
|
+
return onePass();
|
|
2150
|
+
return { fitsInOnePass: false, groups };
|
|
2151
|
+
}
|
|
2152
|
+
// ---------------------------------------------------------------------------
|
|
2153
|
+
// Synopsis utilities (shared by providers + the reduce orchestration)
|
|
2154
|
+
// ---------------------------------------------------------------------------
|
|
2155
|
+
// Trim + drop-empty an unknown list of synopsis strings. Single source for the
|
|
2156
|
+
// "clean beats" rule, so MockProvider, AnthropicProvider, and the orchestrator
|
|
2157
|
+
// can never diverge on what counts as a non-empty beat.
|
|
2158
|
+
export function cleanSynopsisList(items) {
|
|
2159
|
+
return asList(items).map((s) => strOf(s).trim()).filter((s) => s.length > 0);
|
|
2160
|
+
}
|
|
2161
|
+
// Read the synopsis carried through the compact round-trip: the short key `syn`
|
|
2162
|
+
// (current) or a plain `synopsis` (uncompacted / legacy). One definition so the
|
|
2163
|
+
// key precedence can't drift between the expand path and the reuse readers.
|
|
2164
|
+
export function readCarriedSynopsis(d) {
|
|
2165
|
+
return isDict(d) ? strOf(d["syn"] ?? d["synopsis"]).trim() : "";
|
|
2166
|
+
}
|
|
2167
|
+
// Recursively sort object keys for a stable JSON serialization (cache keys /
|
|
2168
|
+
// content hashes). Canonical home for the helper duplicated in the script/parse
|
|
2169
|
+
// usecases — re-exported there for back-compat.
|
|
2170
|
+
export function sortDeep(value) {
|
|
2171
|
+
if (Array.isArray(value))
|
|
2172
|
+
return value.map((v) => sortDeep(v));
|
|
2173
|
+
if (isDict(value)) {
|
|
2174
|
+
const sorted = {};
|
|
2175
|
+
for (const k of Object.keys(value).sort())
|
|
2176
|
+
sorted[k] = sortDeep(value[k]);
|
|
2177
|
+
return sorted;
|
|
2178
|
+
}
|
|
2179
|
+
return value;
|
|
2180
|
+
}
|
|
2005
2181
|
// ---------------------------------------------------------------------------
|
|
2006
2182
|
// Normalize episode result
|
|
2007
2183
|
// ---------------------------------------------------------------------------
|
|
@@ -2380,6 +2556,95 @@ export function deterministicExtractMetadata(script) {
|
|
|
2380
2556
|
})),
|
|
2381
2557
|
};
|
|
2382
2558
|
}
|
|
2559
|
+
// Split a metadata markdown document (see METADATA_MD_SPEC) into its raw pieces.
|
|
2560
|
+
// Tolerant by design: unknown headings reset the section, non-bullet lines under
|
|
2561
|
+
// asset sections are ignored, and a missing field just yields "". The id-keyed
|
|
2562
|
+
// maps and the raw worldview line are validated/backfilled by the caller.
|
|
2563
|
+
function parseMetadataMdSections(raw) {
|
|
2564
|
+
const actors = new Map();
|
|
2565
|
+
const locations = new Map();
|
|
2566
|
+
const props = new Map();
|
|
2567
|
+
let worldviewRaw = "";
|
|
2568
|
+
let section = "";
|
|
2569
|
+
for (const lineRaw of raw.split("\n")) {
|
|
2570
|
+
const line = lineRaw.trim();
|
|
2571
|
+
if (!line)
|
|
2572
|
+
continue;
|
|
2573
|
+
if (line.startsWith("#")) {
|
|
2574
|
+
const heading = line.replace(/^#+/, "").trim();
|
|
2575
|
+
if (heading.includes("世界观"))
|
|
2576
|
+
section = "worldview";
|
|
2577
|
+
else if (heading.includes("人物"))
|
|
2578
|
+
section = "actors";
|
|
2579
|
+
else if (heading.includes("场景"))
|
|
2580
|
+
section = "locations";
|
|
2581
|
+
else if (heading.includes("道具"))
|
|
2582
|
+
section = "props";
|
|
2583
|
+
else
|
|
2584
|
+
section = "";
|
|
2585
|
+
continue;
|
|
2586
|
+
}
|
|
2587
|
+
if (section === "worldview") {
|
|
2588
|
+
if (!worldviewRaw)
|
|
2589
|
+
worldviewRaw = line;
|
|
2590
|
+
continue;
|
|
2591
|
+
}
|
|
2592
|
+
if (section === "")
|
|
2593
|
+
continue;
|
|
2594
|
+
const body = line.replace(/^[-*]\s*/, "").trim();
|
|
2595
|
+
const parts = body.split("|").map((s) => s.trim());
|
|
2596
|
+
const id = parts[0] ?? "";
|
|
2597
|
+
if (!id)
|
|
2598
|
+
continue;
|
|
2599
|
+
if (section === "actors")
|
|
2600
|
+
actors.set(id, { role: parts[1] ?? "", description: parts[2] ?? "" });
|
|
2601
|
+
else if (section === "locations")
|
|
2602
|
+
locations.set(id, parts[1] ?? "");
|
|
2603
|
+
else
|
|
2604
|
+
props.set(id, parts[1] ?? "");
|
|
2605
|
+
}
|
|
2606
|
+
return { worldviewRaw, actors, locations, props };
|
|
2607
|
+
}
|
|
2608
|
+
// Parse a metadata markdown response into the same dict shape as
|
|
2609
|
+
// deterministicExtractMetadata. Strategy: start from the deterministic baseline
|
|
2610
|
+
// (guarantees every script asset is present with a valid role/description), then
|
|
2611
|
+
// overlay the model's worldview/role/description wherever it supplied a usable
|
|
2612
|
+
// value. So a truncated or partial response still produces a complete,
|
|
2613
|
+
// schema-valid result — missing assets simply keep their deterministic default.
|
|
2614
|
+
export function parseMarkdownMetadata(raw, script) {
|
|
2615
|
+
const base = deterministicExtractMetadata(script);
|
|
2616
|
+
const parsed = parseMetadataMdSections(raw);
|
|
2617
|
+
const worldviewRaw = parsed.worldviewRaw.trim();
|
|
2618
|
+
if (worldviewRaw) {
|
|
2619
|
+
const matched = WORLDVIEW_VALUES.includes(worldviewRaw)
|
|
2620
|
+
? worldviewRaw
|
|
2621
|
+
: WORLDVIEW_VALUES.find((v) => worldviewRaw.includes(v));
|
|
2622
|
+
if (matched)
|
|
2623
|
+
base["worldview"] = matched;
|
|
2624
|
+
base["worldview_raw"] = worldviewRaw;
|
|
2625
|
+
}
|
|
2626
|
+
for (const actor of base["actors"]) {
|
|
2627
|
+
const item = parsed.actors.get(strOf(actor["actor_id"]));
|
|
2628
|
+
if (!item)
|
|
2629
|
+
continue;
|
|
2630
|
+
if (ROLE_TYPE_VALUES.includes(item.role))
|
|
2631
|
+
actor["role_type"] = item.role;
|
|
2632
|
+
if (item.description)
|
|
2633
|
+
actor["description"] = item.description;
|
|
2634
|
+
}
|
|
2635
|
+
for (const loc of base["locations"]) {
|
|
2636
|
+
const desc = parsed.locations.get(strOf(loc["location_id"]));
|
|
2637
|
+
if (desc)
|
|
2638
|
+
loc["description"] = desc;
|
|
2639
|
+
}
|
|
2640
|
+
for (const prop of base["props"]) {
|
|
2641
|
+
const desc = parsed.props.get(strOf(prop["prop_id"]));
|
|
2642
|
+
if (desc)
|
|
2643
|
+
prop["description"] = desc;
|
|
2644
|
+
}
|
|
2645
|
+
base["confidence"] = "high";
|
|
2646
|
+
return base;
|
|
2647
|
+
}
|
|
2383
2648
|
export function applyMetadataToScript(script, metadata) {
|
|
2384
2649
|
script["worldview"] = metadata["worldview"];
|
|
2385
2650
|
script["worldview_raw"] = strOf(metadata["worldview_raw"]).trim();
|
|
@@ -2452,52 +2717,6 @@ function compactStateNames(asset) {
|
|
|
2452
2717
|
}
|
|
2453
2718
|
return out;
|
|
2454
2719
|
}
|
|
2455
|
-
function usagePayload(usage) {
|
|
2456
|
-
return {
|
|
2457
|
-
episode_count: usage.episodes.size,
|
|
2458
|
-
scene_count: usage.scenes.size,
|
|
2459
|
-
episodes: [...usage.episodes].sort().slice(0, 12),
|
|
2460
|
-
scenes: [...usage.scenes].sort().slice(0, 12),
|
|
2461
|
-
};
|
|
2462
|
-
}
|
|
2463
|
-
export function buildAssetCurationContext(script) {
|
|
2464
|
-
const locationUsage = sceneAssetUsage(script, "locations", "location_id");
|
|
2465
|
-
return {
|
|
2466
|
-
title: script["title"],
|
|
2467
|
-
guidance: [
|
|
2468
|
-
"Decide from the script context, not from asset names alone.",
|
|
2469
|
-
"Merge a location only when it is clearly the same stable space as an existing target location.",
|
|
2470
|
-
"Keep a location whenever no stable parent location is clear.",
|
|
2471
|
-
],
|
|
2472
|
-
locations: asList(script["locations"]).filter(isDict).map((loc) => ({
|
|
2473
|
-
location_id: loc["location_id"],
|
|
2474
|
-
location_name: loc["location_name"],
|
|
2475
|
-
description: loc["description"],
|
|
2476
|
-
aliases: isList(loc["aliases"]) ? loc["aliases"] : [],
|
|
2477
|
-
states: compactStateNames(loc),
|
|
2478
|
-
usage: usagePayload(locationUsage.get(strOf(loc["location_id"])) ?? { episodes: new Set(), scenes: new Set() }),
|
|
2479
|
-
examples: locationUsageExamples(script, strOf(loc["location_id"])),
|
|
2480
|
-
})),
|
|
2481
|
-
};
|
|
2482
|
-
}
|
|
2483
|
-
export function locationUsageExamples(script, locationId) {
|
|
2484
|
-
const examples = [];
|
|
2485
|
-
for (const ep of asList(script["episodes"])) {
|
|
2486
|
-
for (const scene of asList(ep["scenes"])) {
|
|
2487
|
-
const refs = asList(sceneContext(scene)["locations"]);
|
|
2488
|
-
const found = refs.some((ref) => isDict(ref) && ref["location_id"] === locationId);
|
|
2489
|
-
if (!found)
|
|
2490
|
-
continue;
|
|
2491
|
-
const example = sceneExample(scene);
|
|
2492
|
-
if (example) {
|
|
2493
|
-
examples.push({ episode_id: ep["episode_id"], scene_id: scene["scene_id"], example });
|
|
2494
|
-
}
|
|
2495
|
-
if (examples.length >= 4)
|
|
2496
|
-
return examples;
|
|
2497
|
-
}
|
|
2498
|
-
}
|
|
2499
|
-
return examples;
|
|
2500
|
-
}
|
|
2501
2720
|
function compactAssetRefs(refs, idKey, replacements, droppedIds) {
|
|
2502
2721
|
const compacted = [];
|
|
2503
2722
|
const seen = new Set();
|
|
@@ -3165,6 +3384,13 @@ export function mergeEpisodeResults(results, title) {
|
|
|
3165
3384
|
worldview: "",
|
|
3166
3385
|
worldview_raw: "",
|
|
3167
3386
|
style: "",
|
|
3387
|
+
// Whole-script overview placeholders — filled by the summary stage (the
|
|
3388
|
+
// synopsis reduce) after merge. Present-but-empty keeps consumer fields
|
|
3389
|
+
// stable and gives the summary stage a well-defined slot to write into.
|
|
3390
|
+
synopsis: "",
|
|
3391
|
+
theme: "",
|
|
3392
|
+
logline: "",
|
|
3393
|
+
main_characters: [],
|
|
3168
3394
|
actors,
|
|
3169
3395
|
locations,
|
|
3170
3396
|
props,
|