@lingjingai/scriptctl 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +12 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +433 -0
- package/dist/cli.js.map +1 -0
- package/dist/common.d.ts +124 -0
- package/dist/common.js +337 -0
- package/dist/common.js.map +1 -0
- package/dist/domain/direct-core.d.ts +114 -0
- package/dist/domain/direct-core.js +3040 -0
- package/dist/domain/direct-core.js.map +1 -0
- package/dist/domain/script-core.d.ts +18 -0
- package/dist/domain/script-core.js +1886 -0
- package/dist/domain/script-core.js.map +1 -0
- package/dist/help-text.d.ts +2 -0
- package/dist/help-text.js +399 -0
- package/dist/help-text.js.map +1 -0
- package/dist/infra/converters.d.ts +10 -0
- package/dist/infra/converters.js +560 -0
- package/dist/infra/converters.js.map +1 -0
- package/dist/infra/env.d.ts +2 -0
- package/dist/infra/env.js +81 -0
- package/dist/infra/env.js.map +1 -0
- package/dist/infra/providers.d.ts +25 -0
- package/dist/infra/providers.js +330 -0
- package/dist/infra/providers.js.map +1 -0
- package/dist/infra/script-output-api.d.ts +44 -0
- package/dist/infra/script-output-api.js +160 -0
- package/dist/infra/script-output-api.js.map +1 -0
- package/dist/infra/storage.d.ts +35 -0
- package/dist/infra/storage.js +91 -0
- package/dist/infra/storage.js.map +1 -0
- package/dist/output.d.ts +4 -0
- package/dist/output.js +55 -0
- package/dist/output.js.map +1 -0
- package/dist/usecases/direct.d.ts +13 -0
- package/dist/usecases/direct.js +1679 -0
- package/dist/usecases/direct.js.map +1 -0
- package/dist/usecases/doctor.d.ts +2 -0
- package/dist/usecases/doctor.js +75 -0
- package/dist/usecases/doctor.js.map +1 -0
- package/dist/usecases/script.d.ts +32 -0
- package/dist/usecases/script.js +949 -0
- package/dist/usecases/script.js.map +1 -0
- package/package.json +50 -0
|
@@ -0,0 +1,3040 @@
|
|
|
1
|
+
import { ACTION_TYPE_VALUES, ACTOR_REUSE_SCENE_LIMIT, CliError, DEFAULT_BATCH_MAX_CHARS, DEFAULT_BATCH_MIN_LINES, DEFAULT_BATCH_MODE, DEFAULT_BATCH_TARGET_LINES, DEFAULT_PROVIDER_ATTEMPTS, EXIT_NEEDS_AGENT, EXIT_RUNTIME, PROP_REUSE_SCENE_LIMIT, SCRIPT_SCHEMA_VERSION, SCRIPT_TARGET_KINDS, WORLDVIEW_VALUES, fmtId, } from "../common.js";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Regexes & lookup tables (shared with infra/converters.ts via re-export)
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
export const _ZH_DIGITS = {
|
|
6
|
+
"零": 0, "〇": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4,
|
|
7
|
+
"五": 5, "六": 6, "七": 7, "八": 8, "九": 9,
|
|
8
|
+
};
|
|
9
|
+
export const _EP_HEADER_RE = /^[\t ]*(?:[#=]+[\t ]*)?(?:第[\t ]*(?<zh>[0-90-9一二三四五六七八九十百千零〇两]+)[\t ]*集|(?:EP|Episode)[\t _\-#:.]*(?<ep>[0-90-9]+))[^\n]*$/gim;
|
|
10
|
+
export const _EP_TITLE_PREFIX_RE = /^[#=\s]*(?:第\s*([0-90-9一二三四五六七八九十百千零〇两]+)\s*集|(?:EP|Episode)[\s_\-#:.]*([0-90-9]+))/i;
|
|
11
|
+
export const _TECHNICAL_EP_TITLE_RE = /^(?:ep|episode)[\s_\-#:.]*[0-90-9]+$/i;
|
|
12
|
+
const _TIME_MAP = {
|
|
13
|
+
"日": "day", "白天": "day", "上午": "day", "下午": "day", "午后": "day", "中午": "day",
|
|
14
|
+
"晨": "day", "清晨": "day", "早晨": "day", "黎明": "day",
|
|
15
|
+
"夜": "night", "夜晚": "night", "深夜": "night", "凌晨": "night", "黄昏": "night", "傍晚": "night",
|
|
16
|
+
};
|
|
17
|
+
const _SPACE_MAP = {
|
|
18
|
+
"内": "interior", "外": "exterior", "内/外": "interior", "外/内": "exterior",
|
|
19
|
+
};
|
|
20
|
+
const _SCENE_TIME_FIRST_RE = /^(?:场)?(\d+)-(\d+)\s+(日|夜|晨|清晨|黄昏|午后|凌晨|夜晚|深夜|黎明|白天|傍晚|中午|上午|下午|早晨)\s+((?:内|外|内\/外|外\/内))\s+(.+?)\s*$/;
|
|
21
|
+
const _SCENE_LOCATION_FIRST_RE = /^(?:场)?(\d+)-(\d+)\s+(.+?)\s+(日|夜|晨|清晨|黄昏|午后|凌晨|夜晚|深夜|黎明|白天|傍晚|中午|上午|下午|早晨)\s+((?:内|外|内\/外|外\/内))\s*$/;
|
|
22
|
+
const _CHAR_RE = /^(?:登场|出场)?人物[::](.+)$/;
|
|
23
|
+
const _PROP_RE = /^道具[::](.+)$/;
|
|
24
|
+
const _ACTION_RE = /^[▲△]\s*(.+)$/;
|
|
25
|
+
const _OS_RE = /^(.+?)[((]OS[))][::](.+)$/i;
|
|
26
|
+
const _DIALOGUE_RE = /^([^::]{1,40}?)(?:[((]([^))]{1,30})[))])?[::](.+)$/;
|
|
27
|
+
const _BRACKET_RE = /^【([^::】]+)[::](.+?)】?$/;
|
|
28
|
+
const _STATE_RE = /【([^】]+)】/g;
|
|
29
|
+
const _PAREN_ANNOTATION_RE = /[((]([^))]+)[))]/g;
|
|
30
|
+
const _ANNOTATION_RE = /(?:【[^】]*】|[((][^))]*[))])/g;
|
|
31
|
+
export const BRACKET_ACTION_LABELS = new Set([
|
|
32
|
+
"字幕", "音效", "特效", "屏幕字", "人物牌", "旁白", "画面", "转场",
|
|
33
|
+
]);
|
|
34
|
+
const _SOURCE_SCENE_LINE_RE = /^(?:场)?\d+\s*-\s*\d+/;
|
|
35
|
+
const _SOURCE_META_LINE_RE = /^(?:人物|道具)[::]/;
|
|
36
|
+
const _SOURCE_PREVIEW_LINE_RE = /^[((【\[]?\s*(?:本集)?(?:看点|预告|亮点)(?:\s*[::·].*)?[))】\]]?$/;
|
|
37
|
+
const _EMPTY_SPEAKER_LINE_RE = /^[^::【】]{1,40}(?:[((][^))]{1,30}[))])?[::]\s*$/;
|
|
38
|
+
const _OS_EXT_RE = /^(.+?)[((](OS[^))]*)[))][::](.*)$/i;
|
|
39
|
+
const _BRACKET_PREFIX_RE = /^【[^】]+】.+$/;
|
|
40
|
+
const _STATE_MIXED_SEPARATORS = ["/", "/", "+", "、", ",", ","];
|
|
41
|
+
const _COMPACT_SPACE_TO_LONG = { in: "interior", out: "exterior" };
|
|
42
|
+
const _LONG_SPACE_TO_COMPACT = { interior: "in", exterior: "out" };
|
|
43
|
+
const _COMPACT_ACTION_TO_LONG = { act: "action", dlg: "dialogue", think: "inner_thought" };
|
|
44
|
+
const _LONG_ACTION_TO_COMPACT = { action: "act", dialogue: "dlg", inner_thought: "think" };
|
|
45
|
+
const _MD_TOP_HEADER_RE = /^#\s+(.+?)\s*$/;
|
|
46
|
+
const _MD_SUB_HEADER_RE = /^##\s+(.+?)\s*$/;
|
|
47
|
+
const _MD_SCENE_HEADER_RE = /^##\s*场景\s*(\d+)\s*$/;
|
|
48
|
+
const _MD_SCENE_FIELD_RE = /^-\s*(时空|地点|角色|道具)\s*[::]\s*(.+?)\s*$/;
|
|
49
|
+
const _MD_TIME_SPACE_RE = /^(内|外|interior|exterior|in|out)\s+(日|夜|day|night)$/i;
|
|
50
|
+
const _MD_LOC_STATE_RE = /^(.+?)(?:【([^】]+)】)?$/;
|
|
51
|
+
const _MD_CHAR_ENTRY_RE = /^([^()(),,]+?)(?:[((]([^))]+)[))])?$/;
|
|
52
|
+
const _MD_ACTION_ANCHOR_RE = /^>\s*\[([A-Za-z]+)(?:\|([^|\]]+?)(?:\|([^|\]]+?))?)?\]\s*(.*)$/;
|
|
53
|
+
const _MD_ASSET_ENTRY_RE = /^-\s*([^::]+?)(?:\s*[::]\s*(.+?))?\s*$/;
|
|
54
|
+
const _MD_SPACE_NORM = {
|
|
55
|
+
"内": "interior", "interior": "interior", "in": "interior",
|
|
56
|
+
"外": "exterior", "exterior": "exterior", "out": "exterior",
|
|
57
|
+
};
|
|
58
|
+
const _MD_TIME_NORM = {
|
|
59
|
+
"日": "day", "day": "day",
|
|
60
|
+
"夜": "night", "night": "night",
|
|
61
|
+
};
|
|
62
|
+
const _MD_KIND_NORM = {
|
|
63
|
+
act: "action", dlg: "dialogue", think: "inner_thought",
|
|
64
|
+
};
|
|
65
|
+
const _MD_SPEAKER_KIND_NORM = {
|
|
66
|
+
"actor": "actor", "角色": "actor", "人物": "actor",
|
|
67
|
+
"system": "system", "系统": "system", "系统音": "system", "系统提示音": "system",
|
|
68
|
+
"broadcast": "broadcast", "广播": "broadcast", "喇叭": "broadcast",
|
|
69
|
+
"group": "group", "群体": "group", "众人": "group",
|
|
70
|
+
"prop": "prop", "道具": "prop",
|
|
71
|
+
"location": "location", "地点": "location", "场景": "location",
|
|
72
|
+
"other": "other", "其他": "other",
|
|
73
|
+
};
|
|
74
|
+
const _MD_DELIVERY_NORM = {
|
|
75
|
+
"single": "single", "单人": "single",
|
|
76
|
+
"simultaneous": "simultaneous", "同时": "simultaneous", "齐声": "simultaneous", "同声": "simultaneous", "一起": "simultaneous",
|
|
77
|
+
"overlap": "overlap", "重叠": "overlap", "交叠": "overlap", "同时不同句": "overlap",
|
|
78
|
+
"group": "group", "群体": "group", "众人": "group",
|
|
79
|
+
};
|
|
80
|
+
const _MD_ASSET_SUBSECTION_MAP = {
|
|
81
|
+
"人物": "actors", "角色": "actors",
|
|
82
|
+
"场景": "locations", "地点": "locations",
|
|
83
|
+
"道具": "props",
|
|
84
|
+
"发声源": "speakers", "声音": "speakers", "speaker": "speakers", "speakers": "speakers",
|
|
85
|
+
"人物状态": "actor_states", "角色状态": "actor_states",
|
|
86
|
+
"场景状态": "location_states", "地点状态": "location_states",
|
|
87
|
+
"道具状态": "prop_states",
|
|
88
|
+
};
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Generic helpers (Python builtin equivalents)
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
function strOf(v) {
|
|
93
|
+
if (v === null || v === undefined)
|
|
94
|
+
return "";
|
|
95
|
+
return String(v);
|
|
96
|
+
}
|
|
97
|
+
function isDict(v) {
|
|
98
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
99
|
+
}
|
|
100
|
+
function isList(v) {
|
|
101
|
+
return Array.isArray(v);
|
|
102
|
+
}
|
|
103
|
+
function asList(v) {
|
|
104
|
+
return Array.isArray(v) ? v : [];
|
|
105
|
+
}
|
|
106
|
+
function asDict(v) {
|
|
107
|
+
return isDict(v) ? v : {};
|
|
108
|
+
}
|
|
109
|
+
function isAsciiDigitString(s) {
|
|
110
|
+
if (!s)
|
|
111
|
+
return false;
|
|
112
|
+
for (const ch of s) {
|
|
113
|
+
if (ch < "0" || ch > "9")
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
/** Python `s.splitlines(keepends=True)` */
|
|
119
|
+
function splitLinesKeepEnds(text) {
|
|
120
|
+
if (!text)
|
|
121
|
+
return [];
|
|
122
|
+
const out = [];
|
|
123
|
+
let start = 0;
|
|
124
|
+
let i = 0;
|
|
125
|
+
while (i < text.length) {
|
|
126
|
+
const ch = text[i];
|
|
127
|
+
if (ch === "\n") {
|
|
128
|
+
out.push(text.slice(start, i + 1));
|
|
129
|
+
i += 1;
|
|
130
|
+
start = i;
|
|
131
|
+
}
|
|
132
|
+
else if (ch === "\r") {
|
|
133
|
+
if (text[i + 1] === "\n") {
|
|
134
|
+
out.push(text.slice(start, i + 2));
|
|
135
|
+
i += 2;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
out.push(text.slice(start, i + 1));
|
|
139
|
+
i += 1;
|
|
140
|
+
}
|
|
141
|
+
start = i;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
i += 1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (start < text.length)
|
|
148
|
+
out.push(text.slice(start));
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
/** Python `re.split(pattern, s, maxsplit=N)`. */
|
|
152
|
+
function reSplit(pattern, s, maxsplit = -1) {
|
|
153
|
+
const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
|
|
154
|
+
const re = new RegExp(pattern.source, flags);
|
|
155
|
+
const result = [];
|
|
156
|
+
let lastIndex = 0;
|
|
157
|
+
let count = 0;
|
|
158
|
+
let m;
|
|
159
|
+
while ((m = re.exec(s)) !== null) {
|
|
160
|
+
if (maxsplit >= 0 && count >= maxsplit)
|
|
161
|
+
break;
|
|
162
|
+
result.push(s.slice(lastIndex, m.index));
|
|
163
|
+
for (let g = 1; g < m.length; g++)
|
|
164
|
+
result.push(m[g] ?? "");
|
|
165
|
+
lastIndex = m.index + m[0].length;
|
|
166
|
+
count += 1;
|
|
167
|
+
if (m[0].length === 0)
|
|
168
|
+
re.lastIndex += 1;
|
|
169
|
+
}
|
|
170
|
+
result.push(s.slice(lastIndex));
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
function reSearch(pattern, s) {
|
|
174
|
+
const flags = pattern.flags.includes("g") ? pattern.flags.replace("g", "") : pattern.flags;
|
|
175
|
+
return new RegExp(pattern.source, flags).exec(s);
|
|
176
|
+
}
|
|
177
|
+
function reMatch(pattern, s) {
|
|
178
|
+
const src = pattern.source.startsWith("^") ? pattern.source : "^" + pattern.source;
|
|
179
|
+
const flags = pattern.flags.includes("g") ? pattern.flags.replace("g", "") : pattern.flags;
|
|
180
|
+
return new RegExp(src, flags).exec(s);
|
|
181
|
+
}
|
|
182
|
+
function reFullMatch(pattern, s) {
|
|
183
|
+
let src = pattern.source;
|
|
184
|
+
if (!src.startsWith("^"))
|
|
185
|
+
src = "^" + src;
|
|
186
|
+
if (!src.endsWith("$"))
|
|
187
|
+
src = src + "$";
|
|
188
|
+
const flags = pattern.flags.includes("g") ? pattern.flags.replace("g", "") : pattern.flags;
|
|
189
|
+
return new RegExp(src, flags).exec(s);
|
|
190
|
+
}
|
|
191
|
+
function reFindAll(pattern, s) {
|
|
192
|
+
const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
|
|
193
|
+
const re = new RegExp(pattern.source, flags);
|
|
194
|
+
const out = [];
|
|
195
|
+
let m;
|
|
196
|
+
while ((m = re.exec(s)) !== null) {
|
|
197
|
+
out.push(m.length > 1 ? (m[1] ?? "") : m[0]);
|
|
198
|
+
if (m[0].length === 0)
|
|
199
|
+
re.lastIndex += 1;
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
function reFindAllMatches(pattern, s) {
|
|
204
|
+
const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
|
|
205
|
+
const re = new RegExp(pattern.source, flags);
|
|
206
|
+
const out = [];
|
|
207
|
+
let m;
|
|
208
|
+
while ((m = re.exec(s)) !== null) {
|
|
209
|
+
out.push(m);
|
|
210
|
+
if (m[0].length === 0)
|
|
211
|
+
re.lastIndex += 1;
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
function reReplace(pattern, s, replacement) {
|
|
216
|
+
const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
|
|
217
|
+
return s.replace(new RegExp(pattern.source, flags), replacement);
|
|
218
|
+
}
|
|
219
|
+
/** Python `str.partition(sep)` */
|
|
220
|
+
function partition(s, sep) {
|
|
221
|
+
const idx = s.indexOf(sep);
|
|
222
|
+
if (idx < 0)
|
|
223
|
+
return [s, "", ""];
|
|
224
|
+
return [s.slice(0, idx), sep, s.slice(idx + sep.length)];
|
|
225
|
+
}
|
|
226
|
+
/** Python `str.strip(chars)` */
|
|
227
|
+
function pyStrip(s, chars) {
|
|
228
|
+
if (chars === undefined)
|
|
229
|
+
return s.trim();
|
|
230
|
+
let start = 0;
|
|
231
|
+
let end = s.length;
|
|
232
|
+
while (start < end && chars.includes(s[start]))
|
|
233
|
+
start += 1;
|
|
234
|
+
while (end > start && chars.includes(s[end - 1]))
|
|
235
|
+
end -= 1;
|
|
236
|
+
return s.slice(start, end);
|
|
237
|
+
}
|
|
238
|
+
function pyLstrip(s, chars) {
|
|
239
|
+
if (chars === undefined)
|
|
240
|
+
return s.replace(/^\s+/, "");
|
|
241
|
+
let start = 0;
|
|
242
|
+
while (start < s.length && chars.includes(s[start]))
|
|
243
|
+
start += 1;
|
|
244
|
+
return s.slice(start);
|
|
245
|
+
}
|
|
246
|
+
function countOccurrences(s, sub, end) {
|
|
247
|
+
if (!sub)
|
|
248
|
+
return 0;
|
|
249
|
+
const slice = s.slice(0, end);
|
|
250
|
+
let count = 0;
|
|
251
|
+
let pos = 0;
|
|
252
|
+
while ((pos = slice.indexOf(sub, pos)) !== -1) {
|
|
253
|
+
count += 1;
|
|
254
|
+
pos += sub.length;
|
|
255
|
+
}
|
|
256
|
+
return count;
|
|
257
|
+
}
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Scene context
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
export function sceneContext(scene) {
|
|
262
|
+
const context = isDict(scene["context"]) ? scene["context"] : {};
|
|
263
|
+
return {
|
|
264
|
+
locations: isList(context["locations"]) ? context["locations"] : (asList(scene["locations"]) ?? []),
|
|
265
|
+
actors: isList(context["actors"]) ? context["actors"] : (asList(scene["actors"]) ?? []),
|
|
266
|
+
props: isList(context["props"]) ? context["props"] : (asList(scene["props"]) ?? []),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
export function setSceneContext(scene, context) {
|
|
270
|
+
const ctx = {
|
|
271
|
+
locations: [...asList(context["locations"])],
|
|
272
|
+
actors: [...asList(context["actors"])],
|
|
273
|
+
props: [...asList(context["props"])],
|
|
274
|
+
};
|
|
275
|
+
scene["context"] = ctx;
|
|
276
|
+
scene["locations"] = ctx.locations;
|
|
277
|
+
scene["actors"] = ctx.actors;
|
|
278
|
+
scene["props"] = ctx.props;
|
|
279
|
+
}
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// Number / title parsing
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
export function normalizeDigits(value) {
|
|
284
|
+
let out = "";
|
|
285
|
+
for (const ch of value) {
|
|
286
|
+
const code = ch.charCodeAt(0);
|
|
287
|
+
if (code >= 0xFF10 && code <= 0xFF19) {
|
|
288
|
+
out += String.fromCharCode(code - 0xFF10 + 48);
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
out += ch;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
export function parseEpNumber(raw) {
|
|
297
|
+
const value = normalizeDigits(raw.trim());
|
|
298
|
+
if (isAsciiDigitString(value))
|
|
299
|
+
return parseInt(value, 10);
|
|
300
|
+
if (value === "十")
|
|
301
|
+
return 10;
|
|
302
|
+
if (value.startsWith("十"))
|
|
303
|
+
return 10 + (_ZH_DIGITS[value.slice(1)] ?? 0);
|
|
304
|
+
if (value.endsWith("十"))
|
|
305
|
+
return (_ZH_DIGITS[value.slice(0, -1)] ?? 0) * 10;
|
|
306
|
+
if (value.includes("百")) {
|
|
307
|
+
const [left, , right] = partition(value, "百");
|
|
308
|
+
return (_ZH_DIGITS[left] ?? 0) * 100 + (parseEpNumber(right) ?? 0);
|
|
309
|
+
}
|
|
310
|
+
if (value.includes("十")) {
|
|
311
|
+
const [left, , right] = partition(value, "十");
|
|
312
|
+
return (_ZH_DIGITS[left] ?? 0) * 10 + (right ? (_ZH_DIGITS[right] ?? 0) : 0);
|
|
313
|
+
}
|
|
314
|
+
return _ZH_DIGITS[value] ?? null;
|
|
315
|
+
}
|
|
316
|
+
export function episodeNumberFromValue(value, defaultVal = null) {
|
|
317
|
+
if (typeof value === "number" && Number.isInteger(value) && value > 0)
|
|
318
|
+
return value;
|
|
319
|
+
const text = strOf(value).trim();
|
|
320
|
+
if (!text)
|
|
321
|
+
return defaultVal;
|
|
322
|
+
const marker = reSearch(/(?:第\s*([0-90-9一二三四五六七八九十百千零〇两]+)\s*集|(?:EP|Episode)[\s_\-#:.]*([0-90-9]+))/i, text);
|
|
323
|
+
if (marker) {
|
|
324
|
+
const parsed = parseEpNumber(marker[1] || marker[2] || "");
|
|
325
|
+
if (parsed)
|
|
326
|
+
return parsed;
|
|
327
|
+
}
|
|
328
|
+
const digits = normalizeDigits(text);
|
|
329
|
+
if (isAsciiDigitString(digits))
|
|
330
|
+
return parseInt(digits, 10);
|
|
331
|
+
return defaultVal;
|
|
332
|
+
}
|
|
333
|
+
export function cleanEpisodeShortTitle(rawTitle) {
|
|
334
|
+
let title = strOf(rawTitle).trim();
|
|
335
|
+
title = title.replace(/^[#=\s]+/, "").trim();
|
|
336
|
+
for (let i = 0; i < 2; i++) {
|
|
337
|
+
const match = reMatch(_EP_TITLE_PREFIX_RE, title);
|
|
338
|
+
if (!match)
|
|
339
|
+
break;
|
|
340
|
+
title = title.slice(match[0].length).trim();
|
|
341
|
+
title = pyStrip(title, " \t\r\n::-—–《》");
|
|
342
|
+
}
|
|
343
|
+
title = title.replace(/\s+/g, " ");
|
|
344
|
+
return pyStrip(title, " \t\r\n::-—–《》「」『』“”\"'");
|
|
345
|
+
}
|
|
346
|
+
export function isTechnicalEpisodeTitle(rawTitle) {
|
|
347
|
+
const value = strOf(rawTitle).trim();
|
|
348
|
+
if (!value)
|
|
349
|
+
return true;
|
|
350
|
+
const short = cleanEpisodeShortTitle(value);
|
|
351
|
+
if (!short)
|
|
352
|
+
return true;
|
|
353
|
+
const normalized = normalizeDigits(short).trim();
|
|
354
|
+
const compact = pyStrip(normalized.replace(/\s+/g, ""), " \t\r\n::-—–_#.");
|
|
355
|
+
if (!compact)
|
|
356
|
+
return true;
|
|
357
|
+
if (isAsciiDigitString(compact))
|
|
358
|
+
return true;
|
|
359
|
+
if (reFullMatch(_TECHNICAL_EP_TITLE_RE, normalized))
|
|
360
|
+
return true;
|
|
361
|
+
if (reFullMatch(/第[0-9一二三四五六七八九十百千零〇两]+集/, compact))
|
|
362
|
+
return true;
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
export function formatEpisodeTitle(episodeNum, rawTitle) {
|
|
366
|
+
const short = cleanEpisodeShortTitle(rawTitle);
|
|
367
|
+
if (isTechnicalEpisodeTitle(short))
|
|
368
|
+
return null;
|
|
369
|
+
return `第${episodeNum}集:${short}`;
|
|
370
|
+
}
|
|
371
|
+
export function extractEpisodeTitleCandidate(header) {
|
|
372
|
+
let value = strOf(header).trim().replace(/^[#=\s]*/, "");
|
|
373
|
+
const match = reMatch(_EP_TITLE_PREFIX_RE, value);
|
|
374
|
+
if (match)
|
|
375
|
+
value = value.slice(match[0].length);
|
|
376
|
+
return pyStrip(value, " \t\r\n::-—–《》");
|
|
377
|
+
}
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// Episode plan
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
export function buildEpisodePlan(sourceText) {
|
|
382
|
+
const matches = reFindAllMatches(_EP_HEADER_RE, sourceText);
|
|
383
|
+
const episodes = [];
|
|
384
|
+
let confidence;
|
|
385
|
+
let strategy;
|
|
386
|
+
if (matches.length > 0) {
|
|
387
|
+
for (let idx = 0; idx < matches.length; idx++) {
|
|
388
|
+
const match = matches[idx];
|
|
389
|
+
const groups = match.groups ?? {};
|
|
390
|
+
const raw = groups["zh"] || groups["ep"] || String(idx + 1);
|
|
391
|
+
const epNum = parseEpNumber(raw) ?? idx + 1;
|
|
392
|
+
const end = idx + 1 < matches.length ? matches[idx + 1].index : sourceText.length;
|
|
393
|
+
const header = match[0].trim();
|
|
394
|
+
const rawTitle = extractEpisodeTitleCandidate(header);
|
|
395
|
+
const title = formatEpisodeTitle(epNum, rawTitle);
|
|
396
|
+
const titleStatus = title ? "explicit" : (rawTitle ? "technical" : "missing");
|
|
397
|
+
episodes.push({
|
|
398
|
+
episode: epNum,
|
|
399
|
+
title,
|
|
400
|
+
raw_header: header,
|
|
401
|
+
raw_title: rawTitle,
|
|
402
|
+
title_status: titleStatus,
|
|
403
|
+
title_source: title ? "source" : titleStatus,
|
|
404
|
+
source_span: { start: match.index, end },
|
|
405
|
+
header_span: { start: match.index, end: match.index + match[0].length },
|
|
406
|
+
char_count: end - match.index,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
confidence = "high";
|
|
410
|
+
strategy = "explicit_episode_markers";
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
episodes.push({
|
|
414
|
+
episode: 1,
|
|
415
|
+
title: null,
|
|
416
|
+
raw_header: "",
|
|
417
|
+
raw_title: "",
|
|
418
|
+
title_status: "missing",
|
|
419
|
+
title_source: "missing",
|
|
420
|
+
source_span: { start: 0, end: sourceText.length },
|
|
421
|
+
header_span: null,
|
|
422
|
+
char_count: sourceText.length,
|
|
423
|
+
});
|
|
424
|
+
confidence = "low";
|
|
425
|
+
strategy = "single_episode_fallback";
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
version: 1,
|
|
429
|
+
strategy,
|
|
430
|
+
boundary_confidence: confidence,
|
|
431
|
+
total_episodes: episodes.length,
|
|
432
|
+
episodes,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// Name / state utilities
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
export function cleanName(name) {
|
|
439
|
+
let value = strOf(name).trim();
|
|
440
|
+
value = reReplace(_ANNOTATION_RE, value, "");
|
|
441
|
+
return pyStrip(value, " \t\r\n::,,、");
|
|
442
|
+
}
|
|
443
|
+
export function splitEntityState(raw) {
|
|
444
|
+
const value = strOf(raw).trim();
|
|
445
|
+
if (!value)
|
|
446
|
+
return ["", null];
|
|
447
|
+
const states = [];
|
|
448
|
+
for (const item of reFindAll(_STATE_RE, value)) {
|
|
449
|
+
const v = item.trim();
|
|
450
|
+
if (v)
|
|
451
|
+
states.push(v);
|
|
452
|
+
}
|
|
453
|
+
for (const item of reFindAll(_PAREN_ANNOTATION_RE, value)) {
|
|
454
|
+
const v = item.trim();
|
|
455
|
+
if (v)
|
|
456
|
+
states.push(v);
|
|
457
|
+
}
|
|
458
|
+
return [cleanName(value), states.length > 0 ? states[states.length - 1] : null];
|
|
459
|
+
}
|
|
460
|
+
export function stateLabelError(stateName) {
|
|
461
|
+
const text = strOf(stateName).trim().replace(/\s+/g, "");
|
|
462
|
+
if (!text)
|
|
463
|
+
return "empty";
|
|
464
|
+
if (_STATE_MIXED_SEPARATORS.some((sep) => text.includes(sep)))
|
|
465
|
+
return "mixed_state_label";
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
export function isStateLabelValid(stateName) {
|
|
469
|
+
return stateLabelError(stateName) === null;
|
|
470
|
+
}
|
|
471
|
+
export function stateRejectionReason(targetKind, stateName) {
|
|
472
|
+
const kind = strOf(targetKind).trim();
|
|
473
|
+
if (kind === "actor" || kind === "location" || kind === "prop") {
|
|
474
|
+
return stateLabelError(stateName);
|
|
475
|
+
}
|
|
476
|
+
const text = strOf(stateName).trim().replace(/\s+/g, "");
|
|
477
|
+
if (!text)
|
|
478
|
+
return "empty";
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
export function isReusableState(targetKind, stateName) {
|
|
482
|
+
return stateRejectionReason(targetKind, stateName) === null;
|
|
483
|
+
}
|
|
484
|
+
export function splitNames(raw) {
|
|
485
|
+
let s = raw.replace(/;/g, "、").replace(/;/g, "、").replace(/,/g, "、");
|
|
486
|
+
const result = [];
|
|
487
|
+
for (let item of s.split(/[、,]/)) {
|
|
488
|
+
item = item.trim();
|
|
489
|
+
if (!item || ["无", "暂无", "/", "—"].includes(item))
|
|
490
|
+
continue;
|
|
491
|
+
const [name, state] = splitEntityState(item);
|
|
492
|
+
if (name && !reSearch(/若干|众人|等人|们$|×\d+|x\d+/i, name)) {
|
|
493
|
+
result.push([name, state]);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return result;
|
|
497
|
+
}
|
|
498
|
+
export function stripLocationState(location) {
|
|
499
|
+
return splitEntityState(location);
|
|
500
|
+
}
|
|
501
|
+
export function parseSceneHeader(line) {
|
|
502
|
+
const timeFirst = reMatch(_SCENE_TIME_FIRST_RE, line);
|
|
503
|
+
if (timeFirst) {
|
|
504
|
+
const [locName, locState] = stripLocationState(timeFirst[5].trim());
|
|
505
|
+
return {
|
|
506
|
+
space: _SPACE_MAP[timeFirst[4]] ?? "interior",
|
|
507
|
+
time: _TIME_MAP[timeFirst[3]] ?? "day",
|
|
508
|
+
location_name: locName || "未知场景",
|
|
509
|
+
location_state: locState,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
const locFirst = reMatch(_SCENE_LOCATION_FIRST_RE, line);
|
|
513
|
+
if (locFirst) {
|
|
514
|
+
const [locName, locState] = stripLocationState(locFirst[3].trim());
|
|
515
|
+
return {
|
|
516
|
+
space: _SPACE_MAP[locFirst[5]] ?? "interior",
|
|
517
|
+
time: _TIME_MAP[locFirst[4]] ?? "day",
|
|
518
|
+
location_name: locName || "未知场景",
|
|
519
|
+
location_state: locState,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
// Line spans / scene helpers
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
export function lineItemsWithSpans(text, base) {
|
|
528
|
+
const items = [];
|
|
529
|
+
let cursor = 0;
|
|
530
|
+
for (const raw of splitLinesKeepEnds(text)) {
|
|
531
|
+
const line = raw.replace(/[\r\n]+$/, "");
|
|
532
|
+
const stripped = line.trim();
|
|
533
|
+
if (stripped) {
|
|
534
|
+
const left = line.indexOf(stripped);
|
|
535
|
+
const start = base + cursor + left;
|
|
536
|
+
items.push([stripped, start, start + stripped.length]);
|
|
537
|
+
}
|
|
538
|
+
cursor += raw.length;
|
|
539
|
+
}
|
|
540
|
+
return items;
|
|
541
|
+
}
|
|
542
|
+
export function contentSpan(line, lineStart, content) {
|
|
543
|
+
const idx = line.indexOf(content);
|
|
544
|
+
if (idx < 0)
|
|
545
|
+
return { start: lineStart, end: lineStart + line.length };
|
|
546
|
+
return { start: lineStart + idx, end: lineStart + idx + content.length };
|
|
547
|
+
}
|
|
548
|
+
function ensureScene(scenes, _episodeNum) {
|
|
549
|
+
if (scenes.length > 0)
|
|
550
|
+
return scenes[scenes.length - 1];
|
|
551
|
+
const scene = {
|
|
552
|
+
scene_num: 1,
|
|
553
|
+
environment: { space: "interior", time: "day" },
|
|
554
|
+
location_name: "未知场景",
|
|
555
|
+
location_state: null,
|
|
556
|
+
actor_names: [],
|
|
557
|
+
actor_states: {},
|
|
558
|
+
prop_names: [],
|
|
559
|
+
actions: [],
|
|
560
|
+
};
|
|
561
|
+
scenes.push(scene);
|
|
562
|
+
return scene;
|
|
563
|
+
}
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
// Deterministic episode extraction
|
|
566
|
+
// ---------------------------------------------------------------------------
|
|
567
|
+
export function deterministicExtractEpisode(sourceText, episodePlan) {
|
|
568
|
+
const span = episodePlan["source_span"];
|
|
569
|
+
const epText = sourceText.slice(span.start, span.end);
|
|
570
|
+
const scenes = [];
|
|
571
|
+
for (const [line, start] of lineItemsWithSpans(epText, span.start)) {
|
|
572
|
+
if (reMatch(_EP_HEADER_RE, line) || reMatch(_SOURCE_PREVIEW_LINE_RE, line))
|
|
573
|
+
continue;
|
|
574
|
+
const sceneHeader = parseSceneHeader(line);
|
|
575
|
+
if (sceneHeader) {
|
|
576
|
+
scenes.push({
|
|
577
|
+
scene_num: scenes.length + 1,
|
|
578
|
+
environment: { space: sceneHeader["space"], time: sceneHeader["time"] },
|
|
579
|
+
location_name: sceneHeader["location_name"],
|
|
580
|
+
location_state: sceneHeader["location_state"],
|
|
581
|
+
actor_names: [],
|
|
582
|
+
actor_states: {},
|
|
583
|
+
prop_names: [],
|
|
584
|
+
actions: [],
|
|
585
|
+
});
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
const scene = ensureScene(scenes, Number(episodePlan["episode"] ?? 1));
|
|
589
|
+
const charMatch = reMatch(_CHAR_RE, line);
|
|
590
|
+
if (charMatch) {
|
|
591
|
+
for (const [name, state] of splitNames(charMatch[1])) {
|
|
592
|
+
const actors = scene["actor_names"];
|
|
593
|
+
if (!actors.includes(name))
|
|
594
|
+
actors.push(name);
|
|
595
|
+
if (state && isStateLabelValid(state)) {
|
|
596
|
+
scene["actor_states"][name] = state;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
const propMatch = reMatch(_PROP_RE, line);
|
|
602
|
+
if (propMatch) {
|
|
603
|
+
for (const [prop] of splitNames(propMatch[1])) {
|
|
604
|
+
const props = scene["prop_names"];
|
|
605
|
+
if (!props.includes(prop))
|
|
606
|
+
props.push(prop);
|
|
607
|
+
}
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
const bracket = reMatch(_BRACKET_RE, line);
|
|
611
|
+
if (bracket) {
|
|
612
|
+
const label = bracket[1].trim();
|
|
613
|
+
const content = bracket[2].trim();
|
|
614
|
+
if (BRACKET_ACTION_LABELS.has(label)) {
|
|
615
|
+
scene["actions"].push({
|
|
616
|
+
type: "action",
|
|
617
|
+
content,
|
|
618
|
+
source_span: contentSpan(line, start, content),
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
const item = {
|
|
623
|
+
type: "dialogue",
|
|
624
|
+
content,
|
|
625
|
+
source_span: contentSpan(line, start, content),
|
|
626
|
+
};
|
|
627
|
+
applyDialogueSpeaker(item, scene, label);
|
|
628
|
+
scene["actions"].push(item);
|
|
629
|
+
}
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
const action = reMatch(_ACTION_RE, line);
|
|
633
|
+
if (action) {
|
|
634
|
+
const content = action[1].trim();
|
|
635
|
+
scene["actions"].push({
|
|
636
|
+
type: "action",
|
|
637
|
+
content,
|
|
638
|
+
source_span: contentSpan(line, start, content),
|
|
639
|
+
});
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
const osMatch = reMatch(_OS_RE, line);
|
|
643
|
+
if (osMatch) {
|
|
644
|
+
const speaker = cleanName(osMatch[1]);
|
|
645
|
+
const content = osMatch[2].trim();
|
|
646
|
+
const item = {
|
|
647
|
+
type: "inner_thought",
|
|
648
|
+
content,
|
|
649
|
+
source_span: contentSpan(line, start, content),
|
|
650
|
+
};
|
|
651
|
+
applyDialogueSpeaker(item, scene, speaker);
|
|
652
|
+
scene["actions"].push(item);
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
const dialogue = reMatch(_DIALOGUE_RE, line);
|
|
656
|
+
if (dialogue) {
|
|
657
|
+
const speaker = cleanName(dialogue[1]);
|
|
658
|
+
const emotion = dialogue[2] ? dialogue[2].trim() : null;
|
|
659
|
+
const content = dialogue[3].trim();
|
|
660
|
+
if (speaker && speaker !== "旁白") {
|
|
661
|
+
const item = {
|
|
662
|
+
type: "dialogue",
|
|
663
|
+
speaker,
|
|
664
|
+
content,
|
|
665
|
+
source_span: contentSpan(line, start, content),
|
|
666
|
+
};
|
|
667
|
+
applyDialogueSpeaker(item, scene, speaker);
|
|
668
|
+
if (emotion)
|
|
669
|
+
item["emotion"] = emotion;
|
|
670
|
+
scene["actions"].push(item);
|
|
671
|
+
}
|
|
672
|
+
else {
|
|
673
|
+
scene["actions"].push({
|
|
674
|
+
type: "action",
|
|
675
|
+
content,
|
|
676
|
+
source_span: contentSpan(line, start, content),
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
scene["actions"].push({
|
|
682
|
+
type: "action",
|
|
683
|
+
content: line,
|
|
684
|
+
source_span: { start, end: start + line.length },
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
if (scenes.length === 0) {
|
|
688
|
+
scenes.push({
|
|
689
|
+
scene_num: 1,
|
|
690
|
+
environment: { space: "interior", time: "day" },
|
|
691
|
+
location_name: "未知场景",
|
|
692
|
+
location_state: null,
|
|
693
|
+
actor_names: [],
|
|
694
|
+
actor_states: {},
|
|
695
|
+
prop_names: [],
|
|
696
|
+
actions: [],
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
return {
|
|
700
|
+
episode: episodePlan["episode"],
|
|
701
|
+
title: episodePlan["title"],
|
|
702
|
+
source_span: episodePlan["source_span"],
|
|
703
|
+
scenes,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
// ---------------------------------------------------------------------------
|
|
707
|
+
// Misc number helpers
|
|
708
|
+
// ---------------------------------------------------------------------------
|
|
709
|
+
export function normalizeInt(value, defaultVal) {
|
|
710
|
+
if (typeof value === "number" && Number.isInteger(value))
|
|
711
|
+
return value;
|
|
712
|
+
const text = strOf(value).trim();
|
|
713
|
+
if (isAsciiDigitString(text))
|
|
714
|
+
return parseInt(text, 10);
|
|
715
|
+
const match = reSearch(/(\d+)\s*$/, text);
|
|
716
|
+
return match ? parseInt(match[1], 10) : defaultVal;
|
|
717
|
+
}
|
|
718
|
+
// ---------------------------------------------------------------------------
|
|
719
|
+
// Source line classification
|
|
720
|
+
// ---------------------------------------------------------------------------
|
|
721
|
+
export function validateResultActionTypes(planItem, result) {
|
|
722
|
+
let label = `episode ${planItem["episode"]}`;
|
|
723
|
+
if (planItem["batch_id"]) {
|
|
724
|
+
label = `${planItem["batch_id"]} episode ${planItem["episode"]} part ${planItem["part"]}`;
|
|
725
|
+
}
|
|
726
|
+
const scenes = asList(result["scenes"]);
|
|
727
|
+
for (let i = 0; i < scenes.length; i++) {
|
|
728
|
+
const scene = scenes[i];
|
|
729
|
+
const actions = asList(scene["actions"]);
|
|
730
|
+
for (let j = 0; j < actions.length; j++) {
|
|
731
|
+
const action = actions[j];
|
|
732
|
+
const kind = strOf(action["type"]).trim();
|
|
733
|
+
if (!ACTION_TYPE_VALUES.has(kind)) {
|
|
734
|
+
throw new CliError("INIT FAILED: Provider returned unsupported action type", "Provider returned unsupported action type.", {
|
|
735
|
+
exitCode: EXIT_NEEDS_AGENT,
|
|
736
|
+
required: ["action.type from final contract: dialogue, inner_thought, action"],
|
|
737
|
+
received: [`${label} scene ${i + 1} action ${j + 1}: ${kind || "<empty>"}`],
|
|
738
|
+
nextSteps: ["Rerun init; the invalid checkpoint will not be reused."],
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
export function validateEpisodeExtractionQuality(_sourceText, episodePlan, result) {
|
|
745
|
+
validateResultActionTypes(episodePlan, result);
|
|
746
|
+
}
|
|
747
|
+
export function isMetadataLine(line) {
|
|
748
|
+
return Boolean(reMatch(_EP_HEADER_RE, line) ||
|
|
749
|
+
reMatch(_SOURCE_SCENE_LINE_RE, line) ||
|
|
750
|
+
reMatch(_SOURCE_META_LINE_RE, line) ||
|
|
751
|
+
reMatch(_SOURCE_PREVIEW_LINE_RE, line));
|
|
752
|
+
}
|
|
753
|
+
export function isSceneLine(line) {
|
|
754
|
+
return Boolean(reMatch(_SOURCE_SCENE_LINE_RE, line) || parseSceneHeader(line));
|
|
755
|
+
}
|
|
756
|
+
export function extractSourceLineAction(line, start) {
|
|
757
|
+
if (isMetadataLine(line) || isSceneLine(line) || reMatch(_CHAR_RE, line) || reMatch(_PROP_RE, line)) {
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
const bracket = reMatch(_BRACKET_RE, line);
|
|
761
|
+
if (bracket) {
|
|
762
|
+
const label = bracket[1].trim();
|
|
763
|
+
const content = bracket[2].trim();
|
|
764
|
+
if (!content)
|
|
765
|
+
return null;
|
|
766
|
+
const span = contentSpan(line, start, content);
|
|
767
|
+
if (BRACKET_ACTION_LABELS.has(label)) {
|
|
768
|
+
return { type: "action", content, source_span: span };
|
|
769
|
+
}
|
|
770
|
+
const speaker = cleanName(label);
|
|
771
|
+
const result = { type: "dialogue", speaker, content, source_span: span };
|
|
772
|
+
const speakerKind = inferNonActorSpeakerKind(speaker);
|
|
773
|
+
if (speakerKind !== "actor")
|
|
774
|
+
result["speaker_kind"] = speakerKind;
|
|
775
|
+
return result;
|
|
776
|
+
}
|
|
777
|
+
const action = reMatch(_ACTION_RE, line);
|
|
778
|
+
if (action) {
|
|
779
|
+
const content = action[1].trim();
|
|
780
|
+
if (!content)
|
|
781
|
+
return null;
|
|
782
|
+
return { type: "action", content, source_span: contentSpan(line, start, content) };
|
|
783
|
+
}
|
|
784
|
+
const osMatch = reMatch(_OS_EXT_RE, line);
|
|
785
|
+
if (osMatch) {
|
|
786
|
+
const content = osMatch[3].trim();
|
|
787
|
+
if (!content)
|
|
788
|
+
return null;
|
|
789
|
+
const rawEmotion = pyStrip(osMatch[2].trim().slice(2), " ,,、");
|
|
790
|
+
const result = {
|
|
791
|
+
type: "inner_thought",
|
|
792
|
+
speaker: cleanName(osMatch[1]),
|
|
793
|
+
content,
|
|
794
|
+
source_span: contentSpan(line, start, content),
|
|
795
|
+
};
|
|
796
|
+
if (rawEmotion)
|
|
797
|
+
result["emotion"] = rawEmotion;
|
|
798
|
+
return result;
|
|
799
|
+
}
|
|
800
|
+
if (reMatch(_BRACKET_PREFIX_RE, line)) {
|
|
801
|
+
return { type: "action", content: line, source_span: { start, end: start + line.length } };
|
|
802
|
+
}
|
|
803
|
+
const dialogue = reMatch(_DIALOGUE_RE, line);
|
|
804
|
+
if (dialogue) {
|
|
805
|
+
const content = dialogue[3].trim();
|
|
806
|
+
if (!content)
|
|
807
|
+
return null;
|
|
808
|
+
const speaker = cleanName(dialogue[1]);
|
|
809
|
+
if (speaker && speaker !== "旁白") {
|
|
810
|
+
const result = { type: "dialogue", speaker, content, source_span: contentSpan(line, start, content) };
|
|
811
|
+
const speakerKind = inferNonActorSpeakerKind(speaker);
|
|
812
|
+
if (speakerKind !== "actor")
|
|
813
|
+
result["speaker_kind"] = speakerKind;
|
|
814
|
+
const emotion = dialogue[2] ? dialogue[2].trim() : "";
|
|
815
|
+
if (emotion)
|
|
816
|
+
result["emotion"] = emotion;
|
|
817
|
+
return result;
|
|
818
|
+
}
|
|
819
|
+
return { type: "action", content, source_span: contentSpan(line, start, content) };
|
|
820
|
+
}
|
|
821
|
+
if (reMatch(_EMPTY_SPEAKER_LINE_RE, line))
|
|
822
|
+
return null;
|
|
823
|
+
return { type: "action", content: line, source_span: { start, end: start + line.length } };
|
|
824
|
+
}
|
|
825
|
+
export function sourceContentLineRecords(sourceText, planItem) {
|
|
826
|
+
const span = planItem["source_span"];
|
|
827
|
+
const records = [];
|
|
828
|
+
for (const [line, start, end] of lineItemsWithSpans(sourceText.slice(span.start, span.end), span.start)) {
|
|
829
|
+
const action = extractSourceLineAction(line, start);
|
|
830
|
+
if (action === null)
|
|
831
|
+
continue;
|
|
832
|
+
records.push({
|
|
833
|
+
id: `l${String(records.length + 1).padStart(3, "0")}`,
|
|
834
|
+
line,
|
|
835
|
+
start,
|
|
836
|
+
end,
|
|
837
|
+
action,
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
return records;
|
|
841
|
+
}
|
|
842
|
+
// ---------------------------------------------------------------------------
|
|
843
|
+
// Episode titles
|
|
844
|
+
// ---------------------------------------------------------------------------
|
|
845
|
+
export function episodesNeedingGeneratedTitles(episodePlan) {
|
|
846
|
+
const out = [];
|
|
847
|
+
for (const episode of asList(episodePlan["episodes"])) {
|
|
848
|
+
if (!isDict(episode))
|
|
849
|
+
continue;
|
|
850
|
+
const title = strOf(episode["title"]).trim();
|
|
851
|
+
const status = strOf(episode["title_status"]).trim();
|
|
852
|
+
if (!title || status === "missing" || status === "technical" || isTechnicalEpisodeTitle(title)) {
|
|
853
|
+
out.push(episode);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
return out;
|
|
857
|
+
}
|
|
858
|
+
export function deterministicEpisodeShortTitle(sourceText, episode) {
|
|
859
|
+
for (const record of sourceContentLineRecords(sourceText, episode)) {
|
|
860
|
+
const action = isDict(record["action"]) ? record["action"] : {};
|
|
861
|
+
let content = strOf(action["content"] || record["line"]).trim();
|
|
862
|
+
content = content.replace(/[^\w一-鿿]+/gu, "");
|
|
863
|
+
if (content.length >= 2)
|
|
864
|
+
return content.slice(0, 8);
|
|
865
|
+
}
|
|
866
|
+
const episodeNum = episode["episode"];
|
|
867
|
+
const suffix = episodeNum ? strOf(episodeNum).trim() : "";
|
|
868
|
+
return suffix ? `剧情转折${suffix}` : "剧情转折";
|
|
869
|
+
}
|
|
870
|
+
export function buildEpisodeTitleContext(sourceText, episodePlan) {
|
|
871
|
+
const context = [];
|
|
872
|
+
for (const episode of episodesNeedingGeneratedTitles(episodePlan)) {
|
|
873
|
+
const records = sourceContentLineRecords(sourceText, episode);
|
|
874
|
+
const previewLines = [];
|
|
875
|
+
for (const r of records.slice(0, 10)) {
|
|
876
|
+
const v = strOf(r["line"]).trim();
|
|
877
|
+
if (v)
|
|
878
|
+
previewLines.push(v);
|
|
879
|
+
}
|
|
880
|
+
const tailLines = [];
|
|
881
|
+
for (const r of records.slice(-4)) {
|
|
882
|
+
const v = strOf(r["line"]).trim();
|
|
883
|
+
if (v)
|
|
884
|
+
tailLines.push(v);
|
|
885
|
+
}
|
|
886
|
+
context.push({
|
|
887
|
+
episode: Number(episode["episode"] ?? 0),
|
|
888
|
+
raw_header: strOf(episode["raw_header"]).trim(),
|
|
889
|
+
title_status: strOf(episode["title_status"]).trim() || "missing",
|
|
890
|
+
preview: previewLines,
|
|
891
|
+
ending: tailLines,
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
return context;
|
|
895
|
+
}
|
|
896
|
+
export function episodeTitleResponseMap(payload) {
|
|
897
|
+
const rawItems = isDict(payload) ? payload["episode_titles"] : payload;
|
|
898
|
+
const result = new Map();
|
|
899
|
+
if (!isList(rawItems))
|
|
900
|
+
return result;
|
|
901
|
+
for (const item of rawItems) {
|
|
902
|
+
if (!isDict(item))
|
|
903
|
+
continue;
|
|
904
|
+
const episodeNum = episodeNumberFromValue(item["episode"] ?? item["episode_id"]);
|
|
905
|
+
const title = strOf(item["title"]).trim();
|
|
906
|
+
if (episodeNum && title)
|
|
907
|
+
result.set(episodeNum, title);
|
|
908
|
+
}
|
|
909
|
+
return result;
|
|
910
|
+
}
|
|
911
|
+
export async function enrichEpisodePlanTitles(sourceText, episodePlan, provider) {
|
|
912
|
+
const needsTitles = episodesNeedingGeneratedTitles(episodePlan);
|
|
913
|
+
if (needsTitles.length === 0)
|
|
914
|
+
return episodePlan;
|
|
915
|
+
let payload;
|
|
916
|
+
if (provider.extractEpisodeTitles) {
|
|
917
|
+
payload = await provider.extractEpisodeTitles(sourceText, episodePlan);
|
|
918
|
+
}
|
|
919
|
+
else {
|
|
920
|
+
payload = {
|
|
921
|
+
episode_titles: needsTitles.map((ep) => ({
|
|
922
|
+
episode: Number(ep["episode"] ?? 0),
|
|
923
|
+
title: deterministicEpisodeShortTitle(sourceText, ep),
|
|
924
|
+
})),
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
const generated = episodeTitleResponseMap(payload);
|
|
928
|
+
for (const episode of needsTitles) {
|
|
929
|
+
const episodeNum = Number(episode["episode"] ?? 0);
|
|
930
|
+
let rawTitle = generated.get(episodeNum) || deterministicEpisodeShortTitle(sourceText, episode);
|
|
931
|
+
let title = formatEpisodeTitle(episodeNum, rawTitle);
|
|
932
|
+
if (!title) {
|
|
933
|
+
rawTitle = deterministicEpisodeShortTitle(sourceText, episode);
|
|
934
|
+
title = formatEpisodeTitle(episodeNum, rawTitle) || `第${episodeNum}集:剧情转折${episodeNum}`;
|
|
935
|
+
}
|
|
936
|
+
episode["generated_title"] = rawTitle;
|
|
937
|
+
episode["title"] = title;
|
|
938
|
+
episode["title_status"] = "generated";
|
|
939
|
+
episode["title_source"] = "generated";
|
|
940
|
+
}
|
|
941
|
+
return episodePlan;
|
|
942
|
+
}
|
|
943
|
+
// ---------------------------------------------------------------------------
|
|
944
|
+
// Batch source formatting and planning
|
|
945
|
+
// ---------------------------------------------------------------------------
|
|
946
|
+
export function formatBatchSource(sourceText, batchPlan) {
|
|
947
|
+
const span = batchPlan["source_span"];
|
|
948
|
+
const contentStarts = new Set();
|
|
949
|
+
for (const r of sourceContentLineRecords(sourceText, batchPlan)) {
|
|
950
|
+
contentStarts.add(Number(r["start"]));
|
|
951
|
+
}
|
|
952
|
+
const lines = [];
|
|
953
|
+
for (const [line, start] of lineItemsWithSpans(sourceText.slice(span.start, span.end), span.start)) {
|
|
954
|
+
lines.push(contentStarts.has(start) ? line : `[meta] ${line}`);
|
|
955
|
+
}
|
|
956
|
+
return lines.join("\n");
|
|
957
|
+
}
|
|
958
|
+
export function lineNumberAt(sourceText, offset) {
|
|
959
|
+
return countOccurrences(sourceText, "\n", Math.max(0, offset)) + 1;
|
|
960
|
+
}
|
|
961
|
+
export function batchContextFor(sourceText, episode, start) {
|
|
962
|
+
const context = {};
|
|
963
|
+
const headerSpan = episode["header_span"];
|
|
964
|
+
if (isDict(headerSpan) && typeof headerSpan["start"] === "number" && typeof headerSpan["end"] === "number") {
|
|
965
|
+
context["episode_header"] = sourceText.slice(headerSpan["start"], headerSpan["end"]).trim();
|
|
966
|
+
}
|
|
967
|
+
let latestScene = "";
|
|
968
|
+
let latestCharacters = "";
|
|
969
|
+
let latestProps = "";
|
|
970
|
+
const span = episode["source_span"];
|
|
971
|
+
for (const [line, , lineEnd] of lineItemsWithSpans(sourceText.slice(span.start, start), span.start)) {
|
|
972
|
+
if (lineEnd > start)
|
|
973
|
+
break;
|
|
974
|
+
if (isSceneLine(line))
|
|
975
|
+
latestScene = line;
|
|
976
|
+
else if (reMatch(_CHAR_RE, line))
|
|
977
|
+
latestCharacters = line;
|
|
978
|
+
else if (reMatch(_PROP_RE, line))
|
|
979
|
+
latestProps = line;
|
|
980
|
+
}
|
|
981
|
+
if (latestScene)
|
|
982
|
+
context["scene_header"] = latestScene;
|
|
983
|
+
if (latestCharacters)
|
|
984
|
+
context["characters"] = latestCharacters;
|
|
985
|
+
if (latestProps)
|
|
986
|
+
context["props"] = latestProps;
|
|
987
|
+
return context;
|
|
988
|
+
}
|
|
989
|
+
function batchResultKey(batch) {
|
|
990
|
+
const bid = strOf(batch["batch_id"]).trim();
|
|
991
|
+
if (bid)
|
|
992
|
+
return bid;
|
|
993
|
+
return `bat_${String(Number(batch["batch_index"] ?? 0)).padStart(4, "0")}`;
|
|
994
|
+
}
|
|
995
|
+
export function makeBatch(sourceText, episode, batchIndex, part, lines) {
|
|
996
|
+
const epSpan = episode["source_span"];
|
|
997
|
+
const start = lines.length > 0 ? lines[0][1] : epSpan.start;
|
|
998
|
+
const end = lines.length > 0 ? lines[lines.length - 1][2] : epSpan.end;
|
|
999
|
+
const firstLine = lines.length > 0 ? lines[0][0] : "";
|
|
1000
|
+
let signalCount = 0;
|
|
1001
|
+
for (const [line, lstart] of lines) {
|
|
1002
|
+
if (extractSourceLineAction(line, lstart) !== null)
|
|
1003
|
+
signalCount += 1;
|
|
1004
|
+
}
|
|
1005
|
+
return {
|
|
1006
|
+
batch_id: `bat_${String(batchIndex).padStart(4, "0")}`,
|
|
1007
|
+
episode: Number(episode["episode"]),
|
|
1008
|
+
title: episode["title"],
|
|
1009
|
+
part,
|
|
1010
|
+
source_span: { start, end },
|
|
1011
|
+
line_range: { start: lineNumberAt(sourceText, start), end: lineNumberAt(sourceText, end) },
|
|
1012
|
+
signal_line_count: signalCount,
|
|
1013
|
+
starts_inside_scene: part > 1 && !isSceneLine(firstLine),
|
|
1014
|
+
context: batchContextFor(sourceText, episode, start),
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
export function buildBatchPlan(sourceText, episodePlan, opts = {}) {
|
|
1018
|
+
const targetLines = opts.targetLines ?? DEFAULT_BATCH_TARGET_LINES;
|
|
1019
|
+
const maxChars = opts.maxChars ?? DEFAULT_BATCH_MAX_CHARS;
|
|
1020
|
+
const minLines = opts.minLines ?? DEFAULT_BATCH_MIN_LINES;
|
|
1021
|
+
const mode = opts.mode ?? DEFAULT_BATCH_MODE;
|
|
1022
|
+
if (mode !== "episode") {
|
|
1023
|
+
throw new Error(`Unsupported batch mode: ${mode}`);
|
|
1024
|
+
}
|
|
1025
|
+
const batches = [];
|
|
1026
|
+
let batchIndex = 0;
|
|
1027
|
+
for (const episode of asList(episodePlan["episodes"])) {
|
|
1028
|
+
if (!isDict(episode))
|
|
1029
|
+
continue;
|
|
1030
|
+
const epSpan = episode["source_span"];
|
|
1031
|
+
const items = lineItemsWithSpans(sourceText.slice(epSpan.start, epSpan.end), epSpan.start);
|
|
1032
|
+
if (items.length === 0) {
|
|
1033
|
+
batchIndex += 1;
|
|
1034
|
+
batches.push(makeBatch(sourceText, episode, batchIndex, 1, []));
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
let startIdx = 0;
|
|
1038
|
+
let part = 1;
|
|
1039
|
+
let signalCount = 0;
|
|
1040
|
+
for (let idx = 0; idx < items.length; idx++) {
|
|
1041
|
+
const [line, lstart, lend] = items[idx];
|
|
1042
|
+
if (extractSourceLineAction(line, lstart) !== null)
|
|
1043
|
+
signalCount += 1;
|
|
1044
|
+
const currentStart = items[startIdx][1];
|
|
1045
|
+
const charCount = lend - currentStart;
|
|
1046
|
+
const nextIsScene = idx + 1 < items.length && isSceneLine(items[idx + 1][0]);
|
|
1047
|
+
const currentIsMetadata = isMetadataLine(line);
|
|
1048
|
+
const shouldCut = idx + 1 < items.length &&
|
|
1049
|
+
signalCount >= minLines &&
|
|
1050
|
+
(signalCount >= targetLines ||
|
|
1051
|
+
charCount >= maxChars ||
|
|
1052
|
+
(nextIsScene && signalCount >= minLines)) &&
|
|
1053
|
+
!currentIsMetadata;
|
|
1054
|
+
if (shouldCut) {
|
|
1055
|
+
batchIndex += 1;
|
|
1056
|
+
batches.push(makeBatch(sourceText, episode, batchIndex, part, items.slice(startIdx, idx + 1)));
|
|
1057
|
+
startIdx = idx + 1;
|
|
1058
|
+
part += 1;
|
|
1059
|
+
signalCount = 0;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
if (startIdx < items.length) {
|
|
1063
|
+
batchIndex += 1;
|
|
1064
|
+
batches.push(makeBatch(sourceText, episode, batchIndex, part, items.slice(startIdx)));
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
return {
|
|
1068
|
+
version: 1,
|
|
1069
|
+
mode,
|
|
1070
|
+
target_lines: targetLines,
|
|
1071
|
+
max_chars: maxChars,
|
|
1072
|
+
min_lines: minLines,
|
|
1073
|
+
total_batches: batches.length,
|
|
1074
|
+
total_episodes: Number(episodePlan["total_episodes"] ?? asList(episodePlan["episodes"]).length),
|
|
1075
|
+
batches,
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
export function validateBatchExtractionQuality(_sourceText, batchPlan, result) {
|
|
1079
|
+
validateResultActionTypes(batchPlan, result);
|
|
1080
|
+
}
|
|
1081
|
+
export function recoverBatchFromSource(sourceText, batchPlan) {
|
|
1082
|
+
const result = normalizeEpisodeResult(deterministicExtractEpisode(sourceText, batchPlan), batchPlan);
|
|
1083
|
+
validateBatchExtractionQuality(sourceText, batchPlan, result);
|
|
1084
|
+
return result;
|
|
1085
|
+
}
|
|
1086
|
+
export function isRecoverableBatchCliError(exc) {
|
|
1087
|
+
return (exc.title === "INIT FAILED: Provider output truncated" ||
|
|
1088
|
+
exc.title === "INIT FAILED: Provider returned invalid markdown" ||
|
|
1089
|
+
exc.title === "INIT FAILED: Provider returned unsupported action type");
|
|
1090
|
+
}
|
|
1091
|
+
export function isRetryableProviderError(exc) {
|
|
1092
|
+
const anyExc = exc;
|
|
1093
|
+
const statusCode = normalizeInt(anyExc?.status_code ?? anyExc?.statusCode ?? anyExc?.status, 0);
|
|
1094
|
+
if ([408, 409, 429, 500, 502, 503, 504, 529].includes(statusCode))
|
|
1095
|
+
return true;
|
|
1096
|
+
if ([400, 401, 403, 404].includes(statusCode))
|
|
1097
|
+
return false;
|
|
1098
|
+
const name = (anyExc?.name ?? "").toLowerCase();
|
|
1099
|
+
const message = (typeof exc === "object" && exc !== null && "message" in exc ? String(exc.message) : String(exc)).toLowerCase();
|
|
1100
|
+
const retryMarkers = [
|
|
1101
|
+
"apierror", "apiconnectionerror", "apitimeouterror", "ratelimiterror",
|
|
1102
|
+
"remoteprotocolerror", "timeout", "overloaded", "rate_limit", "temporarily",
|
|
1103
|
+
"service unavailable", "bad gateway", "gateway timeout", "connection reset",
|
|
1104
|
+
"peer closed", "incomplete chunked read",
|
|
1105
|
+
];
|
|
1106
|
+
return retryMarkers.some((m) => name.includes(m) || message.includes(m));
|
|
1107
|
+
}
|
|
1108
|
+
export function providerAttempts() {
|
|
1109
|
+
const raw = (process.env.SCRIPTCTL_PROVIDER_ATTEMPTS ?? "").trim();
|
|
1110
|
+
let attempts = DEFAULT_PROVIDER_ATTEMPTS;
|
|
1111
|
+
if (raw) {
|
|
1112
|
+
const parsed = parseInt(raw, 10);
|
|
1113
|
+
if (!Number.isNaN(parsed))
|
|
1114
|
+
attempts = parsed;
|
|
1115
|
+
}
|
|
1116
|
+
return Math.max(1, Math.min(attempts, 6));
|
|
1117
|
+
}
|
|
1118
|
+
export async function providerExtractBatch(provider, sourceText, batch) {
|
|
1119
|
+
if (provider.extractBatch)
|
|
1120
|
+
return await provider.extractBatch(sourceText, batch);
|
|
1121
|
+
if (provider.extractEpisode)
|
|
1122
|
+
return await provider.extractEpisode(sourceText, batch);
|
|
1123
|
+
throw new Error("Provider has neither extractBatch nor extractEpisode");
|
|
1124
|
+
}
|
|
1125
|
+
export async function providerExtractAssetCuration(provider, sourceText, script) {
|
|
1126
|
+
if (provider.extractAssetCuration) {
|
|
1127
|
+
const payload = await provider.extractAssetCuration(sourceText, script);
|
|
1128
|
+
return isDict(payload) ? payload : {};
|
|
1129
|
+
}
|
|
1130
|
+
return {};
|
|
1131
|
+
}
|
|
1132
|
+
function sleepMs(ms) {
|
|
1133
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1134
|
+
}
|
|
1135
|
+
export async function extractBatchWithRecovery(provider, sourceText, batch) {
|
|
1136
|
+
const attempts = providerAttempts();
|
|
1137
|
+
let lastExc = null;
|
|
1138
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
1139
|
+
try {
|
|
1140
|
+
const rawResult = await providerExtractBatch(provider, sourceText, batch);
|
|
1141
|
+
const result = normalizeEpisodeResult(rawResult, batch);
|
|
1142
|
+
validateBatchExtractionQuality(sourceText, batch, result);
|
|
1143
|
+
return result;
|
|
1144
|
+
}
|
|
1145
|
+
catch (exc) {
|
|
1146
|
+
const retryable = isRetryableProviderError(exc);
|
|
1147
|
+
if (retryable && attempt < attempts) {
|
|
1148
|
+
lastExc = exc;
|
|
1149
|
+
await sleepMs(Math.min(8000, 800 * attempt));
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
throw exc;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
if (lastExc)
|
|
1156
|
+
throw lastExc;
|
|
1157
|
+
throw new CliError("INIT FAILED: Batch extraction failed", "Batch extraction failed.", {
|
|
1158
|
+
exitCode: EXIT_RUNTIME,
|
|
1159
|
+
required: ["provider result that passes batch quality gates"],
|
|
1160
|
+
received: [batchResultKey(batch)],
|
|
1161
|
+
nextSteps: ["Rerun init; only failed batches will retry."],
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
// ---------------------------------------------------------------------------
|
|
1165
|
+
// Markdown parsing helpers
|
|
1166
|
+
// ---------------------------------------------------------------------------
|
|
1167
|
+
function _mdInvalid(label, detail) {
|
|
1168
|
+
return new CliError("INIT FAILED: Provider returned invalid markdown", "Provider returned invalid markdown.", {
|
|
1169
|
+
exitCode: EXIT_RUNTIME,
|
|
1170
|
+
required: ["markdown matching the documented batch template"],
|
|
1171
|
+
received: [`${label}: ${detail}`],
|
|
1172
|
+
nextSteps: ["Rerun init; the invalid batch checkpoint will not be reused."],
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
export function _md_push_asset(items, rawName, description) {
|
|
1176
|
+
const name = cleanName(strOf(rawName));
|
|
1177
|
+
if (!name)
|
|
1178
|
+
return;
|
|
1179
|
+
for (const item of items) {
|
|
1180
|
+
if (item["name"] === name) {
|
|
1181
|
+
if (description && !item["description"])
|
|
1182
|
+
item["description"] = description;
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
items.push({ name, description: description || null });
|
|
1187
|
+
}
|
|
1188
|
+
function _md_push_state_def(items, kind, rawName, description) {
|
|
1189
|
+
const [namePart, sep, statePart] = partition(strOf(rawName), "|");
|
|
1190
|
+
const assetName = cleanName(namePart);
|
|
1191
|
+
const stateName = statePart.trim();
|
|
1192
|
+
if (!sep || !assetName || !stateName)
|
|
1193
|
+
return;
|
|
1194
|
+
for (const item of items) {
|
|
1195
|
+
if (item["kind"] === kind && item["asset_name"] === assetName && item["state_name"] === stateName) {
|
|
1196
|
+
if (description && !item["description"])
|
|
1197
|
+
item["description"] = description;
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
items.push({ kind, asset_name: assetName, state_name: stateName, description: description || null });
|
|
1202
|
+
}
|
|
1203
|
+
export function parseSpeakerRef(raw) {
|
|
1204
|
+
const value = strOf(raw).trim();
|
|
1205
|
+
if (!value)
|
|
1206
|
+
return ["actor", ""];
|
|
1207
|
+
if (value.includes(":") || value.includes(":")) {
|
|
1208
|
+
const parts = reSplit(/[::]/, value, 1);
|
|
1209
|
+
const left = parts[0] ?? "";
|
|
1210
|
+
const right = parts[2] ?? "";
|
|
1211
|
+
const kind = _MD_SPEAKER_KIND_NORM[left.trim().toLowerCase()] ?? _MD_SPEAKER_KIND_NORM[left.trim()];
|
|
1212
|
+
if (kind)
|
|
1213
|
+
return [kind, right.trim()];
|
|
1214
|
+
}
|
|
1215
|
+
return ["actor", value];
|
|
1216
|
+
}
|
|
1217
|
+
export function parseSpeakerAsset(raw) {
|
|
1218
|
+
const value = strOf(raw).trim();
|
|
1219
|
+
const [kind, name] = parseSpeakerRef(value);
|
|
1220
|
+
if (kind !== "actor")
|
|
1221
|
+
return [kind, name];
|
|
1222
|
+
const match = reMatch(/^(.+?)[((]([^))]+)[))]$/, value);
|
|
1223
|
+
if (match) {
|
|
1224
|
+
const parsedKind = _MD_SPEAKER_KIND_NORM[match[2].trim().toLowerCase()] ?? _MD_SPEAKER_KIND_NORM[match[2].trim()];
|
|
1225
|
+
if (parsedKind && parsedKind !== "actor")
|
|
1226
|
+
return [parsedKind, match[1].trim()];
|
|
1227
|
+
}
|
|
1228
|
+
return ["other", value];
|
|
1229
|
+
}
|
|
1230
|
+
export function inferNonActorSpeakerKind(name, scene = null) {
|
|
1231
|
+
const value = strOf(name).trim();
|
|
1232
|
+
if (!value)
|
|
1233
|
+
return "actor";
|
|
1234
|
+
if (reSearch(/系统|提示音|提示声|机械音|电子音/, value))
|
|
1235
|
+
return "system";
|
|
1236
|
+
if (reSearch(/广播(?!员)|喇叭|电台|播报|通知|警报/, value))
|
|
1237
|
+
return "broadcast";
|
|
1238
|
+
if (reSearch(/众人|众|群体|全体|大家|人群/, value))
|
|
1239
|
+
return "group";
|
|
1240
|
+
if (scene) {
|
|
1241
|
+
const propSet = new Set(asList(scene["prop_names"]).map((p) => strOf(p).trim()));
|
|
1242
|
+
if (propSet.has(value))
|
|
1243
|
+
return "prop";
|
|
1244
|
+
if (value === strOf(scene["location_name"]).trim())
|
|
1245
|
+
return "location";
|
|
1246
|
+
}
|
|
1247
|
+
return "actor";
|
|
1248
|
+
}
|
|
1249
|
+
export function normalizeDeliveryToken(value) {
|
|
1250
|
+
const token = strOf(value).trim();
|
|
1251
|
+
if (!token)
|
|
1252
|
+
return "";
|
|
1253
|
+
return _MD_DELIVERY_NORM[token.toLowerCase()] ?? _MD_DELIVERY_NORM[token] ?? "";
|
|
1254
|
+
}
|
|
1255
|
+
export function contentHasSpeakerPrefix(content, speakerNames) {
|
|
1256
|
+
for (const rawPart of reSplit(/\s*(?:\/|/|\n)\s*/, strOf(content))) {
|
|
1257
|
+
const part = rawPart.trim();
|
|
1258
|
+
if (!part || (!part.includes(":") && !part.includes(":")))
|
|
1259
|
+
continue;
|
|
1260
|
+
const prefix = reSplit(/[::]/, part, 1)[0].trim();
|
|
1261
|
+
if (!prefix)
|
|
1262
|
+
continue;
|
|
1263
|
+
if (speakerNames.has(prefix))
|
|
1264
|
+
return true;
|
|
1265
|
+
if (_MD_SPEAKER_KIND_NORM[prefix.toLowerCase()] || _MD_SPEAKER_KIND_NORM[prefix])
|
|
1266
|
+
return true;
|
|
1267
|
+
}
|
|
1268
|
+
return false;
|
|
1269
|
+
}
|
|
1270
|
+
export function splitSpeakerRefs(raw) {
|
|
1271
|
+
const value = strOf(raw).trim();
|
|
1272
|
+
if (!value)
|
|
1273
|
+
return [];
|
|
1274
|
+
if (normalizeDeliveryToken(value))
|
|
1275
|
+
return [];
|
|
1276
|
+
const parts = reSplit(/\s*(?:\/|/|\+|&|、|,|,|\s+和\s+|\s+与\s+)\s*/, value);
|
|
1277
|
+
return parts.filter((p) => p.trim().length > 0).map((p) => p.trim());
|
|
1278
|
+
}
|
|
1279
|
+
export function speakerRefEntry(scene, rawSpeaker, defaultKind = "actor") {
|
|
1280
|
+
const [explicitKind, raw] = parseSpeakerRef(rawSpeaker);
|
|
1281
|
+
const speaker = cleanName(raw);
|
|
1282
|
+
if (!speaker)
|
|
1283
|
+
return null;
|
|
1284
|
+
let speakerKind = explicitKind !== "actor" ? explicitKind : defaultKind;
|
|
1285
|
+
if (speakerKind === "actor") {
|
|
1286
|
+
const inferred = inferNonActorSpeakerKind(speaker, scene);
|
|
1287
|
+
speakerKind = inferred !== "actor" ? inferred : "actor";
|
|
1288
|
+
}
|
|
1289
|
+
if (speakerKind === "prop") {
|
|
1290
|
+
const props = asList(scene["prop_names"]);
|
|
1291
|
+
if (!props.includes(speaker)) {
|
|
1292
|
+
if (!Array.isArray(scene["prop_names"]))
|
|
1293
|
+
scene["prop_names"] = [];
|
|
1294
|
+
scene["prop_names"].push(speaker);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
if (speakerKind === "actor") {
|
|
1298
|
+
const actors = scene["actor_names"];
|
|
1299
|
+
if (!actors.includes(speaker))
|
|
1300
|
+
actors.push(speaker);
|
|
1301
|
+
}
|
|
1302
|
+
return { speaker, speaker_kind: speakerKind };
|
|
1303
|
+
}
|
|
1304
|
+
export function applyDialogueSpeaker(action, scene, rawSpeaker, defaultKind = "actor") {
|
|
1305
|
+
const entry = speakerRefEntry(scene, rawSpeaker, defaultKind);
|
|
1306
|
+
if (!entry)
|
|
1307
|
+
return;
|
|
1308
|
+
action["speaker"] = entry.speaker;
|
|
1309
|
+
if (entry.speaker_kind !== "actor")
|
|
1310
|
+
action["speaker_kind"] = entry.speaker_kind;
|
|
1311
|
+
}
|
|
1312
|
+
export function parseOverlapMarkdownLines(content, scene) {
|
|
1313
|
+
const lines = [];
|
|
1314
|
+
for (const rawPart of reSplit(/\s*(?:\/|/|\n)\s*/, strOf(content).trim())) {
|
|
1315
|
+
const part = rawPart.trim();
|
|
1316
|
+
if (!part || (!part.includes(":") && !part.includes(":")))
|
|
1317
|
+
continue;
|
|
1318
|
+
const pieces = reSplit(/[::]/, part);
|
|
1319
|
+
let rawSpeaker;
|
|
1320
|
+
let lineContent;
|
|
1321
|
+
if (pieces.length >= 3) {
|
|
1322
|
+
const maybeKind = pieces[0].trim();
|
|
1323
|
+
const parsedKind = _MD_SPEAKER_KIND_NORM[maybeKind.toLowerCase()] ?? _MD_SPEAKER_KIND_NORM[maybeKind];
|
|
1324
|
+
if (parsedKind && parsedKind !== "actor") {
|
|
1325
|
+
rawSpeaker = `${parsedKind}:${pieces[1].trim()}`;
|
|
1326
|
+
lineContent = pieces.slice(2).join(":");
|
|
1327
|
+
}
|
|
1328
|
+
else {
|
|
1329
|
+
const parts = reSplit(/[::]/, part, 1);
|
|
1330
|
+
rawSpeaker = parts[0] ?? "";
|
|
1331
|
+
lineContent = parts[2] ?? "";
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
else {
|
|
1335
|
+
const parts = reSplit(/[::]/, part, 1);
|
|
1336
|
+
rawSpeaker = parts[0] ?? "";
|
|
1337
|
+
lineContent = parts[2] ?? "";
|
|
1338
|
+
}
|
|
1339
|
+
const entry = speakerRefEntry(scene, rawSpeaker);
|
|
1340
|
+
lineContent = lineContent.trim();
|
|
1341
|
+
if (!entry || !lineContent)
|
|
1342
|
+
continue;
|
|
1343
|
+
lines.push({ speaker: entry.speaker, speaker_kind: entry.speaker_kind, content: lineContent });
|
|
1344
|
+
}
|
|
1345
|
+
return lines;
|
|
1346
|
+
}
|
|
1347
|
+
function applyMarkdownDialogueAnchor(action, scene, rawSpeakerArg, rawTag) {
|
|
1348
|
+
let raw_speaker = rawSpeakerArg;
|
|
1349
|
+
let delivery = normalizeDeliveryToken(rawTag);
|
|
1350
|
+
let emotion = delivery ? "" : strOf(rawTag).trim();
|
|
1351
|
+
if (!delivery && normalizeDeliveryToken(raw_speaker)) {
|
|
1352
|
+
delivery = normalizeDeliveryToken(raw_speaker);
|
|
1353
|
+
raw_speaker = "";
|
|
1354
|
+
}
|
|
1355
|
+
if (delivery === "overlap") {
|
|
1356
|
+
const lines = parseOverlapMarkdownLines(strOf(action["content"]), scene);
|
|
1357
|
+
if (lines.length > 0) {
|
|
1358
|
+
action["delivery"] = "overlap";
|
|
1359
|
+
action["lines"] = lines;
|
|
1360
|
+
action["content"] = lines.map((l) => l.content).join(" / ");
|
|
1361
|
+
}
|
|
1362
|
+
else if (raw_speaker) {
|
|
1363
|
+
applyDialogueSpeaker(action, scene, raw_speaker);
|
|
1364
|
+
action["delivery"] = "overlap";
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
else if (raw_speaker) {
|
|
1368
|
+
const speakerRefs = splitSpeakerRefs(raw_speaker);
|
|
1369
|
+
if (speakerRefs.length > 1) {
|
|
1370
|
+
const speakers = [];
|
|
1371
|
+
for (const ref of speakerRefs) {
|
|
1372
|
+
const entry = speakerRefEntry(scene, ref);
|
|
1373
|
+
if (entry)
|
|
1374
|
+
speakers.push(entry);
|
|
1375
|
+
}
|
|
1376
|
+
if (speakers.length > 0) {
|
|
1377
|
+
action["speakers"] = speakers;
|
|
1378
|
+
action["delivery"] = delivery || "simultaneous";
|
|
1379
|
+
action["speaker"] = speakers.map((s) => s.speaker).join(" / ");
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
else {
|
|
1383
|
+
applyDialogueSpeaker(action, scene, raw_speaker);
|
|
1384
|
+
if (delivery && delivery !== "single")
|
|
1385
|
+
action["delivery"] = delivery;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
if (emotion)
|
|
1389
|
+
action["emotion"] = emotion;
|
|
1390
|
+
}
|
|
1391
|
+
export function parseMarkdownBatch(text, batchPlan) {
|
|
1392
|
+
const label = `${batchPlan["batch_id"] || "batch"} episode ${batchPlan["episode"]} part ${batchPlan["part"]}`;
|
|
1393
|
+
let section = null;
|
|
1394
|
+
let assetSubsection = null;
|
|
1395
|
+
const scenes = [];
|
|
1396
|
+
const actors = [];
|
|
1397
|
+
const locations = [];
|
|
1398
|
+
const props = [];
|
|
1399
|
+
const speakers = [];
|
|
1400
|
+
const stateDefs = [];
|
|
1401
|
+
let lastAction = null;
|
|
1402
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
1403
|
+
const stripped = rawLine.trim();
|
|
1404
|
+
const subMatch = reMatch(_MD_SUB_HEADER_RE, stripped);
|
|
1405
|
+
const sceneMatch = subMatch ? reMatch(_MD_SCENE_HEADER_RE, stripped) : null;
|
|
1406
|
+
if (sceneMatch && section === "script") {
|
|
1407
|
+
scenes.push({
|
|
1408
|
+
scene_num: parseInt(sceneMatch[1], 10),
|
|
1409
|
+
environment: { space: "interior", time: "day" },
|
|
1410
|
+
location_name: "未知场景",
|
|
1411
|
+
location_state: null,
|
|
1412
|
+
actor_names: [],
|
|
1413
|
+
actor_states: {},
|
|
1414
|
+
prop_names: [],
|
|
1415
|
+
actions: [],
|
|
1416
|
+
});
|
|
1417
|
+
lastAction = null;
|
|
1418
|
+
continue;
|
|
1419
|
+
}
|
|
1420
|
+
if (subMatch && section === "asset") {
|
|
1421
|
+
const heading = subMatch[1].trim();
|
|
1422
|
+
assetSubsection = _MD_ASSET_SUBSECTION_MAP[heading] ?? null;
|
|
1423
|
+
lastAction = null;
|
|
1424
|
+
continue;
|
|
1425
|
+
}
|
|
1426
|
+
if (subMatch) {
|
|
1427
|
+
lastAction = null;
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
const topMatch = reMatch(_MD_TOP_HEADER_RE, stripped);
|
|
1431
|
+
if (topMatch) {
|
|
1432
|
+
const heading = topMatch[1].trim();
|
|
1433
|
+
if (heading.startsWith("剧本")) {
|
|
1434
|
+
section = "script";
|
|
1435
|
+
}
|
|
1436
|
+
else if (heading.startsWith("资产")) {
|
|
1437
|
+
section = "asset";
|
|
1438
|
+
assetSubsection = null;
|
|
1439
|
+
}
|
|
1440
|
+
else {
|
|
1441
|
+
section = null;
|
|
1442
|
+
}
|
|
1443
|
+
lastAction = null;
|
|
1444
|
+
continue;
|
|
1445
|
+
}
|
|
1446
|
+
if (section === "script") {
|
|
1447
|
+
const fieldMatch = reMatch(_MD_SCENE_FIELD_RE, stripped);
|
|
1448
|
+
if (fieldMatch && scenes.length > 0) {
|
|
1449
|
+
const scene = scenes[scenes.length - 1];
|
|
1450
|
+
const key = fieldMatch[1];
|
|
1451
|
+
const value = fieldMatch[2].trim();
|
|
1452
|
+
if (key === "时空") {
|
|
1453
|
+
const tsMatch = reMatch(_MD_TIME_SPACE_RE, value);
|
|
1454
|
+
if (tsMatch) {
|
|
1455
|
+
scene["environment"] = {
|
|
1456
|
+
space: _MD_SPACE_NORM[tsMatch[1].toLowerCase()] ?? "interior",
|
|
1457
|
+
time: _MD_TIME_NORM[tsMatch[2].toLowerCase()] ?? "day",
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
else {
|
|
1461
|
+
const env = scene["environment"];
|
|
1462
|
+
for (const tok of value.split(/\s+/)) {
|
|
1463
|
+
const tl = tok.toLowerCase();
|
|
1464
|
+
if (tl in _MD_SPACE_NORM)
|
|
1465
|
+
env["space"] = _MD_SPACE_NORM[tl];
|
|
1466
|
+
if (tl in _MD_TIME_NORM)
|
|
1467
|
+
env["time"] = _MD_TIME_NORM[tl];
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
else if (key === "地点") {
|
|
1472
|
+
const locMatch = reMatch(_MD_LOC_STATE_RE, value);
|
|
1473
|
+
if (locMatch) {
|
|
1474
|
+
const [locName, locStateFromName] = splitEntityState(value);
|
|
1475
|
+
scene["location_name"] = locName || "未知场景";
|
|
1476
|
+
const locState = (locMatch[2] || locStateFromName || "").trim();
|
|
1477
|
+
scene["location_state"] = locState && isReusableState("location", locState) ? locState : null;
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
else if (key === "角色") {
|
|
1481
|
+
for (let entry of value.split(/[,,、]/)) {
|
|
1482
|
+
entry = entry.trim();
|
|
1483
|
+
if (!entry)
|
|
1484
|
+
continue;
|
|
1485
|
+
const charMatch = reMatch(_MD_CHAR_ENTRY_RE, entry);
|
|
1486
|
+
if (!charMatch)
|
|
1487
|
+
continue;
|
|
1488
|
+
const [name, inferredState] = splitEntityState(entry);
|
|
1489
|
+
const state = (charMatch[2] || inferredState || "").trim() || null;
|
|
1490
|
+
if (!name)
|
|
1491
|
+
continue;
|
|
1492
|
+
const actorNames = scene["actor_names"];
|
|
1493
|
+
if (!actorNames.includes(name))
|
|
1494
|
+
actorNames.push(name);
|
|
1495
|
+
if (state && isReusableState("actor", state)) {
|
|
1496
|
+
scene["actor_states"][name] = state;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
else if (key === "道具") {
|
|
1501
|
+
for (const entry of value.split(/[,,、]/)) {
|
|
1502
|
+
const [prop] = splitEntityState(entry);
|
|
1503
|
+
const propNames = scene["prop_names"];
|
|
1504
|
+
if (prop && !propNames.includes(prop))
|
|
1505
|
+
propNames.push(prop);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
lastAction = null;
|
|
1509
|
+
continue;
|
|
1510
|
+
}
|
|
1511
|
+
const actionMatch = reMatch(_MD_ACTION_ANCHOR_RE, stripped);
|
|
1512
|
+
if (actionMatch && scenes.length > 0) {
|
|
1513
|
+
const kindRaw = actionMatch[1].toLowerCase();
|
|
1514
|
+
const kind = _MD_KIND_NORM[kindRaw];
|
|
1515
|
+
if (!kind)
|
|
1516
|
+
throw _mdInvalid(label, `unknown action kind in anchor: ${kindRaw}`);
|
|
1517
|
+
const rawSpeaker = (actionMatch[2] || "").trim();
|
|
1518
|
+
const rawTag = (actionMatch[3] || "").trim();
|
|
1519
|
+
const content = (actionMatch[4] || "").replace(/\s+$/, "");
|
|
1520
|
+
const action = { type: kind, content };
|
|
1521
|
+
if (kind === "dialogue") {
|
|
1522
|
+
applyMarkdownDialogueAnchor(action, scenes[scenes.length - 1], rawSpeaker, rawTag);
|
|
1523
|
+
}
|
|
1524
|
+
else {
|
|
1525
|
+
if (rawSpeaker)
|
|
1526
|
+
applyDialogueSpeaker(action, scenes[scenes.length - 1], rawSpeaker);
|
|
1527
|
+
if (rawTag)
|
|
1528
|
+
action["emotion"] = rawTag;
|
|
1529
|
+
}
|
|
1530
|
+
scenes[scenes.length - 1]["actions"].push(action);
|
|
1531
|
+
lastAction = action;
|
|
1532
|
+
continue;
|
|
1533
|
+
}
|
|
1534
|
+
if (!stripped) {
|
|
1535
|
+
lastAction = null;
|
|
1536
|
+
continue;
|
|
1537
|
+
}
|
|
1538
|
+
if (lastAction !== null && !stripped.startsWith("-")) {
|
|
1539
|
+
if (lastAction["content"]) {
|
|
1540
|
+
lastAction["content"] = strOf(lastAction["content"]) + "\n" + stripped;
|
|
1541
|
+
}
|
|
1542
|
+
else {
|
|
1543
|
+
lastAction["content"] = stripped;
|
|
1544
|
+
}
|
|
1545
|
+
continue;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
else if (section === "asset") {
|
|
1549
|
+
if (assetSubsection === null || !stripped)
|
|
1550
|
+
continue;
|
|
1551
|
+
const entryMatch = reMatch(_MD_ASSET_ENTRY_RE, stripped);
|
|
1552
|
+
if (entryMatch) {
|
|
1553
|
+
const name = entryMatch[1].trim();
|
|
1554
|
+
const description = (entryMatch[2] || "").trim() || null;
|
|
1555
|
+
if (assetSubsection === "actors")
|
|
1556
|
+
_md_push_asset(actors, name, description);
|
|
1557
|
+
else if (assetSubsection === "locations")
|
|
1558
|
+
_md_push_asset(locations, name, description);
|
|
1559
|
+
else if (assetSubsection === "props")
|
|
1560
|
+
_md_push_asset(props, name, description);
|
|
1561
|
+
else if (assetSubsection === "speakers") {
|
|
1562
|
+
const [sourceKind, displayName] = parseSpeakerAsset(name);
|
|
1563
|
+
if (displayName)
|
|
1564
|
+
speakers.push({ name: displayName, source_kind: sourceKind, description });
|
|
1565
|
+
}
|
|
1566
|
+
else if (assetSubsection === "actor_states") {
|
|
1567
|
+
_md_push_state_def(stateDefs, "actor", name, description);
|
|
1568
|
+
}
|
|
1569
|
+
else if (assetSubsection === "location_states") {
|
|
1570
|
+
_md_push_state_def(stateDefs, "location", name, description);
|
|
1571
|
+
}
|
|
1572
|
+
else if (assetSubsection === "prop_states") {
|
|
1573
|
+
_md_push_state_def(stateDefs, "prop", name, description);
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
for (const scene of scenes) {
|
|
1579
|
+
scene["actions"] = scene["actions"].filter((a) => strOf(a["content"]).trim().length > 0);
|
|
1580
|
+
}
|
|
1581
|
+
const stateLookup = new Map();
|
|
1582
|
+
for (const stateDef of stateDefs) {
|
|
1583
|
+
const key = `${strOf(stateDef["kind"])}::${strOf(stateDef["asset_name"])}`;
|
|
1584
|
+
if (!stateLookup.has(key))
|
|
1585
|
+
stateLookup.set(key, new Set());
|
|
1586
|
+
stateLookup.get(key).add(strOf(stateDef["state_name"]).trim());
|
|
1587
|
+
}
|
|
1588
|
+
for (const scene of scenes) {
|
|
1589
|
+
const actorStates = scene["actor_states"] || {};
|
|
1590
|
+
const filteredActorStates = {};
|
|
1591
|
+
for (const [actorName, stateName] of Object.entries(actorStates)) {
|
|
1592
|
+
const allowed = stateLookup.get(`actor::${actorName.trim()}`) ?? new Set();
|
|
1593
|
+
if (allowed.has(strOf(stateName).trim()))
|
|
1594
|
+
filteredActorStates[actorName] = stateName;
|
|
1595
|
+
}
|
|
1596
|
+
scene["actor_states"] = filteredActorStates;
|
|
1597
|
+
const locName = strOf(scene["location_name"]).trim();
|
|
1598
|
+
const locState = strOf(scene["location_state"]).trim();
|
|
1599
|
+
if (locState) {
|
|
1600
|
+
const allowed = stateLookup.get(`location::${locName}`) ?? new Set();
|
|
1601
|
+
if (!allowed.has(locState))
|
|
1602
|
+
scene["location_state"] = null;
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
if (scenes.length === 0) {
|
|
1606
|
+
throw _mdInvalid(label, "no scenes parsed; expected '## 场景 N' headers under '# 剧本'");
|
|
1607
|
+
}
|
|
1608
|
+
return {
|
|
1609
|
+
episode: Number(batchPlan["episode"] ?? 0),
|
|
1610
|
+
title: batchPlan["title"],
|
|
1611
|
+
source_span: batchPlan["source_span"],
|
|
1612
|
+
scenes,
|
|
1613
|
+
actors,
|
|
1614
|
+
locations,
|
|
1615
|
+
props,
|
|
1616
|
+
speakers,
|
|
1617
|
+
state_definitions: stateDefs,
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
// ---------------------------------------------------------------------------
|
|
1621
|
+
// Compact / expand transforms
|
|
1622
|
+
// ---------------------------------------------------------------------------
|
|
1623
|
+
export function expandCompactEpisodeResult(result, episodePlan) {
|
|
1624
|
+
if (!("sc" in result))
|
|
1625
|
+
return { ...result };
|
|
1626
|
+
const scenes = [];
|
|
1627
|
+
const rawScenes = asList(result["sc"]);
|
|
1628
|
+
for (let index = 0; index < rawScenes.length; index++) {
|
|
1629
|
+
const rawScene = rawScenes[index];
|
|
1630
|
+
if (!isDict(rawScene))
|
|
1631
|
+
continue;
|
|
1632
|
+
const rawEnv = isDict(rawScene["env"]) ? rawScene["env"] : {};
|
|
1633
|
+
const actorNames = [];
|
|
1634
|
+
const actorStates = {};
|
|
1635
|
+
for (const rawActor of asList(rawScene["ch"])) {
|
|
1636
|
+
let name = "";
|
|
1637
|
+
let state = "";
|
|
1638
|
+
if (typeof rawActor === "string") {
|
|
1639
|
+
name = rawActor.trim();
|
|
1640
|
+
}
|
|
1641
|
+
else if (isDict(rawActor)) {
|
|
1642
|
+
name = strOf(rawActor["n"]).trim();
|
|
1643
|
+
state = strOf(rawActor["st"]).trim();
|
|
1644
|
+
}
|
|
1645
|
+
else
|
|
1646
|
+
continue;
|
|
1647
|
+
if (!name)
|
|
1648
|
+
continue;
|
|
1649
|
+
uniqueAdd(actorNames, name);
|
|
1650
|
+
if (state && isStateLabelValid(state))
|
|
1651
|
+
actorStates[name] = state;
|
|
1652
|
+
}
|
|
1653
|
+
const actions = [];
|
|
1654
|
+
for (const rawAction of asList(rawScene["a"])) {
|
|
1655
|
+
if (!isDict(rawAction))
|
|
1656
|
+
continue;
|
|
1657
|
+
const content = strOf(rawAction["c"]).trim();
|
|
1658
|
+
if (!content)
|
|
1659
|
+
continue;
|
|
1660
|
+
const rawKind = strOf(rawAction["k"]).trim();
|
|
1661
|
+
const kind = _COMPACT_ACTION_TO_LONG[rawKind] ?? rawKind ?? "action";
|
|
1662
|
+
const action = { type: kind, content };
|
|
1663
|
+
const speaker = strOf(rawAction["p"]).trim();
|
|
1664
|
+
if (speaker) {
|
|
1665
|
+
action["speaker"] = speaker;
|
|
1666
|
+
const speakerKind = strOf(rawAction["pk"]).trim();
|
|
1667
|
+
if (speakerKind)
|
|
1668
|
+
action["speaker_kind"] = speakerKind;
|
|
1669
|
+
if ((kind === "dialogue" || kind === "inner_thought") && !speakerKind)
|
|
1670
|
+
uniqueAdd(actorNames, speaker);
|
|
1671
|
+
}
|
|
1672
|
+
const delivery = normalizeDeliveryToken(rawAction["d"]);
|
|
1673
|
+
if (delivery)
|
|
1674
|
+
action["delivery"] = delivery;
|
|
1675
|
+
const rawSpeakers = rawAction["sp"];
|
|
1676
|
+
if (isList(rawSpeakers)) {
|
|
1677
|
+
const speakerList = [];
|
|
1678
|
+
for (const item of rawSpeakers) {
|
|
1679
|
+
if (!isDict(item))
|
|
1680
|
+
continue;
|
|
1681
|
+
const sn = strOf(item["p"]).trim();
|
|
1682
|
+
if (!sn)
|
|
1683
|
+
continue;
|
|
1684
|
+
const sk = strOf(item["pk"]).trim() || "actor";
|
|
1685
|
+
speakerList.push({ speaker: sn, speaker_kind: sk });
|
|
1686
|
+
if (sk === "actor")
|
|
1687
|
+
uniqueAdd(actorNames, sn);
|
|
1688
|
+
}
|
|
1689
|
+
if (speakerList.length > 0)
|
|
1690
|
+
action["speakers"] = speakerList;
|
|
1691
|
+
}
|
|
1692
|
+
const rawLines = rawAction["ln"];
|
|
1693
|
+
if (isList(rawLines)) {
|
|
1694
|
+
const lineList = [];
|
|
1695
|
+
for (const item of rawLines) {
|
|
1696
|
+
if (!isDict(item))
|
|
1697
|
+
continue;
|
|
1698
|
+
const sn = strOf(item["p"]).trim();
|
|
1699
|
+
const lc = strOf(item["c"]).trim();
|
|
1700
|
+
if (!sn || !lc)
|
|
1701
|
+
continue;
|
|
1702
|
+
const sk = strOf(item["pk"]).trim() || "actor";
|
|
1703
|
+
lineList.push({ speaker: sn, speaker_kind: sk, content: lc });
|
|
1704
|
+
if (sk === "actor")
|
|
1705
|
+
uniqueAdd(actorNames, sn);
|
|
1706
|
+
}
|
|
1707
|
+
if (lineList.length > 0)
|
|
1708
|
+
action["lines"] = lineList;
|
|
1709
|
+
}
|
|
1710
|
+
const emotion = strOf(rawAction["m"]).trim();
|
|
1711
|
+
if (emotion)
|
|
1712
|
+
action["emotion"] = emotion;
|
|
1713
|
+
const refs = rawAction["_source_line_ids"] ?? rawAction["source_line_ids"];
|
|
1714
|
+
if (isList(refs)) {
|
|
1715
|
+
action["source_line_ids"] = refs.map((r) => strOf(r).trim()).filter((r) => r);
|
|
1716
|
+
}
|
|
1717
|
+
const span = rawAction["_source_span"] ?? rawAction["source_span"];
|
|
1718
|
+
if (isDict(span) && typeof span["start"] === "number" && typeof span["end"] === "number") {
|
|
1719
|
+
action["source_span"] = { start: span["start"], end: span["end"] };
|
|
1720
|
+
}
|
|
1721
|
+
actions.push(action);
|
|
1722
|
+
}
|
|
1723
|
+
scenes.push({
|
|
1724
|
+
scene_num: normalizeInt(rawScene["n"], index + 1),
|
|
1725
|
+
environment: {
|
|
1726
|
+
space: _COMPACT_SPACE_TO_LONG[strOf(rawEnv["s"])] ?? "interior",
|
|
1727
|
+
time: rawEnv["t"] === "day" || rawEnv["t"] === "night" ? rawEnv["t"] : "day",
|
|
1728
|
+
},
|
|
1729
|
+
location_name: strOf(rawScene["loc"] || "未知场景").trim() || "未知场景",
|
|
1730
|
+
location_state: strOf(rawScene["ls"]).trim() || null,
|
|
1731
|
+
actor_names: actorNames,
|
|
1732
|
+
actor_states: actorStates,
|
|
1733
|
+
prop_names: isList(rawScene["pr"])
|
|
1734
|
+
? rawScene["pr"].map((it) => strOf(it).trim()).filter((it) => it)
|
|
1735
|
+
: [],
|
|
1736
|
+
actions,
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
const expanded = {
|
|
1740
|
+
episode: episodePlan["episode"],
|
|
1741
|
+
title: episodePlan["title"],
|
|
1742
|
+
source_span: episodePlan["source_span"],
|
|
1743
|
+
scenes,
|
|
1744
|
+
};
|
|
1745
|
+
for (const key of ["actors", "locations", "props"]) {
|
|
1746
|
+
if (key in result)
|
|
1747
|
+
expanded[key] = result[key];
|
|
1748
|
+
}
|
|
1749
|
+
if ("speakers" in result)
|
|
1750
|
+
expanded["speakers"] = result["speakers"];
|
|
1751
|
+
if ("state_definitions" in result)
|
|
1752
|
+
expanded["state_definitions"] = result["state_definitions"];
|
|
1753
|
+
return expanded;
|
|
1754
|
+
}
|
|
1755
|
+
export function compactEpisodeResult(result) {
|
|
1756
|
+
const compactScenes = [];
|
|
1757
|
+
for (const scene of asList(result["scenes"])) {
|
|
1758
|
+
if (!isDict(scene))
|
|
1759
|
+
continue;
|
|
1760
|
+
const env = isDict(scene["environment"]) ? scene["environment"] : {};
|
|
1761
|
+
const compactScene = {
|
|
1762
|
+
n: normalizeInt(scene["scene_num"], compactScenes.length + 1),
|
|
1763
|
+
env: {
|
|
1764
|
+
s: _LONG_SPACE_TO_COMPACT[strOf(env["space"])] ?? "in",
|
|
1765
|
+
t: env["time"] === "day" || env["time"] === "night" ? env["time"] : "day",
|
|
1766
|
+
},
|
|
1767
|
+
loc: strOf(scene["location_name"] || "未知场景").trim() || "未知场景",
|
|
1768
|
+
a: [],
|
|
1769
|
+
};
|
|
1770
|
+
const locationState = strOf(scene["location_state"]).trim();
|
|
1771
|
+
if (locationState)
|
|
1772
|
+
compactScene["ls"] = locationState;
|
|
1773
|
+
const actorStates = isDict(scene["actor_states"]) ? scene["actor_states"] : {};
|
|
1774
|
+
const compactActors = [];
|
|
1775
|
+
for (const actorName of asList(scene["actor_names"])) {
|
|
1776
|
+
const name = strOf(actorName).trim();
|
|
1777
|
+
if (!name)
|
|
1778
|
+
continue;
|
|
1779
|
+
const actorItem = { n: name };
|
|
1780
|
+
const state = strOf(actorStates[name]).trim();
|
|
1781
|
+
if (state)
|
|
1782
|
+
actorItem["st"] = state;
|
|
1783
|
+
compactActors.push(actorItem);
|
|
1784
|
+
}
|
|
1785
|
+
if (compactActors.length > 0)
|
|
1786
|
+
compactScene["ch"] = compactActors;
|
|
1787
|
+
const propList = isList(scene["prop_names"])
|
|
1788
|
+
? scene["prop_names"].map((it) => strOf(it).trim()).filter((it) => it)
|
|
1789
|
+
: [];
|
|
1790
|
+
if (propList.length > 0)
|
|
1791
|
+
compactScene["pr"] = propList;
|
|
1792
|
+
const compactActions = [];
|
|
1793
|
+
for (const action of asList(scene["actions"])) {
|
|
1794
|
+
if (!isDict(action))
|
|
1795
|
+
continue;
|
|
1796
|
+
const content = strOf(action["content"]).trim();
|
|
1797
|
+
if (!content)
|
|
1798
|
+
continue;
|
|
1799
|
+
const actionItem = {
|
|
1800
|
+
k: _LONG_ACTION_TO_COMPACT[strOf(action["type"])] ?? "act",
|
|
1801
|
+
c: content,
|
|
1802
|
+
};
|
|
1803
|
+
const speaker = strOf(action["speaker"]).trim();
|
|
1804
|
+
if (speaker)
|
|
1805
|
+
actionItem["p"] = speaker;
|
|
1806
|
+
const speakerKind = strOf(action["speaker_kind"]).trim();
|
|
1807
|
+
if (speakerKind && speakerKind !== "actor")
|
|
1808
|
+
actionItem["pk"] = speakerKind;
|
|
1809
|
+
const delivery = strOf(action["delivery"]).trim();
|
|
1810
|
+
if (delivery && delivery !== "single")
|
|
1811
|
+
actionItem["d"] = delivery;
|
|
1812
|
+
const speakers = asList(action["speakers"]);
|
|
1813
|
+
if (speakers.length > 0) {
|
|
1814
|
+
actionItem["sp"] = speakers
|
|
1815
|
+
.filter((it) => isDict(it) && strOf(it["speaker"]).trim())
|
|
1816
|
+
.map((it) => ({
|
|
1817
|
+
p: strOf(it["speaker"]).trim(),
|
|
1818
|
+
pk: strOf(it["speaker_kind"]).trim() || "actor",
|
|
1819
|
+
}));
|
|
1820
|
+
}
|
|
1821
|
+
const lines = asList(action["lines"]);
|
|
1822
|
+
if (lines.length > 0) {
|
|
1823
|
+
actionItem["ln"] = lines
|
|
1824
|
+
.filter((it) => isDict(it) && strOf(it["speaker"]).trim() && strOf(it["content"]).trim())
|
|
1825
|
+
.map((it) => ({
|
|
1826
|
+
p: strOf(it["speaker"]).trim(),
|
|
1827
|
+
pk: strOf(it["speaker_kind"]).trim() || "actor",
|
|
1828
|
+
c: strOf(it["content"]).trim(),
|
|
1829
|
+
}));
|
|
1830
|
+
}
|
|
1831
|
+
const emotion = strOf(action["emotion"]).trim();
|
|
1832
|
+
if (emotion)
|
|
1833
|
+
actionItem["m"] = emotion;
|
|
1834
|
+
compactActions.push(actionItem);
|
|
1835
|
+
}
|
|
1836
|
+
compactScene["a"] = compactActions;
|
|
1837
|
+
compactScenes.push(compactScene);
|
|
1838
|
+
}
|
|
1839
|
+
return { sc: compactScenes };
|
|
1840
|
+
}
|
|
1841
|
+
export function compactBatchResult(result) {
|
|
1842
|
+
const compact = compactEpisodeResult(result);
|
|
1843
|
+
const compactScenes = asList(compact["sc"]);
|
|
1844
|
+
const sourceScenes = asList(result["scenes"]);
|
|
1845
|
+
for (let i = 0; i < Math.min(compactScenes.length, sourceScenes.length); i++) {
|
|
1846
|
+
const compactActions = asList(compactScenes[i]["a"]);
|
|
1847
|
+
const sourceActions = asList(sourceScenes[i]["actions"]);
|
|
1848
|
+
for (let j = 0; j < Math.min(compactActions.length, sourceActions.length); j++) {
|
|
1849
|
+
const sourceAction = sourceActions[j];
|
|
1850
|
+
const refs = isDict(sourceAction) ? sourceAction["source_line_ids"] : null;
|
|
1851
|
+
if (!isList(refs) || refs.length === 0)
|
|
1852
|
+
continue;
|
|
1853
|
+
delete compactActions[j]["c"];
|
|
1854
|
+
compactActions[j]["r"] = refs.map((r) => strOf(r).trim()).filter((r) => r);
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
for (const key of ["actors", "locations", "props", "speakers", "state_definitions"]) {
|
|
1858
|
+
const items = result[key];
|
|
1859
|
+
if (isList(items) && items.length > 0) {
|
|
1860
|
+
compact[key] = items.map((item) => {
|
|
1861
|
+
if (!isDict(item))
|
|
1862
|
+
return item;
|
|
1863
|
+
const cleaned = {};
|
|
1864
|
+
for (const [k, v] of Object.entries(item)) {
|
|
1865
|
+
if (v !== null && v !== undefined)
|
|
1866
|
+
cleaned[k] = v;
|
|
1867
|
+
}
|
|
1868
|
+
return cleaned;
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
return compact;
|
|
1873
|
+
}
|
|
1874
|
+
// ---------------------------------------------------------------------------
|
|
1875
|
+
// Normalize episode result
|
|
1876
|
+
// ---------------------------------------------------------------------------
|
|
1877
|
+
export function normalizeEpisodeResult(result, episodePlan) {
|
|
1878
|
+
let normalized = expandCompactEpisodeResult(result, episodePlan);
|
|
1879
|
+
normalized["episode"] = normalizeInt(normalized["episode"], Number(episodePlan["episode"]));
|
|
1880
|
+
normalized["title"] = normalized["title"] || episodePlan["title"];
|
|
1881
|
+
normalized["source_span"] = normalized["source_span"] || episodePlan["source_span"];
|
|
1882
|
+
const scenes = isList(normalized["scenes"]) ? normalized["scenes"] : [];
|
|
1883
|
+
normalized["scenes"] = scenes;
|
|
1884
|
+
for (let idx = 0; idx < scenes.length; idx++) {
|
|
1885
|
+
const scene = scenes[idx];
|
|
1886
|
+
scene["scene_num"] = normalizeInt(scene["scene_num"], idx + 1);
|
|
1887
|
+
const env = isDict(scene["environment"]) ? scene["environment"] : {};
|
|
1888
|
+
scene["environment"] = {
|
|
1889
|
+
space: env["space"] === "interior" || env["space"] === "exterior" ? env["space"] : "interior",
|
|
1890
|
+
time: env["time"] === "day" || env["time"] === "night" ? env["time"] : "day",
|
|
1891
|
+
};
|
|
1892
|
+
const [locName, locStateFromName] = splitEntityState(strOf(scene["location_name"] || "未知场景"));
|
|
1893
|
+
scene["location_name"] = locName || "未知场景";
|
|
1894
|
+
const rawLocationState = strOf(scene["location_state"] || locStateFromName || "").trim();
|
|
1895
|
+
scene["location_state"] = rawLocationState && isReusableState("location", rawLocationState) ? rawLocationState : null;
|
|
1896
|
+
const actorNames = [];
|
|
1897
|
+
const inferredActorStates = {};
|
|
1898
|
+
for (const rawName of asList(scene["actor_names"])) {
|
|
1899
|
+
const [actorName, stateFromName] = splitEntityState(rawName);
|
|
1900
|
+
if (!actorName)
|
|
1901
|
+
continue;
|
|
1902
|
+
uniqueAdd(actorNames, actorName);
|
|
1903
|
+
if (stateFromName && isReusableState("actor", stateFromName))
|
|
1904
|
+
inferredActorStates[actorName] = stateFromName;
|
|
1905
|
+
}
|
|
1906
|
+
scene["actor_names"] = actorNames;
|
|
1907
|
+
const rawActorStates = scene["actor_states"];
|
|
1908
|
+
const actorStates = { ...inferredActorStates };
|
|
1909
|
+
if (isDict(rawActorStates)) {
|
|
1910
|
+
for (const [actorName, state] of Object.entries(rawActorStates)) {
|
|
1911
|
+
const [normalizedActor] = splitEntityState(actorName);
|
|
1912
|
+
const normalizedState = strOf(state).trim();
|
|
1913
|
+
if (normalizedActor && normalizedState && isReusableState("actor", normalizedState)) {
|
|
1914
|
+
actorStates[normalizedActor] = normalizedState;
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
else if (isList(rawActorStates)) {
|
|
1919
|
+
for (const item of rawActorStates) {
|
|
1920
|
+
if (!isDict(item))
|
|
1921
|
+
continue;
|
|
1922
|
+
const [actorName] = splitEntityState(strOf(item["actor_name"]));
|
|
1923
|
+
const state = strOf(item["state"]).trim();
|
|
1924
|
+
if (actorName && state && isReusableState("actor", state))
|
|
1925
|
+
actorStates[actorName] = state;
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
scene["actor_states"] = actorStates;
|
|
1929
|
+
const propNames = [];
|
|
1930
|
+
for (const rawProp of asList(scene["prop_names"])) {
|
|
1931
|
+
const [propName] = splitEntityState(rawProp);
|
|
1932
|
+
uniqueAdd(propNames, propName);
|
|
1933
|
+
}
|
|
1934
|
+
scene["prop_names"] = propNames;
|
|
1935
|
+
const actions = [];
|
|
1936
|
+
for (const action of asList(scene["actions"])) {
|
|
1937
|
+
if (!isDict(action))
|
|
1938
|
+
continue;
|
|
1939
|
+
const rawKind = strOf(action["type"]).trim();
|
|
1940
|
+
const kind = rawKind || "action";
|
|
1941
|
+
const content = strOf(action["content"]).trim();
|
|
1942
|
+
if (!content)
|
|
1943
|
+
continue;
|
|
1944
|
+
const item = { type: kind, content };
|
|
1945
|
+
const hasStructuredSpeakers = Boolean(action["speakers"]) || Boolean(action["lines"]);
|
|
1946
|
+
if (action["speaker"] && !hasStructuredSpeakers) {
|
|
1947
|
+
const speakerKind = strOf(action["speaker_kind"] || "actor").trim() || "actor";
|
|
1948
|
+
const entry = speakerRefEntry(scene, action["speaker"], speakerKind);
|
|
1949
|
+
if (entry) {
|
|
1950
|
+
item["speaker"] = entry.speaker;
|
|
1951
|
+
if (entry.speaker_kind !== "actor")
|
|
1952
|
+
item["speaker_kind"] = entry.speaker_kind;
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
const delivery = normalizeDeliveryToken(action["delivery"]);
|
|
1956
|
+
if (delivery)
|
|
1957
|
+
item["delivery"] = delivery;
|
|
1958
|
+
const speakerList = [];
|
|
1959
|
+
for (const speaker of asList(action["speakers"])) {
|
|
1960
|
+
if (!isDict(speaker))
|
|
1961
|
+
continue;
|
|
1962
|
+
const speakerKind = strOf(speaker["speaker_kind"] || speaker["kind"] || "actor").trim() || "actor";
|
|
1963
|
+
const entry = speakerRefEntry(scene, speaker["speaker"] || speaker["name"], speakerKind);
|
|
1964
|
+
if (entry)
|
|
1965
|
+
speakerList.push(entry);
|
|
1966
|
+
}
|
|
1967
|
+
if (speakerList.length > 0)
|
|
1968
|
+
item["speakers"] = speakerList;
|
|
1969
|
+
const lineList = [];
|
|
1970
|
+
for (const line of asList(action["lines"])) {
|
|
1971
|
+
if (!isDict(line))
|
|
1972
|
+
continue;
|
|
1973
|
+
const lineContent = strOf(line["content"]).trim();
|
|
1974
|
+
if (!lineContent)
|
|
1975
|
+
continue;
|
|
1976
|
+
const speakerKind = strOf(line["speaker_kind"] || line["kind"] || "actor").trim() || "actor";
|
|
1977
|
+
const entry = speakerRefEntry(scene, line["speaker"] || line["name"], speakerKind);
|
|
1978
|
+
if (entry)
|
|
1979
|
+
lineList.push({ speaker: entry.speaker, speaker_kind: entry.speaker_kind, content: lineContent });
|
|
1980
|
+
}
|
|
1981
|
+
if (lineList.length > 0)
|
|
1982
|
+
item["lines"] = lineList;
|
|
1983
|
+
if (action["emotion"])
|
|
1984
|
+
item["emotion"] = strOf(action["emotion"]).trim();
|
|
1985
|
+
const span = action["source_span"];
|
|
1986
|
+
if (isDict(span) && typeof span["start"] === "number" && typeof span["end"] === "number") {
|
|
1987
|
+
item["source_span"] = { start: span["start"], end: span["end"] };
|
|
1988
|
+
}
|
|
1989
|
+
const refs = action["source_line_ids"];
|
|
1990
|
+
if (isList(refs))
|
|
1991
|
+
item["source_line_ids"] = refs.map((r) => strOf(r).trim()).filter((r) => r);
|
|
1992
|
+
actions.push(item);
|
|
1993
|
+
}
|
|
1994
|
+
scene["actions"] = actions;
|
|
1995
|
+
}
|
|
1996
|
+
normalized["actors"] = _normalizeAssetList(normalized["actors"]);
|
|
1997
|
+
normalized["locations"] = _normalizeAssetList(normalized["locations"]);
|
|
1998
|
+
normalized["props"] = _normalizeAssetList(normalized["props"]);
|
|
1999
|
+
normalized["speakers"] = _normalize_speaker_list(normalized["speakers"]);
|
|
2000
|
+
normalized["state_definitions"] = _normalizeStateDefinitions(normalized["state_definitions"]);
|
|
2001
|
+
const stateLookup = new Map();
|
|
2002
|
+
for (const stateDef of asList(normalized["state_definitions"])) {
|
|
2003
|
+
const key = `${strOf(stateDef["kind"])}::${strOf(stateDef["asset_name"])}`;
|
|
2004
|
+
if (!stateLookup.has(key))
|
|
2005
|
+
stateLookup.set(key, new Set());
|
|
2006
|
+
stateLookup.get(key).add(strOf(stateDef["state_name"]).trim());
|
|
2007
|
+
}
|
|
2008
|
+
for (const scene of scenes) {
|
|
2009
|
+
const allowedActorStates = {};
|
|
2010
|
+
const sceneActorStates = scene["actor_states"] || {};
|
|
2011
|
+
for (const [actorName, actorState] of Object.entries(sceneActorStates)) {
|
|
2012
|
+
const allowed = stateLookup.get(`actor::${strOf(actorName).trim()}`) ?? new Set();
|
|
2013
|
+
if (allowed.has(strOf(actorState).trim()))
|
|
2014
|
+
allowedActorStates[actorName] = actorState;
|
|
2015
|
+
}
|
|
2016
|
+
scene["actor_states"] = allowedActorStates;
|
|
2017
|
+
const loc = strOf(scene["location_name"]).trim();
|
|
2018
|
+
const ls = strOf(scene["location_state"]).trim();
|
|
2019
|
+
if (ls) {
|
|
2020
|
+
const allowed = stateLookup.get(`location::${loc}`) ?? new Set();
|
|
2021
|
+
if (!allowed.has(ls))
|
|
2022
|
+
scene["location_state"] = null;
|
|
2023
|
+
}
|
|
2024
|
+
for (const name of asList(scene["actor_names"])) {
|
|
2025
|
+
_md_push_asset(normalized["actors"], strOf(name), null);
|
|
2026
|
+
}
|
|
2027
|
+
if (loc && loc !== "未知场景")
|
|
2028
|
+
_md_push_asset(normalized["locations"], loc, null);
|
|
2029
|
+
for (const prop of asList(scene["prop_names"])) {
|
|
2030
|
+
_md_push_asset(normalized["props"], strOf(prop), null);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
return normalized;
|
|
2034
|
+
}
|
|
2035
|
+
function _normalizeAssetList(items) {
|
|
2036
|
+
if (!isList(items))
|
|
2037
|
+
return [];
|
|
2038
|
+
const out = [];
|
|
2039
|
+
const byName = new Map();
|
|
2040
|
+
for (const item of items) {
|
|
2041
|
+
let name = "";
|
|
2042
|
+
let description = null;
|
|
2043
|
+
if (isDict(item)) {
|
|
2044
|
+
name = cleanName(strOf(item["name"]));
|
|
2045
|
+
description = strOf(item["description"]).trim() || null;
|
|
2046
|
+
}
|
|
2047
|
+
else if (typeof item === "string") {
|
|
2048
|
+
name = cleanName(item);
|
|
2049
|
+
}
|
|
2050
|
+
else
|
|
2051
|
+
continue;
|
|
2052
|
+
if (!name)
|
|
2053
|
+
continue;
|
|
2054
|
+
if (byName.has(name)) {
|
|
2055
|
+
const existing = byName.get(name);
|
|
2056
|
+
if (description && !existing["description"])
|
|
2057
|
+
existing["description"] = description;
|
|
2058
|
+
continue;
|
|
2059
|
+
}
|
|
2060
|
+
const entry = { name, description };
|
|
2061
|
+
byName.set(name, entry);
|
|
2062
|
+
out.push(entry);
|
|
2063
|
+
}
|
|
2064
|
+
return out;
|
|
2065
|
+
}
|
|
2066
|
+
export function _normalize_speaker_list(items) {
|
|
2067
|
+
if (!isList(items))
|
|
2068
|
+
return [];
|
|
2069
|
+
const out = [];
|
|
2070
|
+
const seen = new Set();
|
|
2071
|
+
for (const item of items) {
|
|
2072
|
+
let rawName = "";
|
|
2073
|
+
let sourceKind = "";
|
|
2074
|
+
let description = null;
|
|
2075
|
+
if (isDict(item)) {
|
|
2076
|
+
rawName = strOf(item["name"] || item["display_name"]).trim();
|
|
2077
|
+
sourceKind = strOf(item["source_kind"] || item["kind"]).trim();
|
|
2078
|
+
description = strOf(item["description"] || item["voice_desc"]).trim() || null;
|
|
2079
|
+
}
|
|
2080
|
+
else if (typeof item === "string") {
|
|
2081
|
+
rawName = item.trim();
|
|
2082
|
+
}
|
|
2083
|
+
else
|
|
2084
|
+
continue;
|
|
2085
|
+
const [parsedKind, parsedName] = parseSpeakerAsset(rawName);
|
|
2086
|
+
let kind = sourceKind || parsedKind;
|
|
2087
|
+
kind = _MD_SPEAKER_KIND_NORM[kind.toLowerCase()] ?? _MD_SPEAKER_KIND_NORM[kind] ?? "other";
|
|
2088
|
+
const name = cleanName(parsedName);
|
|
2089
|
+
if (!name || seen.has(name))
|
|
2090
|
+
continue;
|
|
2091
|
+
seen.add(name);
|
|
2092
|
+
out.push({ name, source_kind: kind, description });
|
|
2093
|
+
}
|
|
2094
|
+
return out;
|
|
2095
|
+
}
|
|
2096
|
+
function _normalizeStateDefinitions(items) {
|
|
2097
|
+
if (!isList(items))
|
|
2098
|
+
return [];
|
|
2099
|
+
const out = [];
|
|
2100
|
+
const seen = new Set();
|
|
2101
|
+
for (const item of items) {
|
|
2102
|
+
if (!isDict(item))
|
|
2103
|
+
continue;
|
|
2104
|
+
const kind = strOf(item["kind"] || item["target_kind"]).trim();
|
|
2105
|
+
if (!SCRIPT_TARGET_KINDS.has(kind))
|
|
2106
|
+
continue;
|
|
2107
|
+
const assetName = cleanName(strOf(item["asset_name"] || item["name"]));
|
|
2108
|
+
const stateName = strOf(item["state_name"] || item["state"]).trim();
|
|
2109
|
+
if (!assetName || !stateName || stateRejectionReason(kind, stateName))
|
|
2110
|
+
continue;
|
|
2111
|
+
const key = `${kind}::${assetName}::${stateName}`;
|
|
2112
|
+
if (seen.has(key))
|
|
2113
|
+
continue;
|
|
2114
|
+
seen.add(key);
|
|
2115
|
+
out.push({
|
|
2116
|
+
kind,
|
|
2117
|
+
asset_name: assetName,
|
|
2118
|
+
state_name: stateName,
|
|
2119
|
+
description: strOf(item["description"]).trim() || null,
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2122
|
+
return out;
|
|
2123
|
+
}
|
|
2124
|
+
export function uniqueAdd(order, value) {
|
|
2125
|
+
if (value && !order.includes(value))
|
|
2126
|
+
order.push(value);
|
|
2127
|
+
}
|
|
2128
|
+
// ---------------------------------------------------------------------------
|
|
2129
|
+
// Metadata / asset curation contexts
|
|
2130
|
+
// ---------------------------------------------------------------------------
|
|
2131
|
+
export function clipText(text, limit = 120) {
|
|
2132
|
+
const cleaned = strOf(text).trim().replace(/\s+/g, " ");
|
|
2133
|
+
return cleaned.slice(0, limit);
|
|
2134
|
+
}
|
|
2135
|
+
export function sceneExample(scene, limit = 160) {
|
|
2136
|
+
const parts = [];
|
|
2137
|
+
for (const action of asList(scene["actions"])) {
|
|
2138
|
+
const content = clipText(strOf(action["content"]), 80);
|
|
2139
|
+
if (content)
|
|
2140
|
+
parts.push(content);
|
|
2141
|
+
if (parts.length >= 2)
|
|
2142
|
+
break;
|
|
2143
|
+
}
|
|
2144
|
+
return clipText(parts.join(" / "), limit);
|
|
2145
|
+
}
|
|
2146
|
+
export function buildMetadataContext(script) {
|
|
2147
|
+
const actorExamples = {};
|
|
2148
|
+
const locationExamples = {};
|
|
2149
|
+
const propExamples = {};
|
|
2150
|
+
for (const ep of asList(script["episodes"])) {
|
|
2151
|
+
for (const scene of asList(ep["scenes"])) {
|
|
2152
|
+
const example = sceneExample(scene);
|
|
2153
|
+
if (!example)
|
|
2154
|
+
continue;
|
|
2155
|
+
for (const ref of asList(scene["actors"])) {
|
|
2156
|
+
const actorId = strOf(ref["actor_id"]);
|
|
2157
|
+
if (actorId) {
|
|
2158
|
+
if (!actorExamples[actorId])
|
|
2159
|
+
actorExamples[actorId] = [];
|
|
2160
|
+
uniqueAdd(actorExamples[actorId], example);
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
for (const ref of asList(scene["locations"])) {
|
|
2164
|
+
const locId = strOf(ref["location_id"]);
|
|
2165
|
+
if (locId) {
|
|
2166
|
+
if (!locationExamples[locId])
|
|
2167
|
+
locationExamples[locId] = [];
|
|
2168
|
+
uniqueAdd(locationExamples[locId], example);
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
for (const ref of asList(scene["props"])) {
|
|
2172
|
+
const propId = strOf(ref["prop_id"]);
|
|
2173
|
+
if (propId) {
|
|
2174
|
+
if (!propExamples[propId])
|
|
2175
|
+
propExamples[propId] = [];
|
|
2176
|
+
uniqueAdd(propExamples[propId], example);
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
const compactStates = (asset) => {
|
|
2182
|
+
const states = [];
|
|
2183
|
+
for (const state of asList(asset["states"])) {
|
|
2184
|
+
const name = strOf(state["state_name"]).trim();
|
|
2185
|
+
if (name)
|
|
2186
|
+
states.push(name);
|
|
2187
|
+
}
|
|
2188
|
+
return states.slice(0, 8);
|
|
2189
|
+
};
|
|
2190
|
+
return {
|
|
2191
|
+
title: script["title"],
|
|
2192
|
+
worldview_options: WORLDVIEW_VALUES,
|
|
2193
|
+
episodes: asList(script["episodes"]).slice(0, 80).map((ep) => ({
|
|
2194
|
+
episode_id: ep["episode_id"],
|
|
2195
|
+
title: ep["title"],
|
|
2196
|
+
})),
|
|
2197
|
+
actors: asList(script["actors"]).map((actor) => ({
|
|
2198
|
+
actor_id: actor["actor_id"],
|
|
2199
|
+
actor_name: actor["actor_name"],
|
|
2200
|
+
states: compactStates(actor),
|
|
2201
|
+
examples: (actorExamples[strOf(actor["actor_id"])] ?? []).slice(0, 3),
|
|
2202
|
+
})),
|
|
2203
|
+
locations: asList(script["locations"]).map((loc) => ({
|
|
2204
|
+
location_id: loc["location_id"],
|
|
2205
|
+
location_name: loc["location_name"],
|
|
2206
|
+
states: compactStates(loc),
|
|
2207
|
+
examples: (locationExamples[strOf(loc["location_id"])] ?? []).slice(0, 2),
|
|
2208
|
+
})),
|
|
2209
|
+
props: asList(script["props"]).map((prop) => ({
|
|
2210
|
+
prop_id: prop["prop_id"],
|
|
2211
|
+
prop_name: prop["prop_name"],
|
|
2212
|
+
examples: (propExamples[strOf(prop["prop_id"])] ?? []).slice(0, 2),
|
|
2213
|
+
})),
|
|
2214
|
+
};
|
|
2215
|
+
}
|
|
2216
|
+
export function deterministicExtractMetadata(script) {
|
|
2217
|
+
const actors = [];
|
|
2218
|
+
const scriptActors = asList(script["actors"]);
|
|
2219
|
+
for (let idx = 0; idx < scriptActors.length; idx++) {
|
|
2220
|
+
const actor = scriptActors[idx];
|
|
2221
|
+
const name = strOf(actor["actor_name"] || actor["actor_id"]).trim();
|
|
2222
|
+
const states = [];
|
|
2223
|
+
for (const state of asList(actor["states"])) {
|
|
2224
|
+
const stateName = strOf(state["state_name"]).trim();
|
|
2225
|
+
if (stateName)
|
|
2226
|
+
states.push(stateName);
|
|
2227
|
+
}
|
|
2228
|
+
let description = name;
|
|
2229
|
+
if (states.length > 0)
|
|
2230
|
+
description = `${name};状态:${states.slice(0, 6).join("、")}`;
|
|
2231
|
+
actors.push({
|
|
2232
|
+
actor_id: actor["actor_id"],
|
|
2233
|
+
role_type: idx === 0 ? "主角" : "配角",
|
|
2234
|
+
description,
|
|
2235
|
+
});
|
|
2236
|
+
}
|
|
2237
|
+
return {
|
|
2238
|
+
confidence: "high",
|
|
2239
|
+
worldview: "现代",
|
|
2240
|
+
worldview_raw: "mock provider generated metadata for CLI tests",
|
|
2241
|
+
actors,
|
|
2242
|
+
locations: asList(script["locations"]).map((loc) => ({
|
|
2243
|
+
location_id: loc["location_id"],
|
|
2244
|
+
description: strOf(loc["location_name"] || loc["location_id"]).trim(),
|
|
2245
|
+
})),
|
|
2246
|
+
props: asList(script["props"]).map((prop) => ({
|
|
2247
|
+
prop_id: prop["prop_id"],
|
|
2248
|
+
description: strOf(prop["prop_name"] || prop["prop_id"]).trim(),
|
|
2249
|
+
})),
|
|
2250
|
+
};
|
|
2251
|
+
}
|
|
2252
|
+
export function applyMetadataToScript(script, metadata) {
|
|
2253
|
+
script["worldview"] = metadata["worldview"];
|
|
2254
|
+
script["worldview_raw"] = strOf(metadata["worldview_raw"]).trim();
|
|
2255
|
+
const actorMeta = new Map();
|
|
2256
|
+
for (const item of asList(metadata["actors"])) {
|
|
2257
|
+
if (isDict(item))
|
|
2258
|
+
actorMeta.set(strOf(item["actor_id"]), item);
|
|
2259
|
+
}
|
|
2260
|
+
for (const actor of asList(script["actors"])) {
|
|
2261
|
+
const item = actorMeta.get(strOf(actor["actor_id"]));
|
|
2262
|
+
if (!item)
|
|
2263
|
+
continue;
|
|
2264
|
+
actor["role_type"] = item["role_type"];
|
|
2265
|
+
actor["description"] = strOf(item["description"]).trim();
|
|
2266
|
+
}
|
|
2267
|
+
const locationMeta = new Map();
|
|
2268
|
+
for (const item of asList(metadata["locations"])) {
|
|
2269
|
+
if (isDict(item))
|
|
2270
|
+
locationMeta.set(strOf(item["location_id"]), item);
|
|
2271
|
+
}
|
|
2272
|
+
for (const loc of asList(script["locations"])) {
|
|
2273
|
+
const item = locationMeta.get(strOf(loc["location_id"]));
|
|
2274
|
+
if (item)
|
|
2275
|
+
loc["description"] = strOf(item["description"]).trim();
|
|
2276
|
+
}
|
|
2277
|
+
const propMeta = new Map();
|
|
2278
|
+
for (const item of asList(metadata["props"])) {
|
|
2279
|
+
if (isDict(item))
|
|
2280
|
+
propMeta.set(strOf(item["prop_id"]), item);
|
|
2281
|
+
}
|
|
2282
|
+
for (const prop of asList(script["props"])) {
|
|
2283
|
+
const item = propMeta.get(strOf(prop["prop_id"]));
|
|
2284
|
+
if (item)
|
|
2285
|
+
prop["description"] = strOf(item["description"]).trim();
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
export function sceneAssetUsage(script, refKey, idKey) {
|
|
2289
|
+
const usage = new Map();
|
|
2290
|
+
for (const ep of asList(script["episodes"])) {
|
|
2291
|
+
const episodeId = strOf(ep["episode_id"]);
|
|
2292
|
+
for (const scene of asList(ep["scenes"])) {
|
|
2293
|
+
const sceneId = strOf(scene["scene_id"]);
|
|
2294
|
+
const ctx = sceneContext(scene);
|
|
2295
|
+
for (const ref of asList(ctx[refKey])) {
|
|
2296
|
+
if (!isDict(ref))
|
|
2297
|
+
continue;
|
|
2298
|
+
const assetId = strOf(ref[idKey]);
|
|
2299
|
+
if (!assetId)
|
|
2300
|
+
continue;
|
|
2301
|
+
if (!usage.has(assetId))
|
|
2302
|
+
usage.set(assetId, { episodes: new Set(), scenes: new Set() });
|
|
2303
|
+
const item = usage.get(assetId);
|
|
2304
|
+
if (episodeId)
|
|
2305
|
+
item.episodes.add(episodeId);
|
|
2306
|
+
if (sceneId)
|
|
2307
|
+
item.scenes.add(sceneId);
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
return usage;
|
|
2312
|
+
}
|
|
2313
|
+
function compactStateNames(asset) {
|
|
2314
|
+
const out = [];
|
|
2315
|
+
for (const state of asList(asset["states"])) {
|
|
2316
|
+
if (!isDict(state))
|
|
2317
|
+
continue;
|
|
2318
|
+
const n = strOf(state["state_name"]).trim();
|
|
2319
|
+
if (n)
|
|
2320
|
+
out.push(n);
|
|
2321
|
+
}
|
|
2322
|
+
return out;
|
|
2323
|
+
}
|
|
2324
|
+
function usagePayload(usage) {
|
|
2325
|
+
return {
|
|
2326
|
+
episode_count: usage.episodes.size,
|
|
2327
|
+
scene_count: usage.scenes.size,
|
|
2328
|
+
episodes: [...usage.episodes].sort().slice(0, 12),
|
|
2329
|
+
scenes: [...usage.scenes].sort().slice(0, 12),
|
|
2330
|
+
};
|
|
2331
|
+
}
|
|
2332
|
+
export function buildAssetCurationContext(script) {
|
|
2333
|
+
const locationUsage = sceneAssetUsage(script, "locations", "location_id");
|
|
2334
|
+
return {
|
|
2335
|
+
title: script["title"],
|
|
2336
|
+
guidance: [
|
|
2337
|
+
"Decide from the script context, not from asset names alone.",
|
|
2338
|
+
"Merge a location only when it is clearly the same stable space as an existing target location.",
|
|
2339
|
+
"Keep a location whenever no stable parent location is clear.",
|
|
2340
|
+
],
|
|
2341
|
+
locations: asList(script["locations"]).filter(isDict).map((loc) => ({
|
|
2342
|
+
location_id: loc["location_id"],
|
|
2343
|
+
location_name: loc["location_name"],
|
|
2344
|
+
description: loc["description"],
|
|
2345
|
+
aliases: isList(loc["aliases"]) ? loc["aliases"] : [],
|
|
2346
|
+
states: compactStateNames(loc),
|
|
2347
|
+
usage: usagePayload(locationUsage.get(strOf(loc["location_id"])) ?? { episodes: new Set(), scenes: new Set() }),
|
|
2348
|
+
examples: locationUsageExamples(script, strOf(loc["location_id"])),
|
|
2349
|
+
})),
|
|
2350
|
+
};
|
|
2351
|
+
}
|
|
2352
|
+
export function locationUsageExamples(script, locationId) {
|
|
2353
|
+
const examples = [];
|
|
2354
|
+
for (const ep of asList(script["episodes"])) {
|
|
2355
|
+
for (const scene of asList(ep["scenes"])) {
|
|
2356
|
+
const refs = asList(sceneContext(scene)["locations"]);
|
|
2357
|
+
const found = refs.some((ref) => isDict(ref) && ref["location_id"] === locationId);
|
|
2358
|
+
if (!found)
|
|
2359
|
+
continue;
|
|
2360
|
+
const example = sceneExample(scene);
|
|
2361
|
+
if (example) {
|
|
2362
|
+
examples.push({ episode_id: ep["episode_id"], scene_id: scene["scene_id"], example });
|
|
2363
|
+
}
|
|
2364
|
+
if (examples.length >= 4)
|
|
2365
|
+
return examples;
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
return examples;
|
|
2369
|
+
}
|
|
2370
|
+
function compactAssetRefs(refs, idKey, replacements, droppedIds) {
|
|
2371
|
+
const compacted = [];
|
|
2372
|
+
const seen = new Set();
|
|
2373
|
+
for (const ref of asList(refs)) {
|
|
2374
|
+
if (!isDict(ref))
|
|
2375
|
+
continue;
|
|
2376
|
+
const rawId = strOf(ref[idKey]);
|
|
2377
|
+
if (!rawId || droppedIds.has(rawId))
|
|
2378
|
+
continue;
|
|
2379
|
+
const nextId = replacements.get(rawId) ?? rawId;
|
|
2380
|
+
const stateId = nextId !== rawId ? null : ref["state_id"];
|
|
2381
|
+
const key = `${nextId}::${strOf(stateId)}`;
|
|
2382
|
+
if (seen.has(key))
|
|
2383
|
+
continue;
|
|
2384
|
+
seen.add(key);
|
|
2385
|
+
compacted.push({ [idKey]: nextId, state_id: stateId });
|
|
2386
|
+
}
|
|
2387
|
+
return compacted;
|
|
2388
|
+
}
|
|
2389
|
+
function addLocationAlias(target, aliasRaw) {
|
|
2390
|
+
const alias = cleanName(aliasRaw);
|
|
2391
|
+
const targetName = strOf(target["location_name"]).trim();
|
|
2392
|
+
if (!alias || alias === targetName)
|
|
2393
|
+
return;
|
|
2394
|
+
if (!isList(target["aliases"]))
|
|
2395
|
+
target["aliases"] = [];
|
|
2396
|
+
uniqueAdd(target["aliases"], alias);
|
|
2397
|
+
}
|
|
2398
|
+
export function applyLocationMerges(script, replacements) {
|
|
2399
|
+
if (replacements.size === 0)
|
|
2400
|
+
return;
|
|
2401
|
+
const locations = asList(script["locations"]);
|
|
2402
|
+
const byId = new Map();
|
|
2403
|
+
for (const loc of locations) {
|
|
2404
|
+
if (isDict(loc))
|
|
2405
|
+
byId.set(strOf(loc["location_id"]), loc);
|
|
2406
|
+
}
|
|
2407
|
+
for (const [oldId, targetId] of replacements) {
|
|
2408
|
+
const old = byId.get(oldId);
|
|
2409
|
+
const target = byId.get(targetId);
|
|
2410
|
+
if (!old || !target)
|
|
2411
|
+
continue;
|
|
2412
|
+
addLocationAlias(target, strOf(old["location_name"]));
|
|
2413
|
+
for (const alias of asList(old["aliases"])) {
|
|
2414
|
+
addLocationAlias(target, strOf(alias));
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
script["locations"] = locations.filter((loc) => isDict(loc) && !replacements.has(strOf(loc["location_id"])));
|
|
2418
|
+
}
|
|
2419
|
+
function sceneCountFromUsage(usage) {
|
|
2420
|
+
return usage.scenes.size;
|
|
2421
|
+
}
|
|
2422
|
+
export function actorCurationDecisions(script) {
|
|
2423
|
+
const usage = sceneAssetUsage(script, "actors", "actor_id");
|
|
2424
|
+
const decisions = [];
|
|
2425
|
+
for (const actor of asList(script["actors"])) {
|
|
2426
|
+
if (!isDict(actor))
|
|
2427
|
+
continue;
|
|
2428
|
+
const actorId = strOf(actor["actor_id"]);
|
|
2429
|
+
const u = usage.get(actorId) ?? { episodes: new Set(), scenes: new Set() };
|
|
2430
|
+
const sceneCount = sceneCountFromUsage(u);
|
|
2431
|
+
let decision;
|
|
2432
|
+
let reason;
|
|
2433
|
+
if (sceneCount <= ACTOR_REUSE_SCENE_LIMIT) {
|
|
2434
|
+
decision = "remove";
|
|
2435
|
+
reason = `固定裁剪:仅出现在 ${sceneCount} 个 scene,不作为跨场景复用人物资产;对白归因保留在 speakers/actions。`;
|
|
2436
|
+
}
|
|
2437
|
+
else {
|
|
2438
|
+
decision = "keep";
|
|
2439
|
+
reason = `保留:出现在 ${sceneCount} 个 scene,超过单场人物裁剪阈值。`;
|
|
2440
|
+
}
|
|
2441
|
+
decisions.push({ actor_id: actorId, name: actor["actor_name"], scene_count: sceneCount, decision, reason });
|
|
2442
|
+
}
|
|
2443
|
+
return decisions;
|
|
2444
|
+
}
|
|
2445
|
+
export function propCurationDecisions(script) {
|
|
2446
|
+
const usage = sceneAssetUsage(script, "props", "prop_id");
|
|
2447
|
+
const decisions = [];
|
|
2448
|
+
for (const prop of asList(script["props"])) {
|
|
2449
|
+
if (!isDict(prop))
|
|
2450
|
+
continue;
|
|
2451
|
+
const propId = strOf(prop["prop_id"]);
|
|
2452
|
+
const u = usage.get(propId) ?? { episodes: new Set(), scenes: new Set() };
|
|
2453
|
+
const sceneCount = sceneCountFromUsage(u);
|
|
2454
|
+
let decision;
|
|
2455
|
+
let reason;
|
|
2456
|
+
if (sceneCount <= PROP_REUSE_SCENE_LIMIT) {
|
|
2457
|
+
decision = "remove";
|
|
2458
|
+
reason = `固定裁剪:仅出现在 ${sceneCount} 个 scene,未超过 ${PROP_REUSE_SCENE_LIMIT} 个 scene 的道具复用阈值;原文动作保留。`;
|
|
2459
|
+
}
|
|
2460
|
+
else {
|
|
2461
|
+
decision = "keep";
|
|
2462
|
+
reason = `保留:出现在 ${sceneCount} 个 scene,超过道具复用裁剪阈值。`;
|
|
2463
|
+
}
|
|
2464
|
+
decisions.push({ prop_id: propId, name: prop["prop_name"], scene_count: sceneCount, decision, reason });
|
|
2465
|
+
}
|
|
2466
|
+
return decisions;
|
|
2467
|
+
}
|
|
2468
|
+
function rewriteStateChanges(changes, locationReplacements, droppedPropIds, droppedActorIds) {
|
|
2469
|
+
const rewritten = [];
|
|
2470
|
+
for (const change of asList(changes)) {
|
|
2471
|
+
if (!isDict(change))
|
|
2472
|
+
continue;
|
|
2473
|
+
const targetKind = strOf(change["target_kind"]);
|
|
2474
|
+
const targetId = strOf(change["target_id"]);
|
|
2475
|
+
if (targetKind === "actor" && droppedActorIds.has(targetId))
|
|
2476
|
+
continue;
|
|
2477
|
+
if (targetKind === "prop" && droppedPropIds.has(targetId))
|
|
2478
|
+
continue;
|
|
2479
|
+
const next = { ...change };
|
|
2480
|
+
if (targetKind === "location" && locationReplacements.has(targetId)) {
|
|
2481
|
+
next["target_id"] = locationReplacements.get(targetId);
|
|
2482
|
+
delete next["from_state_id"];
|
|
2483
|
+
delete next["to_state_id"];
|
|
2484
|
+
}
|
|
2485
|
+
rewritten.push(next);
|
|
2486
|
+
}
|
|
2487
|
+
return rewritten;
|
|
2488
|
+
}
|
|
2489
|
+
function rewriteTransitionPrompt(transition, locationReplacements, droppedPropIds, droppedActorIds) {
|
|
2490
|
+
if (!isDict(transition))
|
|
2491
|
+
return transition;
|
|
2492
|
+
const targetKind = strOf(transition["target_kind"]);
|
|
2493
|
+
const targetId = strOf(transition["target_id"]);
|
|
2494
|
+
if (targetKind === "actor" && droppedActorIds.has(targetId))
|
|
2495
|
+
return null;
|
|
2496
|
+
if (targetKind === "prop" && droppedPropIds.has(targetId))
|
|
2497
|
+
return null;
|
|
2498
|
+
const next = { ...transition };
|
|
2499
|
+
if (targetKind === "location" && locationReplacements.has(targetId)) {
|
|
2500
|
+
next["target_id"] = locationReplacements.get(targetId);
|
|
2501
|
+
}
|
|
2502
|
+
return next;
|
|
2503
|
+
}
|
|
2504
|
+
function applyAssetCurationRefs(script, locationReplacements, droppedPropIds, droppedActorIds) {
|
|
2505
|
+
if (locationReplacements.size === 0 && droppedPropIds.size === 0 && droppedActorIds.size === 0)
|
|
2506
|
+
return;
|
|
2507
|
+
for (const ep of asList(script["episodes"])) {
|
|
2508
|
+
for (const scene of asList(ep["scenes"])) {
|
|
2509
|
+
const ctx = sceneContext(scene);
|
|
2510
|
+
if (locationReplacements.size > 0) {
|
|
2511
|
+
ctx["locations"] = compactAssetRefs(ctx["locations"], "location_id", locationReplacements, new Set());
|
|
2512
|
+
}
|
|
2513
|
+
if (droppedActorIds.size > 0) {
|
|
2514
|
+
ctx["actors"] = compactAssetRefs(ctx["actors"], "actor_id", new Map(), droppedActorIds);
|
|
2515
|
+
}
|
|
2516
|
+
if (droppedPropIds.size > 0) {
|
|
2517
|
+
ctx["props"] = compactAssetRefs(ctx["props"], "prop_id", new Map(), droppedPropIds);
|
|
2518
|
+
}
|
|
2519
|
+
setSceneContext(scene, ctx);
|
|
2520
|
+
for (const action of asList(scene["actions"])) {
|
|
2521
|
+
if (!isDict(action))
|
|
2522
|
+
continue;
|
|
2523
|
+
if (droppedActorIds.has(strOf(action["actor_id"])))
|
|
2524
|
+
delete action["actor_id"];
|
|
2525
|
+
if (action["state_changes"] !== undefined && action["state_changes"] !== null) {
|
|
2526
|
+
action["state_changes"] = rewriteStateChanges(action["state_changes"], locationReplacements, droppedPropIds, droppedActorIds);
|
|
2527
|
+
}
|
|
2528
|
+
if (action["transition_prompt"] !== undefined && action["transition_prompt"] !== null) {
|
|
2529
|
+
const transition = rewriteTransitionPrompt(action["transition_prompt"], locationReplacements, droppedPropIds, droppedActorIds);
|
|
2530
|
+
if (transition === null)
|
|
2531
|
+
delete action["transition_prompt"];
|
|
2532
|
+
else
|
|
2533
|
+
action["transition_prompt"] = transition;
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
for (const speaker of asList(script["speakers"])) {
|
|
2539
|
+
if (!isDict(speaker))
|
|
2540
|
+
continue;
|
|
2541
|
+
const sourceKind = strOf(speaker["source_kind"]);
|
|
2542
|
+
const sourceId = strOf(speaker["source_id"]);
|
|
2543
|
+
if (sourceKind === "actor" && droppedActorIds.has(sourceId)) {
|
|
2544
|
+
speaker["source_kind"] = "other";
|
|
2545
|
+
speaker["source_id"] = null;
|
|
2546
|
+
}
|
|
2547
|
+
else if (sourceKind === "location" && locationReplacements.has(sourceId)) {
|
|
2548
|
+
speaker["source_id"] = locationReplacements.get(sourceId);
|
|
2549
|
+
}
|
|
2550
|
+
else if (sourceKind === "prop" && droppedPropIds.has(sourceId)) {
|
|
2551
|
+
speaker["source_kind"] = "other";
|
|
2552
|
+
speaker["source_id"] = null;
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
export function normalizeAssetCuration(script, rawCuration) {
|
|
2557
|
+
const payload = isDict(rawCuration) ? rawCuration : {};
|
|
2558
|
+
const rawLocations = new Map();
|
|
2559
|
+
for (const item of asList(payload["locations"])) {
|
|
2560
|
+
if (!isDict(item))
|
|
2561
|
+
continue;
|
|
2562
|
+
const id = strOf(item["location_id"]);
|
|
2563
|
+
if (id)
|
|
2564
|
+
rawLocations.set(id, item);
|
|
2565
|
+
}
|
|
2566
|
+
const locationUsage = sceneAssetUsage(script, "locations", "location_id");
|
|
2567
|
+
const validLocationIds = new Set();
|
|
2568
|
+
for (const loc of asList(script["locations"])) {
|
|
2569
|
+
if (isDict(loc)) {
|
|
2570
|
+
const id = strOf(loc["location_id"]);
|
|
2571
|
+
if (id)
|
|
2572
|
+
validLocationIds.add(id);
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
const rawLocationDecisions = new Map();
|
|
2576
|
+
for (const [locId, item] of rawLocations) {
|
|
2577
|
+
rawLocationDecisions.set(locId, strOf(item["decision"]).trim());
|
|
2578
|
+
}
|
|
2579
|
+
const locations = [];
|
|
2580
|
+
for (const loc of asList(script["locations"])) {
|
|
2581
|
+
if (!isDict(loc))
|
|
2582
|
+
continue;
|
|
2583
|
+
const locationId = strOf(loc["location_id"]);
|
|
2584
|
+
const sceneCount = sceneCountFromUsage(locationUsage.get(locationId) ?? { episodes: new Set(), scenes: new Set() });
|
|
2585
|
+
const item = rawLocations.get(locationId) ?? {};
|
|
2586
|
+
let decision = strOf(item["decision"]).trim();
|
|
2587
|
+
let targetLocationId = strOf(item["target_location_id"]).trim() || null;
|
|
2588
|
+
let reason = strOf(item["reason"]).trim() || "provider 基于剧本上下文给出决策。";
|
|
2589
|
+
if (decision === "merge") {
|
|
2590
|
+
if (targetLocationId === null ||
|
|
2591
|
+
!validLocationIds.has(targetLocationId) ||
|
|
2592
|
+
targetLocationId === locationId ||
|
|
2593
|
+
rawLocationDecisions.get(targetLocationId) === "merge") {
|
|
2594
|
+
decision = "keep";
|
|
2595
|
+
targetLocationId = null;
|
|
2596
|
+
reason = "保留:provider 给出的地点合并目标无效。";
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
else if (decision !== "keep") {
|
|
2600
|
+
decision = "keep";
|
|
2601
|
+
targetLocationId = null;
|
|
2602
|
+
reason = "保留:provider 未给出有效地点裁剪决策。";
|
|
2603
|
+
}
|
|
2604
|
+
else {
|
|
2605
|
+
targetLocationId = null;
|
|
2606
|
+
}
|
|
2607
|
+
locations.push({
|
|
2608
|
+
location_id: locationId,
|
|
2609
|
+
name: loc["location_name"],
|
|
2610
|
+
scene_count: sceneCount,
|
|
2611
|
+
decision,
|
|
2612
|
+
target_location_id: targetLocationId,
|
|
2613
|
+
reason,
|
|
2614
|
+
});
|
|
2615
|
+
}
|
|
2616
|
+
return {
|
|
2617
|
+
version: 2,
|
|
2618
|
+
actors: actorCurationDecisions(script),
|
|
2619
|
+
props: propCurationDecisions(script),
|
|
2620
|
+
locations,
|
|
2621
|
+
};
|
|
2622
|
+
}
|
|
2623
|
+
export function curateScriptAssets(script, rawCuration = null) {
|
|
2624
|
+
const actorsBefore = asList(script["actors"]).length;
|
|
2625
|
+
const propsBefore = asList(script["props"]).length;
|
|
2626
|
+
const locationsBefore = asList(script["locations"]).length;
|
|
2627
|
+
const normalized = normalizeAssetCuration(script, rawCuration);
|
|
2628
|
+
const actorDecisions = normalized["actors"];
|
|
2629
|
+
const propDecisions = normalized["props"];
|
|
2630
|
+
const locationDecisions = normalized["locations"];
|
|
2631
|
+
const droppedActorIds = new Set(actorDecisions.filter((it) => it["decision"] === "remove").map((it) => strOf(it["actor_id"])).filter((s) => s));
|
|
2632
|
+
const droppedPropIds = new Set(propDecisions.filter((it) => it["decision"] === "remove").map((it) => strOf(it["prop_id"])).filter((s) => s));
|
|
2633
|
+
if (droppedActorIds.size > 0) {
|
|
2634
|
+
script["actors"] = asList(script["actors"]).filter((a) => isDict(a) && !droppedActorIds.has(strOf(a["actor_id"])));
|
|
2635
|
+
}
|
|
2636
|
+
if (droppedPropIds.size > 0) {
|
|
2637
|
+
script["props"] = asList(script["props"]).filter((p) => isDict(p) && !droppedPropIds.has(strOf(p["prop_id"])));
|
|
2638
|
+
}
|
|
2639
|
+
const locationReplacements = new Map();
|
|
2640
|
+
for (const item of locationDecisions) {
|
|
2641
|
+
if (item["decision"] === "merge") {
|
|
2642
|
+
const fromId = strOf(item["location_id"]);
|
|
2643
|
+
const toId = strOf(item["target_location_id"]);
|
|
2644
|
+
if (fromId && toId)
|
|
2645
|
+
locationReplacements.set(fromId, toId);
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
applyLocationMerges(script, locationReplacements);
|
|
2649
|
+
applyAssetCurationRefs(script, locationReplacements, droppedPropIds, droppedActorIds);
|
|
2650
|
+
const actorsAfter = asList(script["actors"]).length;
|
|
2651
|
+
const propsAfter = asList(script["props"]).length;
|
|
2652
|
+
const locationsAfter = asList(script["locations"]).length;
|
|
2653
|
+
return {
|
|
2654
|
+
version: normalized["version"],
|
|
2655
|
+
summary: {
|
|
2656
|
+
actors_before: actorsBefore,
|
|
2657
|
+
actors_after: actorsAfter,
|
|
2658
|
+
actors_removed: actorsBefore - actorsAfter,
|
|
2659
|
+
props_before: propsBefore,
|
|
2660
|
+
props_after: propsAfter,
|
|
2661
|
+
props_removed: propsBefore - propsAfter,
|
|
2662
|
+
locations_before: locationsBefore,
|
|
2663
|
+
locations_after: locationsAfter,
|
|
2664
|
+
locations_merged: locationsBefore - locationsAfter,
|
|
2665
|
+
},
|
|
2666
|
+
actors: actorDecisions,
|
|
2667
|
+
props: propDecisions,
|
|
2668
|
+
locations: locationDecisions,
|
|
2669
|
+
};
|
|
2670
|
+
}
|
|
2671
|
+
// ---------------------------------------------------------------------------
|
|
2672
|
+
// Merge episode results into a final script
|
|
2673
|
+
// ---------------------------------------------------------------------------
|
|
2674
|
+
export function mergeEpisodeResults(results, title) {
|
|
2675
|
+
const actorNames = [];
|
|
2676
|
+
const locationNames = [];
|
|
2677
|
+
const propNames = [];
|
|
2678
|
+
const actorDescriptions = new Map();
|
|
2679
|
+
const locationDescriptions = new Map();
|
|
2680
|
+
const propDescriptions = new Map();
|
|
2681
|
+
const stateDefinitions = [];
|
|
2682
|
+
const speakerRecords = new Map();
|
|
2683
|
+
const speakerOrder = [];
|
|
2684
|
+
const rememberSpeaker = (name, sourceKind = "other", description = "") => {
|
|
2685
|
+
const speakerName = strOf(name).trim();
|
|
2686
|
+
if (!speakerName)
|
|
2687
|
+
return;
|
|
2688
|
+
let kind = strOf(sourceKind).trim() || "other";
|
|
2689
|
+
kind = _MD_SPEAKER_KIND_NORM[kind.toLowerCase()] ?? _MD_SPEAKER_KIND_NORM[kind] ?? "other";
|
|
2690
|
+
if (kind === "actor") {
|
|
2691
|
+
uniqueAdd(actorNames, speakerName);
|
|
2692
|
+
return;
|
|
2693
|
+
}
|
|
2694
|
+
if (!speakerOrder.includes(speakerName))
|
|
2695
|
+
speakerOrder.push(speakerName);
|
|
2696
|
+
const existing = speakerRecords.get(speakerName) ?? { source_kind: "other" };
|
|
2697
|
+
if (existing.source_kind === "other" || kind !== "other")
|
|
2698
|
+
existing.source_kind = kind;
|
|
2699
|
+
const desc = strOf(description).trim();
|
|
2700
|
+
if (desc && !existing.description)
|
|
2701
|
+
existing.description = desc;
|
|
2702
|
+
speakerRecords.set(speakerName, existing);
|
|
2703
|
+
};
|
|
2704
|
+
for (const result of results) {
|
|
2705
|
+
for (const asset of asList(result["actors"])) {
|
|
2706
|
+
if (!isDict(asset))
|
|
2707
|
+
continue;
|
|
2708
|
+
const name = cleanName(strOf(asset["name"]));
|
|
2709
|
+
const desc = strOf(asset["description"]).trim();
|
|
2710
|
+
if (name && desc && !actorDescriptions.has(name))
|
|
2711
|
+
actorDescriptions.set(name, desc);
|
|
2712
|
+
}
|
|
2713
|
+
for (const asset of asList(result["locations"])) {
|
|
2714
|
+
if (!isDict(asset))
|
|
2715
|
+
continue;
|
|
2716
|
+
const name = cleanName(strOf(asset["name"]));
|
|
2717
|
+
const desc = strOf(asset["description"]).trim();
|
|
2718
|
+
if (name && desc && !locationDescriptions.has(name))
|
|
2719
|
+
locationDescriptions.set(name, desc);
|
|
2720
|
+
}
|
|
2721
|
+
for (const asset of asList(result["props"])) {
|
|
2722
|
+
if (!isDict(asset))
|
|
2723
|
+
continue;
|
|
2724
|
+
const name = cleanName(strOf(asset["name"]));
|
|
2725
|
+
const desc = strOf(asset["description"]).trim();
|
|
2726
|
+
if (name && desc && !propDescriptions.has(name))
|
|
2727
|
+
propDescriptions.set(name, desc);
|
|
2728
|
+
}
|
|
2729
|
+
for (const speaker of _normalize_speaker_list(result["speakers"])) {
|
|
2730
|
+
rememberSpeaker(speaker["name"], speaker["source_kind"], speaker["description"]);
|
|
2731
|
+
}
|
|
2732
|
+
for (const stateDef of _normalizeStateDefinitions(result["state_definitions"])) {
|
|
2733
|
+
stateDefinitions.push(stateDef);
|
|
2734
|
+
const assetName = strOf(stateDef["asset_name"]).trim();
|
|
2735
|
+
if (stateDef["kind"] === "actor")
|
|
2736
|
+
uniqueAdd(actorNames, assetName);
|
|
2737
|
+
else if (stateDef["kind"] === "location")
|
|
2738
|
+
uniqueAdd(locationNames, assetName);
|
|
2739
|
+
else if (stateDef["kind"] === "prop")
|
|
2740
|
+
uniqueAdd(propNames, assetName);
|
|
2741
|
+
}
|
|
2742
|
+
for (const scene of asList(result["scenes"])) {
|
|
2743
|
+
const locName = cleanName(strOf(scene["location_name"] || "未知场景")) || "未知场景";
|
|
2744
|
+
uniqueAdd(locationNames, locName);
|
|
2745
|
+
for (const actor of asList(scene["actor_names"])) {
|
|
2746
|
+
uniqueAdd(actorNames, cleanName(strOf(actor)));
|
|
2747
|
+
}
|
|
2748
|
+
for (const prop of asList(scene["prop_names"])) {
|
|
2749
|
+
uniqueAdd(propNames, cleanName(strOf(prop)));
|
|
2750
|
+
}
|
|
2751
|
+
for (const action of asList(scene["actions"])) {
|
|
2752
|
+
for (const speakerRef of asList(action["speakers"])) {
|
|
2753
|
+
if (!isDict(speakerRef))
|
|
2754
|
+
continue;
|
|
2755
|
+
const speaker = cleanName(strOf(speakerRef["speaker"]));
|
|
2756
|
+
const speakerKind = strOf(speakerRef["speaker_kind"] || "actor").trim() || "actor";
|
|
2757
|
+
if (speakerKind === "actor")
|
|
2758
|
+
uniqueAdd(actorNames, speaker);
|
|
2759
|
+
else {
|
|
2760
|
+
rememberSpeaker(speaker, speakerKind);
|
|
2761
|
+
if (speakerKind === "prop")
|
|
2762
|
+
uniqueAdd(propNames, speaker);
|
|
2763
|
+
else if (speakerKind === "location")
|
|
2764
|
+
uniqueAdd(locationNames, speaker);
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
for (const line of asList(action["lines"])) {
|
|
2768
|
+
if (!isDict(line))
|
|
2769
|
+
continue;
|
|
2770
|
+
const speaker = cleanName(strOf(line["speaker"]));
|
|
2771
|
+
const speakerKind = strOf(line["speaker_kind"] || "actor").trim() || "actor";
|
|
2772
|
+
if (speakerKind === "actor")
|
|
2773
|
+
uniqueAdd(actorNames, speaker);
|
|
2774
|
+
else {
|
|
2775
|
+
rememberSpeaker(speaker, speakerKind);
|
|
2776
|
+
if (speakerKind === "prop")
|
|
2777
|
+
uniqueAdd(propNames, speaker);
|
|
2778
|
+
else if (speakerKind === "location")
|
|
2779
|
+
uniqueAdd(locationNames, speaker);
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
const speaker = cleanName(strOf(action["speaker"]));
|
|
2783
|
+
const hasStructured = Boolean(action["speakers"]) || Boolean(action["lines"]);
|
|
2784
|
+
if (speaker &&
|
|
2785
|
+
!hasStructured &&
|
|
2786
|
+
(action["type"] === "dialogue" || action["type"] === "inner_thought")) {
|
|
2787
|
+
const speakerKind = strOf(action["speaker_kind"] || "actor").trim() || "actor";
|
|
2788
|
+
if (speakerKind === "actor")
|
|
2789
|
+
uniqueAdd(actorNames, speaker);
|
|
2790
|
+
else {
|
|
2791
|
+
rememberSpeaker(speaker, speakerKind);
|
|
2792
|
+
if (speakerKind === "prop")
|
|
2793
|
+
uniqueAdd(propNames, speaker);
|
|
2794
|
+
else if (speakerKind === "location")
|
|
2795
|
+
uniqueAdd(locationNames, speaker);
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
const actorIds = new Map(actorNames.map((name, idx) => [name, fmtId("act", idx + 1)]));
|
|
2802
|
+
const locationIds = new Map(locationNames.map((name, idx) => [name, fmtId("loc", idx + 1)]));
|
|
2803
|
+
const propIds = new Map(propNames.map((name, idx) => [name, fmtId("prp", idx + 1)]));
|
|
2804
|
+
const stateDefsByTarget = new Map();
|
|
2805
|
+
for (const stateDef of stateDefinitions) {
|
|
2806
|
+
const key = `${strOf(stateDef["kind"])}::${strOf(stateDef["asset_name"])}`;
|
|
2807
|
+
if (!stateDefsByTarget.has(key))
|
|
2808
|
+
stateDefsByTarget.set(key, []);
|
|
2809
|
+
stateDefsByTarget.get(key).push(stateDef);
|
|
2810
|
+
}
|
|
2811
|
+
const speakers = actorNames.map((name) => ({
|
|
2812
|
+
speaker_id: `spk_${actorIds.get(name)}`,
|
|
2813
|
+
display_name: name,
|
|
2814
|
+
source_kind: "actor",
|
|
2815
|
+
source_id: actorIds.get(name),
|
|
2816
|
+
voice_desc: "",
|
|
2817
|
+
}));
|
|
2818
|
+
const speakerIdsByName = new Map();
|
|
2819
|
+
const speakerKindCounts = new Map();
|
|
2820
|
+
for (const name of speakerOrder) {
|
|
2821
|
+
const record = speakerRecords.get(name) ?? { source_kind: "other" };
|
|
2822
|
+
let sourceKind = record.source_kind || "other";
|
|
2823
|
+
let sourceId = null;
|
|
2824
|
+
if (sourceKind === "prop")
|
|
2825
|
+
sourceId = propIds.get(name) ?? null;
|
|
2826
|
+
else if (sourceKind === "location")
|
|
2827
|
+
sourceId = locationIds.get(name) ?? null;
|
|
2828
|
+
if ((sourceKind === "prop" || sourceKind === "location") && !sourceId)
|
|
2829
|
+
sourceKind = "other";
|
|
2830
|
+
speakerKindCounts.set(sourceKind, (speakerKindCounts.get(sourceKind) ?? 0) + 1);
|
|
2831
|
+
const speakerId = `spk_${sourceKind}_${String(speakerKindCounts.get(sourceKind)).padStart(3, "0")}`;
|
|
2832
|
+
speakerIdsByName.set(`${sourceKind}::${name}`, speakerId);
|
|
2833
|
+
speakerIdsByName.set(`${record.source_kind}::${name}`, speakerId);
|
|
2834
|
+
speakers.push({
|
|
2835
|
+
speaker_id: speakerId,
|
|
2836
|
+
display_name: name,
|
|
2837
|
+
source_kind: sourceKind,
|
|
2838
|
+
source_id: sourceId,
|
|
2839
|
+
voice_desc: record.description ?? "",
|
|
2840
|
+
});
|
|
2841
|
+
}
|
|
2842
|
+
let stateCounter = 0;
|
|
2843
|
+
const stateIdsByTarget = new Map();
|
|
2844
|
+
const actors = [];
|
|
2845
|
+
for (const name of actorNames) {
|
|
2846
|
+
const entry = { actor_id: actorIds.get(name), actor_name: name };
|
|
2847
|
+
if (actorDescriptions.has(name))
|
|
2848
|
+
entry["description"] = actorDescriptions.get(name);
|
|
2849
|
+
const states = [];
|
|
2850
|
+
for (const stateDef of stateDefsByTarget.get(`actor::${name}`) ?? []) {
|
|
2851
|
+
stateCounter += 1;
|
|
2852
|
+
const sid = fmtId("st", stateCounter);
|
|
2853
|
+
const stateName = strOf(stateDef["state_name"]).trim();
|
|
2854
|
+
stateIdsByTarget.set(`actor::${name}::${stateName}`, sid);
|
|
2855
|
+
const stateEntry = { state_id: sid, state_name: stateName };
|
|
2856
|
+
const description = strOf(stateDef["description"]).trim();
|
|
2857
|
+
if (description)
|
|
2858
|
+
stateEntry["description"] = description;
|
|
2859
|
+
states.push(stateEntry);
|
|
2860
|
+
}
|
|
2861
|
+
if (states.length > 0)
|
|
2862
|
+
entry["states"] = states;
|
|
2863
|
+
actors.push(entry);
|
|
2864
|
+
}
|
|
2865
|
+
const locations = [];
|
|
2866
|
+
for (const name of locationNames) {
|
|
2867
|
+
const entry = { location_id: locationIds.get(name), location_name: name, group: name };
|
|
2868
|
+
if (locationDescriptions.has(name))
|
|
2869
|
+
entry["description"] = locationDescriptions.get(name);
|
|
2870
|
+
const states = [];
|
|
2871
|
+
for (const stateDef of stateDefsByTarget.get(`location::${name}`) ?? []) {
|
|
2872
|
+
stateCounter += 1;
|
|
2873
|
+
const sid = fmtId("st", stateCounter);
|
|
2874
|
+
const stateName = strOf(stateDef["state_name"]).trim();
|
|
2875
|
+
stateIdsByTarget.set(`location::${name}::${stateName}`, sid);
|
|
2876
|
+
const stateEntry = { state_id: sid, state_name: stateName };
|
|
2877
|
+
const description = strOf(stateDef["description"]).trim();
|
|
2878
|
+
if (description)
|
|
2879
|
+
stateEntry["description"] = description;
|
|
2880
|
+
states.push(stateEntry);
|
|
2881
|
+
}
|
|
2882
|
+
if (states.length > 0)
|
|
2883
|
+
entry["states"] = states;
|
|
2884
|
+
locations.push(entry);
|
|
2885
|
+
}
|
|
2886
|
+
const props = [];
|
|
2887
|
+
for (const name of propNames) {
|
|
2888
|
+
const entry = { prop_id: propIds.get(name), prop_name: name };
|
|
2889
|
+
if (propDescriptions.has(name))
|
|
2890
|
+
entry["description"] = propDescriptions.get(name);
|
|
2891
|
+
const states = [];
|
|
2892
|
+
for (const stateDef of stateDefsByTarget.get(`prop::${name}`) ?? []) {
|
|
2893
|
+
stateCounter += 1;
|
|
2894
|
+
const sid = fmtId("st", stateCounter);
|
|
2895
|
+
const stateName = strOf(stateDef["state_name"]).trim();
|
|
2896
|
+
stateIdsByTarget.set(`prop::${name}::${stateName}`, sid);
|
|
2897
|
+
const stateEntry = { state_id: sid, state_name: stateName };
|
|
2898
|
+
const description = strOf(stateDef["description"]).trim();
|
|
2899
|
+
if (description)
|
|
2900
|
+
stateEntry["description"] = description;
|
|
2901
|
+
states.push(stateEntry);
|
|
2902
|
+
}
|
|
2903
|
+
if (states.length > 0)
|
|
2904
|
+
entry["states"] = states;
|
|
2905
|
+
props.push(entry);
|
|
2906
|
+
}
|
|
2907
|
+
const speakerIdForSceneRef = (ref) => {
|
|
2908
|
+
const speaker = cleanName(strOf(ref["speaker"]));
|
|
2909
|
+
const speakerKind = strOf(ref["speaker_kind"] || "actor").trim() || "actor";
|
|
2910
|
+
if (!speaker)
|
|
2911
|
+
return null;
|
|
2912
|
+
if (speakerKind === "actor" && actorIds.has(speaker))
|
|
2913
|
+
return `spk_${actorIds.get(speaker)}`;
|
|
2914
|
+
const normKind = _MD_SPEAKER_KIND_NORM[speakerKind.toLowerCase()] ?? _MD_SPEAKER_KIND_NORM[speakerKind] ?? "other";
|
|
2915
|
+
return speakerIdsByName.get(`${normKind}::${speaker}`) ?? speakerIdsByName.get(`other::${speaker}`) ?? null;
|
|
2916
|
+
};
|
|
2917
|
+
const sortedResults = [...results].sort((a, b) => Number(a["episode"] ?? 0) - Number(b["episode"] ?? 0));
|
|
2918
|
+
const episodes = [];
|
|
2919
|
+
let sceneCounter = 0;
|
|
2920
|
+
for (const result of sortedResults) {
|
|
2921
|
+
const scenes = [];
|
|
2922
|
+
for (const scene of asList(result["scenes"])) {
|
|
2923
|
+
sceneCounter += 1;
|
|
2924
|
+
const locName = cleanName(strOf(scene["location_name"] || "未知场景")) || "未知场景";
|
|
2925
|
+
const locState = strOf(scene["location_state"]).trim();
|
|
2926
|
+
const locStateId = locState ? stateIdsByTarget.get(`location::${locName}::${locState}`) ?? null : null;
|
|
2927
|
+
const sceneActorRefs = [];
|
|
2928
|
+
for (const actorRaw of asList(scene["actor_names"])) {
|
|
2929
|
+
const actor = cleanName(strOf(actorRaw));
|
|
2930
|
+
const aid = actorIds.get(actor);
|
|
2931
|
+
if (!aid)
|
|
2932
|
+
continue;
|
|
2933
|
+
const actorStates = isDict(scene["actor_states"]) ? scene["actor_states"] : {};
|
|
2934
|
+
const state = actorStates[actor];
|
|
2935
|
+
const stateId = state ? stateIdsByTarget.get(`actor::${actor}::${strOf(state).trim()}`) ?? null : null;
|
|
2936
|
+
sceneActorRefs.push({ actor_id: aid, state_id: stateId });
|
|
2937
|
+
}
|
|
2938
|
+
const scenePropRefs = [];
|
|
2939
|
+
for (const rawProp of asList(scene["prop_names"])) {
|
|
2940
|
+
const prop = cleanName(strOf(rawProp));
|
|
2941
|
+
if (propIds.has(prop))
|
|
2942
|
+
scenePropRefs.push({ prop_id: propIds.get(prop), state_id: null });
|
|
2943
|
+
}
|
|
2944
|
+
const actions = [];
|
|
2945
|
+
for (const action of asList(scene["actions"])) {
|
|
2946
|
+
const item = { type: action["type"], content: action["content"] };
|
|
2947
|
+
const delivery = normalizeDeliveryToken(action["delivery"]);
|
|
2948
|
+
if (action["type"] === "dialogue" && delivery)
|
|
2949
|
+
item["delivery"] = delivery;
|
|
2950
|
+
if (action["type"] === "dialogue" && delivery === "overlap" && asList(action["lines"]).length > 0) {
|
|
2951
|
+
const lines = [];
|
|
2952
|
+
for (const line of asList(action["lines"])) {
|
|
2953
|
+
if (!isDict(line))
|
|
2954
|
+
continue;
|
|
2955
|
+
const speakerId = speakerIdForSceneRef(line);
|
|
2956
|
+
const content = strOf(line["content"]).trim();
|
|
2957
|
+
if (speakerId && content)
|
|
2958
|
+
lines.push({ speaker_id: speakerId, content });
|
|
2959
|
+
}
|
|
2960
|
+
if (lines.length > 0) {
|
|
2961
|
+
item["lines"] = lines;
|
|
2962
|
+
item["content"] = lines.map((l) => l["content"]).join(" / ");
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
else if (action["type"] === "dialogue" && asList(action["speakers"]).length > 0) {
|
|
2966
|
+
const speakerIds = [];
|
|
2967
|
+
for (const ref of asList(action["speakers"])) {
|
|
2968
|
+
if (!isDict(ref))
|
|
2969
|
+
continue;
|
|
2970
|
+
const sid = speakerIdForSceneRef(ref);
|
|
2971
|
+
if (sid)
|
|
2972
|
+
speakerIds.push(sid);
|
|
2973
|
+
}
|
|
2974
|
+
if (speakerIds.length > 0) {
|
|
2975
|
+
item["delivery"] = delivery || (speakerIds.length > 1 ? "simultaneous" : "single");
|
|
2976
|
+
item["speakers"] = speakerIds.map((sid) => ({ speaker_id: sid }));
|
|
2977
|
+
if (speakerIds.length === 1) {
|
|
2978
|
+
const sid = speakerIds[0];
|
|
2979
|
+
const actorId = sid.startsWith("spk_act_") ? sid.slice(4) : null;
|
|
2980
|
+
if (actorId) {
|
|
2981
|
+
item["actor_id"] = actorId;
|
|
2982
|
+
item["speaker_id"] = sid;
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
else {
|
|
2988
|
+
const speaker = action["speaker"];
|
|
2989
|
+
if ((action["type"] === "dialogue" || action["type"] === "inner_thought") && speaker) {
|
|
2990
|
+
const sid = speakerIdForSceneRef({
|
|
2991
|
+
speaker,
|
|
2992
|
+
speaker_kind: strOf(action["speaker_kind"] || "actor").trim() || "actor",
|
|
2993
|
+
});
|
|
2994
|
+
if (sid) {
|
|
2995
|
+
item["speaker_id"] = sid;
|
|
2996
|
+
const actorId = sid.startsWith("spk_act_") ? sid.slice(4) : null;
|
|
2997
|
+
if (actorId)
|
|
2998
|
+
item["actor_id"] = actorId;
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
if (action["emotion"])
|
|
3003
|
+
item["emotion"] = action["emotion"];
|
|
3004
|
+
actions.push(item);
|
|
3005
|
+
}
|
|
3006
|
+
const context = {
|
|
3007
|
+
locations: [{ location_id: locationIds.get(locName) ?? null, state_id: locStateId }],
|
|
3008
|
+
actors: sceneActorRefs,
|
|
3009
|
+
props: scenePropRefs,
|
|
3010
|
+
};
|
|
3011
|
+
scenes.push({
|
|
3012
|
+
scene_id: fmtId("scn", sceneCounter),
|
|
3013
|
+
environment: scene["environment"],
|
|
3014
|
+
context,
|
|
3015
|
+
locations: context["locations"],
|
|
3016
|
+
actors: context["actors"],
|
|
3017
|
+
props: context["props"],
|
|
3018
|
+
actions,
|
|
3019
|
+
});
|
|
3020
|
+
}
|
|
3021
|
+
episodes.push({
|
|
3022
|
+
episode_id: fmtId("ep", Number(result["episode"] ?? episodes.length + 1)),
|
|
3023
|
+
title: result["title"],
|
|
3024
|
+
scenes,
|
|
3025
|
+
});
|
|
3026
|
+
}
|
|
3027
|
+
return {
|
|
3028
|
+
version: SCRIPT_SCHEMA_VERSION,
|
|
3029
|
+
title,
|
|
3030
|
+
worldview: "",
|
|
3031
|
+
worldview_raw: "",
|
|
3032
|
+
style: "",
|
|
3033
|
+
actors,
|
|
3034
|
+
locations,
|
|
3035
|
+
props,
|
|
3036
|
+
speakers,
|
|
3037
|
+
episodes,
|
|
3038
|
+
};
|
|
3039
|
+
}
|
|
3040
|
+
//# sourceMappingURL=direct-core.js.map
|