@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.
Files changed (41) hide show
  1. package/README.md +10 -8
  2. package/lib/client.js +298 -87
  3. package/lib/client.js.map +4 -4
  4. package/lib/drama/manifest.json +34 -34
  5. package/lib/drama/skills/short-drama/SKILL.md +4 -1
  6. package/lib/drama/skills/short-drama/references/contract-and-ownership.md +2 -0
  7. package/lib/drama/skills/short-drama/references/creator-documents.md +30 -4
  8. package/lib/drama/skills/short-drama/references/creator-workflow.md +8 -2
  9. package/lib/drama/skills/short-drama/references/reference-roles.md +16 -2
  10. package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +328 -48
  11. package/lib/drama/skills/short-drama-produce/SKILL.md +4 -1
  12. package/lib/drama/skills/short-drama-produce/scripts/production_tool.py +34 -2
  13. package/lib/drama/skills/short-drama-storyboard/SKILL.md +27 -5
  14. package/lib/drama/skills/short-drama-storyboard/references/review-and-fixtures.md +2 -1
  15. package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +2 -2
  16. package/lib/drama/skills/short-drama-video-prompts/SKILL.md +11 -2
  17. package/lib/drama/skills/short-drama-video-prompts/assets/video-prompts.md +6 -1
  18. package/lib/drama/skills/short-drama-video-prompts/references/minimax-h3.md +19 -3
  19. package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +8 -2
  20. package/lib/drama/skills/short-drama-video-prompts/references/target-model-profile.md +1 -0
  21. package/lib/index.js +194 -62
  22. package/lib/index.js.map +4 -4
  23. package/lib/types/client/drama-production-view.d.ts +1 -0
  24. package/lib/types/client/drama-production-view.d.ts.map +1 -1
  25. package/lib/types/client/drama-production.d.ts +3 -0
  26. package/lib/types/client/drama-production.d.ts.map +1 -1
  27. package/lib/types/client/index.d.ts.map +1 -1
  28. package/lib/types/client/video-studio.d.ts +2 -1
  29. package/lib/types/client/video-studio.d.ts.map +1 -1
  30. package/lib/types/client/workbench-presence.d.ts +29 -0
  31. package/lib/types/client/workbench-presence.d.ts.map +1 -0
  32. package/lib/types/drama-adapters.d.ts +65 -0
  33. package/lib/types/drama-adapters.d.ts.map +1 -0
  34. package/lib/types/host-python.d.ts +19 -0
  35. package/lib/types/host-python.d.ts.map +1 -0
  36. package/lib/types/index.d.ts +1 -0
  37. package/lib/types/index.d.ts.map +1 -1
  38. package/lib/types/skill-provider.d.ts +1 -1
  39. package/lib/types/skill-provider.d.ts.map +1 -1
  40. package/lib/types/workspace-route.d.ts.map +1 -1
  41. 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
- return not _contains_ref_token(value) and bool(
156
- re.fullmatch(r"无(?:([^)]+))?", _plain(value))
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 _contains_ref_token(value) and bool(
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
- def _references(value: str, owner: str, project_root: Path, errors: list[str]) -> None:
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 = list(REF_RE.finditer(value))
291
+ matches = _slot_matches(reference_value)
213
292
  cursor = 0
214
293
  separators_are_valid = True
215
- for index, match in enumerate(matches):
216
- if reference_value[cursor : match.start()] != ("" if index == 0 else ";"):
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 = match.end()
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) != len(re.findall(r"\bREF-[A-Z0-9-]+\b", value))
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
- refs = [(match.group(1), int(match.group(2)), match.group(3)) for match in matches]
238
- slots = [item[0] for item in refs]
239
- orders = [item[1] for item in refs]
240
- paths = [item[2] for item in refs]
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}: REF 槽位重复")
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}: REF 顺序必须唯一且从 1 连续编号")
247
- purposes = [
248
- match.group(5).strip() if match.group(5) else "" for match in matches
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}: REF 缺少用途: {slot};用途只能是"
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}: REF 用途不在允许集合内: {slot}({purpose});"
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 match, (_, _, raw_path) in zip(matches, refs):
270
- label, may_control, must_not_control = (
271
- match.group(4),
272
- match.group(6),
273
- match.group(7),
274
- )
275
- if not _portable_path(raw_path):
276
- errors.append(f"{owner}: REF 路径不是安全的项目相对路径: {raw_path}")
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
- reference_path = project_root / raw_path
279
- if not _inside(reference_path, project_root):
280
- errors.append(f"{owner}: REF 路径越出项目根目录: {raw_path}")
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}: REF 缺少中文名称: {match.group(1)}")
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}: REF 必须同时声明控制与不得控制: {match.group(1)}")
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}: REF 控制与不得控制范围冲突: {match.group(1)}")
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(reference_value, match.group(1), project_root, errors)
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
- has_real_image = not _is_none(shot_input)
939
- expected_mode = "图生视频" if has_real_image else "文生视频"
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 has_real_image:
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(section, field_name=field_name)
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",