@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.
- package/README.md +12 -12
- package/lib/drama/manifest.json +77 -247
- package/lib/drama/skills/short-drama/SKILL.md +9 -1
- package/lib/drama/skills/short-drama/references/creator-documents.md +51 -23
- package/lib/drama/skills/short-drama/references/reference-roles.md +1 -1
- package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +442 -129
- package/lib/drama/skills/short-drama/scripts/project_tool.py +54 -1
- package/lib/drama/skills/short-drama-assets/SKILL.md +2 -6
- package/lib/drama/skills/short-drama-assets/references/continuity-lock.md +9 -3
- package/lib/drama/skills/short-drama-assets/references/era-anchors.md +3 -3
- package/lib/drama/skills/short-drama-assets/references/location-and-view.md +4 -0
- package/lib/drama/skills/short-drama-develop/references/adaptation-craft.md +2 -2
- package/lib/drama/skills/short-drama-develop/references/episode-design.md +19 -4
- package/lib/drama/skills/short-drama-develop/references/genre-cards//347/263/273/347/273/237/346/265/201.md +1 -1
- package/lib/drama/skills/short-drama-edit/SKILL.md +1 -1
- package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +28 -6
- package/lib/drama/skills/short-drama-edit/scripts/selftest.py +1 -1
- package/lib/drama/skills/short-drama-image-prompts/SKILL.md +1 -5
- package/lib/drama/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +2 -2
- package/lib/drama/skills/short-drama-produce/SKILL.md +2 -0
- package/lib/drama/skills/short-drama-review/SKILL.md +0 -4
- package/lib/drama/skills/short-drama-review/references/production-quality-gates.md +1 -1
- package/lib/drama/skills/short-drama-storyboard/SKILL.md +9 -9
- package/lib/drama/skills/short-drama-storyboard/references/blocking-playbooks.md +43 -13
- package/lib/drama/skills/short-drama-storyboard/references/keyframe-craft.md +5 -5
- package/lib/drama/skills/short-drama-storyboard/references/production-shot-grammar.md +5 -5
- package/lib/drama/skills/short-drama-storyboard/references/shot-craft.md +27 -3
- package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +2 -1
- package/lib/drama/skills/short-drama-video-prompts/SKILL.md +6 -8
- package/lib/drama/skills/short-drama-video-prompts/references/camera-audio-continuity.md +16 -1
- package/lib/drama/skills/short-drama-video-prompts/references/delivery-profile.md +23 -6
- package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +2 -2
- package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +8 -1
- package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.0.md +41 -2
- package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +1 -1
- package/lib/drama/skills/short-drama-video-prompts/references/target-model-profile.md +5 -1
- package/lib/drama/skills/short-drama-write/SKILL.md +7 -3
- package/lib/drama/skills/short-drama-write/references/screenplay-format.md +1 -1
- package/lib/drama/skills/short-drama-write/scripts/duration_estimate.py +94 -1
- package/lib/drama/skills/short-drama-write/scripts/screenplay_index.py +13 -4
- package/lib/drama/skills/short-drama-write/scripts/selftest.py +3 -77
- package/lib/index.js +2 -2
- package/lib/index.js.map +1 -1
- package/lib/novel-to-game/README.md +20 -34
- package/lib/novel-to-game/README_ZH.md +20 -34
- package/lib/novel-to-game/VERSION +1 -1
- package/lib/novel-to-game/examples/jin-ping-mei/build/BUILD_BRIEF.md +2 -6
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/audio.js +33 -39
- package/lib/novel-to-game/examples/jin-ping-mei/qa/README.md +2 -2
- package/lib/novel-to-game/manifest.json +46 -46
- package/lib/novel-to-game/skills/game-art-direction/SKILL.md +1 -1
- package/lib/novel-to-game/skills/game-build/references/baked-sprite-workflow.md +2 -3
- package/lib/novel-to-game/skills/game-build/references/blender-asset-workflow.md +6 -7
- package/lib/novel-to-game/skills/game-build/references/build-brief-contract.md +2 -2
- package/lib/novel-to-game/skills/game-build/references/generative-media-pipeline.md +1 -4
- package/lib/novel-to-game/skills/game-build/references/playable-model-contract.md +1 -3
- package/lib/novel-to-game/skills/game-build/references/tts-production-contract.md +1 -3
- package/lib/novel-to-game/skills/game-concept/SKILL.md +1 -1
- package/lib/novel-to-game/skills/game-qa/SKILL.md +3 -4
- package/lib/novel-to-game/skills/game-qa/references/qa-contract.md +1 -1
- package/lib/novel-to-game/skills/game-qa/references/test-design-method.md +3 -2
- package/lib/novel-to-game/skills/novel-game-analyze/SKILL.md +13 -14
- package/lib/novel-to-game/skills/novel-game-analyze/references/gameability-protocol.md +1 -2
- package/lib/novel-to-game/skills/novel-to-game/SKILL.md +4 -4
- package/lib/novel-to-game/skills/novel-to-game/references/intake-method.md +2 -3
- package/lib/novel-to-game/skills/novel-to-game/references/pipeline-contract.md +0 -8
- package/lib/oh-story/manifest.json +49 -49
- package/lib/oh-story/skills/story/SKILL.md +2 -2
- package/lib/oh-story/skills/story/VERSION +1 -1
- package/lib/oh-story/skills/story-deslop/SKILL.md +2 -2
- package/lib/oh-story/skills/story-import/SKILL.md +5 -5
- package/lib/oh-story/skills/story-import/references/deep-analysis.md +1 -1
- package/lib/oh-story/skills/story-import/references/import-report.md +1 -1
- package/lib/oh-story/skills/story-import/references/import-tracking.md +1 -1
- package/lib/oh-story/skills/story-import/references/outline-reverse.md +1 -1
- package/lib/oh-story/skills/story-import/scripts/tracking_commit.py +6 -1
- package/lib/oh-story/skills/story-long-analyze/SKILL.md +2 -2
- package/lib/oh-story/skills/story-long-write/SKILL.md +2 -2
- package/lib/oh-story/skills/story-long-write/references/agent-calls.md +3 -3
- package/lib/oh-story/skills/story-long-write/references/tracking-transaction.md +2 -2
- package/lib/oh-story/skills/story-long-write/scripts/tracking_commit.py +6 -1
- package/lib/oh-story/skills/story-review/SKILL.md +8 -7
- package/lib/oh-story/skills/story-review/references/agent-prompts.md +7 -7
- package/lib/oh-story/skills/story-review/references/tracking-transaction.md +2 -2
- package/lib/oh-story/skills/story-review/scripts/tracking_commit.py +6 -1
- package/lib/oh-story/skills/story-setup/SKILL.md +6 -6
- package/lib/oh-story/skills/story-short-write/SKILL.md +2 -2
- package/lib/oh-story/skills/story-short-write/references/agent-calls.md +3 -3
- package/lib/oh-story/skills/story-short-write/references/workflow-design.md +2 -2
- package/lib/oh-story/skills/story-short-write/references/workflow-revision.md +1 -1
- package/package.json +21 -21
- package/lib/drama/skills/short-drama-assets/assets/character-look.example.jsonl +0 -3
- package/lib/drama/skills/short-drama-assets/assets/continuity.example.jsonl +0 -4
- package/lib/drama/skills/short-drama-assets/assets/decisions.example.jsonl +0 -5
- package/lib/drama/skills/short-drama-assets/assets/location-view.example.jsonl +0 -3
- package/lib/drama/skills/short-drama-assets/assets/occurrences.example.jsonl +0 -5
- package/lib/drama/skills/short-drama-assets/assets/prop-state.example.jsonl +0 -3
- package/lib/drama/skills/short-drama-assets/examples/minimal/characters.jsonl +0 -2
- package/lib/drama/skills/short-drama-assets/examples/minimal/looks.jsonl +0 -2
- package/lib/drama/skills/short-drama-assets/scripts/asset_check.py +0 -267
- package/lib/drama/skills/short-drama-assets/scripts/selftest.py +0 -89
- package/lib/drama/skills/short-drama-image-prompts/examples/minimal-image-prompt-specs.jsonl +0 -2
- package/lib/drama/skills/short-drama-image-prompts/scripts/image_prompt_check.py +0 -443
- package/lib/drama/skills/short-drama-image-prompts/scripts/selftest.py +0 -95
- package/lib/drama/skills/short-drama-review/assets/finding-template.jsonl +0 -2
- package/lib/drama/skills/short-drama-review/assets/supersession-decision.example.json +0 -42
- package/lib/drama/skills/short-drama-review/assets/verdict-template.json +0 -32
- package/lib/drama/skills/short-drama-review/examples/minimal-findings.jsonl +0 -2
- package/lib/drama/skills/short-drama-review/examples/minimal-verdict.json +0 -34
- package/lib/drama/skills/short-drama-review/scripts/review_check.py +0 -325
- package/lib/drama/skills/short-drama-review/scripts/selftest.py +0 -87
- package/lib/drama/skills/short-drama-storyboard/assets/coverage-audition.example.jsonl +0 -2
- package/lib/drama/skills/short-drama-storyboard/assets/revision-lineage.fragment.json +0 -12
- package/lib/drama/skills/short-drama-storyboard/assets/scene-visual-plan.example.jsonl +0 -2
- package/lib/drama/skills/short-drama-storyboard/scripts/selftest.py +0 -191
- package/lib/drama/skills/short-drama-storyboard/scripts/storyboard_check.py +0 -824
- package/lib/drama/skills/short-drama-video-prompts/assets/coverage-scope.fragment.json +0 -21
- package/lib/drama/skills/short-drama-video-prompts/assets/motion-terminal.example.jsonl +0 -2
- package/lib/drama/skills/short-drama-video-prompts/assets/performance.fragment.json +0 -38
- package/lib/drama/skills/short-drama-video-prompts/examples/minimal-music-specs.jsonl +0 -2
- package/lib/drama/skills/short-drama-video-prompts/scripts/container_check.py +0 -329
- package/lib/drama/skills/short-drama-video-prompts/scripts/motion_timing_check.py +0 -451
- package/lib/drama/skills/short-drama-video-prompts/scripts/music_spec_check.py +0 -327
- package/lib/drama/skills/short-drama-video-prompts/scripts/selftest.py +0 -82
- 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})
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
for
|
|
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 --
|
|
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
|
-
|
|
362
|
-
|
|
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
|
|
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
|
|
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
|
|
921
|
-
the
|
|
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
|
|
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
|
-
|
|
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
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
-
|
|
1307
|
-
|
|
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
|
|
1390
|
-
"""
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
|
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 =
|
|
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(
|
|
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,
|
|
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 =
|
|
1634
|
-
|
|
1635
|
-
|
|
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
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
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
|
-
|
|
1660
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|