@lingjingai/scriptctl 0.32.0 → 0.34.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.
@@ -104,6 +104,9 @@ const _MD_ASSET_SUBSECTION_MAP = {
104
104
  "场景状态": "location_states", "地点状态": "location_states",
105
105
  "道具状态": "prop_states",
106
106
  };
107
+ const _MD_STATE_REASON_RE = /^(?:提取缘由|抽取缘由|提取理由|状态缘由|状态理由)\s*[::]\s*(.+)$/;
108
+ const STATE_REASON_MAX_CHARS = 360;
109
+ const MERGED_FROM_MAX_ITEMS = 12;
107
110
  // ---------------------------------------------------------------------------
108
111
  // Generic helpers (Python builtin equivalents)
109
112
  // ---------------------------------------------------------------------------
@@ -411,8 +414,19 @@ export function extractEpisodeTitleCandidate(header) {
411
414
  // ---------------------------------------------------------------------------
412
415
  // Episode plan
413
416
  // ---------------------------------------------------------------------------
417
+ const _SCREENPLAY_BODY_MARKER_RE = /^[\t ]*(?:剧本正文|正文|短剧正文|正片剧本|剧本内容)[\t ]*[::]?[\t ]*$/gim;
418
+ function screenplayBodyStartOffset(sourceText) {
419
+ for (const match of reFindAllMatches(_SCREENPLAY_BODY_MARKER_RE, sourceText)) {
420
+ const start = match.index + match[0].length;
421
+ if (reFindAllMatches(_EP_HEADER_RE, sourceText.slice(start)).length > 0)
422
+ return start;
423
+ }
424
+ return 0;
425
+ }
414
426
  export function buildEpisodePlan(sourceText) {
415
- const matches = reFindAllMatches(_EP_HEADER_RE, sourceText);
427
+ const bodyStart = screenplayBodyStartOffset(sourceText);
428
+ const bodyText = bodyStart > 0 ? sourceText.slice(bodyStart) : sourceText;
429
+ const matches = reFindAllMatches(_EP_HEADER_RE, bodyText);
416
430
  const episodes = [];
417
431
  let confidence;
418
432
  let strategy;
@@ -436,7 +450,8 @@ export function buildEpisodePlan(sourceText) {
436
450
  epNum = next;
437
451
  }
438
452
  usedEpNums.add(epNum);
439
- const end = idx + 1 < matches.length ? matches[idx + 1].index : sourceText.length;
453
+ const start = bodyStart + match.index;
454
+ const end = idx + 1 < matches.length ? bodyStart + matches[idx + 1].index : sourceText.length;
440
455
  const header = match[0].trim();
441
456
  const rawTitle = extractEpisodeTitleCandidate(header);
442
457
  const title = formatEpisodeTitle(epNum, rawTitle);
@@ -448,13 +463,13 @@ export function buildEpisodePlan(sourceText) {
448
463
  raw_title: rawTitle,
449
464
  title_status: titleStatus,
450
465
  title_source: title ? "source" : titleStatus,
451
- source_span: { start: match.index, end },
452
- header_span: { start: match.index, end: match.index + match[0].length },
453
- char_count: end - match.index,
466
+ source_span: { start, end },
467
+ header_span: { start, end: start + match[0].length },
468
+ char_count: end - start,
454
469
  });
455
470
  }
456
471
  confidence = "high";
457
- strategy = "explicit_episode_markers";
472
+ strategy = bodyStart > 0 ? "body_marker_episode_markers" : "explicit_episode_markers";
458
473
  }
459
474
  else {
460
475
  episodes.push({
@@ -475,6 +490,7 @@ export function buildEpisodePlan(sourceText) {
475
490
  version: 1,
476
491
  strategy,
477
492
  boundary_confidence: confidence,
493
+ body_start_offset: bodyStart,
478
494
  total_episodes: episodes.length,
479
495
  episodes,
480
496
  };
@@ -492,6 +508,11 @@ export function splitEntityState(raw) {
492
508
  if (!value)
493
509
  return ["", null];
494
510
  const states = [];
511
+ for (const item of reFindAll(_LOC_STATE_RE, value)) {
512
+ const v = item.trim();
513
+ if (v)
514
+ states.push(v);
515
+ }
495
516
  for (const item of reFindAll(_STATE_RE, value)) {
496
517
  const v = item.trim();
497
518
  if (v)
@@ -502,7 +523,7 @@ export function splitEntityState(raw) {
502
523
  if (v)
503
524
  states.push(v);
504
525
  }
505
- return [cleanName(value), states.length > 0 ? states[states.length - 1] : null];
526
+ return [cleanName(reReplace(_LOC_STATE_RE, value, "")), states.length > 0 ? states[states.length - 1] : null];
506
527
  }
507
528
  export function splitLocationState(raw) {
508
529
  const value = strOf(raw).trim();
@@ -1189,19 +1210,21 @@ export function isRecoverableBatchCliError(exc) {
1189
1210
  export function isRetryableProviderError(exc) {
1190
1211
  const anyExc = exc;
1191
1212
  const statusCode = normalizeInt(anyExc?.status_code ?? anyExc?.statusCode ?? anyExc?.status, 0);
1192
- if ([408, 409, 429, 500, 502, 503, 504, 529].includes(statusCode))
1193
- return true;
1194
- if ([400, 401, 403, 404].includes(statusCode))
1195
- return false;
1196
1213
  const name = (anyExc?.name ?? "").toLowerCase();
1197
1214
  const message = (typeof exc === "object" && exc !== null && "message" in exc ? String(exc.message) : String(exc)).toLowerCase();
1198
1215
  const retryMarkers = [
1199
1216
  "apierror", "apiconnectionerror", "apitimeouterror", "ratelimiterror",
1200
1217
  "remoteprotocolerror", "timeout", "overloaded", "rate_limit", "temporarily",
1201
1218
  "service unavailable", "bad gateway", "gateway timeout", "connection reset",
1202
- "peer closed", "incomplete chunked read",
1219
+ "peer closed", "incomplete chunked read", "all connection attempts failed",
1203
1220
  ];
1204
- return retryMarkers.some((m) => name.includes(m) || message.includes(m));
1221
+ if (retryMarkers.some((m) => name.includes(m) || message.includes(m)))
1222
+ return true;
1223
+ if ([408, 409, 429, 500, 502, 503, 504, 529].includes(statusCode))
1224
+ return true;
1225
+ if ([400, 401, 403, 404].includes(statusCode))
1226
+ return false;
1227
+ return false;
1205
1228
  }
1206
1229
  export function providerAttempts() {
1207
1230
  const raw = (process.env.SCRIPTCTL_PROVIDER_ATTEMPTS ?? "").trim();
@@ -1276,20 +1299,35 @@ export function _md_push_asset(items, rawName, description) {
1276
1299
  }
1277
1300
  items.push({ name, description: description || null });
1278
1301
  }
1279
- function _md_push_state_def(items, kind, rawName, description) {
1302
+ function cleanStateReason(raw) {
1303
+ const text = strOf(raw).replace(/\s+/g, " ").trim();
1304
+ if (!text)
1305
+ return "";
1306
+ return text.length > STATE_REASON_MAX_CHARS ? `${text.slice(0, STATE_REASON_MAX_CHARS - 1)}…` : text;
1307
+ }
1308
+ function setStateReason(item, reason) {
1309
+ const text = cleanStateReason(reason);
1310
+ if (text && !item["state_reason"])
1311
+ item["state_reason"] = text;
1312
+ }
1313
+ function _md_push_state_def(items, kind, rawName, description, stateReason = "") {
1280
1314
  const [namePart, sep, statePart] = partition(strOf(rawName), "|");
1281
1315
  const assetName = cleanName(namePart);
1282
1316
  const stateName = statePart.trim();
1283
1317
  if (!sep || !assetName || !stateName)
1284
- return;
1318
+ return null;
1285
1319
  for (const item of items) {
1286
1320
  if (item["kind"] === kind && item["asset_name"] === assetName && item["state_name"] === stateName) {
1287
1321
  if (description && !item["description"])
1288
1322
  item["description"] = description;
1289
- return;
1323
+ setStateReason(item, stateReason);
1324
+ return item;
1290
1325
  }
1291
1326
  }
1292
- items.push({ kind, asset_name: assetName, state_name: stateName, description: description || null });
1327
+ const item = { kind, asset_name: assetName, state_name: stateName, description: description || null };
1328
+ setStateReason(item, stateReason);
1329
+ items.push(item);
1330
+ return item;
1293
1331
  }
1294
1332
  export function parseSpeakerRef(raw) {
1295
1333
  const value = strOf(raw).trim();
@@ -1330,7 +1368,7 @@ export function inferNonActorSpeakerKind(name, scene = null) {
1330
1368
  return "system";
1331
1369
  if (reSearch(/广播(?!员)|喇叭|电台|播报|通知|警报/, value))
1332
1370
  return "broadcast";
1333
- if (reSearch(/众人|众|群体|全体|大家|人群/, value))
1371
+ if (reSearch(/众人|众|群体|全体|大家|人群|宾客|观众|群众|围观者/, value))
1334
1372
  return "group";
1335
1373
  if (scene) {
1336
1374
  const propSet = new Set(asList(scene["prop_names"]).map((p) => strOf(p).trim()));
@@ -1500,6 +1538,7 @@ export function parseMarkdownBatch(text, batchPlan, opts = {}) {
1500
1538
  const speakers = [];
1501
1539
  const stateDefs = [];
1502
1540
  let lastAction = null;
1541
+ let lastStateDef = null;
1503
1542
  // synopsis (## 梗概 sub-header → per-episode; # 梗概 top header → whole-script)
1504
1543
  // and the episode title carried as the first non-section top header.
1505
1544
  const synopsisLines = [];
@@ -1515,6 +1554,7 @@ export function parseMarkdownBatch(text, batchPlan, opts = {}) {
1515
1554
  if (subMatch && !sceneMatch && subMatch[1].trim() === "梗概") {
1516
1555
  capturingSynopsis = true;
1517
1556
  lastAction = null;
1557
+ lastStateDef = null;
1518
1558
  continue;
1519
1559
  }
1520
1560
  if (sceneMatch && section === "script") {
@@ -1531,6 +1571,7 @@ export function parseMarkdownBatch(text, batchPlan, opts = {}) {
1531
1571
  actions: [],
1532
1572
  });
1533
1573
  lastAction = null;
1574
+ lastStateDef = null;
1534
1575
  continue;
1535
1576
  }
1536
1577
  if (subMatch && section === "asset") {
@@ -1538,11 +1579,13 @@ export function parseMarkdownBatch(text, batchPlan, opts = {}) {
1538
1579
  const heading = subMatch[1].trim();
1539
1580
  assetSubsection = _MD_ASSET_SUBSECTION_MAP[heading] ?? null;
1540
1581
  lastAction = null;
1582
+ lastStateDef = null;
1541
1583
  continue;
1542
1584
  }
1543
1585
  if (subMatch) {
1544
1586
  capturingSynopsis = false;
1545
1587
  lastAction = null;
1588
+ lastStateDef = null;
1546
1589
  continue;
1547
1590
  }
1548
1591
  const topMatch = reMatch(_MD_TOP_HEADER_RE, stripped);
@@ -1571,6 +1614,7 @@ export function parseMarkdownBatch(text, batchPlan, opts = {}) {
1571
1614
  section = null;
1572
1615
  }
1573
1616
  lastAction = null;
1617
+ lastStateDef = null;
1574
1618
  continue;
1575
1619
  }
1576
1620
  // Inside a synopsis block, capture prose lines until the next header.
@@ -1651,6 +1695,7 @@ export function parseMarkdownBatch(text, batchPlan, opts = {}) {
1651
1695
  }
1652
1696
  }
1653
1697
  lastAction = null;
1698
+ lastStateDef = null;
1654
1699
  continue;
1655
1700
  }
1656
1701
  const actionMatch = reMatch(_MD_ACTION_ANCHOR_RE, stripped);
@@ -1674,10 +1719,12 @@ export function parseMarkdownBatch(text, batchPlan, opts = {}) {
1674
1719
  }
1675
1720
  scenes[scenes.length - 1]["actions"].push(action);
1676
1721
  lastAction = action;
1722
+ lastStateDef = null;
1677
1723
  continue;
1678
1724
  }
1679
1725
  if (!stripped) {
1680
1726
  lastAction = null;
1727
+ lastStateDef = null;
1681
1728
  continue;
1682
1729
  }
1683
1730
  if (lastAction !== null && !stripped.startsWith("-")) {
@@ -1693,8 +1740,14 @@ export function parseMarkdownBatch(text, batchPlan, opts = {}) {
1693
1740
  else if (section === "asset") {
1694
1741
  if (assetSubsection === null || !stripped)
1695
1742
  continue;
1743
+ const reasonMatch = reMatch(_MD_STATE_REASON_RE, stripped);
1744
+ if (reasonMatch && lastStateDef !== null && /_states$/.test(assetSubsection)) {
1745
+ setStateReason(lastStateDef, reasonMatch[1]);
1746
+ continue;
1747
+ }
1696
1748
  const entryMatch = reMatch(_MD_ASSET_ENTRY_RE, stripped);
1697
1749
  if (entryMatch) {
1750
+ lastStateDef = null;
1698
1751
  const name = entryMatch[1].trim();
1699
1752
  const description = (entryMatch[2] || "").trim() || null;
1700
1753
  if (assetSubsection === "actors")
@@ -1709,15 +1762,18 @@ export function parseMarkdownBatch(text, batchPlan, opts = {}) {
1709
1762
  speakers.push({ name: displayName, source_kind: sourceKind, description });
1710
1763
  }
1711
1764
  else if (assetSubsection === "actor_states") {
1712
- _md_push_state_def(stateDefs, "actor", name, description);
1765
+ lastStateDef = _md_push_state_def(stateDefs, "actor", name, description);
1713
1766
  }
1714
1767
  else if (assetSubsection === "location_states") {
1715
- _md_push_state_def(stateDefs, "location", name, description);
1768
+ lastStateDef = _md_push_state_def(stateDefs, "location", name, description);
1716
1769
  }
1717
1770
  else if (assetSubsection === "prop_states") {
1718
- _md_push_state_def(stateDefs, "prop", name, description);
1771
+ lastStateDef = _md_push_state_def(stateDefs, "prop", name, description);
1719
1772
  }
1720
1773
  }
1774
+ else {
1775
+ lastStateDef = null;
1776
+ }
1721
1777
  }
1722
1778
  }
1723
1779
  for (const scene of scenes) {
@@ -1771,15 +1827,23 @@ export function parseAssetDoc(text, kind) {
1771
1827
  const stateDefs = [];
1772
1828
  const stateKind = kind === "actors" ? "actor" : kind === "locations" ? "location" : "prop";
1773
1829
  let currentAsset = null;
1830
+ let currentStateDef = null;
1774
1831
  for (const rawLine of text.split(/\r?\n/)) {
1775
1832
  const stripped = rawLine.trim();
1776
1833
  if (!stripped)
1777
1834
  continue;
1778
1835
  if (reMatch(_MD_SUB_HEADER_RE, stripped) || reMatch(_MD_TOP_HEADER_RE, stripped))
1779
1836
  continue; // headers ignored
1837
+ const reasonMatch = reMatch(_MD_STATE_REASON_RE, stripped);
1838
+ if (reasonMatch && currentStateDef !== null) {
1839
+ setStateReason(currentStateDef, reasonMatch[1]);
1840
+ continue;
1841
+ }
1780
1842
  const m = reMatch(_MD_ASSET_ENTRY_RE, stripped);
1781
- if (!m)
1843
+ if (!m) {
1844
+ currentStateDef = null;
1782
1845
  continue;
1846
+ }
1783
1847
  const name = m[1].trim();
1784
1848
  const description = (m[2] || "").trim() || null;
1785
1849
  if (!name)
@@ -1788,15 +1852,14 @@ export function parseAssetDoc(text, kind) {
1788
1852
  if (kind !== "speakers") {
1789
1853
  // Flat self-contained state: `- name|state: desc`.
1790
1854
  if (name.includes("|")) {
1791
- _md_push_state_def(stateDefs, stateKind, name, description);
1855
+ currentStateDef = _md_push_state_def(stateDefs, stateKind, name, description);
1792
1856
  continue;
1793
1857
  }
1794
1858
  // An indented bullet is always a state sub-bullet — never a top-level
1795
1859
  // asset. Attach to the most recent asset; if there is none yet (malformed
1796
1860
  // input), skip it rather than registering a spurious asset.
1797
1861
  if (indented) {
1798
- if (currentAsset)
1799
- _md_push_state_def(stateDefs, stateKind, `${currentAsset}|${name}`, description);
1862
+ currentStateDef = currentAsset ? _md_push_state_def(stateDefs, stateKind, `${currentAsset}|${name}`, description) : null;
1800
1863
  continue;
1801
1864
  }
1802
1865
  }
@@ -1810,9 +1873,11 @@ export function parseAssetDoc(text, kind) {
1810
1873
  const [sourceKind, displayName] = parseSpeakerAsset(name);
1811
1874
  if (displayName)
1812
1875
  speakers.push({ name: displayName, source_kind: sourceKind, description });
1876
+ currentStateDef = null;
1813
1877
  continue;
1814
1878
  }
1815
1879
  currentAsset = name;
1880
+ currentStateDef = null;
1816
1881
  }
1817
1882
  return { actors, locations, props, speakers, state_definitions: stateDefs };
1818
1883
  }
@@ -2074,6 +2139,24 @@ export function expandCompactEpisodeResult(result, episodePlan) {
2074
2139
  expanded["synopsis"] = synopsis;
2075
2140
  return expanded;
2076
2141
  }
2142
+ const COMPACT_EPISODE_CARRIED_LIST_KEYS = ["actors", "locations", "props", "speakers", "state_definitions"];
2143
+ function carryCompactEpisodeLists(result, compact) {
2144
+ for (const key of COMPACT_EPISODE_CARRIED_LIST_KEYS) {
2145
+ const items = result[key];
2146
+ if (!isList(items) || items.length === 0)
2147
+ continue;
2148
+ compact[key] = items.map((item) => {
2149
+ if (!isDict(item))
2150
+ return item;
2151
+ const cleaned = {};
2152
+ for (const [k, v] of Object.entries(item)) {
2153
+ if (v !== null && v !== undefined)
2154
+ cleaned[k] = v;
2155
+ }
2156
+ return cleaned;
2157
+ });
2158
+ }
2159
+ }
2077
2160
  export function compactEpisodeResult(result) {
2078
2161
  const compactScenes = [];
2079
2162
  for (const scene of asList(result["scenes"])) {
@@ -2174,6 +2257,7 @@ export function compactEpisodeResult(result) {
2174
2257
  compactScenes.push(compactScene);
2175
2258
  }
2176
2259
  const compacted = { sc: compactScenes };
2260
+ carryCompactEpisodeLists(result, compacted);
2177
2261
  // Carry the synopsis through the compact round-trip (short key `syn`). Without
2178
2262
  // this, any synopsis written upstream (## 梗概 → batch result, or the episode
2179
2263
  // reduce) would silently evaporate the moment the result is persisted.
@@ -2198,21 +2282,6 @@ export function compactBatchResult(result) {
2198
2282
  compactActions[j]["r"] = refs.map((r) => strOf(r).trim()).filter((r) => r);
2199
2283
  }
2200
2284
  }
2201
- for (const key of ["actors", "locations", "props", "speakers", "state_definitions"]) {
2202
- const items = result[key];
2203
- if (isList(items) && items.length > 0) {
2204
- compact[key] = items.map((item) => {
2205
- if (!isDict(item))
2206
- return item;
2207
- const cleaned = {};
2208
- for (const [k, v] of Object.entries(item)) {
2209
- if (v !== null && v !== undefined)
2210
- cleaned[k] = v;
2211
- }
2212
- return cleaned;
2213
- });
2214
- }
2215
- }
2216
2285
  return compact;
2217
2286
  }
2218
2287
  /**
@@ -2435,9 +2504,9 @@ export function normalizeEpisodeResult(result, episodePlan) {
2435
2504
  }
2436
2505
  scene["actions"] = actions;
2437
2506
  }
2438
- normalized["actors"] = _normalizeAssetList(normalized["actors"]);
2439
- normalized["locations"] = _normalizeAssetList(normalized["locations"]);
2440
- normalized["props"] = _normalizeAssetList(normalized["props"]);
2507
+ normalized["actors"] = _normalizeAssetList(normalized["actors"], "actor");
2508
+ normalized["locations"] = _normalizeAssetList(normalized["locations"], "location");
2509
+ normalized["props"] = _normalizeAssetList(normalized["props"], "prop");
2441
2510
  normalized["speakers"] = _normalize_speaker_list(normalized["speakers"]);
2442
2511
  normalized["state_definitions"] = _normalizeStateDefinitions(normalized["state_definitions"]);
2443
2512
  for (const scene of scenes) {
@@ -2453,7 +2522,7 @@ export function normalizeEpisodeResult(result, episodePlan) {
2453
2522
  }
2454
2523
  return normalized;
2455
2524
  }
2456
- function _normalizeAssetList(items) {
2525
+ function _normalizeAssetList(items, kind) {
2457
2526
  if (!isList(items))
2458
2527
  return [];
2459
2528
  const out = [];
@@ -2462,11 +2531,22 @@ function _normalizeAssetList(items) {
2462
2531
  let name = "";
2463
2532
  let description = null;
2464
2533
  if (isDict(item)) {
2465
- name = cleanName(strOf(item["name"]));
2534
+ const rawName = strOf(item["name"]);
2535
+ if (kind === "actor")
2536
+ [name] = splitEntityState(rawName);
2537
+ else if (kind === "location" || kind === "prop")
2538
+ [name] = splitLocationState(rawName);
2539
+ else
2540
+ name = cleanName(rawName);
2466
2541
  description = strOf(item["description"]).trim() || null;
2467
2542
  }
2468
2543
  else if (typeof item === "string") {
2469
- name = cleanName(item);
2544
+ if (kind === "actor")
2545
+ [name] = splitEntityState(item);
2546
+ else if (kind === "location" || kind === "prop")
2547
+ [name] = splitLocationState(item);
2548
+ else
2549
+ name = cleanName(item);
2470
2550
  }
2471
2551
  else
2472
2552
  continue;
@@ -2506,7 +2586,13 @@ export function _normalize_speaker_list(items) {
2506
2586
  const [parsedKind, parsedName] = parseSpeakerAsset(rawName);
2507
2587
  let kind = sourceKind || parsedKind;
2508
2588
  kind = _MD_SPEAKER_KIND_NORM[kind.toLowerCase()] ?? _MD_SPEAKER_KIND_NORM[kind] ?? "other";
2509
- const name = cleanName(parsedName);
2589
+ let name = "";
2590
+ if (kind === "actor")
2591
+ [name] = splitEntityState(parsedName);
2592
+ else if (kind === "location" || kind === "prop")
2593
+ [name] = splitLocationState(parsedName);
2594
+ else
2595
+ name = cleanName(parsedName);
2510
2596
  if (!name || seen.has(name))
2511
2597
  continue;
2512
2598
  seen.add(name);
@@ -2525,7 +2611,14 @@ function _normalizeStateDefinitions(items) {
2525
2611
  const kind = strOf(item["kind"] || item["target_kind"]).trim();
2526
2612
  if (!SCRIPT_TARGET_KINDS.has(kind))
2527
2613
  continue;
2528
- const assetName = cleanName(strOf(item["asset_name"] || item["name"]));
2614
+ const rawAssetName = strOf(item["asset_name"] || item["name"]);
2615
+ let assetName = "";
2616
+ if (kind === "actor")
2617
+ [assetName] = splitEntityState(rawAssetName);
2618
+ else if (kind === "location" || kind === "prop")
2619
+ [assetName] = splitLocationState(rawAssetName);
2620
+ else
2621
+ assetName = cleanName(rawAssetName);
2529
2622
  const stateName = strOf(item["state_name"] || item["state"]).trim();
2530
2623
  if (!assetName || !stateName || stateRejectionReason(kind, stateName))
2531
2624
  continue;
@@ -2533,12 +2626,16 @@ function _normalizeStateDefinitions(items) {
2533
2626
  if (seen.has(key))
2534
2627
  continue;
2535
2628
  seen.add(key);
2536
- out.push({
2629
+ const normalized = {
2537
2630
  kind,
2538
2631
  asset_name: assetName,
2539
2632
  state_name: stateName,
2540
2633
  description: strOf(item["description"]).trim() || null,
2541
- });
2634
+ };
2635
+ const stateReason = cleanStateReason(item["state_reason"]);
2636
+ if (stateReason)
2637
+ normalized["state_reason"] = stateReason;
2638
+ out.push(normalized);
2542
2639
  }
2543
2640
  return out;
2544
2641
  }
@@ -3819,17 +3916,31 @@ export function buildPrimaryAssetCurationChunks(script, kind, options = { maxIte
3819
3916
  }
3820
3917
  export function extractAssetGroupingExpressions(rawText) {
3821
3918
  const groups = [];
3919
+ const blocks = [];
3920
+ let currentBlock = [];
3822
3921
  const re = /<group>([\s\S]*?)<\/group>/g;
3823
3922
  let outside = "";
3824
3923
  let lastIndex = 0;
3825
3924
  let match;
3826
3925
  while ((match = re.exec(rawText)) !== null) {
3827
- outside += rawText.slice(lastIndex, match.index);
3828
- groups.push(strOf(match[1]).trim());
3926
+ const gap = rawText.slice(lastIndex, match.index);
3927
+ outside += gap;
3928
+ if (gap.trim() && currentBlock.length > 0) {
3929
+ blocks.push(currentBlock);
3930
+ currentBlock = [];
3931
+ }
3932
+ const expression = strOf(match[1]).trim();
3933
+ groups.push(expression);
3934
+ currentBlock.push(expression);
3829
3935
  lastIndex = match.index + match[0].length;
3830
3936
  }
3831
3937
  outside += rawText.slice(lastIndex);
3938
+ if (currentBlock.length > 0)
3939
+ blocks.push(currentBlock);
3832
3940
  if (outside.trim()) {
3941
+ const lastBlock = blocks[blocks.length - 1] ?? [];
3942
+ if (blocks.length > 1 && lastBlock.length > 0)
3943
+ return lastBlock;
3833
3944
  curationBlocked("Asset grouping response must contain only <group>...</group> lines.", [outside.trim().slice(0, 200)]);
3834
3945
  }
3835
3946
  if (groups.length === 0) {
@@ -3927,7 +4038,7 @@ export function validateAssetGroupingPlan(script, kind, rawPlan) {
3927
4038
  retainedByName.set(key, list);
3928
4039
  }
3929
4040
  const duplicateMerges = [];
3930
- const omittedInvalid = [];
4041
+ const omittedSingletons = [];
3931
4042
  for (const id of inputIds) {
3932
4043
  if (mentioned.has(id))
3933
4044
  continue;
@@ -3937,7 +4048,13 @@ export function validateAssetGroupingPlan(script, kind, rawPlan) {
3937
4048
  const key = assetGroupingNameKey(kind, asset[assetNameKey(kind)]);
3938
4049
  const targets = key ? retainedByName.get(key) ?? [] : [];
3939
4050
  if (targets.length === 0) {
3940
- omittedInvalid.push(`${kind}:${id}:${strOf(asset[assetNameKey(kind)])}`);
4051
+ groups.push({
4052
+ kind,
4053
+ ids: [id],
4054
+ reason: "omitted by grouping; review as standalone asset",
4055
+ });
4056
+ mentioned.add(id);
4057
+ omittedSingletons.push(id);
3941
4058
  continue;
3942
4059
  }
3943
4060
  duplicateMerges.push({
@@ -3949,9 +4066,6 @@ export function validateAssetGroupingPlan(script, kind, rawPlan) {
3949
4066
  reason: "name-level duplicate omitted by asset grouping",
3950
4067
  });
3951
4068
  }
3952
- if (omittedInvalid.length > 0) {
3953
- curationBlocked("Asset grouping omitted non-duplicate asset ids.", omittedInvalid.slice(0, 40));
3954
- }
3955
4069
  return {
3956
4070
  version: 1,
3957
4071
  format: "asset-grouping-v1",
@@ -3966,6 +4080,7 @@ export function validateAssetGroupingPlan(script, kind, rawPlan) {
3966
4080
  group_count: groups.length,
3967
4081
  mentioned_count: mentioned.size,
3968
4082
  omitted_duplicate_count: duplicateMerges.length,
4083
+ omitted_singleton_count: omittedSingletons.length,
3969
4084
  normalization_count: normalizations.length,
3970
4085
  },
3971
4086
  };
@@ -4295,8 +4410,9 @@ function auditCandidateReason(script, decision, evidence, relatedById) {
4295
4410
  return "speaking_actor_removed_or_speaker";
4296
4411
  }
4297
4412
  if (action === "keep" && asset) {
4298
- if (hasEnumeratedActorName(strOf(asset["actor_name"])))
4413
+ if (hasEnumeratedActorName(strOf(asset["actor_name"])) && item.dialogueCount === 0 && item.speakingScenes.size === 0) {
4299
4414
  return "kept_enumerated_actor_review";
4415
+ }
4300
4416
  const identity = actorIdentityCandidates(script, sourceId, strOf(asset["actor_name"]), item);
4301
4417
  if (identity.length > 0)
4302
4418
  return "kept_actor_identity_or_form_review";
@@ -4375,7 +4491,8 @@ export function buildAssetCurationAuditContextText(script, primaryPlan, selected
4375
4491
  lines.push("- Output expressions only for listed candidates when the primary decision should be corrected or when the candidate is marked primary=MISSING.");
4376
4492
  lines.push("- Otherwise output exactly <exp>NOOP # no asset curation changes</exp>.");
4377
4493
  lines.push("- Audit expressions must reference source ids listed here. Existing primary sources are overridden; primary=MISSING and primary=IMPLICIT_KEEP sources are added.");
4378
- lines.push("- For primary KEEP actor candidates, output SPEAKER/MERGE/RENAME only when the kept actor is a generic numbered role, anonymous crowd, pure voice label, or duplicate identity.");
4494
+ lines.push("- For primary KEEP actor candidates, output SPEAKER/MERGE/RENAME only when the kept actor is an anonymous crowd, pure voice label, duplicate identity, or a generic numbered role without dialogue.");
4495
+ lines.push("- Do not downgrade a numbered/title-like actor to SPEAKER when it has dialogue or speaking-scene evidence; keep it as an actor unless it is truly a crowd label such as 众人/宾客们/围观者.");
4379
4496
  lines.push("- For primary KEEP location candidates, output RENAME/MERGE only when canonical scope or duplicate/parent-child granularity is wrong; otherwise leave it unchanged.");
4380
4497
  lines.push(`- For primary KEEP prop candidates, output DROP/MERGE/STATE/RENAME when the prop is too broad; output KEEP only to add/replace a valid category reason from ${propKeepCategoryList()}.`);
4381
4498
  lines.push("- For primary DROP/MERGE/STATE prop candidates, output KEEP/RENAME/MERGE only when standalone hero-prop continuity is stronger than the primary decision.");
@@ -4563,7 +4680,7 @@ export function assertLocationCanonicalizationPlan(rawPlan) {
4563
4680
  const invalid = [];
4564
4681
  for (const decision of normalizedCurationDecisions(rawPlan)) {
4565
4682
  const kind = strOf(decision["kind"]);
4566
- const action = strOf(decision["decision"]);
4683
+ let action = strOf(decision["decision"]);
4567
4684
  const sourceId = strOf(decision["source_id"]);
4568
4685
  if (kind !== "location" || (action !== "rename" && action !== "merge")) {
4569
4686
  invalid.push(`${kind || "<empty>"}:${sourceId || "<empty>"} ${action || "<empty>"}`);
@@ -5049,6 +5166,14 @@ function parseCurationExpression(expression) {
5049
5166
  match = /^PROP\s+(actor|location|prop)\s+(\S+)\s+->\s+(\S+)\s+"((?:\\.|[^"\\])*)"$/i.exec(command);
5050
5167
  if (match) {
5051
5168
  const target = match[3];
5169
+ const normalizedTarget = target.toLowerCase();
5170
+ const targetPrefix = /^([A-Za-z]+):(.+)$/.exec(target);
5171
+ if ((targetPrefix && targetPrefix[1].toLowerCase() !== "prop") ||
5172
+ normalizedTarget === "actor" ||
5173
+ normalizedTarget === "location" ||
5174
+ normalizedTarget === "prop") {
5175
+ curationBlocked("PROP target must be NEW or an existing prop id.", [expression]);
5176
+ }
5052
5177
  const decision = {
5053
5178
  kind: match[1].toLowerCase(),
5054
5179
  source_id: match[2],
@@ -5058,7 +5183,7 @@ function parseCurationExpression(expression) {
5058
5183
  reason,
5059
5184
  };
5060
5185
  if (target.toUpperCase() !== "NEW")
5061
- decision["target_id"] = target;
5186
+ decision["target_id"] = targetPrefix ? targetPrefix[2] : target;
5062
5187
  return decision;
5063
5188
  }
5064
5189
  curationBlocked("Unknown or malformed curation expression.", [expression]);
@@ -5266,34 +5391,44 @@ export function combinePrimaryAssetCurationPlans(plans) {
5266
5391
  primary_part_expression_text: expressionTextByPlan.join("\n"),
5267
5392
  };
5268
5393
  }
5269
- export function combineAssetCurationPlans(primaryPlan, auditPlan, allowedNewAuditSources = []) {
5394
+ export function combineAssetCurationPlans(primaryPlan, auditPlan, allowedNewAuditSources = [], options = {}) {
5270
5395
  const primaryDecisions = asList(primaryPlan["decisions"]).filter(isDict);
5271
- const auditDecisions = asList(auditPlan["decisions"]).filter(isDict);
5396
+ const rawAuditDecisions = asList(auditPlan["decisions"]).filter(isDict);
5272
5397
  const primaryKeys = curationDecisionSourceSet(primaryDecisions);
5273
5398
  const allowedNewKeys = new Set(allowedNewAuditSources);
5274
5399
  const invalidAuditSources = [];
5275
5400
  let addedAuditDecisions = 0;
5276
- for (const decision of auditDecisions) {
5401
+ const auditDecisions = [];
5402
+ for (const decision of rawAuditDecisions) {
5277
5403
  const key = curationSourceKey(strOf(decision["kind"]), strOf(decision["source_id"]));
5278
- if (primaryKeys.has(key))
5404
+ if (primaryKeys.has(key)) {
5405
+ auditDecisions.push(decision);
5279
5406
  continue;
5407
+ }
5280
5408
  if (allowedNewKeys.has(key)) {
5281
5409
  addedAuditDecisions += 1;
5410
+ auditDecisions.push(decision);
5282
5411
  continue;
5283
5412
  }
5284
5413
  invalidAuditSources.push(key);
5285
5414
  }
5286
- if (invalidAuditSources.length > 0) {
5415
+ if (invalidAuditSources.length > 0 && !options.salvageInvalidAuditSources) {
5287
5416
  curationBlocked("Audit curation expressions may only override primary decision sources or listed missing required sources.", invalidAuditSources);
5288
5417
  }
5289
5418
  const primaryExpressions = asList(primaryPlan["expressions"]);
5290
- const auditExpressions = asList(auditPlan["expressions"]);
5419
+ const auditExpressions = invalidAuditSources.length > 0 && options.salvageInvalidAuditSources
5420
+ ? auditDecisions.map(renderAssetCurationDecisionExpression)
5421
+ : asList(auditPlan["expressions"]);
5422
+ const auditExpressionText = invalidAuditSources.length > 0 && options.salvageInvalidAuditSources
5423
+ ? formatAssetCurationDecisionExpressions({ decisions: auditDecisions })
5424
+ : strOf(auditPlan["expression_text"]);
5291
5425
  return {
5292
5426
  version: 3,
5293
5427
  format: "asset-curation-exp-v1",
5294
5428
  primary_expression_text: strOf(primaryPlan["expression_text"]),
5295
- audit_expression_text: strOf(auditPlan["expression_text"]),
5296
- expression_text: [strOf(primaryPlan["expression_text"]).trim(), strOf(auditPlan["expression_text"]).trim()].filter((text) => text).join("\n"),
5429
+ audit_expression_text: auditExpressionText,
5430
+ audit_raw_expression_text: invalidAuditSources.length > 0 && options.salvageInvalidAuditSources ? strOf(auditPlan["expression_text"]) : undefined,
5431
+ expression_text: [strOf(primaryPlan["expression_text"]).trim(), auditExpressionText.trim()].filter((text) => text).join("\n"),
5297
5432
  primary_expressions: primaryExpressions,
5298
5433
  audit_expressions: auditExpressions,
5299
5434
  expressions: [...primaryExpressions, ...auditExpressions],
@@ -5301,7 +5436,10 @@ export function combineAssetCurationPlans(primaryPlan, auditPlan, allowedNewAudi
5301
5436
  audit_summary: {
5302
5437
  primary_decisions: primaryDecisions.length,
5303
5438
  audit_decisions: auditDecisions.length,
5439
+ audit_raw_decisions: rawAuditDecisions.length,
5304
5440
  audit_added_decisions: addedAuditDecisions,
5441
+ audit_rejected_decisions: invalidAuditSources.length,
5442
+ audit_rejected_sources: invalidAuditSources,
5305
5443
  audit_noop: auditDecisions.length === 0,
5306
5444
  },
5307
5445
  };
@@ -5667,7 +5805,7 @@ function normalizeCurationPlanTargets(script, decisions) {
5667
5805
  normalizeMergeCycles(script, decisions);
5668
5806
  const decisionsBySource = buildCurationDecisionIndex(decisions);
5669
5807
  for (const decision of decisions) {
5670
- const action = strOf(decision["decision"]);
5808
+ let action = strOf(decision["decision"]);
5671
5809
  if (action !== "merge" && action !== "move_to_state" && action !== "move_to_prop")
5672
5810
  continue;
5673
5811
  const targetId = strOf(decision["target_id"]);
@@ -5817,6 +5955,71 @@ function rewriteCurationRefs(script, replacementsByKind, droppedByKind, stateRep
5817
5955
  }
5818
5956
  }
5819
5957
  }
5958
+ function normalizeNonActorSpeakersToActorSpeakers(script) {
5959
+ const actorIdByName = new Map();
5960
+ for (const actor of asList(script["actors"])) {
5961
+ if (!isDict(actor))
5962
+ continue;
5963
+ const name = strOf(actor["actor_name"]).trim();
5964
+ const id = strOf(actor["actor_id"]).trim();
5965
+ if (name && id)
5966
+ actorIdByName.set(name, id);
5967
+ }
5968
+ const actorSpeakerByActorId = new Map();
5969
+ for (const speaker of asList(script["speakers"])) {
5970
+ if (!isDict(speaker))
5971
+ continue;
5972
+ if (strOf(speaker["source_kind"]) !== "actor")
5973
+ continue;
5974
+ const sourceId = strOf(speaker["source_id"]).trim();
5975
+ const speakerId = strOf(speaker["speaker_id"]).trim();
5976
+ if (sourceId && speakerId)
5977
+ actorSpeakerByActorId.set(sourceId, speakerId);
5978
+ }
5979
+ const speakerReplacements = new Map();
5980
+ for (const speaker of asList(script["speakers"])) {
5981
+ if (!isDict(speaker))
5982
+ continue;
5983
+ if (strOf(speaker["source_kind"]) === "actor")
5984
+ continue;
5985
+ const speakerId = strOf(speaker["speaker_id"]).trim();
5986
+ const displayName = strOf(speaker["display_name"]).trim();
5987
+ const actorId = actorIdByName.get(displayName);
5988
+ const actorSpeakerId = actorId ? actorSpeakerByActorId.get(actorId) : "";
5989
+ if (speakerId && actorSpeakerId && speakerId !== actorSpeakerId)
5990
+ speakerReplacements.set(speakerId, actorSpeakerId);
5991
+ }
5992
+ if (speakerReplacements.size === 0)
5993
+ return { speaker_refs_normalized: 0, speakers_removed: 0 };
5994
+ let speakerRefsNormalized = 0;
5995
+ const rewriteSpeakerId = (value) => {
5996
+ const speakerId = strOf(value).trim();
5997
+ const replacement = speakerReplacements.get(speakerId);
5998
+ if (!replacement)
5999
+ return speakerId;
6000
+ speakerRefsNormalized += 1;
6001
+ return replacement;
6002
+ };
6003
+ for (const episode of asList(script["episodes"])) {
6004
+ for (const scene of asList(episode["scenes"])) {
6005
+ for (const action of asList(scene["actions"])) {
6006
+ if (!isDict(action))
6007
+ continue;
6008
+ const rewrittenSpeakerId = rewriteSpeakerId(action["speaker_id"]);
6009
+ if (rewrittenSpeakerId) {
6010
+ action["speaker_id"] = rewrittenSpeakerId;
6011
+ const actorId = rewrittenSpeakerId.startsWith("spk_act_") ? rewrittenSpeakerId.slice(4) : "";
6012
+ if (actorId)
6013
+ action["actor_id"] = actorId;
6014
+ }
6015
+ action["speakers"] = rewriteSpeakerRefs(action["speakers"], speakerReplacements);
6016
+ action["lines"] = rewriteSpeakerRefs(action["lines"], speakerReplacements);
6017
+ }
6018
+ }
6019
+ }
6020
+ script["speakers"] = asList(script["speakers"]).filter((speaker) => isDict(speaker) && !speakerReplacements.has(strOf(speaker["speaker_id"])));
6021
+ return { speaker_refs_normalized: speakerRefsNormalized, speakers_removed: speakerReplacements.size };
6022
+ }
5820
6023
  function copyStatesForMerge(script, kind, source, target, stateReplacements) {
5821
6024
  for (const state of asList(source["states"])) {
5822
6025
  const sourceStateId = strOf(state["state_id"]);
@@ -6007,6 +6210,7 @@ function applyAssetCurationDecisions(script, decisions, coverageSummary, default
6007
6210
  }
6008
6211
  }
6009
6212
  }
6213
+ const speakerNormalizationSummary = normalizeNonActorSpeakersToActorSpeakers(script);
6010
6214
  const actorsAfter = asList(script["actors"]).length;
6011
6215
  const locationsAfter = asList(script["locations"]).length;
6012
6216
  const propsAfter = asList(script["props"]).length;
@@ -6030,6 +6234,8 @@ function applyAssetCurationDecisions(script, decisions, coverageSummary, default
6030
6234
  default_policy: defaultPolicy,
6031
6235
  coverage: coverageSummary,
6032
6236
  actions: summarizeActionableCuration(decisions),
6237
+ speaker_refs_normalized: speakerNormalizationSummary["speaker_refs_normalized"],
6238
+ speakers_normalized_removed: speakerNormalizationSummary["speakers_removed"],
6033
6239
  },
6034
6240
  decisions,
6035
6241
  };
@@ -6171,6 +6377,907 @@ export function curateScriptAssets(script, rawCuration = null) {
6171
6377
  };
6172
6378
  }
6173
6379
  // ---------------------------------------------------------------------------
6380
+ // State curation
6381
+ // ---------------------------------------------------------------------------
6382
+ export const STATE_CURATION_MAX_ASSETS = 4;
6383
+ export const STATE_CURATION_MAX_CONTEXT_CHARS = 28000;
6384
+ function stateCurationBlocked(message, received) {
6385
+ throw new CliError("DIRECT STATE CURATION BLOCKED: invalid provider plan", message, {
6386
+ exitCode: EXIT_NEEDS_AGENT,
6387
+ required: ["valid state curation expressions referencing existing asset/state ids"],
6388
+ received,
6389
+ nextSteps: ["Inspect state_curation.json, adjust the draft manually, or rerun direct init after provider plan fixes."],
6390
+ });
6391
+ }
6392
+ function stateCurationSourceKey(kind, assetId, stateId) {
6393
+ return `${kind}:${assetId}/${stateId}`;
6394
+ }
6395
+ function stateCurationDecisionSourceKey(decision) {
6396
+ return stateCurationSourceKey(strOf(decision["kind"]), strOf(decision["asset_id"]), strOf(decision["state_id"]));
6397
+ }
6398
+ function stateCurationAssetSourceKey(kind, assetId) {
6399
+ return `${kind}:${assetId}`;
6400
+ }
6401
+ function stateCurationDefaultSelectionSourceKey(selection) {
6402
+ return stateCurationAssetSourceKey(strOf(selection["kind"]), strOf(selection["asset_id"]));
6403
+ }
6404
+ function stateCurationUsageFor(script) {
6405
+ const usage = new Map();
6406
+ const ensure = (kind, assetId, stateId) => {
6407
+ const key = stateCurationSourceKey(kind, assetId, stateId);
6408
+ const current = usage.get(key);
6409
+ if (current)
6410
+ return current;
6411
+ const next = { sceneRefs: new Set(), stateChanges: new Set() };
6412
+ usage.set(key, next);
6413
+ return next;
6414
+ };
6415
+ for (const ep of asList(script["episodes"])) {
6416
+ const episodeId = strOf(ep["episode_id"]);
6417
+ for (const scene of asList(ep["scenes"])) {
6418
+ const sceneId = strOf(scene["scene_id"]);
6419
+ const sceneRef = [episodeId, sceneId].filter((item) => item).join("/");
6420
+ const ctx = sceneContext(scene);
6421
+ for (const kind of ["actor", "location", "prop"]) {
6422
+ const idKey = assetIdKey(kind);
6423
+ const refKey = assetListKey(kind);
6424
+ for (const ref of asList(ctx[refKey])) {
6425
+ const assetId = strOf(ref[idKey]);
6426
+ const stateId = strOf(ref["state_id"]);
6427
+ if (assetId && stateId && sceneRef)
6428
+ ensure(kind, assetId, stateId).sceneRefs.add(sceneRef);
6429
+ }
6430
+ }
6431
+ for (const [actionIndex, action] of asList(scene["actions"]).entries()) {
6432
+ if (!isDict(action))
6433
+ continue;
6434
+ for (const change of asList(action["state_changes"])) {
6435
+ if (!isDict(change))
6436
+ continue;
6437
+ const kind = strOf(change["target_kind"]);
6438
+ if (!isAssetKindValue(kind))
6439
+ continue;
6440
+ const assetId = strOf(change["target_id"]);
6441
+ if (!assetId)
6442
+ continue;
6443
+ const actionRef = `${sceneRef}#${actionIndex}`;
6444
+ const fromStateId = strOf(change["from_state_id"]);
6445
+ const toStateId = strOf(change["to_state_id"]);
6446
+ if (fromStateId)
6447
+ ensure(kind, assetId, fromStateId).stateChanges.add(actionRef);
6448
+ if (toStateId)
6449
+ ensure(kind, assetId, toStateId).stateChanges.add(actionRef);
6450
+ }
6451
+ }
6452
+ }
6453
+ }
6454
+ return usage;
6455
+ }
6456
+ function statefulAssetsForCuration(script) {
6457
+ const refs = [];
6458
+ for (const kind of ["actor", "location", "prop"]) {
6459
+ for (const asset of asList(script[assetListKey(kind)])) {
6460
+ if (!isDict(asset))
6461
+ continue;
6462
+ const assetId = strOf(asset[assetIdKey(kind)]).trim();
6463
+ if (!assetId || asList(asset["states"]).filter(isDict).length === 0)
6464
+ continue;
6465
+ refs.push({ kind, asset_id: assetId });
6466
+ }
6467
+ }
6468
+ return refs;
6469
+ }
6470
+ function stateCurationAssetByRef(script, ref) {
6471
+ return assetMapById(script, ref.kind).get(ref.asset_id) ?? null;
6472
+ }
6473
+ function compactStateCurationText(value, maxChars = 120) {
6474
+ const text = strOf(value).replace(/\s+/g, " ").replace(/\|/g, "/").trim();
6475
+ return text.length > maxChars ? `${text.slice(0, maxChars - 1)}…` : text;
6476
+ }
6477
+ function formatStateCurationUsage(usage) {
6478
+ if (!usage)
6479
+ return "refs=0 changes=0";
6480
+ return `refs=${usage.sceneRefs.size} changes=${usage.stateChanges.size}`;
6481
+ }
6482
+ function stateCurationClusterKey(stateName) {
6483
+ const name = strOf(stateName).replace(/[默认基础普通日常]/g, "").replace(/(?:状态|形态|造型|姿态|阶段|版本|版|态|装)$/g, "").trim();
6484
+ const cjk = /[\u4e00-\u9fff]{2,}/.exec(name);
6485
+ if (cjk)
6486
+ return cjk[0].slice(0, 2);
6487
+ const ascii = /[A-Za-z0-9]{3,}/.exec(name);
6488
+ return ascii ? ascii[0].slice(0, 6).toLowerCase() : "";
6489
+ }
6490
+ function formatStateCurationClusterHints(states) {
6491
+ const buckets = new Map();
6492
+ for (const state of states) {
6493
+ const stateId = compactStateCurationText(state["state_id"], 40);
6494
+ const stateName = compactStateCurationText(state["state_name"], 80);
6495
+ if (!stateId || !stateName || stateName === "默认")
6496
+ continue;
6497
+ const key = stateCurationClusterKey(stateName);
6498
+ if (!key)
6499
+ continue;
6500
+ if (!buckets.has(key))
6501
+ buckets.set(key, []);
6502
+ buckets.get(key).push(`${stateId}:${stateName}`);
6503
+ }
6504
+ const lines = [];
6505
+ for (const [key, members] of buckets) {
6506
+ if (members.length < 2)
6507
+ continue;
6508
+ lines.push(` C | similar_name_cluster=${key} | ${members.slice(0, 12).join(" ~ ")}`);
6509
+ }
6510
+ return lines;
6511
+ }
6512
+ function stateCurationPrefix(kind) {
6513
+ if (kind === "actor")
6514
+ return "A";
6515
+ if (kind === "location")
6516
+ return "L";
6517
+ return "P";
6518
+ }
6519
+ function formatStateCurationAssetBlock(script, ref, usageByKey) {
6520
+ const asset = stateCurationAssetByRef(script, ref);
6521
+ if (!asset)
6522
+ return [];
6523
+ const name = compactStateCurationText(asset[assetNameKey(ref.kind)], 80);
6524
+ const description = compactStateCurationText(asset["description"], 160);
6525
+ const states = asList(asset["states"]).filter(isDict);
6526
+ const lines = [
6527
+ `${stateCurationPrefix(ref.kind)} | ${ref.kind} | ${ref.asset_id} | ${name || "-"} | states=${states.length} | desc=${description || "-"}`,
6528
+ ];
6529
+ for (const state of states) {
6530
+ const stateId = compactStateCurationText(state["state_id"], 40);
6531
+ const stateName = compactStateCurationText(state["state_name"], 80);
6532
+ if (!stateId || !stateName)
6533
+ continue;
6534
+ const usage = usageByKey.get(stateCurationSourceKey(ref.kind, ref.asset_id, stateId));
6535
+ const stateReason = compactStateCurationText(state["state_reason"], 220);
6536
+ lines.push(` S | ${stateId} | ${stateName} | ${formatStateCurationUsage(usage)} | desc=${compactStateCurationText(state["description"], 180) || "-"} | reason=${stateReason || "-"}`);
6537
+ }
6538
+ lines.push(...formatStateCurationClusterHints(states));
6539
+ return lines;
6540
+ }
6541
+ export function buildStateCurationContextText(script, refs) {
6542
+ const selectedRefs = refs && refs.length > 0 ? refs : statefulAssetsForCuration(script);
6543
+ const usage = stateCurationUsageFor(script);
6544
+ const lines = [];
6545
+ lines.push("# State Curation Context");
6546
+ lines.push(`title: ${compactStateCurationText(script["title"], 120) || "-"}`);
6547
+ lines.push("");
6548
+ lines.push("Goal: compress each asset's states to reusable production visual states.");
6549
+ lines.push("");
6550
+ lines.push("Policy:");
6551
+ lines.push("- A state is a large reusable visual asset form, not a plot/action/emotion/pose label.");
6552
+ lines.push("- KEEP only macro states that require a visibly different reusable asset: major form transformation, Q version, non-human/beast/god/giant form, durable outfit/appearance redesign, major damage/repair condition, a prop/location physical condition that must recur, or a location time/lighting state from a scene header such as 黄昏/夜晚/清晨.");
6553
+ lines.push("- Actor VFX/power intensity is not a separate state by itself: glowing, flashing, aura, light wall, brightness, totem spreading, transparent/solidifying/recovery frames, and one-action power bursts should MERGE into the nearest broad macro form or become ACTION_STATE.");
6554
+ lines.push("- Actor base human/humanoid shape is not a separate state when the default actor already covers it. Keep human-form states only when they carry a distinct age period, disguise, outfit, or major body redesign.");
6555
+ lines.push("- Prefer one broad macro state over many process frames. MERGE states that are only degree/progress variants of the same major appearance.");
6556
+ lines.push("- DROP or ACTION_STATE transient states: emotions, poses, unconscious/asleep/lying/sitting, holding/carrying an item, temporary glow/VFX intensity, fading/transparent/solidifying progress frames, minor mark brightness/thickness/spread, one-off ordinary clothing, camera effects, or scene-only actions.");
6557
+ lines.push("- Treat each `reason=` field as evidence text, not as a keep decision. Phrases like `needs a separate image`, `reusable`, or `stable` in the extraction reason are not enough to KEEP unless the name/description show a distinct macro form.");
6558
+ lines.push("- For one continuous transformation/recovery process, keep at most one broad representative state for the asset form. MERGE/DROP intermediate frames such as half-transparent body, lower body vanished, only head remains, partial solidifying, brightness increasing, or mark spreading.");
6559
+ lines.push("- For actor states, held objects are not actor states. If the state is just holding/using an item, use ACTION_STATE unless the item must remain a separate prop.");
6560
+ lines.push("- State descriptions must contain only durable visible appearance. Remove camera/staging/story wording such as back-to-camera, face hidden, close-up, initial pose, entering, taking hands, kissing, or standing in a scene.");
6561
+ lines.push("- When keeping a useful state whose description contains camera/staging/story wording, keep the state and rewrite the description with DESC.");
6562
+ lines.push("- Every S line below must receive exactly one expression.");
6563
+ lines.push("- Every asset block with at least one S line must also receive exactly one DEFAULT_STATE expression.");
6564
+ lines.push("- DEFAULT_STATE selects the most common normal appearance for that asset. Keep the selected state's readable state_name; only its state_id will become `default` during deterministic apply.");
6565
+ lines.push("- Use only state_id values listed under the same asset block. Do not invent ids or cross-asset merge targets.");
6566
+ lines.push("- If a broad canonical state name is needed, RENAME_STATE one existing representative state and MERGE_STATE the variants into it.");
6567
+ lines.push("");
6568
+ lines.push("Expression grammar:");
6569
+ lines.push("- <exp>KEEP_STATE <kind> <asset_id> <state_id> [DESC=\"<clean visual description>\"] # reason</exp>");
6570
+ lines.push("- <exp>MERGE_STATE <kind> <asset_id> <state_id> -> <target_state_id> # reason</exp>");
6571
+ lines.push("- <exp>DROP_STATE <kind> <asset_id> <state_id> # reason</exp>");
6572
+ lines.push("- <exp>ACTION_STATE <kind> <asset_id> <state_id> # reason</exp>");
6573
+ lines.push("- <exp>RENAME_STATE <kind> <asset_id> <state_id> \"<new_name>\" [DESC=\"<clean visual description>\"] # reason</exp>");
6574
+ lines.push("- <exp>DEFAULT_STATE <kind> <asset_id> <state_id> # reason</exp>");
6575
+ lines.push("- <kind> is one of actor, location, prop. Use double quotes for names; escape only \\\" and \\\\.");
6576
+ lines.push("");
6577
+ lines.push("Assets:");
6578
+ for (const ref of selectedRefs) {
6579
+ const block = formatStateCurationAssetBlock(script, ref, usage);
6580
+ if (block.length > 0)
6581
+ lines.push(...block);
6582
+ }
6583
+ lines.push("");
6584
+ lines.push("required_state_ids:");
6585
+ for (const ref of selectedRefs) {
6586
+ const asset = stateCurationAssetByRef(script, ref);
6587
+ if (!asset)
6588
+ continue;
6589
+ for (const state of asList(asset["states"])) {
6590
+ const stateId = strOf(state["state_id"]);
6591
+ if (stateId)
6592
+ lines.push(`- ${stateCurationSourceKey(ref.kind, ref.asset_id, stateId)}`);
6593
+ }
6594
+ }
6595
+ lines.push("");
6596
+ lines.push("required_default_assets:");
6597
+ for (const ref of selectedRefs) {
6598
+ const asset = stateCurationAssetByRef(script, ref);
6599
+ if (!asset || asList(asset["states"]).filter(isDict).length === 0)
6600
+ continue;
6601
+ lines.push(`- ${stateCurationAssetSourceKey(ref.kind, ref.asset_id)}`);
6602
+ }
6603
+ return `${lines.join("\n")}\n`;
6604
+ }
6605
+ export function buildStateCurationChunks(script, options = {}) {
6606
+ const maxAssets = Math.max(1, Math.floor(options.maxAssets ?? STATE_CURATION_MAX_ASSETS));
6607
+ const maxContextChars = Math.max(2000, Math.floor(options.maxContextChars ?? STATE_CURATION_MAX_CONTEXT_CHARS));
6608
+ const refs = statefulAssetsForCuration(script);
6609
+ const chunks = [];
6610
+ let current = [];
6611
+ const flush = () => {
6612
+ if (current.length === 0)
6613
+ return;
6614
+ chunks.push({ part_id: `part.${String(chunks.length + 1).padStart(3, "0")}`, refs: current });
6615
+ current = [];
6616
+ };
6617
+ for (const ref of refs) {
6618
+ const singletonLength = buildStateCurationContextText(script, [ref]).length;
6619
+ if (current.length === 0) {
6620
+ current.push(ref);
6621
+ if (singletonLength > maxContextChars)
6622
+ flush();
6623
+ continue;
6624
+ }
6625
+ const candidate = [...current, ref];
6626
+ if (candidate.length > maxAssets || buildStateCurationContextText(script, candidate).length > maxContextChars) {
6627
+ flush();
6628
+ current.push(ref);
6629
+ if (singletonLength > maxContextChars)
6630
+ flush();
6631
+ }
6632
+ else {
6633
+ current = candidate;
6634
+ }
6635
+ }
6636
+ flush();
6637
+ return chunks;
6638
+ }
6639
+ function splitStateCurationExpressionReason(expression) {
6640
+ let inQuote = false;
6641
+ let escaped = false;
6642
+ for (let index = 0; index < expression.length; index += 1) {
6643
+ const ch = expression[index];
6644
+ if (escaped) {
6645
+ escaped = false;
6646
+ continue;
6647
+ }
6648
+ if (ch === "\\") {
6649
+ escaped = true;
6650
+ continue;
6651
+ }
6652
+ if (ch === "\"") {
6653
+ inQuote = !inQuote;
6654
+ continue;
6655
+ }
6656
+ if (ch === "#" && !inQuote) {
6657
+ const command = expression.slice(0, index).trim();
6658
+ const reason = expression.slice(index + 1).trim();
6659
+ if (!command)
6660
+ stateCurationBlocked("State curation expression is missing an operation before # reason.", [expression]);
6661
+ if (!reason)
6662
+ stateCurationBlocked("State curation expression is missing reason text after #.", [expression]);
6663
+ return { command, reason };
6664
+ }
6665
+ }
6666
+ stateCurationBlocked("State curation expression must include `# reason`.", [expression]);
6667
+ }
6668
+ function unquoteStateCurationExpressionString(value) {
6669
+ let out = "";
6670
+ let escaped = false;
6671
+ for (const ch of value) {
6672
+ if (escaped) {
6673
+ if (ch !== "\\" && ch !== "\"")
6674
+ stateCurationBlocked("Unsupported escape sequence in state curation expression string.", [`\\${ch}`]);
6675
+ out += ch;
6676
+ escaped = false;
6677
+ }
6678
+ else if (ch === "\\") {
6679
+ escaped = true;
6680
+ }
6681
+ else {
6682
+ out += ch;
6683
+ }
6684
+ }
6685
+ if (escaped)
6686
+ stateCurationBlocked("Unterminated escape sequence in state curation expression string.", [value]);
6687
+ return out;
6688
+ }
6689
+ function parseStateCurationExpression(expression) {
6690
+ if (expression.includes("\n") || expression.includes("\r")) {
6691
+ stateCurationBlocked("Each state curation expression must be a single line.", [expression]);
6692
+ }
6693
+ const { command, reason } = splitStateCurationExpressionReason(expression.trim());
6694
+ if (/^NOOP$/i.test(command))
6695
+ return null;
6696
+ let match = /^DEFAULT_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)$/i.exec(command);
6697
+ if (match) {
6698
+ return {
6699
+ kind: match[1].toLowerCase(),
6700
+ asset_id: match[2],
6701
+ state_id: match[3],
6702
+ decision: "default",
6703
+ reason,
6704
+ };
6705
+ }
6706
+ match = /^KEEP_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)(?:\s+DESC="(.*)")?$/i.exec(command);
6707
+ if (match) {
6708
+ const decision = { kind: match[1].toLowerCase(), asset_id: match[2], state_id: match[3], decision: "keep", reason };
6709
+ if (match[4] !== undefined)
6710
+ decision["new_description"] = unquoteStateCurationExpressionString(match[4]);
6711
+ return decision;
6712
+ }
6713
+ match = /^MERGE_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)\s+->\s+(\S+)$/i.exec(command);
6714
+ if (match) {
6715
+ return {
6716
+ kind: match[1].toLowerCase(),
6717
+ asset_id: match[2],
6718
+ state_id: match[3],
6719
+ decision: "merge",
6720
+ target_state_id: match[4],
6721
+ reason,
6722
+ };
6723
+ }
6724
+ match = /^DROP_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)(?:\s+DESC=".*")?$/i.exec(command);
6725
+ if (match) {
6726
+ return { kind: match[1].toLowerCase(), asset_id: match[2], state_id: match[3], decision: "drop", reason };
6727
+ }
6728
+ match = /^ACTION_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)(?:\s+DESC=".*")?$/i.exec(command);
6729
+ if (match) {
6730
+ return { kind: match[1].toLowerCase(), asset_id: match[2], state_id: match[3], decision: "action_desc", reason };
6731
+ }
6732
+ match = /^RENAME_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)\s+"((?:\\.|[^"\\])*)"(?:\s+DESC="(.*)")?$/i.exec(command);
6733
+ if (match) {
6734
+ const decision = {
6735
+ kind: match[1].toLowerCase(),
6736
+ asset_id: match[2],
6737
+ state_id: match[3],
6738
+ decision: "rename",
6739
+ new_name: unquoteStateCurationExpressionString(match[4]),
6740
+ reason,
6741
+ };
6742
+ if (match[5] !== undefined)
6743
+ decision["new_description"] = unquoteStateCurationExpressionString(match[5]);
6744
+ return decision;
6745
+ }
6746
+ stateCurationBlocked("Unknown or malformed state curation expression.", [expression]);
6747
+ }
6748
+ function extractStateCurationExpressions(rawText) {
6749
+ const expressions = [];
6750
+ const re = /<exp>([\s\S]*?)<\/exp>/g;
6751
+ let match;
6752
+ while ((match = re.exec(rawText)) !== null) {
6753
+ expressions.push(strOf(match[1]).trim());
6754
+ }
6755
+ if (expressions.length === 0) {
6756
+ stateCurationBlocked("State curation response did not contain any <exp> expressions.", [rawText.slice(0, 200)]);
6757
+ }
6758
+ return expressions;
6759
+ }
6760
+ export function parseStateCurationExpressions(rawText) {
6761
+ const decisions = [];
6762
+ const defaultSelections = [];
6763
+ const expressions = extractStateCurationExpressions(rawText);
6764
+ let noop = false;
6765
+ for (const expression of expressions) {
6766
+ const decision = parseStateCurationExpression(expression);
6767
+ if (decision === null)
6768
+ noop = true;
6769
+ else if (strOf(decision["decision"]) === "default")
6770
+ defaultSelections.push(decision);
6771
+ else
6772
+ decisions.push(decision);
6773
+ }
6774
+ if (noop && (decisions.length > 0 || defaultSelections.length > 0)) {
6775
+ stateCurationBlocked("NOOP cannot be combined with actionable state curation expressions.", [rawText.slice(0, 200)]);
6776
+ }
6777
+ return {
6778
+ version: 1,
6779
+ format: "state-curation-exp-v1",
6780
+ expression_text: rawText,
6781
+ expressions,
6782
+ decisions,
6783
+ default_selections: defaultSelections,
6784
+ };
6785
+ }
6786
+ function renderStateCurationDecisionExpression(decision) {
6787
+ const kind = strOf(decision["kind"]);
6788
+ const assetId = strOf(decision["asset_id"]);
6789
+ const stateId = strOf(decision["state_id"]);
6790
+ const reason = strOf(decision["reason"]).trim() || "state curation decision";
6791
+ const action = strOf(decision["decision"]);
6792
+ if (action === "default")
6793
+ return `DEFAULT_STATE ${kind} ${assetId} ${stateId} # ${reason}`;
6794
+ if (action === "merge")
6795
+ return `MERGE_STATE ${kind} ${assetId} ${stateId} -> ${strOf(decision["target_state_id"])} # ${reason}`;
6796
+ if (action === "drop")
6797
+ return `DROP_STATE ${kind} ${assetId} ${stateId} # ${reason}`;
6798
+ if (action === "action_desc")
6799
+ return `ACTION_STATE ${kind} ${assetId} ${stateId} # ${reason}`;
6800
+ if (action === "keep") {
6801
+ const desc = Object.prototype.hasOwnProperty.call(decision, "new_description")
6802
+ ? ` DESC=${quoteCurationExpressionString(decision["new_description"])}`
6803
+ : "";
6804
+ return `KEEP_STATE ${kind} ${assetId} ${stateId}${desc} # ${reason}`;
6805
+ }
6806
+ if (action === "rename") {
6807
+ const desc = Object.prototype.hasOwnProperty.call(decision, "new_description")
6808
+ ? ` DESC=${quoteCurationExpressionString(decision["new_description"])}`
6809
+ : "";
6810
+ return `RENAME_STATE ${kind} ${assetId} ${stateId} ${quoteCurationExpressionString(decision["new_name"])}${desc} # ${reason}`;
6811
+ }
6812
+ stateCurationBlocked("Cannot render unknown state curation decision.", [`decision: ${action || "<empty>"}`]);
6813
+ }
6814
+ export function formatStateCurationDecisionExpressions(plan) {
6815
+ const decisions = asList(plan["decisions"]).filter(isDict);
6816
+ const defaultSelections = asList(plan["default_selections"]).filter(isDict);
6817
+ const expressions = [...decisions, ...defaultSelections].map((decision) => `<exp>${renderStateCurationDecisionExpression(decision)}</exp>`);
6818
+ if (expressions.length === 0)
6819
+ return "<exp>NOOP # no state curation changes</exp>";
6820
+ return `${expressions.join("\n")}\n`;
6821
+ }
6822
+ function stateCurationAllSourceKeys(script) {
6823
+ const keys = [];
6824
+ for (const ref of statefulAssetsForCuration(script)) {
6825
+ const asset = stateCurationAssetByRef(script, ref);
6826
+ if (!asset)
6827
+ continue;
6828
+ for (const state of asList(asset["states"])) {
6829
+ const stateId = strOf(state["state_id"]);
6830
+ if (stateId)
6831
+ keys.push(stateCurationSourceKey(ref.kind, ref.asset_id, stateId));
6832
+ }
6833
+ }
6834
+ return keys;
6835
+ }
6836
+ export function stateCurationSourceKeysForRefs(script, refs) {
6837
+ const keys = [];
6838
+ for (const ref of refs) {
6839
+ const asset = stateCurationAssetByRef(script, ref);
6840
+ if (!asset)
6841
+ continue;
6842
+ for (const state of asList(asset["states"])) {
6843
+ const stateId = strOf(state["state_id"]);
6844
+ if (stateId)
6845
+ keys.push(stateCurationSourceKey(ref.kind, ref.asset_id, stateId));
6846
+ }
6847
+ }
6848
+ return keys;
6849
+ }
6850
+ function stateCurationDecisionMap(decisions) {
6851
+ const map = new Map();
6852
+ for (const decision of decisions) {
6853
+ map.set(stateCurationDecisionSourceKey(decision), decision);
6854
+ }
6855
+ return map;
6856
+ }
6857
+ export function missingStateCurationRequiredSourceKeys(script, plan, requiredKeys) {
6858
+ const keys = requiredKeys && requiredKeys.length > 0 ? [...requiredKeys] : stateCurationAllSourceKeys(script);
6859
+ const decisions = stateCurationDecisionMap(asList(plan["decisions"]).filter(isDict));
6860
+ return keys.filter((key) => !decisions.has(key));
6861
+ }
6862
+ function stateCurationRequiredDefaultAssetKeys(script, requiredKeys) {
6863
+ if (requiredKeys && requiredKeys.length > 0) {
6864
+ const keys = new Set();
6865
+ for (const key of requiredKeys) {
6866
+ const match = /^(actor|location|prop):([^/]+)\//.exec(key);
6867
+ if (match)
6868
+ keys.add(stateCurationAssetSourceKey(match[1], match[2]));
6869
+ }
6870
+ return [...keys];
6871
+ }
6872
+ return statefulAssetsForCuration(script).map((ref) => stateCurationAssetSourceKey(ref.kind, ref.asset_id));
6873
+ }
6874
+ function stateCurationDefaultSelectionMap(selections) {
6875
+ const map = new Map();
6876
+ for (const selection of selections) {
6877
+ const key = stateCurationDefaultSelectionSourceKey(selection);
6878
+ if (map.has(key))
6879
+ stateCurationBlocked("State curation plan selects more than one default state for an asset.", [key]);
6880
+ map.set(key, selection);
6881
+ }
6882
+ return map;
6883
+ }
6884
+ function assertStateExistsForCuration(script, kind, assetId, stateId) {
6885
+ const asset = assertAssetExists(script, kind, assetId);
6886
+ for (const state of asList(asset["states"])) {
6887
+ if (strOf(state["state_id"]) === stateId)
6888
+ return state;
6889
+ }
6890
+ stateCurationBlocked("State curation decision references an unknown state.", [stateCurationSourceKey(kind, assetId, stateId)]);
6891
+ }
6892
+ function validateStateCurationDecision(script, decision) {
6893
+ const kind = strOf(decision["kind"]);
6894
+ if (!isAssetKindValue(kind))
6895
+ stateCurationBlocked("Unknown state curation kind.", [`kind: ${kind || "<empty>"}`]);
6896
+ const assetId = strOf(decision["asset_id"]);
6897
+ const stateId = strOf(decision["state_id"]);
6898
+ const action = strOf(decision["decision"]);
6899
+ const reason = strOf(decision["reason"]);
6900
+ const rewritesDescription = Object.prototype.hasOwnProperty.call(decision, "new_description");
6901
+ if (!assetId || !stateId)
6902
+ stateCurationBlocked("State curation decision is missing asset_id/state_id.", [`${kind}:${assetId}/${stateId}`]);
6903
+ if (!reason)
6904
+ stateCurationBlocked("State curation decision is missing reason.", [`${kind}:${assetId}/${stateId}`]);
6905
+ assertStateExistsForCuration(script, kind, assetId, stateId);
6906
+ if (action === "default") {
6907
+ if (rewritesDescription)
6908
+ stateCurationBlocked("DEFAULT_STATE cannot rewrite descriptions.", [`${kind}:${assetId}/${stateId}`]);
6909
+ return;
6910
+ }
6911
+ if (rewritesDescription) {
6912
+ const newDescription = strOf(decision["new_description"]).trim();
6913
+ if (!newDescription)
6914
+ stateCurationBlocked("State description rewrite requires a non-empty description.", [`${kind}:${assetId}/${stateId}`]);
6915
+ if (action !== "keep" && action !== "rename") {
6916
+ stateCurationBlocked("State description rewrite is only valid on KEEP_STATE or RENAME_STATE.", [`${kind}:${assetId}/${stateId}:${action}`]);
6917
+ }
6918
+ }
6919
+ if (action === "merge") {
6920
+ const targetStateId = strOf(decision["target_state_id"]);
6921
+ if (!targetStateId || targetStateId === stateId) {
6922
+ stateCurationBlocked("MERGE_STATE requires a different target_state_id.", [`${kind}:${assetId}/${stateId}`]);
6923
+ }
6924
+ assertStateExistsForCuration(script, kind, assetId, targetStateId);
6925
+ }
6926
+ else if (action === "rename") {
6927
+ const newName = strOf(decision["new_name"]).trim();
6928
+ if (!newName)
6929
+ stateCurationBlocked("RENAME_STATE requires a non-empty new_name.", [`${kind}:${assetId}/${stateId}`]);
6930
+ }
6931
+ else if (action !== "keep" && action !== "drop" && action !== "action_desc") {
6932
+ stateCurationBlocked("Unknown state curation decision.", [`decision: ${action || "<empty>"}`]);
6933
+ }
6934
+ }
6935
+ function normalizeStateCurationMergeTargets(decisions) {
6936
+ const bySource = stateCurationDecisionMap(decisions);
6937
+ for (const decision of decisions) {
6938
+ if (strOf(decision["decision"]) !== "merge")
6939
+ continue;
6940
+ const kind = strOf(decision["kind"]);
6941
+ const assetId = strOf(decision["asset_id"]);
6942
+ let targetStateId = strOf(decision["target_state_id"]);
6943
+ const seen = new Set([stateCurationDecisionSourceKey(decision)]);
6944
+ while (targetStateId) {
6945
+ const targetKey = stateCurationSourceKey(kind, assetId, targetStateId);
6946
+ if (seen.has(targetKey))
6947
+ stateCurationBlocked("State curation merge plan contains a cycle.", [`cycle at ${targetKey}`]);
6948
+ seen.add(targetKey);
6949
+ const targetDecision = bySource.get(targetKey);
6950
+ if (!targetDecision)
6951
+ break;
6952
+ const targetAction = strOf(targetDecision["decision"]);
6953
+ if (targetAction === "merge") {
6954
+ targetStateId = strOf(targetDecision["target_state_id"]);
6955
+ decision["target_state_id"] = targetStateId;
6956
+ continue;
6957
+ }
6958
+ if (targetAction === "drop" || targetAction === "action_desc") {
6959
+ decision["decision"] = targetAction;
6960
+ delete decision["target_state_id"];
6961
+ }
6962
+ break;
6963
+ }
6964
+ }
6965
+ }
6966
+ export function assertStateCurationPlan(script, plan, requiredKeys) {
6967
+ const rawDecisions = asList(plan["decisions"]).filter(isDict);
6968
+ const decisions = [...stateCurationDecisionMap(rawDecisions).values()];
6969
+ const defaultSelections = asList(plan["default_selections"]).filter(isDict);
6970
+ for (const decision of decisions)
6971
+ validateStateCurationDecision(script, decision);
6972
+ for (const selection of defaultSelections)
6973
+ validateStateCurationDecision(script, selection);
6974
+ normalizeStateCurationMergeTargets(decisions);
6975
+ const missing = missingStateCurationRequiredSourceKeys(script, { decisions }, requiredKeys);
6976
+ if (missing.length > 0) {
6977
+ stateCurationBlocked("State curation plan is missing required state decisions.", missing.slice(0, 80));
6978
+ }
6979
+ const defaultMap = stateCurationDefaultSelectionMap(defaultSelections);
6980
+ const missingDefaults = stateCurationRequiredDefaultAssetKeys(script, requiredKeys).filter((key) => !defaultMap.has(key));
6981
+ if (missingDefaults.length > 0) {
6982
+ stateCurationBlocked("State curation plan is missing required DEFAULT_STATE selections.", missingDefaults.slice(0, 80));
6983
+ }
6984
+ return {
6985
+ ...plan,
6986
+ decisions,
6987
+ default_selections: [...defaultMap.values()],
6988
+ };
6989
+ }
6990
+ function mapStateForCuration(kind, assetId, stateId, replacements, removed) {
6991
+ const id = strOf(stateId);
6992
+ if (!id)
6993
+ return null;
6994
+ const key = stateCurationSourceKey(kind, assetId, id);
6995
+ const replacement = replacements.get(key);
6996
+ if (replacement)
6997
+ return replacement;
6998
+ if (removed.has(key))
6999
+ return null;
7000
+ return id;
7001
+ }
7002
+ function rewriteRefsAfterStateCuration(script, replacements, removed) {
7003
+ let sceneRefsCleared = 0;
7004
+ let sceneRefsMerged = 0;
7005
+ let stateChangesRemoved = 0;
7006
+ let stateChangesRewritten = 0;
7007
+ for (const ep of asList(script["episodes"])) {
7008
+ for (const scene of asList(ep["scenes"])) {
7009
+ const ctx = sceneContext(scene);
7010
+ for (const kind of ["actor", "location", "prop"]) {
7011
+ const idKey = assetIdKey(kind);
7012
+ const refKey = assetListKey(kind);
7013
+ for (const ref of asList(ctx[refKey])) {
7014
+ if (!isDict(ref))
7015
+ continue;
7016
+ const assetId = strOf(ref[idKey]);
7017
+ const prior = strOf(ref["state_id"]);
7018
+ const next = mapStateForCuration(kind, assetId, prior, replacements, removed);
7019
+ if (!next && prior) {
7020
+ ref["state_id"] = null;
7021
+ sceneRefsCleared += 1;
7022
+ }
7023
+ else if (next && next !== prior) {
7024
+ ref["state_id"] = next;
7025
+ sceneRefsMerged += 1;
7026
+ }
7027
+ }
7028
+ }
7029
+ setSceneContext(scene, ctx);
7030
+ for (const action of asList(scene["actions"])) {
7031
+ if (!isDict(action) || action["state_changes"] === undefined || action["state_changes"] === null)
7032
+ continue;
7033
+ const nextChanges = [];
7034
+ for (const change of asList(action["state_changes"])) {
7035
+ if (!isDict(change))
7036
+ continue;
7037
+ const kind = strOf(change["target_kind"]);
7038
+ if (!isAssetKindValue(kind))
7039
+ continue;
7040
+ const assetId = strOf(change["target_id"]);
7041
+ const toState = mapStateForCuration(kind, assetId, change["to_state_id"], replacements, removed);
7042
+ if (!toState) {
7043
+ stateChangesRemoved += 1;
7044
+ continue;
7045
+ }
7046
+ const fromState = mapStateForCuration(kind, assetId, change["from_state_id"], replacements, removed);
7047
+ if (fromState && fromState === toState) {
7048
+ stateChangesRemoved += 1;
7049
+ continue;
7050
+ }
7051
+ const next = { ...change, to_state_id: toState };
7052
+ if (fromState)
7053
+ next["from_state_id"] = fromState;
7054
+ else
7055
+ delete next["from_state_id"];
7056
+ if (toState !== strOf(change["to_state_id"]) || fromState !== strOf(change["from_state_id"]))
7057
+ stateChangesRewritten += 1;
7058
+ nextChanges.push(next);
7059
+ }
7060
+ if (nextChanges.length > 0)
7061
+ action["state_changes"] = nextChanges;
7062
+ else
7063
+ delete action["state_changes"];
7064
+ }
7065
+ }
7066
+ }
7067
+ return {
7068
+ scene_refs_cleared: sceneRefsCleared,
7069
+ scene_refs_merged: sceneRefsMerged,
7070
+ state_changes_removed: stateChangesRemoved,
7071
+ state_changes_rewritten: stateChangesRewritten,
7072
+ };
7073
+ }
7074
+ function appendStateCurationMergedFrom(script, kind, assetId, sourceStateId, targetStateId, reason) {
7075
+ const source = assertStateExistsForCuration(script, kind, assetId, sourceStateId);
7076
+ const target = assertStateExistsForCuration(script, kind, assetId, targetStateId);
7077
+ const sourceName = strOf(source["state_name"]).trim();
7078
+ if (!sourceName)
7079
+ return;
7080
+ const existing = asList(target["merged_from"]).filter(isDict);
7081
+ if (existing.some((item) => item["state_name"] === sourceName))
7082
+ return;
7083
+ const next = [
7084
+ ...existing,
7085
+ {
7086
+ state_name: sourceName,
7087
+ reason: cleanStateReason(reason),
7088
+ },
7089
+ ].slice(0, MERGED_FROM_MAX_ITEMS);
7090
+ target["merged_from"] = next;
7091
+ }
7092
+ function synthesizedDefaultState(asset) {
7093
+ const state = {
7094
+ state_id: "default",
7095
+ state_name: "默认",
7096
+ };
7097
+ const description = strOf(asset["description"]).trim();
7098
+ if (description)
7099
+ state["description"] = description;
7100
+ return state;
7101
+ }
7102
+ function canonicalizeDefaultStatesAfterCuration(script, defaultSelections, replacements, removed) {
7103
+ const selectionByAsset = stateCurationDefaultSelectionMap([...defaultSelections]);
7104
+ const defaultReplacements = new Map();
7105
+ const selectedDefaults = [];
7106
+ let synthesized = 0;
7107
+ let rewritten = 0;
7108
+ for (const kind of ["actor", "location", "prop"]) {
7109
+ for (const asset of asList(script[assetListKey(kind)])) {
7110
+ if (!isDict(asset))
7111
+ continue;
7112
+ const assetId = strOf(asset[assetIdKey(kind)]);
7113
+ if (!assetId)
7114
+ continue;
7115
+ const assetKey = stateCurationAssetSourceKey(kind, assetId);
7116
+ const states = asList(asset["states"]).filter(isDict);
7117
+ if (states.length === 0) {
7118
+ asset["states"] = [synthesizedDefaultState(asset)];
7119
+ synthesized += 1;
7120
+ selectedDefaults.push({
7121
+ kind,
7122
+ asset_id: assetId,
7123
+ input_state_id: null,
7124
+ final_state_id: "default",
7125
+ state_name: "默认",
7126
+ synthesized: true,
7127
+ reason: "asset has no surviving explicit states",
7128
+ });
7129
+ continue;
7130
+ }
7131
+ const selection = selectionByAsset.get(assetKey);
7132
+ if (!selection) {
7133
+ stateCurationBlocked("State curation default canonicalization is missing a DEFAULT_STATE selection.", [assetKey]);
7134
+ }
7135
+ const inputStateId = strOf(selection["state_id"]);
7136
+ const selectedStateId = mapStateForCuration(kind, assetId, inputStateId, replacements, removed);
7137
+ if (!selectedStateId) {
7138
+ stateCurationBlocked("DEFAULT_STATE selection was removed while other states survived.", [
7139
+ `${assetKey}/${inputStateId}`,
7140
+ ]);
7141
+ }
7142
+ const selectedState = states.find((state) => strOf(state["state_id"]) === selectedStateId);
7143
+ if (!selectedState) {
7144
+ stateCurationBlocked("DEFAULT_STATE selection does not resolve to a surviving state.", [
7145
+ `${assetKey}/${inputStateId} -> ${selectedStateId}`,
7146
+ ]);
7147
+ }
7148
+ const existingDefault = states.find((state) => strOf(state["state_id"]) === "default");
7149
+ if (existingDefault && existingDefault !== selectedState) {
7150
+ stateCurationBlocked("State curation would create two `default` states for one asset.", [
7151
+ `${assetKey}: selected=${selectedStateId}`,
7152
+ ]);
7153
+ }
7154
+ if (selectedStateId !== "default") {
7155
+ selectedState["state_id"] = "default";
7156
+ defaultReplacements.set(stateCurationSourceKey(kind, assetId, selectedStateId), "default");
7157
+ rewritten += 1;
7158
+ }
7159
+ selectedDefaults.push({
7160
+ kind,
7161
+ asset_id: assetId,
7162
+ input_state_id: inputStateId,
7163
+ final_state_id: "default",
7164
+ original_final_state_id: selectedStateId,
7165
+ state_name: selectedState["state_name"],
7166
+ synthesized: false,
7167
+ reason: selection["reason"],
7168
+ });
7169
+ }
7170
+ }
7171
+ const rewriteSummary = rewriteRefsAfterStateCuration(script, defaultReplacements, new Set());
7172
+ return {
7173
+ default_states_synthesized: synthesized,
7174
+ default_state_ids_rewritten: rewritten,
7175
+ default_ref_rewrite: rewriteSummary,
7176
+ selected_default_states: selectedDefaults,
7177
+ };
7178
+ }
7179
+ export function applyStateCurationPlan(script, rawPlan) {
7180
+ const plan = assertStateCurationPlan(script, rawPlan);
7181
+ const decisions = asList(plan["decisions"]).filter(isDict);
7182
+ const defaultSelections = asList(plan["default_selections"]).filter(isDict);
7183
+ const beforeByKind = { actor: 0, location: 0, prop: 0 };
7184
+ const afterByKind = { actor: 0, location: 0, prop: 0 };
7185
+ for (const kind of ["actor", "location", "prop"]) {
7186
+ for (const asset of asList(script[assetListKey(kind)]))
7187
+ beforeByKind[kind] += asList(asset["states"]).filter(isDict).length;
7188
+ }
7189
+ const replacements = new Map();
7190
+ const removed = new Set();
7191
+ const decisionSummary = {
7192
+ keep: 0,
7193
+ merge: 0,
7194
+ drop: 0,
7195
+ action_desc: 0,
7196
+ rename: 0,
7197
+ description_rewrite: 0,
7198
+ };
7199
+ for (const decision of decisions) {
7200
+ const kind = strOf(decision["kind"]);
7201
+ const assetId = strOf(decision["asset_id"]);
7202
+ const stateId = strOf(decision["state_id"]);
7203
+ const key = stateCurationSourceKey(kind, assetId, stateId);
7204
+ const action = strOf(decision["decision"]);
7205
+ const inputState = assertStateExistsForCuration(script, kind, assetId, stateId);
7206
+ const inputStateName = strOf(inputState["state_name"]).trim();
7207
+ if (inputStateName)
7208
+ decision["input_state_name"] = inputStateName;
7209
+ const inputStateReason = cleanStateReason(inputState["state_reason"]);
7210
+ if (inputStateReason)
7211
+ decision["input_state_reason"] = inputStateReason;
7212
+ decisionSummary[action] = Number(decisionSummary[action] ?? 0) + 1;
7213
+ if (action === "keep" || action === "rename") {
7214
+ const state = inputState;
7215
+ if (action === "rename")
7216
+ state["state_name"] = strOf(decision["new_name"]).trim();
7217
+ if (Object.prototype.hasOwnProperty.call(decision, "new_description")) {
7218
+ const newDescription = strOf(decision["new_description"]).trim();
7219
+ if (strOf(state["description"]) !== newDescription)
7220
+ decisionSummary["description_rewrite"] = Number(decisionSummary["description_rewrite"] ?? 0) + 1;
7221
+ state["description"] = newDescription;
7222
+ }
7223
+ }
7224
+ else if (action === "merge") {
7225
+ const targetStateId = strOf(decision["target_state_id"]);
7226
+ appendStateCurationMergedFrom(script, kind, assetId, stateId, targetStateId, strOf(decision["reason"]));
7227
+ replacements.set(key, targetStateId);
7228
+ removed.add(key);
7229
+ }
7230
+ else if (action === "drop" || action === "action_desc") {
7231
+ removed.add(key);
7232
+ }
7233
+ }
7234
+ const rewriteSummary = rewriteRefsAfterStateCuration(script, replacements, removed);
7235
+ for (const kind of ["actor", "location", "prop"]) {
7236
+ for (const asset of asList(script[assetListKey(kind)])) {
7237
+ if (!isDict(asset))
7238
+ continue;
7239
+ const assetId = strOf(asset[assetIdKey(kind)]);
7240
+ asset["states"] = asList(asset["states"]).filter((state) => {
7241
+ if (!isDict(state))
7242
+ return false;
7243
+ const key = stateCurationSourceKey(kind, assetId, strOf(state["state_id"]));
7244
+ return !removed.has(key);
7245
+ });
7246
+ }
7247
+ }
7248
+ const defaultSummary = canonicalizeDefaultStatesAfterCuration(script, defaultSelections, replacements, removed);
7249
+ for (const kind of ["actor", "location", "prop"]) {
7250
+ for (const asset of asList(script[assetListKey(kind)]))
7251
+ afterByKind[kind] += asList(asset["states"]).filter(isDict).length;
7252
+ }
7253
+ const statesBefore = beforeByKind.actor + beforeByKind.location + beforeByKind.prop;
7254
+ const statesAfter = afterByKind.actor + afterByKind.location + afterByKind.prop;
7255
+ const synthesizedDefaults = Number(defaultSummary["default_states_synthesized"] ?? 0);
7256
+ return {
7257
+ version: 1,
7258
+ format: "state-curation-exp-v1",
7259
+ summary: {
7260
+ states_before: statesBefore,
7261
+ states_after: statesAfter,
7262
+ states_removed: statesBefore - statesAfter + synthesizedDefaults,
7263
+ actor_states_before: beforeByKind.actor,
7264
+ actor_states_after: afterByKind.actor,
7265
+ location_states_before: beforeByKind.location,
7266
+ location_states_after: afterByKind.location,
7267
+ prop_states_before: beforeByKind.prop,
7268
+ prop_states_after: afterByKind.prop,
7269
+ decisions: decisionSummary,
7270
+ ...rewriteSummary,
7271
+ default_states_synthesized: synthesizedDefaults,
7272
+ default_state_ids_rewritten: defaultSummary["default_state_ids_rewritten"],
7273
+ default_ref_rewrite: defaultSummary["default_ref_rewrite"],
7274
+ },
7275
+ decisions,
7276
+ default_selections: defaultSelections,
7277
+ selected_default_states: defaultSummary["selected_default_states"],
7278
+ };
7279
+ }
7280
+ // ---------------------------------------------------------------------------
6174
7281
  // State binding
6175
7282
  // ---------------------------------------------------------------------------
6176
7283
  function stateBindingBlocked(message, received) {
@@ -6223,29 +7330,35 @@ function stateNameById(asset, stateId) {
6223
7330
  }
6224
7331
  function bindableDefaultStateId(asset) {
6225
7332
  const states = asList(asset["states"]);
6226
- if (states.length === 0)
6227
- return "";
6228
- const defaultState = states.find((state) => strOf(state["state_name"]).trim() === "默认");
7333
+ const defaultState = states.find((state) => strOf(state["state_id"]).trim() === "default");
6229
7334
  if (defaultState)
6230
7335
  return strOf(defaultState["state_id"]);
6231
- if (states.length === 1)
6232
- return strOf(states[0]["state_id"]);
6233
7336
  return "";
6234
7337
  }
6235
7338
  export function ensureDefaultStates(script) {
6236
- let added = 0;
7339
+ let synthesized = 0;
7340
+ let missing = 0;
6237
7341
  for (const kind of ["actor", "location", "prop"]) {
6238
7342
  for (const asset of asList(script[assetListKey(kind)])) {
6239
- const states = asList(asset["states"]);
6240
- if (states.length <= 1)
6241
- continue;
6242
- if (states.some((state) => strOf(state["state_name"]).trim() === "默认"))
7343
+ if (!isDict(asset))
6243
7344
  continue;
6244
- appendState(asset, script, "默认", asset["description"]);
6245
- added += 1;
7345
+ const states = asList(asset["states"]).filter(isDict);
7346
+ const defaults = states.filter((state) => strOf(state["state_id"]).trim() === "default");
7347
+ if (states.length === 0) {
7348
+ asset["states"] = [synthesizedDefaultState(asset)];
7349
+ synthesized += 1;
7350
+ }
7351
+ else if (defaults.length === 0) {
7352
+ missing += 1;
7353
+ }
7354
+ else if (defaults.length > 1) {
7355
+ stateBindingBlocked("Asset has more than one `default` state.", [
7356
+ `${kind}:${strOf(asset[assetIdKey(kind)])}`,
7357
+ ]);
7358
+ }
6246
7359
  }
6247
7360
  }
6248
- return { default_states_added: added };
7361
+ return { default_states_synthesized: synthesized, assets_missing_default_state: missing };
6249
7362
  }
6250
7363
  export function bindDefaultStateRefsInScript(script) {
6251
7364
  const assetMaps = {
@@ -6280,7 +7393,7 @@ export function bindDefaultStateRefsInScript(script) {
6280
7393
  setSceneContext(scene, ctx);
6281
7394
  }
6282
7395
  }
6283
- return { default_or_unique_refs_bound: bound, unresolved_stateful_refs: unresolved };
7396
+ return { default_refs_bound: bound, unresolved_stateful_refs: unresolved };
6284
7397
  }
6285
7398
  function compactStateBindingAsset(kind, asset) {
6286
7399
  return {
@@ -6380,7 +7493,8 @@ export function buildStateBindingContexts(script, chunkSize = 10) {
6380
7493
  title: script["title"],
6381
7494
  policy: [
6382
7495
  "Bind each scene asset ref to the best existing state_id when the actions/context show a durable visual state.",
6383
- "Use null only when no existing state fits and no default/unique state should apply.",
7496
+ "Each asset should already have an asset-scoped state_id `default`; keep current_state_id unless actions prove a non-default state.",
7497
+ "Use null only when no existing state fits and current_state_id is already null.",
6384
7498
  "Emit state_changes only for explicit transformation, outfit, damage, breakage, repair, or form-change actions.",
6385
7499
  "Do not invent new states or assets.",
6386
7500
  ],
@@ -6599,18 +7713,27 @@ function splitLocationStatePhrase(rawName) {
6599
7713
  return null;
6600
7714
  return { name: locationName, state };
6601
7715
  }
6602
- function addStateDefinition(stateDefinitions, seen, kind, assetName, stateName, description = null) {
7716
+ function addStateDefinition(stateDefinitions, seen, kind, assetName, stateName, description = null, stateReason = "") {
6603
7717
  const state = strOf(stateName).trim();
6604
7718
  if (!assetName || !state || stateRejectionReason(kind, state))
6605
7719
  return;
6606
7720
  const key = `${kind}::${assetName}::${state}`;
6607
- if (seen.has(key))
7721
+ if (seen.has(key)) {
7722
+ const existing = stateDefinitions.find((item) => item["kind"] === kind && item["asset_name"] === assetName && item["state_name"] === state);
7723
+ if (existing) {
7724
+ const desc = strOf(description).trim();
7725
+ if (desc && !existing["description"])
7726
+ existing["description"] = desc;
7727
+ setStateReason(existing, stateReason);
7728
+ }
6608
7729
  return;
7730
+ }
6609
7731
  seen.add(key);
6610
7732
  const item = { kind, asset_name: assetName, state_name: state };
6611
7733
  const desc = strOf(description).trim();
6612
7734
  if (desc)
6613
7735
  item["description"] = desc;
7736
+ setStateReason(item, stateReason);
6614
7737
  stateDefinitions.push(item);
6615
7738
  }
6616
7739
  function rawStringMap(value) {
@@ -6624,31 +7747,78 @@ function rawStringMap(value) {
6624
7747
  }
6625
7748
  return out;
6626
7749
  }
7750
+ const ACTOR_VOICE_SUFFIX_RE = /\s*(?:VO|V\.O\.|OS|O\.S\.|旁白|画外音|内心独白|内心)\s*$/i;
7751
+ const LEADING_ACTION_ACTOR_PREFIX_RE = /^\s*([^::]{1,24})[::]\s*(\S[\s\S]*)$/;
7752
+ function actorVoiceDisplayName(speaker, actorRegistry, report) {
7753
+ const raw = cleanName(speaker);
7754
+ if (!raw || !ACTOR_VOICE_SUFFIX_RE.test(raw))
7755
+ return null;
7756
+ const base = cleanName(raw.replace(ACTOR_VOICE_SUFFIX_RE, ""));
7757
+ if (!base || base === raw)
7758
+ return null;
7759
+ if (inferNonActorSpeakerKind(base) !== "actor")
7760
+ return null;
7761
+ return canonicalAssetDisplay(actorRegistry, base, report);
7762
+ }
7763
+ function actionPrefixActorDisplayName(prefix, actorRegistry) {
7764
+ const raw = cleanName(prefix);
7765
+ if (!raw)
7766
+ return "";
7767
+ const direct = actorRegistry.displayByKey.get(canonicalAssetNameKey("actor", raw));
7768
+ if (direct)
7769
+ return direct;
7770
+ if (!hasEnumeratedActorName(raw))
7771
+ return "";
7772
+ const base = cleanName(raw.replace(/(?:[甲乙丙丁戊己庚辛壬癸]|[A-Z]|[0-9]+)$/u, ""));
7773
+ if (!base || base === raw)
7774
+ return "";
7775
+ return actorRegistry.displayByKey.get(canonicalAssetNameKey("actor", base)) || "";
7776
+ }
6627
7777
  function normalizeSpeakerNameInAction(action, actorRegistry, locationRegistry, propRegistry, report) {
6628
7778
  const item = { ...action };
6629
7779
  const normalizeByKind = (speaker, speakerKind) => {
7780
+ const actorVoice = actorVoiceDisplayName(speaker, actorRegistry, report);
7781
+ if (actorVoice)
7782
+ return { speaker: actorVoice, speaker_kind: "actor" };
6630
7783
  const kind = strOf(speakerKind || "actor").trim() || "actor";
6631
7784
  if (kind === "actor")
6632
- return canonicalAssetDisplay(actorRegistry, speaker, report);
7785
+ return { speaker: canonicalAssetDisplay(actorRegistry, speaker, report), speaker_kind: "actor" };
6633
7786
  if (kind === "location")
6634
- return canonicalAssetDisplay(locationRegistry, speaker, report);
7787
+ return { speaker: canonicalAssetDisplay(locationRegistry, speaker, report), speaker_kind: "location" };
6635
7788
  if (kind === "prop")
6636
- return canonicalAssetDisplay(propRegistry, speaker, report);
6637
- return cleanName(speaker);
7789
+ return { speaker: canonicalAssetDisplay(propRegistry, speaker, report), speaker_kind: "prop" };
7790
+ return { speaker: cleanName(speaker), speaker_kind: kind };
6638
7791
  };
6639
- if (item["speaker"])
6640
- item["speaker"] = normalizeByKind(item["speaker"], item["speaker_kind"]);
7792
+ if (item["speaker"]) {
7793
+ const normalized = normalizeByKind(item["speaker"], item["speaker_kind"]);
7794
+ item["speaker"] = normalized.speaker;
7795
+ if (normalized.speaker_kind === "actor")
7796
+ delete item["speaker_kind"];
7797
+ else
7798
+ item["speaker_kind"] = normalized.speaker_kind;
7799
+ }
6641
7800
  if (isList(item["speakers"])) {
6642
- item["speakers"] = asList(item["speakers"]).map((speaker) => ({
6643
- ...speaker,
6644
- speaker: normalizeByKind(speaker["speaker"], speaker["speaker_kind"]),
6645
- }));
7801
+ item["speakers"] = asList(item["speakers"]).map((speaker) => {
7802
+ const normalized = normalizeByKind(speaker["speaker"], speaker["speaker_kind"]);
7803
+ return { ...speaker, speaker: normalized.speaker, speaker_kind: normalized.speaker_kind };
7804
+ });
6646
7805
  }
6647
7806
  if (isList(item["lines"])) {
6648
- item["lines"] = asList(item["lines"]).map((line) => ({
6649
- ...line,
6650
- speaker: normalizeByKind(line["speaker"], line["speaker_kind"]),
6651
- }));
7807
+ item["lines"] = asList(item["lines"]).map((line) => {
7808
+ const normalized = normalizeByKind(line["speaker"], line["speaker_kind"]);
7809
+ return { ...line, speaker: normalized.speaker, speaker_kind: normalized.speaker_kind };
7810
+ });
7811
+ }
7812
+ if (strOf(item["type"]) === "action") {
7813
+ const content = strOf(item["content"]).trim();
7814
+ const prefixed = LEADING_ACTION_ACTOR_PREFIX_RE.exec(content);
7815
+ if (prefixed) {
7816
+ const rawPrefix = cleanName(prefixed[1]);
7817
+ const rest = strOf(prefixed[2]).trim();
7818
+ const actorName = actionPrefixActorDisplayName(rawPrefix, actorRegistry);
7819
+ if (actorName && rest)
7820
+ item["content"] = `${actorName}${rest}`;
7821
+ }
6652
7822
  }
6653
7823
  return item;
6654
7824
  }
@@ -6684,10 +7854,13 @@ function normalizeEpisodeResultsForAssets(results, opts) {
6684
7854
  for (const asset of asList(result["actors"])) {
6685
7855
  if (!isDict(asset))
6686
7856
  continue;
6687
- const rawName = cleanName(asset["name"]);
7857
+ const [rawName, stateFromName] = splitEntityState(asset["name"]);
6688
7858
  const name = canonicalAssetDisplay(actorRegistry, rawName, report);
6689
7859
  if (name)
6690
7860
  normalized["actors"].push({ ...asset, name });
7861
+ if (name && stateFromName) {
7862
+ addStateDefinition(globalStateDefinitions, seenStateDefinitions, "actor", name, stateFromName, asset["description"]);
7863
+ }
6691
7864
  }
6692
7865
  for (const asset of asList(result["locations"])) {
6693
7866
  if (!isDict(asset))
@@ -6719,7 +7892,11 @@ function normalizeEpisodeResultsForAssets(results, opts) {
6719
7892
  if (!isDict(speaker))
6720
7893
  continue;
6721
7894
  const sourceKind = strOf(speaker["source_kind"] || "other").trim();
6722
- if (sourceKind === "prop") {
7895
+ const actorVoice = actorVoiceDisplayName(speaker["name"], actorRegistry, report);
7896
+ if (actorVoice) {
7897
+ normalized["actors"].push({ name: actorVoice });
7898
+ }
7899
+ else if (sourceKind === "prop") {
6723
7900
  normalized["speakers"].push({
6724
7901
  ...speaker,
6725
7902
  name: canonicalAssetDisplay(propRegistry, speaker["name"], report),
@@ -6732,9 +7909,10 @@ function normalizeEpisodeResultsForAssets(results, opts) {
6732
7909
  });
6733
7910
  }
6734
7911
  else if (sourceKind === "actor") {
7912
+ const [speakerName] = splitEntityState(speaker["name"]);
6735
7913
  normalized["speakers"].push({
6736
7914
  ...speaker,
6737
- name: canonicalAssetDisplay(actorRegistry, speaker["name"], report),
7915
+ name: canonicalAssetDisplay(actorRegistry, speakerName, report),
6738
7916
  });
6739
7917
  }
6740
7918
  else {
@@ -6748,14 +7926,18 @@ function normalizeEpisodeResultsForAssets(results, opts) {
6748
7926
  if (kind !== "actor" && kind !== "location" && kind !== "prop")
6749
7927
  continue;
6750
7928
  if (kind === "actor") {
6751
- const assetName = canonicalAssetDisplay(actorRegistry, stateDef["asset_name"], report);
6752
- addStateDefinition(globalStateDefinitions, seenStateDefinitions, "actor", assetName, stateDef["state_name"], stateDef["description"]);
7929
+ const [assetBaseName, stateFromAssetName] = splitEntityState(stateDef["asset_name"]);
7930
+ const assetName = canonicalAssetDisplay(actorRegistry, assetBaseName, report);
7931
+ if (stateFromAssetName) {
7932
+ addStateDefinition(globalStateDefinitions, seenStateDefinitions, "actor", assetName, stateFromAssetName, stateDef["description"], stateDef["state_reason"]);
7933
+ }
7934
+ addStateDefinition(globalStateDefinitions, seenStateDefinitions, "actor", assetName, stateDef["state_name"], stateDef["description"], stateDef["state_reason"]);
6753
7935
  }
6754
7936
  else if (kind === "location") {
6755
7937
  const split = splitLocationStatePhrase(stateDef["asset_name"]);
6756
7938
  const assetName = canonicalAssetDisplay(locationRegistry, split ? split.name : stateDef["asset_name"], report);
6757
7939
  if (split) {
6758
- addStateDefinition(globalStateDefinitions, seenStateDefinitions, "location", assetName, split.state, stateDef["description"]);
7940
+ addStateDefinition(globalStateDefinitions, seenStateDefinitions, "location", assetName, split.state, stateDef["description"], stateDef["state_reason"]);
6759
7941
  addNormalizationDecision(report, {
6760
7942
  action: "location_state_from_name",
6761
7943
  kind: "location",
@@ -6765,11 +7947,11 @@ function normalizeEpisodeResultsForAssets(results, opts) {
6765
7947
  reason: "地点状态定义的资产名含时间状态,拆成稳定地点 + 状态。",
6766
7948
  });
6767
7949
  }
6768
- addStateDefinition(globalStateDefinitions, seenStateDefinitions, "location", assetName, stateDef["state_name"], stateDef["description"]);
7950
+ addStateDefinition(globalStateDefinitions, seenStateDefinitions, "location", assetName, stateDef["state_name"], stateDef["description"], stateDef["state_reason"]);
6769
7951
  }
6770
7952
  else {
6771
7953
  const assetName = canonicalAssetDisplay(propRegistry, stateDef["asset_name"], report);
6772
- addStateDefinition(globalStateDefinitions, seenStateDefinitions, "prop", assetName, stateDef["state_name"], stateDef["description"]);
7954
+ addStateDefinition(globalStateDefinitions, seenStateDefinitions, "prop", assetName, stateDef["state_name"], stateDef["description"], stateDef["state_reason"]);
6773
7955
  }
6774
7956
  }
6775
7957
  const scenes = [];
@@ -6813,12 +7995,12 @@ function normalizeEpisodeResultsForAssets(results, opts) {
6813
7995
  const propNames = [];
6814
7996
  const normalizedPropStates = {};
6815
7997
  for (const rawActor of asList(scene["actor_names"])) {
6816
- const cleanActor = cleanName(rawActor);
7998
+ const [cleanActor, stateFromName] = splitEntityState(rawActor);
6817
7999
  const actorName = canonicalAssetDisplay(actorRegistry, cleanActor, report);
6818
8000
  if (!actorName)
6819
8001
  continue;
6820
8002
  uniqueAdd(actorNames, actorName);
6821
- const state = actorStates[cleanActor] || actorStates[actorName] || "";
8003
+ const state = actorStates[cleanActor] || actorStates[actorName] || stateFromName || "";
6822
8004
  if (!state)
6823
8005
  continue;
6824
8006
  normalizedActorStates[actorName] = state;
@@ -7033,6 +8215,12 @@ function mergeEpisodeResultsInternal(results, title, opts = {}) {
7033
8215
  for (const name of speakerOrder) {
7034
8216
  const record = speakerRecords.get(name) ?? { source_kind: "other" };
7035
8217
  let sourceKind = record.source_kind || "other";
8218
+ if ((sourceKind === "system" || sourceKind === "broadcast" || sourceKind === "other") && actorIds.has(name)) {
8219
+ const speakerId = `spk_${actorIds.get(name)}`;
8220
+ speakerIdsByName.set(`${sourceKind}::${name}`, speakerId);
8221
+ speakerIdsByName.set(`${record.source_kind}::${name}`, speakerId);
8222
+ continue;
8223
+ }
7036
8224
  let sourceId = null;
7037
8225
  if (sourceKind === "prop")
7038
8226
  sourceId = propIds.get(name) ?? null;
@@ -7082,6 +8270,9 @@ function mergeEpisodeResultsInternal(results, title, opts = {}) {
7082
8270
  const description = strOf(stateDef["description"]).trim();
7083
8271
  if (description)
7084
8272
  stateEntry["description"] = description;
8273
+ const stateReason = cleanStateReason(stateDef["state_reason"]);
8274
+ if (stateReason)
8275
+ stateEntry["state_reason"] = stateReason;
7085
8276
  states.push(stateEntry);
7086
8277
  }
7087
8278
  if (states.length > 0)
@@ -7107,6 +8298,9 @@ function mergeEpisodeResultsInternal(results, title, opts = {}) {
7107
8298
  const description = strOf(stateDef["description"]).trim();
7108
8299
  if (description)
7109
8300
  stateEntry["description"] = description;
8301
+ const stateReason = cleanStateReason(stateDef["state_reason"]);
8302
+ if (stateReason)
8303
+ stateEntry["state_reason"] = stateReason;
7110
8304
  states.push(stateEntry);
7111
8305
  }
7112
8306
  if (states.length > 0)
@@ -7132,6 +8326,9 @@ function mergeEpisodeResultsInternal(results, title, opts = {}) {
7132
8326
  const description = strOf(stateDef["description"]).trim();
7133
8327
  if (description)
7134
8328
  stateEntry["description"] = description;
8329
+ const stateReason = cleanStateReason(stateDef["state_reason"]);
8330
+ if (stateReason)
8331
+ stateEntry["state_reason"] = stateReason;
7135
8332
  states.push(stateEntry);
7136
8333
  }
7137
8334
  if (states.length > 0)
@@ -7146,6 +8343,8 @@ function mergeEpisodeResultsInternal(results, title, opts = {}) {
7146
8343
  if (speakerKind === "actor" && actorIds.has(speaker))
7147
8344
  return `spk_${actorIds.get(speaker)}`;
7148
8345
  const normKind = _MD_SPEAKER_KIND_NORM[speakerKind.toLowerCase()] ?? _MD_SPEAKER_KIND_NORM[speakerKind] ?? "other";
8346
+ if ((normKind === "system" || normKind === "broadcast" || normKind === "other") && actorIds.has(speaker))
8347
+ return `spk_${actorIds.get(speaker)}`;
7149
8348
  return speakerIdsByName.get(`${normKind}::${speaker}`) ?? speakerIdsByName.get(`other::${speaker}`) ?? null;
7150
8349
  };
7151
8350
  const stateIdForRef = (kind, assetName, explicitState, unresolved) => {