@oh-story/dsh 0.1.18 → 0.1.19

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 (125) hide show
  1. package/README.md +12 -12
  2. package/lib/drama/manifest.json +77 -247
  3. package/lib/drama/skills/short-drama/SKILL.md +9 -1
  4. package/lib/drama/skills/short-drama/references/creator-documents.md +51 -23
  5. package/lib/drama/skills/short-drama/references/reference-roles.md +1 -1
  6. package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +442 -129
  7. package/lib/drama/skills/short-drama/scripts/project_tool.py +54 -1
  8. package/lib/drama/skills/short-drama-assets/SKILL.md +2 -6
  9. package/lib/drama/skills/short-drama-assets/references/continuity-lock.md +9 -3
  10. package/lib/drama/skills/short-drama-assets/references/era-anchors.md +3 -3
  11. package/lib/drama/skills/short-drama-assets/references/location-and-view.md +4 -0
  12. package/lib/drama/skills/short-drama-develop/references/adaptation-craft.md +2 -2
  13. package/lib/drama/skills/short-drama-develop/references/episode-design.md +19 -4
  14. package/lib/drama/skills/short-drama-develop/references/genre-cards//347/263/273/347/273/237/346/265/201.md +1 -1
  15. package/lib/drama/skills/short-drama-edit/SKILL.md +1 -1
  16. package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +28 -6
  17. package/lib/drama/skills/short-drama-edit/scripts/selftest.py +1 -1
  18. package/lib/drama/skills/short-drama-image-prompts/SKILL.md +1 -5
  19. package/lib/drama/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +2 -2
  20. package/lib/drama/skills/short-drama-produce/SKILL.md +2 -0
  21. package/lib/drama/skills/short-drama-review/SKILL.md +0 -4
  22. package/lib/drama/skills/short-drama-review/references/production-quality-gates.md +1 -1
  23. package/lib/drama/skills/short-drama-storyboard/SKILL.md +9 -9
  24. package/lib/drama/skills/short-drama-storyboard/references/blocking-playbooks.md +43 -13
  25. package/lib/drama/skills/short-drama-storyboard/references/keyframe-craft.md +5 -5
  26. package/lib/drama/skills/short-drama-storyboard/references/production-shot-grammar.md +5 -5
  27. package/lib/drama/skills/short-drama-storyboard/references/shot-craft.md +27 -3
  28. package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +2 -1
  29. package/lib/drama/skills/short-drama-video-prompts/SKILL.md +6 -8
  30. package/lib/drama/skills/short-drama-video-prompts/references/camera-audio-continuity.md +16 -1
  31. package/lib/drama/skills/short-drama-video-prompts/references/delivery-profile.md +23 -6
  32. package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +2 -2
  33. package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +8 -1
  34. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.0.md +41 -2
  35. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +1 -1
  36. package/lib/drama/skills/short-drama-video-prompts/references/target-model-profile.md +5 -1
  37. package/lib/drama/skills/short-drama-write/SKILL.md +7 -3
  38. package/lib/drama/skills/short-drama-write/references/screenplay-format.md +1 -1
  39. package/lib/drama/skills/short-drama-write/scripts/duration_estimate.py +94 -1
  40. package/lib/drama/skills/short-drama-write/scripts/screenplay_index.py +13 -4
  41. package/lib/drama/skills/short-drama-write/scripts/selftest.py +3 -77
  42. package/lib/index.js +2 -2
  43. package/lib/index.js.map +1 -1
  44. package/lib/novel-to-game/README.md +20 -34
  45. package/lib/novel-to-game/README_ZH.md +20 -34
  46. package/lib/novel-to-game/VERSION +1 -1
  47. package/lib/novel-to-game/examples/jin-ping-mei/build/BUILD_BRIEF.md +2 -6
  48. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/audio.js +33 -39
  49. package/lib/novel-to-game/examples/jin-ping-mei/qa/README.md +2 -2
  50. package/lib/novel-to-game/manifest.json +46 -46
  51. package/lib/novel-to-game/skills/game-art-direction/SKILL.md +1 -1
  52. package/lib/novel-to-game/skills/game-build/references/baked-sprite-workflow.md +2 -3
  53. package/lib/novel-to-game/skills/game-build/references/blender-asset-workflow.md +6 -7
  54. package/lib/novel-to-game/skills/game-build/references/build-brief-contract.md +2 -2
  55. package/lib/novel-to-game/skills/game-build/references/generative-media-pipeline.md +1 -4
  56. package/lib/novel-to-game/skills/game-build/references/playable-model-contract.md +1 -3
  57. package/lib/novel-to-game/skills/game-build/references/tts-production-contract.md +1 -3
  58. package/lib/novel-to-game/skills/game-concept/SKILL.md +1 -1
  59. package/lib/novel-to-game/skills/game-qa/SKILL.md +3 -4
  60. package/lib/novel-to-game/skills/game-qa/references/qa-contract.md +1 -1
  61. package/lib/novel-to-game/skills/game-qa/references/test-design-method.md +3 -2
  62. package/lib/novel-to-game/skills/novel-game-analyze/SKILL.md +13 -14
  63. package/lib/novel-to-game/skills/novel-game-analyze/references/gameability-protocol.md +1 -2
  64. package/lib/novel-to-game/skills/novel-to-game/SKILL.md +4 -4
  65. package/lib/novel-to-game/skills/novel-to-game/references/intake-method.md +2 -3
  66. package/lib/novel-to-game/skills/novel-to-game/references/pipeline-contract.md +0 -8
  67. package/lib/oh-story/manifest.json +49 -49
  68. package/lib/oh-story/skills/story/SKILL.md +2 -2
  69. package/lib/oh-story/skills/story/VERSION +1 -1
  70. package/lib/oh-story/skills/story-deslop/SKILL.md +2 -2
  71. package/lib/oh-story/skills/story-import/SKILL.md +5 -5
  72. package/lib/oh-story/skills/story-import/references/deep-analysis.md +1 -1
  73. package/lib/oh-story/skills/story-import/references/import-report.md +1 -1
  74. package/lib/oh-story/skills/story-import/references/import-tracking.md +1 -1
  75. package/lib/oh-story/skills/story-import/references/outline-reverse.md +1 -1
  76. package/lib/oh-story/skills/story-import/scripts/tracking_commit.py +6 -1
  77. package/lib/oh-story/skills/story-long-analyze/SKILL.md +2 -2
  78. package/lib/oh-story/skills/story-long-write/SKILL.md +2 -2
  79. package/lib/oh-story/skills/story-long-write/references/agent-calls.md +3 -3
  80. package/lib/oh-story/skills/story-long-write/references/tracking-transaction.md +2 -2
  81. package/lib/oh-story/skills/story-long-write/scripts/tracking_commit.py +6 -1
  82. package/lib/oh-story/skills/story-review/SKILL.md +8 -7
  83. package/lib/oh-story/skills/story-review/references/agent-prompts.md +7 -7
  84. package/lib/oh-story/skills/story-review/references/tracking-transaction.md +2 -2
  85. package/lib/oh-story/skills/story-review/scripts/tracking_commit.py +6 -1
  86. package/lib/oh-story/skills/story-setup/SKILL.md +6 -6
  87. package/lib/oh-story/skills/story-short-write/SKILL.md +2 -2
  88. package/lib/oh-story/skills/story-short-write/references/agent-calls.md +3 -3
  89. package/lib/oh-story/skills/story-short-write/references/workflow-design.md +2 -2
  90. package/lib/oh-story/skills/story-short-write/references/workflow-revision.md +1 -1
  91. package/package.json +21 -21
  92. package/lib/drama/skills/short-drama-assets/assets/character-look.example.jsonl +0 -3
  93. package/lib/drama/skills/short-drama-assets/assets/continuity.example.jsonl +0 -4
  94. package/lib/drama/skills/short-drama-assets/assets/decisions.example.jsonl +0 -5
  95. package/lib/drama/skills/short-drama-assets/assets/location-view.example.jsonl +0 -3
  96. package/lib/drama/skills/short-drama-assets/assets/occurrences.example.jsonl +0 -5
  97. package/lib/drama/skills/short-drama-assets/assets/prop-state.example.jsonl +0 -3
  98. package/lib/drama/skills/short-drama-assets/examples/minimal/characters.jsonl +0 -2
  99. package/lib/drama/skills/short-drama-assets/examples/minimal/looks.jsonl +0 -2
  100. package/lib/drama/skills/short-drama-assets/scripts/asset_check.py +0 -267
  101. package/lib/drama/skills/short-drama-assets/scripts/selftest.py +0 -89
  102. package/lib/drama/skills/short-drama-image-prompts/examples/minimal-image-prompt-specs.jsonl +0 -2
  103. package/lib/drama/skills/short-drama-image-prompts/scripts/image_prompt_check.py +0 -443
  104. package/lib/drama/skills/short-drama-image-prompts/scripts/selftest.py +0 -95
  105. package/lib/drama/skills/short-drama-review/assets/finding-template.jsonl +0 -2
  106. package/lib/drama/skills/short-drama-review/assets/supersession-decision.example.json +0 -42
  107. package/lib/drama/skills/short-drama-review/assets/verdict-template.json +0 -32
  108. package/lib/drama/skills/short-drama-review/examples/minimal-findings.jsonl +0 -2
  109. package/lib/drama/skills/short-drama-review/examples/minimal-verdict.json +0 -34
  110. package/lib/drama/skills/short-drama-review/scripts/review_check.py +0 -325
  111. package/lib/drama/skills/short-drama-review/scripts/selftest.py +0 -87
  112. package/lib/drama/skills/short-drama-storyboard/assets/coverage-audition.example.jsonl +0 -2
  113. package/lib/drama/skills/short-drama-storyboard/assets/revision-lineage.fragment.json +0 -12
  114. package/lib/drama/skills/short-drama-storyboard/assets/scene-visual-plan.example.jsonl +0 -2
  115. package/lib/drama/skills/short-drama-storyboard/scripts/selftest.py +0 -191
  116. package/lib/drama/skills/short-drama-storyboard/scripts/storyboard_check.py +0 -824
  117. package/lib/drama/skills/short-drama-video-prompts/assets/coverage-scope.fragment.json +0 -21
  118. package/lib/drama/skills/short-drama-video-prompts/assets/motion-terminal.example.jsonl +0 -2
  119. package/lib/drama/skills/short-drama-video-prompts/assets/performance.fragment.json +0 -38
  120. package/lib/drama/skills/short-drama-video-prompts/examples/minimal-music-specs.jsonl +0 -2
  121. package/lib/drama/skills/short-drama-video-prompts/scripts/container_check.py +0 -329
  122. package/lib/drama/skills/short-drama-video-prompts/scripts/motion_timing_check.py +0 -451
  123. package/lib/drama/skills/short-drama-video-prompts/scripts/music_spec_check.py +0 -327
  124. package/lib/drama/skills/short-drama-video-prompts/scripts/selftest.py +0 -82
  125. package/lib/drama/skills/short-drama-write/scripts/voice_sheet_check.py +0 -416
@@ -8,7 +8,7 @@ import json
8
8
  import re
9
9
  import sys
10
10
  from pathlib import Path, PurePosixPath
11
- from collections.abc import Iterable
11
+ from collections.abc import Iterable, Mapping
12
12
  from typing import NamedTuple, Optional
13
13
 
14
14
 
@@ -28,7 +28,8 @@ REQUIRED_DOCUMENTS = (
28
28
  "分镜.md",
29
29
  "视频提示词.md",
30
30
  )
31
- SECTION_RE = re.compile(r"^## ((?:SHOT|MOTION)-[A-Z0-9-]+)\b", re.MULTILINE)
31
+ SECTION_RE = re.compile(r"^## ((?:SHOT|MOTION|GROUP)-[A-Z0-9-]+)\b", re.MULTILINE)
32
+ ANY_SECTION_RE = re.compile(r"^## ", re.MULTILINE)
32
33
  IMG_RE = re.compile(r"\b(IMG-[A-Z0-9-]+)《([^》]+)》(控制:([^)]+))")
33
34
  # 用途 is optional in the pattern on purpose: a declaration written before this
34
35
  # field existed still parses, so the creator gets "REF 缺少用途" instead of the
@@ -105,7 +106,8 @@ SCREENPLAY_DIALOGUE_RE = re.compile(
105
106
  r"(?:([^()\r\n]+))?:(?P<text>\S[\s\S]*)$"
106
107
  )
107
108
  SCREENPLAY_VOICE_TAG_RE = re.compile(
108
- r"^\[(?:VO|OS)\]\s*(?P<speaker>[^\s:():\[\]#]{1,40}):(?P<text>\S[\s\S]*)$"
109
+ r"^\[(?:VO|OS)\]\s*(?P<speaker>[^\s:():\[\]#]{1,40})"
110
+ r"(?:([^()\r\n]+))?:(?P<text>\S[\s\S]*)$"
109
111
  )
110
112
  # A later line of a paragraph that starts another block (the index's
111
113
  # ASCII_DIALOGUE_RE, ANY_TAG_RE and MALFORMED_TAG_RE): the paragraph is missing
@@ -128,6 +130,8 @@ SCENE_HEADING_RE = re.compile(
128
130
  r"^## (" + SCENE_ID_PATTERN + r")\b", re.MULTILINE
129
131
  )
130
132
  SCENE_ID_RE = re.compile(SCENE_ID_PATTERN)
133
+ # A quoted span of the source line, 「…」 or 『…』.
134
+ QUOTE_RE = re.compile(r"「[^」]*」|『[^』]*』")
131
135
  # A scene the storyboard decided not to film. SHT-01 has always allowed the
132
136
  # omission and always required a reason; without a place to write it the
133
137
  # omission and an oversight look identical in the finished document.
@@ -189,6 +193,7 @@ OFFSCREEN_ENTRY_RE = re.compile(
189
193
  # line -- any list marker, any leading whitespace -- is captured here and then
190
194
  # has to parse, so a creator who indents the bullet under 识别锚点 gets an error
191
195
  # instead of silent non-enforcement.
196
+ IN_FRAME_SCOPE = "入画"
192
197
  LOCK_LINE_RE = re.compile(r"^[ \t\u3000]*[-*+][ \t\u3000]*连续性锁[::].*$", re.MULTILINE)
193
198
  LOCK_RE = re.compile(
194
199
  r"^[ \t\u3000]*[-*+][ \t\u3000]*连续性锁:(LOCK-[A-Z0-9-]+)《([^》\n]+)》"
@@ -214,19 +219,20 @@ NEGATION_RE = re.compile(
214
219
 
215
220
 
216
221
  def _sections(document: str, kind: str) -> dict[str, str]:
217
- matches = [
218
- match
219
- for match in SECTION_RE.finditer(document)
220
- if match.group(1).startswith(f"{kind}-")
221
- ]
222
- return {
223
- match.group(1): document[
224
- match.start() : matches[index + 1].start()
225
- if index + 1 < len(matches)
226
- else None
227
- ]
228
- for index, match in enumerate(matches)
229
- }
222
+ """Each `## <kind>-...` entry, running up to the next second-level heading.
223
+
224
+ The entry stops at *any* `## ` heading, not only the next entry of its own
225
+ kind: a report or a delivery group written after the last shot would
226
+ otherwise be read as that shot's fields.
227
+ """
228
+ headings = [match.start() for match in ANY_SECTION_RE.finditer(document)]
229
+ sections: dict[str, str] = {}
230
+ for match in SECTION_RE.finditer(document):
231
+ if not match.group(1).startswith(f"{kind}-"):
232
+ continue
233
+ end = next((start for start in headings if start > match.start()), None)
234
+ sections.setdefault(match.group(1), document[match.start() : end])
235
+ return sections
230
236
 
231
237
 
232
238
  def _fields(section: str, *, owner: str, errors: list[str]) -> dict[str, str]:
@@ -261,7 +267,7 @@ def _contains_slot_token(value: str) -> bool:
261
267
 
262
268
  def _is_none(value: str) -> bool:
263
269
  # `[^)]+` would stop at the first closing bracket, so a gap list that names
264
- # an entry in brackets -- 「江晨人物图(IMG-...)」 -- fell through to the REF
270
+ # an entry in brackets -- 「许棠人物图(IMG-...)」 -- fell through to the REF
265
271
  # syntax error, whose obvious repair is deleting the gap record. That is the
266
272
  # silent downgrade this contract exists to prevent, so the note takes any
267
273
  # content as long as it is bracketed.
@@ -358,8 +364,10 @@ def _inside(path: Path, root: Path) -> bool:
358
364
  class EntryIndex(NamedTuple):
359
365
  """The document entries a `PLAN-...` locator is allowed to name."""
360
366
 
361
- images: dict[str, str]
362
- shots: frozenset
367
+ # None when that document is not part of a partial check: its locators
368
+ # cannot be resolved, so they are not reported as missing either.
369
+ images: Optional[dict[str, str]]
370
+ shots: Optional[frozenset]
363
371
 
364
372
 
365
373
  def _slot_matches(value: str) -> list:
@@ -379,7 +387,7 @@ def _slot_matches(value: str) -> list:
379
387
  def _references(
380
388
  value: str,
381
389
  owner: str,
382
- project_root: Path,
390
+ project_root: Optional[Path],
383
391
  errors: list[str],
384
392
  entries: Optional[EntryIndex] = None,
385
393
  ) -> frozenset:
@@ -403,7 +411,7 @@ def _references(
403
411
  trailing = reference_value[cursor:]
404
412
  # Counted as slot heads on the same text the slots were matched in. A bare
405
413
  # token count would read the `ref-` in a real path like
406
- # `输入/参考图/ref-江晨.png` as a second slot and reject a correct declaration.
414
+ # `输入/参考图/ref-许棠.png` as a second slot and reject a correct declaration.
407
415
  declared = len(SLOT_HEAD_RE.findall(reference_value))
408
416
  kinds = frozenset(item[2] for item in matches)
409
417
  if (
@@ -480,12 +488,18 @@ def _references(
480
488
 
481
489
 
482
490
  def _check_project_file(
483
- locator: str, owner: str, project_root: Path, errors: list[str]
491
+ locator: str, owner: str, project_root: Optional[Path], errors: list[str]
484
492
  ) -> None:
485
- """A REF locator names a readable file inside the project, or it is a defect."""
493
+ """A REF locator names a readable file inside the project, or it is a defect.
494
+
495
+ Without a project root (a reader that only has the documents' text) the
496
+ path's shape is still checked; whether the file exists is not.
497
+ """
486
498
  if not _portable_path(locator):
487
499
  errors.append(f"{owner}: REF 路径不是安全的项目相对路径: {locator}")
488
500
  return
501
+ if project_root is None:
502
+ return
489
503
  reference_path = project_root / locator
490
504
  if not _inside(reference_path, project_root):
491
505
  errors.append(f"{owner}: REF 路径越出项目根目录: {locator}")
@@ -630,23 +644,29 @@ class VoiceContext(NamedTuple):
630
644
  dialogue: list[SpokenLine]
631
645
  # Every name a clause may use for a 人物 entry: its name and 画面代称.
632
646
  names: dict[str, list[str]]
647
+ # False when that document is not part of a partial check: the checks
648
+ # that read it are skipped instead of reporting every name as unknown.
649
+ has_visual: bool = True
650
+ has_screenplay: bool = True
633
651
 
634
652
 
635
653
  def _audio_references(
636
654
  value: str,
637
655
  owner: str,
638
- project_root: Path,
656
+ project_root: Optional[Path],
639
657
  errors: list[str],
640
658
  *,
641
659
  image_slots: list[str],
642
660
  prompt: Optional[str],
643
661
  voices: VoiceContext,
644
- scenes: list[str],
662
+ scenes: Optional[list[str]],
645
663
  ) -> None:
646
664
  """Validate one MOTION's 参考音频 line against the character it names."""
647
665
  if _is_none(value):
648
666
  return
649
667
  # A line said in another scene is not evidence for this shot.
668
+ # None: no storyboard says which scenes this MOTION films, so who speaks a
669
+ # line cannot be told apart and attribution is skipped.
650
670
  in_scope = [line for line in voices.dialogue if not scenes or line.scene in scenes]
651
671
  quotes = _quote_speakers(prompt, in_scope, voices.names)
652
672
  plain = value.strip()
@@ -685,13 +705,15 @@ def _audio_references(
685
705
  errors.append(f"{owner}: REF 控制与不得控制范围冲突: {slot}")
686
706
  if not {"台词", "语气", "情绪"} <= prohibited:
687
707
  errors.append(f"{owner}: 参考音频不得控制至少包含台词、语气、情绪: {slot}")
688
- if name not in voices.characters:
708
+ if voices.has_visual and name not in voices.characters:
689
709
  errors.append(
690
710
  f"{owner}: 参考音频的角色不是《视觉设定.md》里的人物条目: {name}"
691
711
  )
692
712
  continue
693
713
  shared = [span for span, speakers in quotes if name in speakers]
694
- if any(speakers == {name} for _, speakers in quotes):
714
+ if scenes is None or not (voices.has_screenplay and voices.has_visual) or any(
715
+ speakers == {name} for _, speakers in quotes
716
+ ):
695
717
  pass
696
718
  elif shared:
697
719
  others = sorted(
@@ -707,6 +729,8 @@ def _audio_references(
707
729
  f"{owner}: 人物「{name}」在本镜可复制提示词里没有说出《剧本.md》记在其名下的台词;"
708
730
  "不说话的人物不绑定参考音频"
709
731
  )
732
+ if not voices.has_visual:
733
+ continue
710
734
  record = voices.records.get(name)
711
735
  recorded = VOICE_RECORD_PATH_RE.match(record.strip()) if record else None
712
736
  if record is None:
@@ -744,11 +768,15 @@ def _plan_locator(
744
768
  if entries is None:
745
769
  return
746
770
  if locator.startswith("IMG-"):
771
+ if entries.images is None:
772
+ return
747
773
  if locator not in entries.images:
748
774
  errors.append(f"{owner}: PLAN 指向不存在的 IMG 条目: {locator}")
749
775
  elif label != entries.images[locator]:
750
776
  errors.append(f"{owner}: PLAN 中文名称与 IMG 标题不一致: {slot}")
751
777
  elif locator.startswith("SHOT-"):
778
+ if entries.shots is None:
779
+ return
752
780
  if locator not in entries.shots:
753
781
  errors.append(f"{owner}: PLAN 指向不存在的 SHOT 条目: {locator}")
754
782
  elif purpose == "起始帧":
@@ -917,8 +945,8 @@ def _named_entries(
917
945
  ) -> set[tuple[str, str]]:
918
946
  """Which declared entries does this frozen keyframe actually call by name?
919
947
 
920
- Longer designators win: a keyframe that shows 「江晨手机」 names the prop, and
921
- the 人物「江晨」 substring inside it is not a second, unrelated claim.
948
+ Longer designators win: a keyframe that shows 「许棠手机」 names the prop, and
949
+ the 人物「许棠」 substring inside it is not a second, unrelated claim.
922
950
  """
923
951
  haystack = re.sub(r"\s+", " ", prompt).strip()
924
952
  if fold_case:
@@ -1125,13 +1153,26 @@ class ContinuityLock(NamedTuple):
1125
1153
  surface: str
1126
1154
  shots: list[str]
1127
1155
  images: list[str]
1156
+ # The 人物/造型/地点/道具 entry the lock line sits under. `镜头:入画` scopes
1157
+ # the lock to every shot whose 视觉依据 lists that entry, so the lock can
1158
+ # be written before any shot exists.
1159
+ entry: Optional[tuple[str, str]] = None
1128
1160
 
1129
1161
 
1130
1162
  def _continuity_locks(document: str, errors: list[str]) -> list[ContinuityLock]:
1131
1163
  """Parse the declared continuity locks of one 视觉设定.md."""
1132
1164
  locks: list[ContinuityLock] = []
1133
1165
  seen: set[str] = set()
1134
- for line in LOCK_LINE_RE.findall(document):
1166
+ sections = [match.start() for match in ANY_SECTION_RE.finditer(document)]
1167
+ for found in LOCK_LINE_RE.finditer(document):
1168
+ line = found.group(0)
1169
+ # Only the section the line actually sits in owns it; a lock under a
1170
+ # report or note heading belongs to no entry.
1171
+ start = next((at for at in reversed(sections) if at < found.start()), None)
1172
+ owner = (
1173
+ VISUAL_SETTING_HEADING_RE.match(document, start) if start is not None else None
1174
+ )
1175
+ entry = (owner.group(1), owner.group(2).strip()) if owner else None
1135
1176
  match = LOCK_RE.match(line)
1136
1177
  if match is None:
1137
1178
  errors.append(
@@ -1155,9 +1196,27 @@ def _continuity_locks(document: str, errors: list[str]) -> list[ContinuityLock]:
1155
1196
  if not shots:
1156
1197
  errors.append(f"{lock_id}: 连续性锁缺少镜头范围")
1157
1198
  continue
1199
+ malformed = [
1200
+ item
1201
+ for item in shots
1202
+ if item not in ("全集", IN_FRAME_SCOPE) and not re.fullmatch(r"SHOT-[A-Z0-9-]+", item)
1203
+ ]
1204
+ if malformed:
1205
+ errors.append(
1206
+ f"{lock_id}: 连续性锁的镜头范围只能写入画、全集或 SHOT-... 列表: {'、'.join(malformed)}"
1207
+ )
1208
+ continue
1158
1209
  if "全集" in shots and len(shots) != 1:
1159
1210
  errors.append(f"{lock_id}: 连续性锁的镜头范围不能把全集与具体镜头混写")
1160
1211
  continue
1212
+ if IN_FRAME_SCOPE in shots and len(shots) != 1:
1213
+ errors.append(f"{lock_id}: 连续性锁的镜头范围写「入画」时不能再列其他镜头")
1214
+ continue
1215
+ if shots == [IN_FRAME_SCOPE] and entry is None:
1216
+ errors.append(
1217
+ f"{lock_id}: 镜头范围写「入画」的锁必须写在某个人物、造型、地点或道具条目下"
1218
+ )
1219
+ continue
1161
1220
  images: list[str] = []
1162
1221
  if image_scope is not None and not _is_none(image_scope):
1163
1222
  images = _unique(
@@ -1168,17 +1227,19 @@ def _continuity_locks(document: str, errors: list[str]) -> list[ContinuityLock]:
1168
1227
  if any(not item.startswith("IMG-") for item in images):
1169
1228
  errors.append(f"{lock_id}: 连续性锁的图片提示词项必须使用 IMG- ID")
1170
1229
  continue
1171
- locks.append(ContinuityLock(lock_id, surface, shots, images))
1230
+ locks.append(ContinuityLock(lock_id, surface, shots, images, entry))
1172
1231
  return locks
1173
1232
 
1174
1233
 
1175
- def _prompt_language(project_root: Path) -> str:
1234
+ def _prompt_language(project_root: Optional[Path]) -> str:
1176
1235
  """The language a copyable prompt body is written in.
1177
1236
 
1178
1237
  Mirrors the skills' own routing: the project's declared prompt language,
1179
1238
  falling back to `en` when there is no `short-drama.json` -- which is what
1180
1239
  the storyboard skill tells the keyframe author to assume.
1181
1240
  """
1241
+ if project_root is None:
1242
+ return ""
1182
1243
  configuration = project_root / "short-drama.json"
1183
1244
  if not configuration.is_file():
1184
1245
  return "en"
@@ -1204,7 +1265,7 @@ def _prompt_language(project_root: Path) -> str:
1204
1265
 
1205
1266
 
1206
1267
  def _check_language_designators(
1207
- project_root: Path, *, entries: list[VisualEntry], errors: list[str]
1268
+ project_root: Optional[Path], *, entries: list[VisualEntry], errors: list[str]
1208
1269
  ) -> None:
1209
1270
  """Every character must be nameable in the prompt body's language.
1210
1271
 
@@ -1217,7 +1278,9 @@ def _check_language_designators(
1217
1278
  rounds: a document missing 视觉依据 got one error, and only after fixing it
1218
1279
  did the missing 画面代称 appear.
1219
1280
  """
1220
- if _prompt_language(project_root).casefold().startswith("zh"):
1281
+ if project_root is None or _prompt_language(project_root).casefold().startswith("zh"):
1282
+ # Without a project root the prompt language is unknown; validate_documents
1283
+ # promises to skip what needs the disk rather than guess a language.
1221
1284
  return
1222
1285
  for entry in entries:
1223
1286
  if entry.category != "人物":
@@ -1244,25 +1307,51 @@ def _declared_seconds(value: str) -> Optional[float]:
1244
1307
  def _check_continuity_locks(
1245
1308
  locks: list[ContinuityLock],
1246
1309
  *,
1247
- shots: dict[str, str],
1310
+ shots: Optional[dict[str, str]],
1248
1311
  motion_by_shot: dict[str, tuple[str, str, Optional[str]]],
1249
- image_prompts: dict[str, Optional[str]],
1312
+ image_prompts: Optional[dict[str, Optional[str]]],
1250
1313
  errors: list[str],
1314
+ in_frame: Optional[dict[str, set[tuple[str, str]]]] = None,
1315
+ groups: Optional[dict[str, tuple[list[str], Optional[str]]]] = None,
1251
1316
  ) -> None:
1252
- """Require every declared lock surface to be present where it was scoped."""
1317
+ """Require every declared lock surface to be present where it was scoped.
1318
+
1319
+ ``shots`` or ``image_prompts`` is None when that document is not part of a
1320
+ partial check; that half of the lock is skipped.
1321
+ """
1253
1322
  for lock in locks:
1254
1323
  lock_id = lock.lock_id
1255
1324
  surface = lock.surface
1256
- targets = sorted(shots) if lock.shots == ["全集"] else lock.shots
1325
+ if shots is None:
1326
+ # Without 分镜.md: explicit shots and 全集 still reach the MOTION and
1327
+ # GROUP bodies through each MOTION's 分镜 field; 入画 needs the
1328
+ # storyboard's 视觉依据 and waits for it.
1329
+ if lock.shots == ["全集"]:
1330
+ targets: list[str] = sorted(motion_by_shot)
1331
+ elif lock.shots == [IN_FRAME_SCOPE]:
1332
+ targets = []
1333
+ else:
1334
+ targets = lock.shots
1335
+ elif lock.shots == ["全集"]:
1336
+ targets = sorted(shots)
1337
+ elif lock.shots == [IN_FRAME_SCOPE]:
1338
+ targets = sorted(
1339
+ shot_id
1340
+ for shot_id, declared in (in_frame or {}).items()
1341
+ if lock.entry in declared
1342
+ )
1343
+ else:
1344
+ targets = lock.shots
1257
1345
  for shot_id in targets:
1258
- if shot_id not in shots:
1259
- errors.append(f"{lock_id}: 连续性锁指向不存在的镜头: {shot_id}")
1260
- continue
1261
- keyframe = _copyable_prompt(shots[shot_id], heading=r"冻结关键帧提示词")
1262
- if keyframe is None:
1263
- errors.append(f"{lock_id}: {shot_id} 缺少可读的冻结关键帧提示词")
1264
- elif not _carries_surface(keyframe, surface):
1265
- errors.append(f"{lock_id}: {shot_id} 冻结关键帧提示词缺少锁面")
1346
+ if shots is not None:
1347
+ if shot_id not in shots:
1348
+ errors.append(f"{lock_id}: 连续性锁指向不存在的镜头: {shot_id}")
1349
+ continue
1350
+ keyframe = _copyable_prompt(shots[shot_id], heading=r"冻结关键帧提示词")
1351
+ if keyframe is None:
1352
+ errors.append(f"{lock_id}: {shot_id} 缺少可读的冻结关键帧提示词")
1353
+ elif not _carries_surface(keyframe, surface):
1354
+ errors.append(f"{lock_id}: {shot_id} 冻结关键帧提示词缺少锁面")
1266
1355
  motion = motion_by_shot.get(shot_id)
1267
1356
  if motion is None:
1268
1357
  continue
@@ -1271,7 +1360,16 @@ def _check_continuity_locks(
1271
1360
  copyable_prompt, surface
1272
1361
  ):
1273
1362
  errors.append(f"{lock_id}: {motion_id} 可复制提示词缺少锁面")
1363
+ # A delivery group's body is what is actually submitted, so a lock that
1364
+ # covers any member shot has to reach it too.
1365
+ for group_id, (members, group_prompt) in (groups or {}).items():
1366
+ if group_prompt is None or not set(members) & set(targets):
1367
+ continue
1368
+ if not _carries_surface(group_prompt, surface):
1369
+ errors.append(f"{lock_id}: {group_id} 可复制提示词缺少锁面")
1274
1370
  for image_id in lock.images:
1371
+ if image_prompts is None:
1372
+ break
1275
1373
  if image_id not in image_prompts:
1276
1374
  errors.append(f"{lock_id}: 连续性锁指向不存在的 IMG 条目: {image_id}")
1277
1375
  continue
@@ -1280,6 +1378,90 @@ def _check_continuity_locks(
1280
1378
  errors.append(f"{lock_id}: {image_id} 可复制提示词缺少锁面")
1281
1379
 
1282
1380
 
1381
+ def _group_members(value: str) -> list[str]:
1382
+ """A GROUP's 成员 field exactly as written, repeats and typos included."""
1383
+ return [item.strip() for item in re.split(r"[、,,]", _plain(value)) if item.strip()]
1384
+
1385
+ def _check_delivery_groups(
1386
+ groups: dict[str, str],
1387
+ *,
1388
+ video: str,
1389
+ motions: dict[str, str],
1390
+ motion_duration: dict[str, str],
1391
+ shot_order: Optional[list[str]],
1392
+ shot_scenes: dict[str, list],
1393
+ spoken_sources: "Optional[tuple]",
1394
+ errors: list[str],
1395
+ ) -> dict[str, tuple[list[str], Optional[str]]]:
1396
+ """Check the multi-shot delivery groups of 视频提示词.md (VID-13, VID-15).
1397
+
1398
+ A group's copyable body is the text actually submitted, so it answers to
1399
+ the same quote rule as a MOTION body; its members, order, scene and length
1400
+ are arithmetic on the accepted shots. Returns each group's member shots and
1401
+ body for the continuity-lock check.
1402
+ """
1403
+ result: dict[str, tuple[list[str], Optional[str]]] = {}
1404
+ owner_of: dict[str, str] = {}
1405
+ titled = dict(re.findall(r"^## (GROUP-[A-Z0-9-]+) · ([^\n]+)$", video, re.MULTILINE))
1406
+ shot_of_motion = {
1407
+ motion_id: _plain(_fields(body, owner=motion_id, errors=[]).get("分镜", ""))
1408
+ for motion_id, body in motions.items()
1409
+ }
1410
+ for group_id, body in groups.items():
1411
+ if not re.search(r"[\u3400-\u9fff]", titled.get(group_id, "")):
1412
+ errors.append(f"{group_id}: 交付分组标题缺少中文名称")
1413
+ fields = _fields(body, owner=group_id, errors=errors)
1414
+ listed = _group_members(fields.get("成员", ""))
1415
+ members = _unique(listed)
1416
+ if len(members) != len(listed):
1417
+ errors.append(f"{group_id}: 成员重复;一次交付里每个镜头只出现一次")
1418
+ prompt = _copyable_prompt(body)
1419
+ if prompt is None:
1420
+ errors.append(
1421
+ f"{group_id}: 缺少唯一且非空的可复制提示词" + _copyable_prompt_cause(body)
1422
+ )
1423
+ elif spoken_sources is not None:
1424
+ _check_spoken_lines(prompt, group_id, spoken_sources, errors)
1425
+ if not members:
1426
+ errors.append(f"{group_id}: 缺少成员字段")
1427
+ continue
1428
+ unknown = [item for item in members if item not in motions]
1429
+ for item in unknown:
1430
+ errors.append(f"{group_id}: 成员不是已有的 MOTION 条目: {item}")
1431
+ for item in members:
1432
+ if item in owner_of:
1433
+ errors.append(f"{group_id}: {item} 已属于 {owner_of[item]},一个镜头只能进一个交付分组")
1434
+ owner_of.setdefault(item, group_id)
1435
+ if unknown:
1436
+ continue
1437
+ member_shots = [shot_of_motion[item] for item in members]
1438
+ result[group_id] = (member_shots, prompt)
1439
+ seconds = [_declared_seconds(motion_duration.get(shot, "")) for shot in member_shots]
1440
+ known = [value for value in seconds if value is not None]
1441
+ for shot, value in zip(member_shots, seconds):
1442
+ if value is None:
1443
+ errors.append(f"{group_id}: 成员 {shot} 没有可解析的时长,无法核对分组时长")
1444
+ declared = _declared_seconds(_plain(fields.get("时长", "")))
1445
+ if declared is None:
1446
+ errors.append(f"{group_id}: 缺少可解析的时长")
1447
+ elif len(known) == len(seconds) and abs(declared - sum(known)) > 1e-6:
1448
+ errors.append(
1449
+ f"{group_id}: 分组时长 {declared:g} 秒不等于成员时长之和 {sum(known):g} 秒"
1450
+ )
1451
+ if shot_order is not None:
1452
+ positions = [shot_order.index(shot) for shot in member_shots if shot in shot_order]
1453
+ if len(positions) == len(member_shots) and positions != list(
1454
+ range(positions[0], positions[0] + len(positions))
1455
+ ):
1456
+ errors.append(f"{group_id}: 成员必须是分镜里按顺序相邻的镜头")
1457
+ scenes = {scene for shot in member_shots for scene in shot_scenes.get(shot, [])}
1458
+ if len(scenes) > 1:
1459
+ errors.append(
1460
+ f"{group_id}: 交付分组不能跨场次: {'、'.join(sorted(scenes))}"
1461
+ )
1462
+ return result
1463
+
1464
+
1283
1465
  def _storyboard_head(storyboard: str) -> str:
1284
1466
  """The part of 分镜.md that belongs to the episode rather than to a shot."""
1285
1467
  match = re.search(r"^## SHOT-", storyboard, re.MULTILINE)
@@ -1287,9 +1469,13 @@ def _storyboard_head(storyboard: str) -> str:
1287
1469
 
1288
1470
 
1289
1471
  def _shot_sources(
1290
- value: str, owner: str, scenes: "frozenset", errors: list[str]
1472
+ value: str, owner: str, scenes: "Optional[frozenset]", errors: list[str]
1291
1473
  ) -> list:
1292
- """Resolve one shot's 来源 against the scene ids that exist in 剧本.md."""
1474
+ """Resolve one shot's 来源 against the scene ids that exist in 剧本.md.
1475
+
1476
+ ``scenes`` is None when 剧本.md is not part of a partial check; the ids are
1477
+ then taken as written.
1478
+ """
1293
1479
  plain = _plain(value)
1294
1480
  if not plain:
1295
1481
  errors.append(f"{owner}: 缺少来源字段")
@@ -1303,8 +1489,11 @@ def _shot_sources(
1303
1489
  )
1304
1490
  return []
1305
1491
  resolved = []
1306
- for scene_id in _unique(SCENE_ID_RE.findall(plain)):
1307
- if scene_id not in scenes:
1492
+ # A quote may itself mention another scene (「…与 EP001-SC001 同一客厅…」);
1493
+ # that id is quoted text, not a scene this shot carries.
1494
+ unquoted = QUOTE_RE.sub("", plain)
1495
+ for scene_id in _unique(SCENE_ID_RE.findall(unquoted)):
1496
+ if scenes is not None and scene_id not in scenes:
1308
1497
  errors.append(f"{owner}: 来源场景不在《剧本.md》中: {scene_id}")
1309
1498
  else:
1310
1499
  resolved.append(scene_id)
@@ -1386,23 +1575,73 @@ def _check_spoken_lines(
1386
1575
  )
1387
1576
 
1388
1577
 
1389
- def validate_episode(episode: Path, project_root: Optional[Path] = None) -> list[str]:
1390
- """Return all deterministic contract errors for ``episode``."""
1578
+ def missing_documents(episode: Path) -> list[str]:
1579
+ """The creator documents an episode directory does not have yet."""
1580
+ return [name for name in REQUIRED_DOCUMENTS if not (episode / name).is_file()]
1581
+
1582
+
1583
+ def validate_episode(
1584
+ episode: Path, project_root: Optional[Path] = None, *, partial: bool = False
1585
+ ) -> list[str]:
1586
+ """Return all deterministic contract errors for ``episode``.
1587
+
1588
+ By default all five documents are required. ``partial`` checks only the
1589
+ documents that exist: rules that need a missing document are skipped, never
1590
+ reported as passing or failing.
1591
+ """
1391
1592
  episode = episode.resolve()
1392
- project_root = (project_root or episode.parent.parent).resolve()
1593
+ texts = {
1594
+ name: (episode / name).read_text(encoding="utf-8")
1595
+ for name in REQUIRED_DOCUMENTS
1596
+ if (episode / name).is_file()
1597
+ }
1598
+ return validate_documents(
1599
+ texts, (project_root or episode.parent.parent).resolve(), partial=partial
1600
+ )
1601
+
1602
+
1603
+ def validate_documents(
1604
+ texts: "Mapping[str, Optional[str]]",
1605
+ project_root: Optional[Path],
1606
+ *,
1607
+ partial: bool = False,
1608
+ ) -> list[str]:
1609
+ """The same rules over document text already in hand, keyed by file name.
1610
+
1611
+ A missing or None entry is a missing document. ``project_root`` None skips
1612
+ only what needs the disk: whether a REF file exists, and the project's
1613
+ prompt language.
1614
+ """
1393
1615
  errors: list[str] = []
1394
- missing = [name for name in REQUIRED_DOCUMENTS if not (episode / name).is_file()]
1395
- if missing:
1616
+ missing = [name for name in REQUIRED_DOCUMENTS if texts.get(name) is None]
1617
+ if missing and not partial:
1396
1618
  return [f"缺少创作文档: {', '.join(missing)}"]
1619
+ present = frozenset(REQUIRED_DOCUMENTS) - frozenset(missing)
1620
+ if not present:
1621
+ # A partial check before any document exists has nothing to fail on;
1622
+ # the CLI says so instead of reporting an error the creator cannot fix.
1623
+ return []
1397
1624
 
1398
- images = (episode / "图片提示词.md").read_text(encoding="utf-8")
1399
- storyboard = (episode / "分镜.md").read_text(encoding="utf-8")
1400
- video = (episode / "视频提示词.md").read_text(encoding="utf-8")
1401
- visual = (episode / "视觉设定.md").read_text(encoding="utf-8")
1402
- screenplay = (episode / "剧本.md").read_text(encoding="utf-8")
1625
+ def read(name: str) -> str:
1626
+ return texts.get(name) or ""
1627
+
1628
+ has_screenplay = "剧本.md" in present
1629
+ has_visual = "视觉设定.md" in present
1630
+ has_images = "图片提示词.md" in present
1631
+ has_storyboard = "分镜.md" in present
1632
+ has_video = "视频提示词.md" in present
1633
+ images = read("图片提示词.md")
1634
+ storyboard = read("分镜.md")
1635
+ video = read("视频提示词.md")
1636
+ visual = read("视觉设定.md")
1637
+ screenplay = read("剧本.md")
1403
1638
  scene_headings = SCENE_HEADING_RE.findall(screenplay)
1404
1639
  scenes = frozenset(scene_headings)
1405
- if not scenes:
1640
+ known_scenes = scenes if has_screenplay else None
1641
+ # A quote may come from any of the three; with one missing, an unmatched
1642
+ # quote is unverifiable rather than invented.
1643
+ quote_sources_complete = has_screenplay and has_visual and has_storyboard
1644
+ if has_screenplay and not scenes:
1406
1645
  errors.append(
1407
1646
  "剧本.md: 没有可解析的场景标题;场景标题写成 ## <场景 ID> 内|外 · 地点 · 时间"
1408
1647
  )
@@ -1413,8 +1652,14 @@ def validate_episode(episode: Path, project_root: Optional[Path] = None) -> list
1413
1652
  _spoken_key(visual),
1414
1653
  _spoken_key(storyboard),
1415
1654
  )
1416
- unfilmed = _unfilmed_scenes(storyboard, scenes, errors)
1655
+ unfilmed = (
1656
+ _unfilmed_scenes(storyboard, scenes, errors)
1657
+ if has_storyboard and has_screenplay
1658
+ else {}
1659
+ )
1417
1660
  claimed_scenes: set = set()
1661
+ in_frame: dict[str, set[tuple[str, str]]] = {}
1662
+ shot_scene_map: dict[str, list] = {}
1418
1663
  locks = _continuity_locks(visual, errors)
1419
1664
  visual_entries = _visual_entries(visual, errors)
1420
1665
  voices = VoiceContext(
@@ -1428,6 +1673,8 @@ def validate_episode(episode: Path, project_root: Optional[Path] = None) -> list
1428
1673
  for entry in visual_entries
1429
1674
  if entry.category == "人物"
1430
1675
  },
1676
+ has_visual=has_visual,
1677
+ has_screenplay=has_screenplay,
1431
1678
  )
1432
1679
  other_headings = {
1433
1680
  match.group(2).strip(): match.group(1).strip()
@@ -1439,7 +1686,10 @@ def validate_episode(episode: Path, project_root: Optional[Path] = None) -> list
1439
1686
  image_headings = dict(image_pairs)
1440
1687
  # A planned picture names the board or keyframe that already decides what it
1441
1688
  # depicts, so both indexes have to exist before any declaration is read.
1442
- entries = EntryIndex(images=image_headings, shots=frozenset(shots))
1689
+ entries = EntryIndex(
1690
+ images=image_headings if has_images else None,
1691
+ shots=frozenset(shots) if has_storyboard else None,
1692
+ )
1443
1693
  all_image_headings = re.findall(r"^## (IMG-[A-Z0-9-]+)\b", images, re.MULTILINE)
1444
1694
  if len(image_pairs) != len(all_image_headings):
1445
1695
  errors.append("图片提示词.md: IMG 标题必须包含中文名称")
@@ -1486,6 +1736,9 @@ def validate_episode(episode: Path, project_root: Optional[Path] = None) -> list
1486
1736
  errors.append("分镜.md: SHOT 标题 ID 重复")
1487
1737
  if len(motion_ids) != len(set(motion_ids)):
1488
1738
  errors.append("视频提示词.md: MOTION 标题 ID 重复")
1739
+ group_ids = re.findall(r"^## (GROUP-[A-Z0-9-]+)\b", video, re.MULTILINE)
1740
+ if len(group_ids) != len(set(group_ids)):
1741
+ errors.append("视频提示词.md: GROUP 标题 ID 重复")
1489
1742
  named_shots = re.findall(
1490
1743
  r"^## (SHOT-[A-Z0-9-]+) · ([^\n]+)$", storyboard, re.MULTILINE
1491
1744
  )
@@ -1502,9 +1755,9 @@ def validate_episode(episode: Path, project_root: Optional[Path] = None) -> list
1502
1755
  for motion_id, label in named_motions:
1503
1756
  if not re.search(r"[\u3400-\u9fff]", label):
1504
1757
  errors.append(f"{motion_id}: MOTION 标题缺少中文名称")
1505
- if not shots:
1758
+ if has_storyboard and not shots:
1506
1759
  errors.append("分镜.md: 没有 SHOT 条目")
1507
- if not motions:
1760
+ if has_video and not motions:
1508
1761
  errors.append("视频提示词.md: 没有 MOTION 条目")
1509
1762
 
1510
1763
  motion_by_shot: dict[str, tuple[str, str, Optional[str]]] = {}
@@ -1528,14 +1781,80 @@ def validate_episode(episode: Path, project_root: Optional[Path] = None) -> list
1528
1781
  + _copyable_prompt_cause(body)
1529
1782
  )
1530
1783
 
1531
- if set(motion_by_shot) != set(shots):
1784
+ if has_storyboard and has_video and set(motion_by_shot) != set(shots):
1532
1785
  errors.append("分镜.md/视频提示词.md: SHOT 与 MOTION 未一一对应")
1533
1786
 
1787
+ def check_motion(
1788
+ motion_id: str,
1789
+ motion_body: str,
1790
+ copyable_prompt: Optional[str],
1791
+ governing_input: str,
1792
+ scenes: Optional[list],
1793
+ ) -> tuple[dict[str, str], str]:
1794
+ """MOTION rules that need only the MOTION itself and its governing input.
1795
+
1796
+ With 分镜.md the governing 输入参考图 is the shot's, which the MOTION must
1797
+ copy; in a partial check without it, the MOTION's own declaration.
1798
+ """
1799
+ motion_fields = _fields(motion_body, owner=motion_id, errors=errors)
1800
+ if copyable_prompt is not None and quote_sources_complete:
1801
+ _check_spoken_lines(
1802
+ copyable_prompt, motion_id, spoken_sources, errors
1803
+ )
1804
+ motion_input = motion_fields.get("输入参考图", "")
1805
+ _references(motion_input, motion_id, project_root, errors, entries)
1806
+ if "参考音频" in motion_fields:
1807
+ _audio_references(
1808
+ motion_fields["参考音频"],
1809
+ motion_id,
1810
+ project_root,
1811
+ errors,
1812
+ image_slots=[
1813
+ item[3][0]
1814
+ for item in _slot_matches(
1815
+ PENDING_REFERENCE_SUFFIX_RE.sub("", motion_input.strip())
1816
+ )
1817
+ ],
1818
+ prompt=copyable_prompt,
1819
+ voices=voices,
1820
+ scenes=scenes,
1821
+ )
1822
+
1823
+ if _has_pending_references(governing_input):
1824
+ errors.append(f"{motion_id}: 仍有待补参考图,不能生成最终视频提示词")
1825
+
1826
+ # A `PLAN-...` slot is not a file, but the prompt is still written for
1827
+ # image-conditioned generation: the creator attaches those pictures at
1828
+ # generation time. What decides 生成方式 is whether the shot sends
1829
+ # pictures at all, not whether this suite can read them.
1830
+ sends_pictures = not _is_none(governing_input)
1831
+ expected_mode = "图生视频" if sends_pictures else "文生视频"
1832
+ if _plain(motion_fields.get("生成方式", "")) != expected_mode:
1833
+ errors.append(f"{motion_id}: 生成方式应为{expected_mode}")
1834
+ anchor = _plain(motion_fields.get("静态视觉锚点", ""))
1835
+ if not sends_pictures:
1836
+ if not _is_explicit_text_to_video(governing_input):
1837
+ errors.append(
1838
+ f"{motion_id}: 无真实输入参考图时不能静默降级为文生视频;"
1839
+ "请先绑定已有图片、列出待补图片,或记录创作者已明确选择文生视频"
1840
+ )
1841
+ if not anchor or anchor == "无":
1842
+ errors.append(f"{motion_id}: 文生视频缺少静态视觉锚点")
1843
+ if (
1844
+ copyable_prompt is not None
1845
+ and anchor
1846
+ and anchor != "无"
1847
+ and anchor not in copyable_prompt
1848
+ ):
1849
+ errors.append(f"{motion_id}: 可复制提示词没有包含静态视觉锚点")
1850
+ return motion_fields, anchor
1851
+
1534
1852
  for shot_id, shot_body in shots.items():
1535
1853
  fields = _fields(shot_body, owner=shot_id, errors=errors)
1536
1854
  source_value = fields.get("来源", "")
1537
- shot_scenes = _shot_sources(source_value, shot_id, scenes, errors)
1855
+ shot_scenes = _shot_sources(source_value, shot_id, known_scenes, errors)
1538
1856
  claimed_scenes.update(shot_scenes)
1857
+ shot_scene_map[shot_id] = shot_scenes
1539
1858
  shot_seconds = _declared_seconds(_plain(fields.get("时长", "")))
1540
1859
  motion_seconds = _declared_seconds(motion_duration.get(shot_id, ""))
1541
1860
  if (
@@ -1561,6 +1880,8 @@ def validate_episode(episode: Path, project_root: Optional[Path] = None) -> list
1561
1880
  ):
1562
1881
  errors.append(f"{shot_id}: 图片提示词项语法不完整")
1563
1882
  for image_id, label, _ in image_refs:
1883
+ if not has_images:
1884
+ continue
1564
1885
  if image_id not in image_headings:
1565
1886
  errors.append(f"{shot_id}: IMG 标题不存在: {image_id}")
1566
1887
  elif label != image_headings[image_id]:
@@ -1579,10 +1900,16 @@ def validate_episode(episode: Path, project_root: Optional[Path] = None) -> list
1579
1900
  if not basis_value:
1580
1901
  errors.append(f"{shot_id}: 缺少视觉依据字段")
1581
1902
  basis = VisualBasis(False, set(), set())
1903
+ elif not has_visual:
1904
+ # Without 视觉设定.md the entries cannot be resolved, and every
1905
+ # coverage check below would report a name that may well exist.
1906
+ basis = VisualBasis(False, set(), set())
1582
1907
  else:
1583
1908
  basis = _visual_basis(
1584
1909
  basis_value, shot_id, visual_entries, other_headings, errors
1585
1910
  )
1911
+ if basis.parsed:
1912
+ in_frame[shot_id] = basis.declared
1586
1913
  # The keyframe is the only place the frame's contents exist as text, so a
1587
1914
  # shot without one would make the coverage check below vacuous.
1588
1915
  keyframe = _copyable_prompt(shot_body, heading=r"冻结关键帧提示词")
@@ -1630,70 +1957,32 @@ def validate_episode(episode: Path, project_root: Optional[Path] = None) -> list
1630
1957
  if not motion:
1631
1958
  continue
1632
1959
  motion_id, motion_body, copyable_prompt = motion
1633
- motion_fields = _fields(motion_body, owner=motion_id, errors=errors)
1634
- if copyable_prompt is not None:
1635
- _check_spoken_lines(
1636
- copyable_prompt, motion_id, spoken_sources, errors
1637
- )
1960
+ motion_fields, anchor = check_motion(
1961
+ motion_id, motion_body, copyable_prompt, shot_input, shot_scenes
1962
+ )
1638
1963
  motion_input = motion_fields.get("输入参考图", "")
1639
- _references(motion_input, motion_id, project_root, errors, entries)
1640
1964
  if _plain(motion_input) != _plain(shot_input):
1641
1965
  errors.append(f"{motion_id}: 输入参考图与 {shot_id} 不一致")
1642
- if "参考音频" in motion_fields:
1643
- _audio_references(
1644
- motion_fields["参考音频"],
1645
- motion_id,
1646
- project_root,
1647
- errors,
1648
- image_slots=[
1649
- item[3][0]
1650
- for item in _slot_matches(
1651
- PENDING_REFERENCE_SUFFIX_RE.sub("", motion_input.strip())
1652
- )
1653
- ],
1654
- prompt=copyable_prompt,
1655
- voices=voices,
1656
- scenes=shot_scenes,
1966
+ if not _is_none(shot_input):
1967
+ continue
1968
+ # In text-to-video the anchor carries the appearance the keyframe
1969
+ # would otherwise have carried, so it is the same claim about the
1970
+ # same frame and answers to the same visual basis. The rest of the
1971
+ # motion body is not checked: it may legitimately name an offscreen
1972
+ # speaker, which SHT-22 excludes.
1973
+ if anchor and anchor != "无" and basis.parsed:
1974
+ _check_named_coverage(
1975
+ anchor, motion_id, "静态视觉锚点", basis, visual_entries, errors
1657
1976
  )
1658
1977
 
1659
- if _has_pending_references(shot_input):
1660
- errors.append(f"{motion_id}: 仍有待补参考图,不能生成最终视频提示词")
1978
+ if not has_storyboard:
1979
+ # A partial check without 分镜.md still owes each MOTION the rules that
1980
+ # read only the MOTION; its own 输入参考图 governs them.
1981
+ for motion_id, motion_body in motions.items():
1982
+ own_input = _fields(motion_body, owner=motion_id, errors=[]).get("输入参考图", "")
1983
+ check_motion(motion_id, motion_body, _copyable_prompt(motion_body), own_input, None)
1661
1984
 
1662
- # A `PLAN-...` slot is not a file, but the prompt is still written for
1663
- # image-conditioned generation: the creator attaches those pictures at
1664
- # generation time. What decides 生成方式 is whether the shot sends
1665
- # pictures at all, not whether this suite can read them.
1666
- sends_pictures = not _is_none(shot_input)
1667
- expected_mode = "图生视频" if sends_pictures else "文生视频"
1668
- if _plain(motion_fields.get("生成方式", "")) != expected_mode:
1669
- errors.append(f"{motion_id}: 生成方式应为{expected_mode}")
1670
- if not sends_pictures:
1671
- if not _is_explicit_text_to_video(shot_input):
1672
- errors.append(
1673
- f"{motion_id}: 无真实输入参考图时不能静默降级为文生视频;"
1674
- "请先绑定已有图片、列出待补图片,或记录创作者已明确选择文生视频"
1675
- )
1676
- anchor = _plain(motion_fields.get("静态视觉锚点", ""))
1677
- if not anchor or anchor == "无":
1678
- errors.append(f"{motion_id}: 文生视频缺少静态视觉锚点")
1679
- if (
1680
- copyable_prompt is not None
1681
- and anchor
1682
- and anchor != "无"
1683
- and anchor not in copyable_prompt
1684
- ):
1685
- errors.append(f"{motion_id}: 可复制提示词没有包含静态视觉锚点")
1686
- # In text-to-video the anchor carries the appearance the keyframe
1687
- # would otherwise have carried, so it is the same claim about the
1688
- # same frame and answers to the same visual basis. The rest of the
1689
- # motion body is not checked: it may legitimately name an offscreen
1690
- # speaker, which SHT-22 excludes.
1691
- if anchor and anchor != "无" and basis.parsed:
1692
- _check_named_coverage(
1693
- anchor, motion_id, "静态视觉锚点", basis, visual_entries, errors
1694
- )
1695
-
1696
- for scene_id in scene_headings:
1985
+ for scene_id in scene_headings if has_storyboard else ():
1697
1986
  if scene_id in claimed_scenes or scene_id in unfilmed:
1698
1987
  continue
1699
1988
  errors.append(
@@ -1703,15 +1992,28 @@ def validate_episode(episode: Path, project_root: Optional[Path] = None) -> list
1703
1992
  for scene_id in sorted(set(unfilmed) & claimed_scenes):
1704
1993
  errors.append(f"分镜.md: 场景 {scene_id} 既被镜头承载又记为未拍")
1705
1994
 
1995
+ group_members = _check_delivery_groups(
1996
+ _sections(video, "GROUP"),
1997
+ video=video,
1998
+ motions=motions,
1999
+ motion_duration=motion_duration,
2000
+ shot_order=list(shots) if has_storyboard else None,
2001
+ shot_scenes=shot_scene_map,
2002
+ spoken_sources=spoken_sources if quote_sources_complete else None,
2003
+ errors=errors,
2004
+ )
2005
+
1706
2006
  _check_language_designators(
1707
2007
  project_root, entries=visual_entries, errors=errors
1708
2008
  )
1709
2009
  _check_continuity_locks(
1710
2010
  locks,
1711
- shots=shots,
2011
+ shots=shots if has_storyboard else None,
1712
2012
  motion_by_shot=motion_by_shot,
1713
- image_prompts=image_prompts,
2013
+ image_prompts=image_prompts if has_images else None,
1714
2014
  errors=errors,
2015
+ in_frame=in_frame,
2016
+ groups=group_members,
1715
2017
  )
1716
2018
  return errors
1717
2019
 
@@ -1720,6 +2022,11 @@ def main() -> int:
1720
2022
  parser = argparse.ArgumentParser(description=__doc__)
1721
2023
  parser.add_argument("episode", type=Path, help="包含五份 Markdown 的剧集目录")
1722
2024
  parser.add_argument("--project-root", type=Path, help="用于解析 REF 项目相对路径")
2025
+ parser.add_argument(
2026
+ "--partial",
2027
+ action="store_true",
2028
+ help="只核对已有的文档;缺少的文档相关规则跳过,不算通过也不算失败",
2029
+ )
1723
2030
  args = parser.parse_args()
1724
2031
  # 诊断与剧集路径都是中文。stdout 重定向时 Windows 用 ANSI 代码页,默认的
1725
2032
  # strict 处理器会在打印这一步抛错;stderr 早就是 backslashreplace,这里让
@@ -1727,12 +2034,18 @@ def main() -> int:
1727
2034
  reconfigure = getattr(sys.stdout, "reconfigure", None)
1728
2035
  if reconfigure is not None:
1729
2036
  reconfigure(errors="backslashreplace")
1730
- errors = validate_episode(args.episode, args.project_root)
2037
+ errors = validate_episode(args.episode, args.project_root, partial=args.partial)
1731
2038
  if errors:
1732
2039
  for error in errors:
1733
2040
  print(f"ERROR: {error}")
1734
2041
  return 1
1735
- print(f"OK: {args.episode}")
2042
+ missing = missing_documents(args.episode) if args.partial else []
2043
+ if len(missing) == len(REQUIRED_DOCUMENTS):
2044
+ print(f"OK(局部): {args.episode};还没有创作文档,没有可核对的内容")
2045
+ elif missing:
2046
+ print(f"OK(局部): {args.episode};未核对与这些文档相关的规则: {', '.join(missing)}")
2047
+ else:
2048
+ print(f"OK: {args.episode}")
1736
2049
  return 0
1737
2050
 
1738
2051