@oh-story/dsh 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -8
- package/lib/client.js +298 -87
- package/lib/client.js.map +4 -4
- package/lib/drama/manifest.json +34 -34
- package/lib/drama/skills/short-drama/SKILL.md +4 -1
- package/lib/drama/skills/short-drama/references/contract-and-ownership.md +2 -0
- package/lib/drama/skills/short-drama/references/creator-documents.md +30 -4
- package/lib/drama/skills/short-drama/references/creator-workflow.md +8 -2
- package/lib/drama/skills/short-drama/references/reference-roles.md +16 -2
- package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +328 -48
- package/lib/drama/skills/short-drama-produce/SKILL.md +4 -1
- package/lib/drama/skills/short-drama-produce/scripts/production_tool.py +34 -2
- package/lib/drama/skills/short-drama-storyboard/SKILL.md +27 -5
- package/lib/drama/skills/short-drama-storyboard/references/review-and-fixtures.md +2 -1
- package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +2 -2
- package/lib/drama/skills/short-drama-video-prompts/SKILL.md +11 -2
- package/lib/drama/skills/short-drama-video-prompts/assets/video-prompts.md +6 -1
- package/lib/drama/skills/short-drama-video-prompts/references/minimax-h3.md +19 -3
- package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +8 -2
- package/lib/drama/skills/short-drama-video-prompts/references/target-model-profile.md +1 -0
- package/lib/index.js +194 -62
- package/lib/index.js.map +4 -4
- package/lib/types/client/drama-production-view.d.ts +1 -0
- package/lib/types/client/drama-production-view.d.ts.map +1 -1
- package/lib/types/client/drama-production.d.ts +3 -0
- package/lib/types/client/drama-production.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/video-studio.d.ts +2 -1
- package/lib/types/client/video-studio.d.ts.map +1 -1
- package/lib/types/client/workbench-presence.d.ts +29 -0
- package/lib/types/client/workbench-presence.d.ts.map +1 -0
- package/lib/types/drama-adapters.d.ts +65 -0
- package/lib/types/drama-adapters.d.ts.map +1 -0
- package/lib/types/host-python.d.ts +19 -0
- package/lib/types/host-python.d.ts.map +1 -0
- package/lib/types/index.d.ts +1 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/skill-provider.d.ts +1 -1
- package/lib/types/skill-provider.d.ts.map +1 -1
- package/lib/types/workspace-route.d.ts.map +1 -1
- package/package.json +41 -41
|
@@ -39,6 +39,18 @@ REF_RE = re.compile(
|
|
|
39
39
|
r"((?:用途:([^;)\n]+);)?控制:([^;)]+);不得控制:([^)]+))",
|
|
40
40
|
re.IGNORECASE,
|
|
41
41
|
)
|
|
42
|
+
# The same slot grammar, but the locator is a document entry instead of a file.
|
|
43
|
+
# A creator who generates in an external tool never has the file inside the
|
|
44
|
+
# project, and the copyable body only ever needed 顺序 and 用途 -- the path is a
|
|
45
|
+
# production input, not a prompt input. PLAN says "this picture does not exist
|
|
46
|
+
# here; attach it yourself in this order", which is a different claim from
|
|
47
|
+
# 「已有图片」 and from 「待补」.
|
|
48
|
+
PLAN_RE = re.compile(
|
|
49
|
+
r"(PLAN-[A-Z0-9-]+)(顺序:([1-9]\d*))· "
|
|
50
|
+
r"((?:IMG|SHOT)-[A-Z0-9-]+)《([^》\n]+)》"
|
|
51
|
+
r"((?:用途:([^;)\n]+);)?控制:([^;)]+);不得控制:([^)]+))",
|
|
52
|
+
re.IGNORECASE,
|
|
53
|
+
)
|
|
42
54
|
# One picture answers one question. 「同一张图承担两个作用时分开说明」 in
|
|
43
55
|
# references/reference-roles.md is what makes a slot translatable into a
|
|
44
56
|
# provider role, so the vocabulary is closed rather than free prose.
|
|
@@ -53,6 +65,32 @@ REF_PURPOSES = (
|
|
|
53
65
|
"结束帧",
|
|
54
66
|
"风格",
|
|
55
67
|
)
|
|
68
|
+
# `EP001-SC001` is the documented shape, but a project that scopes ids by season
|
|
69
|
+
# writes `S01-EP001-SC001`. Both are one stable scene id, so the pattern takes
|
|
70
|
+
# any hyphenated prefix rather than exactly one segment.
|
|
71
|
+
SLOT_HEAD_RE = re.compile(
|
|
72
|
+
r"(?:REF|PLAN)-[A-Za-z0-9][A-Za-z0-9-]*(顺序:", re.IGNORECASE
|
|
73
|
+
)
|
|
74
|
+
SCENE_ID_PATTERN = r"(?:[A-Za-z0-9]+-)+SC[0-9]+"
|
|
75
|
+
SCENE_HEADING_RE = re.compile(
|
|
76
|
+
r"^## (" + SCENE_ID_PATTERN + r")\b", re.MULTILINE
|
|
77
|
+
)
|
|
78
|
+
SCENE_ID_RE = re.compile(SCENE_ID_PATTERN)
|
|
79
|
+
# A scene the storyboard decided not to film. SHT-01 has always allowed the
|
|
80
|
+
# omission and always required a reason; without a place to write it the
|
|
81
|
+
# omission and an oversight look identical in the finished document.
|
|
82
|
+
UNFILMED_LINE_RE = re.compile(
|
|
83
|
+
r"^[ \t\u3000]*[-*+][ \t\u3000]*未拍场次[::](.*)$", re.MULTILINE
|
|
84
|
+
)
|
|
85
|
+
UNFILMED_ENTRY_RE = re.compile(
|
|
86
|
+
r"(" + SCENE_ID_PATTERN + r")(理由:([^)\n]+))"
|
|
87
|
+
)
|
|
88
|
+
# Dialogue reaches the model as a quoted run, either inside the H3 `<d>` tag or
|
|
89
|
+
# inside quotation marks. Runs shorter than four Han characters are in-frame
|
|
90
|
+
# labels and interjections far more often than they are lines, so the check
|
|
91
|
+
# stays off them rather than inventing an escape hatch for signage.
|
|
92
|
+
SPOKEN_SPAN_RE = re.compile(r"<d>([^<]*)</d>|[\"“]([^\"”]*)[\"”]|「([^」]*)」")
|
|
93
|
+
SPOKEN_RUN_RE = re.compile(r"[\u3400-\u9fff]{4,}")
|
|
56
94
|
EXPLICIT_TEXT_TO_VIDEO = "无(创作者已明确选择文生视频)"
|
|
57
95
|
PENDING_REFERENCE_SUFFIX_RE = re.compile(r";待补参考图:[^;。\n]+。?$")
|
|
58
96
|
VISUAL_CATEGORIES = ("人物", "造型", "地点", "道具")
|
|
@@ -151,9 +189,22 @@ def _contains_ref_token(value: str) -> bool:
|
|
|
151
189
|
return "ref-" in value.casefold()
|
|
152
190
|
|
|
153
191
|
|
|
192
|
+
def _contains_plan_token(value: str) -> bool:
|
|
193
|
+
return "plan-" in value.casefold()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _contains_slot_token(value: str) -> bool:
|
|
197
|
+
return _contains_ref_token(value) or _contains_plan_token(value)
|
|
198
|
+
|
|
199
|
+
|
|
154
200
|
def _is_none(value: str) -> bool:
|
|
155
|
-
|
|
156
|
-
|
|
201
|
+
# `[^)]+` would stop at the first closing bracket, so a gap list that names
|
|
202
|
+
# an entry in brackets -- 「江晨人物图(IMG-...)」 -- fell through to the REF
|
|
203
|
+
# syntax error, whose obvious repair is deleting the gap record. That is the
|
|
204
|
+
# silent downgrade this contract exists to prevent, so the note takes any
|
|
205
|
+
# content as long as it is bracketed.
|
|
206
|
+
return not _contains_slot_token(value) and bool(
|
|
207
|
+
re.fullmatch(r"无(?:(.+))?", _plain(value), re.DOTALL)
|
|
157
208
|
)
|
|
158
209
|
|
|
159
210
|
|
|
@@ -169,7 +220,7 @@ def _has_pending_references(value: str) -> bool:
|
|
|
169
220
|
|
|
170
221
|
|
|
171
222
|
def _is_no_external_reference(value: str) -> bool:
|
|
172
|
-
return not
|
|
223
|
+
return not _contains_slot_token(value) and bool(
|
|
173
224
|
re.fullmatch(r"无(?:外部参考)?(?:;[^\n]*)?。?", value.strip())
|
|
174
225
|
)
|
|
175
226
|
|
|
@@ -205,20 +256,53 @@ def _inside(path: Path, root: Path) -> bool:
|
|
|
205
256
|
return resolved == root or root in resolved.parents
|
|
206
257
|
|
|
207
258
|
|
|
208
|
-
|
|
259
|
+
class EntryIndex(NamedTuple):
|
|
260
|
+
"""The document entries a `PLAN-...` locator is allowed to name."""
|
|
261
|
+
|
|
262
|
+
images: dict[str, str]
|
|
263
|
+
shots: frozenset
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _slot_matches(value: str) -> list:
|
|
267
|
+
"""Every `REF-...` and `PLAN-...` slot in `value`, in written order.
|
|
268
|
+
|
|
269
|
+
The two patterns cannot match the same span: a REF locator has to be an
|
|
270
|
+
image path and a PLAN locator has to be an `IMG-`/`SHOT-` id.
|
|
271
|
+
"""
|
|
272
|
+
found = []
|
|
273
|
+
for kind, pattern in (("REF", REF_RE), ("PLAN", PLAN_RE)):
|
|
274
|
+
for match in pattern.finditer(value):
|
|
275
|
+
found.append((match.start(), match.end(), kind, match.groups()))
|
|
276
|
+
found.sort(key=lambda item: item[0])
|
|
277
|
+
return found
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _references(
|
|
281
|
+
value: str,
|
|
282
|
+
owner: str,
|
|
283
|
+
project_root: Path,
|
|
284
|
+
errors: list[str],
|
|
285
|
+
entries: Optional[EntryIndex] = None,
|
|
286
|
+
) -> frozenset:
|
|
287
|
+
"""Validate one 输入参考图/参考 declaration; return the slot kinds it uses."""
|
|
209
288
|
if _is_none(value):
|
|
210
|
-
return
|
|
289
|
+
return frozenset()
|
|
211
290
|
reference_value = PENDING_REFERENCE_SUFFIX_RE.sub("", value.strip())
|
|
212
|
-
matches =
|
|
291
|
+
matches = _slot_matches(reference_value)
|
|
213
292
|
cursor = 0
|
|
214
293
|
separators_are_valid = True
|
|
215
|
-
for index,
|
|
216
|
-
if reference_value[cursor
|
|
294
|
+
for index, (start, end, _kind, _groups) in enumerate(matches):
|
|
295
|
+
if reference_value[cursor:start] != ("" if index == 0 else ";"):
|
|
217
296
|
separators_are_valid = False
|
|
218
|
-
cursor =
|
|
297
|
+
cursor = end
|
|
219
298
|
trailing = reference_value[cursor:]
|
|
299
|
+
# Counted as slot heads on the same text the slots were matched in. A bare
|
|
300
|
+
# token count would read the `ref-` in a real path like
|
|
301
|
+
# `输入/参考图/ref-江晨.png` as a second slot and reject a correct declaration.
|
|
302
|
+
declared = len(SLOT_HEAD_RE.findall(reference_value))
|
|
303
|
+
kinds = frozenset(item[2] for item in matches)
|
|
220
304
|
if (
|
|
221
|
-
len(matches) !=
|
|
305
|
+
len(matches) != declared
|
|
222
306
|
or not matches
|
|
223
307
|
or not separators_are_valid
|
|
224
308
|
or trailing not in {"", "。"}
|
|
@@ -231,34 +315,38 @@ def _references(value: str, owner: str, project_root: Path, errors: list[str]) -
|
|
|
231
315
|
f"{owner}: 待补参考图必须写在最后一个 REF 槽位之后,"
|
|
232
316
|
"缺口之间只用、分隔"
|
|
233
317
|
)
|
|
318
|
+
elif _contains_plan_token(value) and not _contains_ref_token(value):
|
|
319
|
+
errors.append(f"{owner}: 输入参考图必须使用完整 PLAN 语法")
|
|
234
320
|
else:
|
|
235
321
|
errors.append(f"{owner}: 输入参考图必须使用完整 REF 语法")
|
|
236
|
-
return
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
322
|
+
return kinds
|
|
323
|
+
groups = [item[3] for item in matches]
|
|
324
|
+
slot_kinds = [item[2] for item in matches]
|
|
325
|
+
slots = [group[0] for group in groups]
|
|
326
|
+
orders = [int(group[1]) for group in groups]
|
|
327
|
+
locators = [group[2] for group in groups]
|
|
328
|
+
purposes = [(group[4] or "").strip() for group in groups]
|
|
329
|
+
# A declaration made only of PLAN slots is not a REF declaration, so its
|
|
330
|
+
# diagnostics must not send the creator looking for a REF line.
|
|
331
|
+
noun = "PLAN" if kinds == frozenset({"PLAN"}) else "REF"
|
|
241
332
|
if len(slots) != len(set(slots)):
|
|
242
|
-
errors.append(f"{owner}:
|
|
333
|
+
errors.append(f"{owner}: {noun} 槽位重复")
|
|
243
334
|
if len(orders) != len(set(orders)) or sorted(orders) != list(
|
|
244
335
|
range(1, len(orders) + 1)
|
|
245
336
|
):
|
|
246
|
-
errors.append(f"{owner}:
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
path_purposes = list(zip(paths, purposes))
|
|
251
|
-
if len(path_purposes) != len(set(path_purposes)):
|
|
252
|
-
errors.append(f"{owner}: REF 路径与用途完全重复")
|
|
337
|
+
errors.append(f"{owner}: {noun} 顺序必须唯一且从 1 连续编号")
|
|
338
|
+
locator_purposes = list(zip(locators, purposes))
|
|
339
|
+
if len(locator_purposes) != len(set(locator_purposes)):
|
|
340
|
+
errors.append(f"{owner}: {noun} 路径与用途完全重复")
|
|
253
341
|
for slot, purpose in zip(slots, purposes):
|
|
254
342
|
if not purpose:
|
|
255
343
|
errors.append(
|
|
256
|
-
f"{owner}:
|
|
344
|
+
f"{owner}: {noun} 缺少用途: {slot};用途只能是"
|
|
257
345
|
f"{'、'.join(REF_PURPOSES)}其中一个"
|
|
258
346
|
)
|
|
259
347
|
elif purpose not in REF_PURPOSES:
|
|
260
348
|
errors.append(
|
|
261
|
-
f"{owner}:
|
|
349
|
+
f"{owner}: {noun} 用途不在允许集合内: {slot}({purpose});"
|
|
262
350
|
f"只能是{'、'.join(REF_PURPOSES)}"
|
|
263
351
|
)
|
|
264
352
|
for role in ("起始帧", "结束帧"):
|
|
@@ -266,28 +354,72 @@ def _references(value: str, owner: str, project_root: Path, errors: list[str]) -
|
|
|
266
354
|
errors.append(f"{owner}: 同一条目只能有一张{role}参考图")
|
|
267
355
|
if "结束帧" in purposes and "起始帧" not in purposes:
|
|
268
356
|
errors.append(f"{owner}: 绑定结束帧参考图时必须同时绑定起始帧")
|
|
269
|
-
for
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
357
|
+
for index, (kind, group) in enumerate(zip(slot_kinds, groups)):
|
|
358
|
+
slot, raw_locator, label = group[0], group[2], group[3]
|
|
359
|
+
may_control, must_not_control = group[5], group[6]
|
|
360
|
+
if kind == "REF":
|
|
361
|
+
if not _portable_path(raw_locator):
|
|
362
|
+
errors.append(
|
|
363
|
+
f"{owner}: REF 路径不是安全的项目相对路径: {raw_locator}"
|
|
364
|
+
)
|
|
365
|
+
else:
|
|
366
|
+
reference_path = project_root / raw_locator
|
|
367
|
+
if not _inside(reference_path, project_root):
|
|
368
|
+
errors.append(f"{owner}: REF 路径越出项目根目录: {raw_locator}")
|
|
369
|
+
elif not reference_path.is_file():
|
|
370
|
+
errors.append(f"{owner}: REF 文件不存在: {raw_locator}")
|
|
277
371
|
else:
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
elif not reference_path.is_file():
|
|
282
|
-
errors.append(f"{owner}: REF 文件不存在: {raw_path}")
|
|
372
|
+
_plan_locator(
|
|
373
|
+
raw_locator, label, owner, slot, purposes[index], entries, errors
|
|
374
|
+
)
|
|
283
375
|
if not re.search(r"[\u4e00-\u9fff]", label):
|
|
284
|
-
errors.append(f"{owner}:
|
|
376
|
+
errors.append(f"{owner}: {kind} 缺少中文名称: {slot}")
|
|
285
377
|
if not may_control.strip() or not must_not_control.strip():
|
|
286
|
-
errors.append(f"{owner}:
|
|
378
|
+
errors.append(f"{owner}: {kind} 必须同时声明控制与不得控制: {slot}")
|
|
287
379
|
allowed = {item.strip() for item in re.split(r"[、,,]", may_control)}
|
|
288
380
|
prohibited = {item.strip() for item in re.split(r"[、,,]", must_not_control)}
|
|
289
381
|
if "" in allowed or "" in prohibited or allowed & prohibited:
|
|
290
|
-
errors.append(f"{owner}:
|
|
382
|
+
errors.append(f"{owner}: {kind} 控制与不得控制范围冲突: {slot}")
|
|
383
|
+
return kinds
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _plan_locator(
|
|
387
|
+
locator: str,
|
|
388
|
+
label: str,
|
|
389
|
+
owner: str,
|
|
390
|
+
slot: str,
|
|
391
|
+
purpose: str,
|
|
392
|
+
entries: Optional[EntryIndex],
|
|
393
|
+
errors: list[str],
|
|
394
|
+
) -> None:
|
|
395
|
+
"""A planned picture still has to name an entry that exists today.
|
|
396
|
+
|
|
397
|
+
The picture itself is the creator's to supply, but what it depicts is
|
|
398
|
+
already decided by an `IMG-...` board or a `SHOT-...` frozen keyframe. A
|
|
399
|
+
locator that resolves to neither is a reference nobody can act on.
|
|
400
|
+
"""
|
|
401
|
+
if entries is None:
|
|
402
|
+
return
|
|
403
|
+
if locator.startswith("IMG-"):
|
|
404
|
+
if locator not in entries.images:
|
|
405
|
+
errors.append(f"{owner}: PLAN 指向不存在的 IMG 条目: {locator}")
|
|
406
|
+
elif label != entries.images[locator]:
|
|
407
|
+
errors.append(f"{owner}: PLAN 中文名称与 IMG 标题不一致: {slot}")
|
|
408
|
+
elif locator.startswith("SHOT-"):
|
|
409
|
+
if locator not in entries.shots:
|
|
410
|
+
errors.append(f"{owner}: PLAN 指向不存在的 SHOT 条目: {locator}")
|
|
411
|
+
elif purpose == "起始帧":
|
|
412
|
+
# The picture that opens this shot is this shot's own frozen
|
|
413
|
+
# keyframe. Another shot's keyframe opens a different moment.
|
|
414
|
+
own = re.sub(r"^(?:SHOT|MOTION)-", "SHOT-", owner)
|
|
415
|
+
if own.startswith("SHOT-") and locator != own:
|
|
416
|
+
errors.append(
|
|
417
|
+
f"{owner}: 起始帧 PLAN 必须指向本镜的冻结关键帧: {locator}"
|
|
418
|
+
)
|
|
419
|
+
else:
|
|
420
|
+
errors.append(
|
|
421
|
+
f"{owner}: PLAN 定位符只能是 IMG-... 或 SHOT-...: {locator}"
|
|
422
|
+
)
|
|
291
423
|
|
|
292
424
|
|
|
293
425
|
def _excerpt(value: str, limit: int = 60) -> str:
|
|
@@ -777,6 +909,112 @@ def _check_continuity_locks(
|
|
|
777
909
|
errors.append(f"{lock_id}: {image_id} 可复制提示词缺少锁面")
|
|
778
910
|
|
|
779
911
|
|
|
912
|
+
def _storyboard_head(storyboard: str) -> str:
|
|
913
|
+
"""The part of 分镜.md that belongs to the episode rather than to a shot."""
|
|
914
|
+
match = re.search(r"^## SHOT-", storyboard, re.MULTILINE)
|
|
915
|
+
return storyboard if match is None else storyboard[: match.start()]
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _shot_sources(
|
|
919
|
+
value: str, owner: str, scenes: "frozenset", errors: list[str]
|
|
920
|
+
) -> list:
|
|
921
|
+
"""Resolve one shot's 来源 against the scene ids that exist in 剧本.md."""
|
|
922
|
+
plain = _plain(value)
|
|
923
|
+
if not plain:
|
|
924
|
+
errors.append(f"{owner}: 缺少来源字段")
|
|
925
|
+
return []
|
|
926
|
+
# 《镜头手艺》 asks for the scene id plus a short quote of the source line,
|
|
927
|
+
# and a quote contains commas of its own. So the ids are read out of the
|
|
928
|
+
# field rather than by cutting it into pieces on punctuation.
|
|
929
|
+
if SCENE_ID_RE.match(plain) is None:
|
|
930
|
+
errors.append(
|
|
931
|
+
f"{owner}: 来源必须以《剧本.md》的场景 ID 开头: {_excerpt(plain, 24)}"
|
|
932
|
+
)
|
|
933
|
+
return []
|
|
934
|
+
resolved = []
|
|
935
|
+
for scene_id in _unique(SCENE_ID_RE.findall(plain)):
|
|
936
|
+
if scene_id not in scenes:
|
|
937
|
+
errors.append(f"{owner}: 来源场景不在《剧本.md》中: {scene_id}")
|
|
938
|
+
else:
|
|
939
|
+
resolved.append(scene_id)
|
|
940
|
+
return resolved
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _unfilmed_scenes(
|
|
944
|
+
storyboard: str, scenes: "frozenset", errors: list[str]
|
|
945
|
+
) -> dict:
|
|
946
|
+
"""Scenes 分镜.md declares it deliberately does not film, with reasons."""
|
|
947
|
+
head = _storyboard_head(storyboard)
|
|
948
|
+
declared: dict = {}
|
|
949
|
+
# The line belongs to the episode, not to a shot. Placed inside a SHOT
|
|
950
|
+
# section it would be read as that shot's field and quietly do nothing,
|
|
951
|
+
# which looks exactly like forgetting to write it.
|
|
952
|
+
if len(UNFILMED_LINE_RE.findall(storyboard)) != len(
|
|
953
|
+
UNFILMED_LINE_RE.findall(head)
|
|
954
|
+
):
|
|
955
|
+
errors.append("分镜.md: 未拍场次要写在第一个 SHOT 之前的正文开头")
|
|
956
|
+
for value in UNFILMED_LINE_RE.findall(head):
|
|
957
|
+
value = value.strip()
|
|
958
|
+
entries = UNFILMED_ENTRY_RE.findall(value)
|
|
959
|
+
remainder = UNFILMED_ENTRY_RE.sub("", value).strip(";;。 ")
|
|
960
|
+
if not entries or remainder:
|
|
961
|
+
errors.append(
|
|
962
|
+
"分镜.md: 未拍场次必须写成 <场景 ID>(理由:……),多项用;分隔"
|
|
963
|
+
)
|
|
964
|
+
continue
|
|
965
|
+
for scene_id, reason in entries:
|
|
966
|
+
if not reason.strip():
|
|
967
|
+
errors.append(f"分镜.md: 未拍场次缺少理由: {scene_id}")
|
|
968
|
+
elif scene_id not in scenes:
|
|
969
|
+
errors.append(f"分镜.md: 未拍场次不在《剧本.md》中: {scene_id}")
|
|
970
|
+
else:
|
|
971
|
+
declared[scene_id] = reason.strip()
|
|
972
|
+
return declared
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _spoken_key(value: str) -> str:
|
|
976
|
+
"""Drop punctuation and spacing so quoting choices do not decide identity."""
|
|
977
|
+
return re.sub(r"[^\u3400-\u9fff0-9A-Za-z]", "", value)
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _quoted_speech(prompt: str) -> list:
|
|
981
|
+
# The body is one prompt; a quote that happens to wrap across lines is the
|
|
982
|
+
# same quote. Chinese does not separate words with spaces, so rejoining the
|
|
983
|
+
# lines reconstructs the run exactly.
|
|
984
|
+
joined = prompt.replace("\n", "")
|
|
985
|
+
runs: list = []
|
|
986
|
+
for match in SPOKEN_SPAN_RE.finditer(joined):
|
|
987
|
+
span = next((group for group in match.groups() if group), "")
|
|
988
|
+
# An unpaired quotation mark can pair with one much later and swallow
|
|
989
|
+
# the narration between them. That span is not a quotation, so it is
|
|
990
|
+
# not evidence of an invented line either.
|
|
991
|
+
if len(span) > 200:
|
|
992
|
+
continue
|
|
993
|
+
runs.extend(SPOKEN_RUN_RE.findall(span))
|
|
994
|
+
return runs
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _check_spoken_lines(
|
|
998
|
+
prompt: str, owner: str, sources: "tuple", errors: list[str]
|
|
999
|
+
) -> None:
|
|
1000
|
+
"""Quoted Chinese in a copyable body has to come from an upstream document.
|
|
1001
|
+
|
|
1002
|
+
The video stage already owes the screenplay verbatim dialogue. Checking it
|
|
1003
|
+
here is what stops a prompt from writing the line the shot wishes the
|
|
1004
|
+
character had said. 视觉设定.md and 分镜.md count as sources too: a location
|
|
1005
|
+
or prop entry carries the exact text on a sign, and a shot's own fields
|
|
1006
|
+
carry the on-screen text and sound this shot was designed around. What no
|
|
1007
|
+
upstream document contains is a line the prompt made up.
|
|
1008
|
+
"""
|
|
1009
|
+
for run in _quoted_speech(prompt):
|
|
1010
|
+
key = _spoken_key(run)
|
|
1011
|
+
if key and not any(key in source for source in sources):
|
|
1012
|
+
errors.append(
|
|
1013
|
+
f"{owner}: 可复制提示词的引文在《剧本.md》《视觉设定.md》《分镜.md》里都找不到: "
|
|
1014
|
+
f"{_excerpt(run, 24)}"
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
|
|
780
1018
|
def validate_episode(episode: Path, project_root: Optional[Path] = None) -> list[str]:
|
|
781
1019
|
"""Return all deterministic contract errors for ``episode``."""
|
|
782
1020
|
episode = episode.resolve()
|
|
@@ -790,6 +1028,22 @@ def validate_episode(episode: Path, project_root: Optional[Path] = None) -> list
|
|
|
790
1028
|
storyboard = (episode / "分镜.md").read_text(encoding="utf-8")
|
|
791
1029
|
video = (episode / "视频提示词.md").read_text(encoding="utf-8")
|
|
792
1030
|
visual = (episode / "视觉设定.md").read_text(encoding="utf-8")
|
|
1031
|
+
screenplay = (episode / "剧本.md").read_text(encoding="utf-8")
|
|
1032
|
+
scene_headings = SCENE_HEADING_RE.findall(screenplay)
|
|
1033
|
+
scenes = frozenset(scene_headings)
|
|
1034
|
+
if not scenes:
|
|
1035
|
+
errors.append(
|
|
1036
|
+
"剧本.md: 没有可解析的场景标题;场景标题写成 ## <场景 ID> 内|外 · 地点 · 时间"
|
|
1037
|
+
)
|
|
1038
|
+
if len(scene_headings) != len(scenes):
|
|
1039
|
+
errors.append("剧本.md: 场景 ID 重复")
|
|
1040
|
+
spoken_sources = (
|
|
1041
|
+
_spoken_key(screenplay),
|
|
1042
|
+
_spoken_key(visual),
|
|
1043
|
+
_spoken_key(storyboard),
|
|
1044
|
+
)
|
|
1045
|
+
unfilmed = _unfilmed_scenes(storyboard, scenes, errors)
|
|
1046
|
+
claimed_scenes: set = set()
|
|
793
1047
|
locks = _continuity_locks(visual, errors)
|
|
794
1048
|
visual_entries = _visual_entries(visual, errors)
|
|
795
1049
|
other_headings = {
|
|
@@ -797,8 +1051,12 @@ def validate_episode(episode: Path, project_root: Optional[Path] = None) -> list
|
|
|
797
1051
|
for match in OTHER_SETTING_HEADING_RE.finditer(visual)
|
|
798
1052
|
if match.group(1).strip() not in VISUAL_CATEGORIES
|
|
799
1053
|
}
|
|
1054
|
+
shots = _sections(storyboard, "SHOT")
|
|
800
1055
|
image_pairs = re.findall(r"^## (IMG-[A-Z0-9-]+) · (.+)$", images, re.MULTILINE)
|
|
801
1056
|
image_headings = dict(image_pairs)
|
|
1057
|
+
# A planned picture names the board or keyframe that already decides what it
|
|
1058
|
+
# depicts, so both indexes have to exist before any declaration is read.
|
|
1059
|
+
entries = EntryIndex(images=image_headings, shots=frozenset(shots))
|
|
802
1060
|
all_image_headings = re.findall(r"^## (IMG-[A-Z0-9-]+)\b", images, re.MULTILINE)
|
|
803
1061
|
if len(image_pairs) != len(all_image_headings):
|
|
804
1062
|
errors.append("图片提示词.md: IMG 标题必须包含中文名称")
|
|
@@ -823,7 +1081,9 @@ def validate_episode(episode: Path, project_root: Optional[Path] = None) -> list
|
|
|
823
1081
|
elif _is_no_external_reference(reference_value):
|
|
824
1082
|
pass
|
|
825
1083
|
elif "REF-" in reference_value:
|
|
826
|
-
_references(
|
|
1084
|
+
_references(
|
|
1085
|
+
reference_value, match.group(1), project_root, errors, entries
|
|
1086
|
+
)
|
|
827
1087
|
else:
|
|
828
1088
|
errors.append(
|
|
829
1089
|
f"{match.group(1)}: 参考必须声明无外部参考或使用完整 REF 语法"
|
|
@@ -833,7 +1093,6 @@ def validate_episode(episode: Path, project_root: Optional[Path] = None) -> list
|
|
|
833
1093
|
if image_prompt is None:
|
|
834
1094
|
errors.append(f"{match.group(1)}: 缺少唯一且非空的可复制提示词")
|
|
835
1095
|
|
|
836
|
-
shots = _sections(storyboard, "SHOT")
|
|
837
1096
|
motions = _sections(video, "MOTION")
|
|
838
1097
|
shot_ids = re.findall(r"^## (SHOT-[A-Z0-9-]+)\b", storyboard, re.MULTILINE)
|
|
839
1098
|
motion_ids = re.findall(r"^## (MOTION-[A-Z0-9-]+)\b", video, re.MULTILINE)
|
|
@@ -883,6 +1142,9 @@ def validate_episode(episode: Path, project_root: Optional[Path] = None) -> list
|
|
|
883
1142
|
|
|
884
1143
|
for shot_id, shot_body in shots.items():
|
|
885
1144
|
fields = _fields(shot_body, owner=shot_id, errors=errors)
|
|
1145
|
+
claimed_scenes.update(
|
|
1146
|
+
_shot_sources(fields.get("来源", ""), shot_id, scenes, errors)
|
|
1147
|
+
)
|
|
886
1148
|
image_value = fields.get("图片提示词项", "")
|
|
887
1149
|
if not image_value:
|
|
888
1150
|
errors.append(f"{shot_id}: 缺少图片提示词项字段")
|
|
@@ -902,7 +1164,7 @@ def validate_episode(episode: Path, project_root: Optional[Path] = None) -> list
|
|
|
902
1164
|
shot_input = fields.get("输入参考图", "")
|
|
903
1165
|
if not shot_input:
|
|
904
1166
|
errors.append(f"{shot_id}: 缺少输入参考图字段")
|
|
905
|
-
_references(shot_input, shot_id, project_root, errors)
|
|
1167
|
+
_references(shot_input, shot_id, project_root, errors, entries)
|
|
906
1168
|
|
|
907
1169
|
basis_value = fields.get("视觉依据", "")
|
|
908
1170
|
if not basis_value:
|
|
@@ -927,19 +1189,27 @@ def validate_episode(episode: Path, project_root: Optional[Path] = None) -> list
|
|
|
927
1189
|
continue
|
|
928
1190
|
motion_id, motion_body, copyable_prompt = motion
|
|
929
1191
|
motion_fields = _fields(motion_body, owner=motion_id, errors=errors)
|
|
1192
|
+
if copyable_prompt is not None:
|
|
1193
|
+
_check_spoken_lines(
|
|
1194
|
+
copyable_prompt, motion_id, spoken_sources, errors
|
|
1195
|
+
)
|
|
930
1196
|
motion_input = motion_fields.get("输入参考图", "")
|
|
931
|
-
_references(motion_input, motion_id, project_root, errors)
|
|
1197
|
+
_references(motion_input, motion_id, project_root, errors, entries)
|
|
932
1198
|
if _plain(motion_input) != _plain(shot_input):
|
|
933
1199
|
errors.append(f"{motion_id}: 输入参考图与 {shot_id} 不一致")
|
|
934
1200
|
|
|
935
1201
|
if _has_pending_references(shot_input):
|
|
936
1202
|
errors.append(f"{motion_id}: 仍有待补参考图,不能生成最终视频提示词")
|
|
937
1203
|
|
|
938
|
-
|
|
939
|
-
|
|
1204
|
+
# A `PLAN-...` slot is not a file, but the prompt is still written for
|
|
1205
|
+
# image-conditioned generation: the creator attaches those pictures at
|
|
1206
|
+
# generation time. What decides 生成方式 is whether the shot sends
|
|
1207
|
+
# pictures at all, not whether this suite can read them.
|
|
1208
|
+
sends_pictures = not _is_none(shot_input)
|
|
1209
|
+
expected_mode = "图生视频" if sends_pictures else "文生视频"
|
|
940
1210
|
if _plain(motion_fields.get("生成方式", "")) != expected_mode:
|
|
941
1211
|
errors.append(f"{motion_id}: 生成方式应为{expected_mode}")
|
|
942
|
-
if not
|
|
1212
|
+
if not sends_pictures:
|
|
943
1213
|
if not _is_explicit_text_to_video(shot_input):
|
|
944
1214
|
errors.append(
|
|
945
1215
|
f"{motion_id}: 无真实输入参考图时不能静默降级为文生视频;"
|
|
@@ -965,6 +1235,16 @@ def validate_episode(episode: Path, project_root: Optional[Path] = None) -> list
|
|
|
965
1235
|
anchor, motion_id, "静态视觉锚点", basis, visual_entries, errors
|
|
966
1236
|
)
|
|
967
1237
|
|
|
1238
|
+
for scene_id in scene_headings:
|
|
1239
|
+
if scene_id in claimed_scenes or scene_id in unfilmed:
|
|
1240
|
+
continue
|
|
1241
|
+
errors.append(
|
|
1242
|
+
f"分镜.md: 没有镜头承载场景 {scene_id};"
|
|
1243
|
+
"拍它就写进某一镜的来源,不拍就写进未拍场次并给出理由"
|
|
1244
|
+
)
|
|
1245
|
+
for scene_id in sorted(set(unfilmed) & claimed_scenes):
|
|
1246
|
+
errors.append(f"分镜.md: 场景 {scene_id} 既被镜头承载又记为未拍")
|
|
1247
|
+
|
|
968
1248
|
_check_language_designators(
|
|
969
1249
|
project_root, entries=visual_entries, errors=errors
|
|
970
1250
|
)
|
|
@@ -16,13 +16,16 @@ license: MIT
|
|
|
16
16
|
取出本次提示词,建立一个有边界的运行 job。creator-first job 的 `source` 必须指向拥有这条提示词的
|
|
17
17
|
当前 Markdown,`source_entry` 必须点名该文档允许的二级标题:`图片提示词.md` 用 `IMG-*`,
|
|
18
18
|
`视频提示词.md` 用 `MOTION-*`,`分镜.md` 用 `SHOT-*`(modality 为 `image`,正文取该镜的
|
|
19
|
-
`###
|
|
19
|
+
`### 冻结关键帧提示词`)。分镜这一路是本套件里唯一能把某一镜的起始画面渲染成文件的入口,但不是创作者获得起始帧的唯一办法——
|
|
20
|
+
在自己的工具里出图再放进项目同样成立,图片始终不进项目时走分镜的 `PLAN-...`;
|
|
20
21
|
产出落在 `剧集/<EP>/制作成果/images/` 后,分镜 owner 才能把它绑成 `用途:起始帧` 的 `REF-...`。存在真实参考图时,
|
|
21
22
|
还必须逐张填写 `reference_bindings` 的槽位、顺序、路径、中文名、用途以及允许/禁止控制范围;
|
|
22
23
|
内置视频 adapter 把这里的 `用途` 字段读成供应商自己的 role,只接受它公布的取值
|
|
23
24
|
(MiniMax 为 `first_frame`/`last_frame`/`reference_image`/`reference_video`/`reference_audio`,
|
|
24
25
|
Seedance 为三个 `reference_*`);带参考图却没有绑定的 job 会直接失败,不替它猜一个 role。
|
|
25
26
|
本地图片由内置 adapter 按 base64 data URI 直接送出,不需要自建上传服务。
|
|
27
|
+
条目的「输入参考图」写成 `PLAN-...` 时,那些图片在创作者自己的工具里,项目内没有文件可送:
|
|
28
|
+
`prepare` 直接失败并说明先把真实文件放进项目、改写成 `REF-...`,不拿计划当输入,也不静默丢掉参考。
|
|
26
29
|
`references` 可以省略并由绑定顺序生成,也可以作为相同顺序的显式镜像。输出放在
|
|
27
30
|
`剧集/<EP>/制作成果/`;这个 job 是生产工具的临时输入,不是第六份创作文档:
|
|
28
31
|
|
|
@@ -129,6 +129,13 @@ CREATOR_SOURCE_ENTRIES = {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
REF_SLOT_RE = re.compile(r"REF-[A-Z0-9][A-Z0-9-]{0,79}")
|
|
132
|
+
# A slot head, not a bare substring: a real reference file may legitimately be
|
|
133
|
+
# named `plan-sheet.png`, and that is not a creator-supplied declaration.
|
|
134
|
+
PLAN_SLOT_HEAD_RE = re.compile(r"PLAN-[A-Za-z0-9][A-Za-z0-9-]{0,79}(顺序:")
|
|
135
|
+
PLAN_SLOT_RE = re.compile(
|
|
136
|
+
r"PLAN-[A-Za-z0-9][A-Za-z0-9-]{0,79}(顺序:[1-9]\d*)· "
|
|
137
|
+
r"(?:IMG|SHOT)-[A-Za-z0-9-]+《[^》\n]+》([^)\n]*)"
|
|
138
|
+
)
|
|
132
139
|
SOURCE_ENTRY_RE = re.compile(r"[A-Z][A-Z0-9-]{1,99}")
|
|
133
140
|
REFERENCE_SUFFIX_RE = r"(?:png|jpe?g|webp)"
|
|
134
141
|
# 用途 is the creator-facing question "what does this picture decide here"; the
|
|
@@ -806,8 +813,12 @@ def _contains_ref_token(value: str) -> bool:
|
|
|
806
813
|
return "ref-" in value.casefold()
|
|
807
814
|
|
|
808
815
|
|
|
816
|
+
def _contains_plan_token(value: str) -> bool:
|
|
817
|
+
return "plan-" in value.casefold()
|
|
818
|
+
|
|
819
|
+
|
|
809
820
|
def _markdown_reference_bindings(
|
|
810
|
-
section: str, *, field_name: str
|
|
821
|
+
section: str, *, field_name: str, creator_supplied_ok: bool = False
|
|
811
822
|
) -> list[dict[str, Any]]:
|
|
812
823
|
lines = re.findall(
|
|
813
824
|
rf"^- {re.escape(field_name)}:(.+)$", section, re.MULTILINE
|
|
@@ -829,6 +840,21 @@ def _markdown_reference_bindings(
|
|
|
829
840
|
and re.fullmatch(r"无(?:外部参考)?(?:;[^\n]*)?。?", value)
|
|
830
841
|
):
|
|
831
842
|
return []
|
|
843
|
+
# A `PLAN-...` slot is a picture the creator attaches in their own tool, so
|
|
844
|
+
# this suite has no file to send. The rendering job that draws a shot's own
|
|
845
|
+
# start frame is unaffected -- it works from the frozen keyframe text, the
|
|
846
|
+
# way it already does for 「待补参考图」 -- so only the job that would have
|
|
847
|
+
# sent these pictures as model inputs refuses. Matched as a slot head rather
|
|
848
|
+
# than a bare substring, or a real file named `plan-sheet.png` would refuse.
|
|
849
|
+
if PLAN_SLOT_HEAD_RE.search(value):
|
|
850
|
+
if not creator_supplied_ok:
|
|
851
|
+
raise ValueError(
|
|
852
|
+
"source entry declares creator-supplied references (PLAN-...); "
|
|
853
|
+
"bind the real files as REF-... before producing"
|
|
854
|
+
)
|
|
855
|
+
value = PLAN_SLOT_RE.sub("", value).strip(";;。 ")
|
|
856
|
+
if not value:
|
|
857
|
+
return []
|
|
832
858
|
matches = list(REFERENCE_LINE_RE.finditer(value))
|
|
833
859
|
if not matches:
|
|
834
860
|
raise ValueError("source entry input-reference declaration is invalid")
|
|
@@ -869,7 +895,13 @@ def _verify_markdown_source(
|
|
|
869
895
|
_, heading, field_name = CREATOR_SOURCE_ENTRIES[source_path.name]
|
|
870
896
|
if _copyable_prompt(section, heading) != prompt.strip():
|
|
871
897
|
raise ValueError("job prompt does not match the selected source entry")
|
|
872
|
-
declared = _markdown_reference_bindings(
|
|
898
|
+
declared = _markdown_reference_bindings(
|
|
899
|
+
section,
|
|
900
|
+
field_name=field_name,
|
|
901
|
+
# 分镜.md is the entry that renders a shot's own start frame. Its
|
|
902
|
+
# reference field records creative intent, not this job's inputs.
|
|
903
|
+
creator_supplied_ok=source_path.name == "分镜.md",
|
|
904
|
+
)
|
|
873
905
|
comparable = [
|
|
874
906
|
{key: binding[key] for key in (
|
|
875
907
|
"slot_id",
|