@oh-story/dsh 0.1.17 → 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 (136) hide show
  1. package/README.md +14 -14
  2. package/lib/client.js +147 -96
  3. package/lib/client.js.map +3 -3
  4. package/lib/drama/manifest.json +77 -247
  5. package/lib/drama/skills/short-drama/SKILL.md +9 -1
  6. package/lib/drama/skills/short-drama/references/creator-documents.md +51 -23
  7. package/lib/drama/skills/short-drama/references/reference-roles.md +1 -1
  8. package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +442 -129
  9. package/lib/drama/skills/short-drama/scripts/project_tool.py +54 -1
  10. package/lib/drama/skills/short-drama-assets/SKILL.md +2 -6
  11. package/lib/drama/skills/short-drama-assets/references/continuity-lock.md +9 -3
  12. package/lib/drama/skills/short-drama-assets/references/era-anchors.md +3 -3
  13. package/lib/drama/skills/short-drama-assets/references/location-and-view.md +4 -0
  14. package/lib/drama/skills/short-drama-develop/references/adaptation-craft.md +2 -2
  15. package/lib/drama/skills/short-drama-develop/references/episode-design.md +19 -4
  16. package/lib/drama/skills/short-drama-develop/references/genre-cards//347/263/273/347/273/237/346/265/201.md +1 -1
  17. package/lib/drama/skills/short-drama-edit/SKILL.md +1 -1
  18. package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +28 -6
  19. package/lib/drama/skills/short-drama-edit/scripts/selftest.py +1 -1
  20. package/lib/drama/skills/short-drama-image-prompts/SKILL.md +1 -5
  21. package/lib/drama/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +2 -2
  22. package/lib/drama/skills/short-drama-produce/SKILL.md +2 -0
  23. package/lib/drama/skills/short-drama-review/SKILL.md +0 -4
  24. package/lib/drama/skills/short-drama-review/references/production-quality-gates.md +1 -1
  25. package/lib/drama/skills/short-drama-storyboard/SKILL.md +9 -9
  26. package/lib/drama/skills/short-drama-storyboard/references/blocking-playbooks.md +43 -13
  27. package/lib/drama/skills/short-drama-storyboard/references/keyframe-craft.md +5 -5
  28. package/lib/drama/skills/short-drama-storyboard/references/production-shot-grammar.md +5 -5
  29. package/lib/drama/skills/short-drama-storyboard/references/shot-craft.md +27 -3
  30. package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +2 -1
  31. package/lib/drama/skills/short-drama-video-prompts/SKILL.md +6 -8
  32. package/lib/drama/skills/short-drama-video-prompts/references/camera-audio-continuity.md +16 -1
  33. package/lib/drama/skills/short-drama-video-prompts/references/delivery-profile.md +23 -6
  34. package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +2 -2
  35. package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +8 -1
  36. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.0.md +41 -2
  37. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +1 -1
  38. package/lib/drama/skills/short-drama-video-prompts/references/target-model-profile.md +5 -1
  39. package/lib/drama/skills/short-drama-write/SKILL.md +7 -3
  40. package/lib/drama/skills/short-drama-write/references/screenplay-format.md +1 -1
  41. package/lib/drama/skills/short-drama-write/scripts/duration_estimate.py +94 -1
  42. package/lib/drama/skills/short-drama-write/scripts/screenplay_index.py +13 -4
  43. package/lib/drama/skills/short-drama-write/scripts/selftest.py +3 -77
  44. package/lib/index.js +18 -14
  45. package/lib/index.js.map +2 -2
  46. package/lib/novel-to-game/README.md +20 -34
  47. package/lib/novel-to-game/README_ZH.md +20 -34
  48. package/lib/novel-to-game/VERSION +1 -1
  49. package/lib/novel-to-game/examples/jin-ping-mei/build/BUILD_BRIEF.md +2 -6
  50. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/audio.js +33 -39
  51. package/lib/novel-to-game/examples/jin-ping-mei/qa/README.md +2 -2
  52. package/lib/novel-to-game/manifest.json +46 -46
  53. package/lib/novel-to-game/skills/game-art-direction/SKILL.md +1 -1
  54. package/lib/novel-to-game/skills/game-build/references/baked-sprite-workflow.md +2 -3
  55. package/lib/novel-to-game/skills/game-build/references/blender-asset-workflow.md +6 -7
  56. package/lib/novel-to-game/skills/game-build/references/build-brief-contract.md +2 -2
  57. package/lib/novel-to-game/skills/game-build/references/generative-media-pipeline.md +1 -4
  58. package/lib/novel-to-game/skills/game-build/references/playable-model-contract.md +1 -3
  59. package/lib/novel-to-game/skills/game-build/references/tts-production-contract.md +1 -3
  60. package/lib/novel-to-game/skills/game-concept/SKILL.md +1 -1
  61. package/lib/novel-to-game/skills/game-qa/SKILL.md +3 -4
  62. package/lib/novel-to-game/skills/game-qa/references/qa-contract.md +1 -1
  63. package/lib/novel-to-game/skills/game-qa/references/test-design-method.md +3 -2
  64. package/lib/novel-to-game/skills/novel-game-analyze/SKILL.md +13 -14
  65. package/lib/novel-to-game/skills/novel-game-analyze/references/gameability-protocol.md +1 -2
  66. package/lib/novel-to-game/skills/novel-to-game/SKILL.md +4 -4
  67. package/lib/novel-to-game/skills/novel-to-game/references/intake-method.md +2 -3
  68. package/lib/novel-to-game/skills/novel-to-game/references/pipeline-contract.md +0 -8
  69. package/lib/oh-story/manifest.json +49 -49
  70. package/lib/oh-story/skills/story/SKILL.md +2 -2
  71. package/lib/oh-story/skills/story/VERSION +1 -1
  72. package/lib/oh-story/skills/story-deslop/SKILL.md +2 -2
  73. package/lib/oh-story/skills/story-import/SKILL.md +5 -5
  74. package/lib/oh-story/skills/story-import/references/deep-analysis.md +1 -1
  75. package/lib/oh-story/skills/story-import/references/import-report.md +1 -1
  76. package/lib/oh-story/skills/story-import/references/import-tracking.md +1 -1
  77. package/lib/oh-story/skills/story-import/references/outline-reverse.md +1 -1
  78. package/lib/oh-story/skills/story-import/scripts/tracking_commit.py +6 -1
  79. package/lib/oh-story/skills/story-long-analyze/SKILL.md +2 -2
  80. package/lib/oh-story/skills/story-long-write/SKILL.md +2 -2
  81. package/lib/oh-story/skills/story-long-write/references/agent-calls.md +3 -3
  82. package/lib/oh-story/skills/story-long-write/references/tracking-transaction.md +2 -2
  83. package/lib/oh-story/skills/story-long-write/scripts/tracking_commit.py +6 -1
  84. package/lib/oh-story/skills/story-review/SKILL.md +8 -7
  85. package/lib/oh-story/skills/story-review/references/agent-prompts.md +7 -7
  86. package/lib/oh-story/skills/story-review/references/tracking-transaction.md +2 -2
  87. package/lib/oh-story/skills/story-review/scripts/tracking_commit.py +6 -1
  88. package/lib/oh-story/skills/story-setup/SKILL.md +6 -6
  89. package/lib/oh-story/skills/story-short-write/SKILL.md +2 -2
  90. package/lib/oh-story/skills/story-short-write/references/agent-calls.md +3 -3
  91. package/lib/oh-story/skills/story-short-write/references/workflow-design.md +2 -2
  92. package/lib/oh-story/skills/story-short-write/references/workflow-revision.md +1 -1
  93. package/lib/types/client/game-preview-state.d.ts +34 -0
  94. package/lib/types/client/game-preview-state.d.ts.map +1 -0
  95. package/lib/types/client/index.d.ts.map +1 -1
  96. package/lib/types/client/video-studio.d.ts +24 -0
  97. package/lib/types/client/video-studio.d.ts.map +1 -1
  98. package/lib/types/host-python.d.ts +10 -3
  99. package/lib/types/host-python.d.ts.map +1 -1
  100. package/lib/types/workspace-route.d.ts +6 -0
  101. package/lib/types/workspace-route.d.ts.map +1 -1
  102. package/package.json +21 -21
  103. package/lib/drama/skills/short-drama-assets/assets/character-look.example.jsonl +0 -3
  104. package/lib/drama/skills/short-drama-assets/assets/continuity.example.jsonl +0 -4
  105. package/lib/drama/skills/short-drama-assets/assets/decisions.example.jsonl +0 -5
  106. package/lib/drama/skills/short-drama-assets/assets/location-view.example.jsonl +0 -3
  107. package/lib/drama/skills/short-drama-assets/assets/occurrences.example.jsonl +0 -5
  108. package/lib/drama/skills/short-drama-assets/assets/prop-state.example.jsonl +0 -3
  109. package/lib/drama/skills/short-drama-assets/examples/minimal/characters.jsonl +0 -2
  110. package/lib/drama/skills/short-drama-assets/examples/minimal/looks.jsonl +0 -2
  111. package/lib/drama/skills/short-drama-assets/scripts/asset_check.py +0 -267
  112. package/lib/drama/skills/short-drama-assets/scripts/selftest.py +0 -89
  113. package/lib/drama/skills/short-drama-image-prompts/examples/minimal-image-prompt-specs.jsonl +0 -2
  114. package/lib/drama/skills/short-drama-image-prompts/scripts/image_prompt_check.py +0 -443
  115. package/lib/drama/skills/short-drama-image-prompts/scripts/selftest.py +0 -95
  116. package/lib/drama/skills/short-drama-review/assets/finding-template.jsonl +0 -2
  117. package/lib/drama/skills/short-drama-review/assets/supersession-decision.example.json +0 -42
  118. package/lib/drama/skills/short-drama-review/assets/verdict-template.json +0 -32
  119. package/lib/drama/skills/short-drama-review/examples/minimal-findings.jsonl +0 -2
  120. package/lib/drama/skills/short-drama-review/examples/minimal-verdict.json +0 -34
  121. package/lib/drama/skills/short-drama-review/scripts/review_check.py +0 -325
  122. package/lib/drama/skills/short-drama-review/scripts/selftest.py +0 -87
  123. package/lib/drama/skills/short-drama-storyboard/assets/coverage-audition.example.jsonl +0 -2
  124. package/lib/drama/skills/short-drama-storyboard/assets/revision-lineage.fragment.json +0 -12
  125. package/lib/drama/skills/short-drama-storyboard/assets/scene-visual-plan.example.jsonl +0 -2
  126. package/lib/drama/skills/short-drama-storyboard/scripts/selftest.py +0 -191
  127. package/lib/drama/skills/short-drama-storyboard/scripts/storyboard_check.py +0 -824
  128. package/lib/drama/skills/short-drama-video-prompts/assets/coverage-scope.fragment.json +0 -21
  129. package/lib/drama/skills/short-drama-video-prompts/assets/motion-terminal.example.jsonl +0 -2
  130. package/lib/drama/skills/short-drama-video-prompts/assets/performance.fragment.json +0 -38
  131. package/lib/drama/skills/short-drama-video-prompts/examples/minimal-music-specs.jsonl +0 -2
  132. package/lib/drama/skills/short-drama-video-prompts/scripts/container_check.py +0 -329
  133. package/lib/drama/skills/short-drama-video-prompts/scripts/motion_timing_check.py +0 -451
  134. package/lib/drama/skills/short-drama-video-prompts/scripts/music_spec_check.py +0 -327
  135. package/lib/drama/skills/short-drama-video-prompts/scripts/selftest.py +0 -82
  136. package/lib/drama/skills/short-drama-write/scripts/voice_sheet_check.py +0 -416
@@ -1,451 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Check explicit motion segment timing against accepted shot duration (`VID-04`).
3
-
4
- `VID-04` is arithmetic, and it is violated in two directions that fail
5
- differently:
6
-
7
- * **Overflow** — segments extend past the accepted duration. Whatever falls
8
- outside is truncated, taking the closing action, the verbatim dialogue tail
9
- and the end pose with it.
10
- * **Shortfall** — segments stop short. The execution end may not stop or
11
- hold at the last written mark; in a small H3 sample it stretched the written
12
- action to fill the shot, so the written seconds landed later than planned and
13
- the plan no longer described the output.
14
-
15
- Both are the same rule, so both are reported under the same ID with distinct
16
- diagnostic codes. Relative timing plans are out of scope by contract: only a
17
- plan that declares itself `explicit` makes a checkable arithmetic claim.
18
-
19
- The script reads accepted creator files and writes nothing.
20
- """
21
-
22
- from __future__ import annotations
23
-
24
- import argparse
25
- import json
26
- import math
27
- import re
28
- import sys
29
- from pathlib import Path
30
- from typing import Any
31
-
32
-
33
- # Creators run these scripts on whatever interpreter their machine provides, so
34
- # an unsupported version must say so instead of failing inside an import.
35
- MINIMUM_PYTHON = (3, 9)
36
- if sys.version_info < MINIMUM_PYTHON:
37
- raise SystemExit(
38
- "short-drama needs Python {}.{} or newer; this interpreter is {}.{}".format(
39
- *MINIMUM_PYTHON, sys.version_info.major, sys.version_info.minor
40
- )
41
- )
42
-
43
- SCHEMA_VERSION = "1.0.0"
44
- # A .jsonl file opens with a header record declaring the upstream snapshots its
45
- # references name. The header is a declaration, not one of the file's records.
46
- SOURCES_RECORD_TYPE = "sources"
47
- # Seconds are authored by hand, so compare with a tolerance rather than by
48
- # equality: 0.1 + 0.2 != 0.3 in binary floating point, and a rule that fires on
49
- # that would be noise. A millisecond is far below anything a shot can express.
50
- TOLERANCE_SECONDS = 1e-6
51
- # An interval reads `0.0-1.5`. Accept the en dash and the CJK wave dash too:
52
- # creators type these on Chinese input methods and the distinction carries no
53
- # meaning here.
54
- INTERVAL_RE = re.compile(
55
- r"^\s*(?P<start>\d+(?:\.\d+)?)\s*[-–—~~]\s*(?P<end>\d+(?:\.\d+)?)\s*(?:s|秒)?\s*$"
56
- )
57
-
58
-
59
- class CheckError(ValueError):
60
- """The inputs cannot be checked at all, as opposed to failing a check."""
61
-
62
-
63
- def _reject_json_constant(value: str) -> None:
64
- raise CheckError(f"non-finite JSON number is not allowed: {value}")
65
-
66
-
67
- def _finite_number(value: Any) -> float | None:
68
- if isinstance(value, bool) or not isinstance(value, (int, float)):
69
- return None
70
- number = float(value)
71
- return number if math.isfinite(number) else None
72
-
73
-
74
- def _load_jsonl(path: Path) -> list[dict[str, Any]]:
75
- records: list[dict[str, Any]] = []
76
- try:
77
- text = path.read_text(encoding="utf-8")
78
- except OSError as error:
79
- raise CheckError(f"cannot read {path}: {error}") from error
80
- for number, line in enumerate(text.splitlines(), start=1):
81
- stripped = line.strip()
82
- if not stripped or stripped.startswith("#"):
83
- continue
84
- try:
85
- record = json.loads(stripped, parse_constant=_reject_json_constant)
86
- except json.JSONDecodeError as error:
87
- raise CheckError(f"{path.name} line {number} is not valid JSON: {error}") from error
88
- if not isinstance(record, dict):
89
- raise CheckError(f"{path.name} line {number} is not a JSON object")
90
- records.append(record)
91
- if records and records[0].get("record_type") == SOURCES_RECORD_TYPE:
92
- del records[0]
93
- return records
94
-
95
-
96
- def _finding(code: str, motion_id: str, message: str, **extra: Any) -> dict[str, Any]:
97
- return {
98
- "code": code,
99
- "rule": "VID-04",
100
- "severity": "error",
101
- "motion_id": motion_id,
102
- "message": message,
103
- **extra,
104
- }
105
-
106
-
107
- def _parse_interval(value: Any) -> tuple[float, float] | None:
108
- """Return an explicit ``(start, end)`` window, or None when unparseable."""
109
-
110
- if isinstance(value, dict):
111
- start = _finite_number(value.get("start_seconds"))
112
- end = _finite_number(value.get("end_seconds"))
113
- if start is not None and end is not None:
114
- return start, end
115
- return None
116
- if isinstance(value, str):
117
- match = INTERVAL_RE.match(value)
118
- if match:
119
- return float(match.group("start")), float(match.group("end"))
120
- return None
121
-
122
-
123
- def _union_length(windows: list[tuple[float, float]]) -> float:
124
- """Total time occupied by ``windows``, counting overlap once."""
125
-
126
- total = 0.0
127
- cursor: float | None = None
128
- span_end = 0.0
129
- for start, end in sorted(windows):
130
- if cursor is None or start > span_end:
131
- if cursor is not None:
132
- total += span_end - cursor
133
- cursor, span_end = start, end
134
- else:
135
- span_end = max(span_end, end)
136
- if cursor is not None:
137
- total += span_end - cursor
138
- return total
139
-
140
-
141
- def _accepted_duration(
142
- spec: dict[str, Any], shots_by_id: dict[str, dict[str, Any]]
143
- ) -> tuple[float | None, str | None]:
144
- """Resolve the accepted shot duration and any conflict with the projection.
145
-
146
- ``boundary_refs.duration.value_seconds`` is a read-only projection of the
147
- shot record. When both are present and disagree, the projection is stale and
148
- the arithmetic below would be checked against a number the storyboard never
149
- accepted, so that is reported instead of silently preferring one.
150
- """
151
-
152
- boundary = spec.get("boundary_refs")
153
- duration_ref = boundary.get("duration") if isinstance(boundary, dict) else None
154
- projected: float | None = None
155
- record_id: str | None = None
156
- if isinstance(duration_ref, dict):
157
- projected = _finite_number(duration_ref.get("value_seconds"))
158
- if isinstance(duration_ref.get("record_id"), str):
159
- record_id = duration_ref["record_id"]
160
-
161
- shot_ref = spec.get("shot_ref")
162
- if record_id is None and isinstance(shot_ref, dict):
163
- if isinstance(shot_ref.get("record_id"), str):
164
- record_id = shot_ref["record_id"]
165
-
166
- authoritative: float | None = None
167
- if record_id is not None:
168
- shot = shots_by_id.get(record_id)
169
- if isinstance(shot, dict):
170
- authoritative = _finite_number(shot.get("duration_seconds"))
171
- elif projected is not None:
172
- # A reference naming no shot used to fall through to
173
- # `resolved = projected`, so the arithmetic was then checked against
174
- # the spec's own self-declared number and the staleness guard never
175
- # ran -- one mistyped character switched off VID-04. Only reported
176
- # when a projection exists: with neither a shot nor a projection
177
- # there is nothing to be wrong about, and `unmeasured` stays the
178
- # honest answer.
179
- return None, (
180
- f"duration reference {record_id} resolves to no shot, so the "
181
- f"projected {projected}s is checked against nothing"
182
- )
183
-
184
- if projected is not None and authoritative is not None:
185
- if abs(projected - authoritative) > TOLERANCE_SECONDS:
186
- return None, (
187
- f"projected duration {projected}s does not match accepted "
188
- f"{record_id} duration {authoritative}s"
189
- )
190
- return authoritative, None
191
- resolved = authoritative if authoritative is not None else projected
192
- return resolved, None
193
-
194
-
195
- def check(
196
- specs: list[dict[str, Any]], shots: list[dict[str, Any]]
197
- ) -> dict[str, Any]:
198
- shots_by_id = {
199
- shot["shot_id"]: shot
200
- for shot in shots
201
- if isinstance(shot.get("shot_id"), str)
202
- }
203
- findings: list[dict[str, Any]] = []
204
- checked = 0
205
- # Reported, not a finding: a relative plan makes no arithmetic claim, so it
206
- # is out of scope rather than passing. Keeping the count visible stops a
207
- # file of entirely relative plans from reading as a clean explicit check.
208
- relative: list[str] = []
209
- unmeasured: list[str] = []
210
-
211
- for index, spec in enumerate(specs):
212
- motion_id = spec.get("motion_id")
213
- if not isinstance(motion_id, str) or not motion_id:
214
- raise CheckError(f"motion spec {index} has no motion_id")
215
-
216
- plan = spec.get("timing_plan")
217
- mode = plan.get("mode") if isinstance(plan, dict) else None
218
- segments = [
219
- entry.get("timing")
220
- for entry in spec.get("ordered_subject_motion") or []
221
- if isinstance(entry, dict)
222
- ]
223
- explicit_segments = [
224
- timing
225
- for timing in segments
226
- if isinstance(timing, dict) and timing.get("mode") == "explicit"
227
- ]
228
- # Whether the projection matches the shot is a fact about the data, not
229
- # about the timing mode, so it is checked before the mode gate below.
230
- # Gated behind `explicit` it never ran on a file of relative plans --
231
- # which is every spec in the recorded run -- and a projection authored
232
- # wrong from the start was carried downstream unchallenged.
233
- duration, conflict = _accepted_duration(spec, shots_by_id)
234
- if conflict is not None:
235
- findings.append(_finding("VID_DURATION_PROJECTION_STALE", motion_id, conflict))
236
- continue
237
-
238
- if mode != "explicit":
239
- # A relative plan makes no arithmetic claim, so it is out of scope
240
- # by contract rather than passing. Explicit segments underneath one
241
- # are a contradiction the creator has to resolve — silently doing
242
- # the arithmetic anyway would judge a plan the docs promise not to.
243
- if explicit_segments:
244
- findings.append(
245
- _finding(
246
- "VID_TIMING_MODE_INCONSISTENT",
247
- motion_id,
248
- f"timing_plan mode is {mode!r} but "
249
- f"{len(explicit_segments)} segment(s) declare explicit timing",
250
- )
251
- )
252
- else:
253
- relative.append(motion_id)
254
- continue
255
-
256
- if duration is None:
257
- # Cannot be judged either way, and staying silent here is the
258
- # failure mode this script exists to remove.
259
- unmeasured.append(motion_id)
260
- continue
261
-
262
- windows: list[tuple[float, float]] = []
263
- unparseable: list[Any] = []
264
- for timing in explicit_segments:
265
- interval = _parse_interval(timing.get("value"))
266
- if interval is None:
267
- unparseable.append(timing.get("value"))
268
- else:
269
- windows.append(interval)
270
- if unparseable:
271
- findings.append(
272
- _finding(
273
- "VID_EXPLICIT_TIMING_UNPARSEABLE",
274
- motion_id,
275
- "explicit segment timing is not a readable seconds interval",
276
- values=unparseable,
277
- )
278
- )
279
- continue
280
- if not windows:
281
- findings.append(
282
- _finding(
283
- "VID_EXPLICIT_TIMING_UNPARSEABLE",
284
- motion_id,
285
- "timing_plan declares explicit mode but no segment carries an interval",
286
- )
287
- )
288
- continue
289
-
290
- inverted = [
291
- f"{start}-{end}"
292
- for start, end in windows
293
- if start < 0 or end < 0 or end < start
294
- ]
295
- if inverted:
296
- findings.append(
297
- _finding(
298
- "VID_EXPLICIT_TIMING_UNPARSEABLE",
299
- motion_id,
300
- "explicit segment ends before it starts",
301
- values=inverted,
302
- )
303
- )
304
- continue
305
-
306
- checked += 1
307
- ordered = sorted(windows)
308
- declared_overlap = bool(
309
- isinstance(plan, dict) and plan.get("declares_overlap") is True
310
- )
311
- overlaps = [
312
- f"{ordered[position - 1][0]}-{ordered[position - 1][1]} / {start}-{end}"
313
- for position, (start, end) in enumerate(ordered)
314
- if position and start < ordered[position - 1][1] - TOLERANCE_SECONDS
315
- ]
316
- if overlaps and not declared_overlap:
317
- findings.append(
318
- _finding(
319
- "VID_EXPLICIT_TIMING_UNDECLARED_OVERLAP",
320
- motion_id,
321
- "segments overlap without an explicit overlap declaration",
322
- values=overlaps,
323
- )
324
- )
325
- continue
326
-
327
- # The two failure directions are measured from different quantities on
328
- # purpose. Comparing one total against the duration hides the plan that
329
- # commits both at once: 0.0-2.0 plus 3.0-5.0 covers exactly 4.0s of a
330
- # 4.0s shot, so the totals match — while a segment runs a second past
331
- # the end (truncated) and the 2-3s window sits unallocated (the written
332
- # action stretches over it). Overflow is therefore read off the endpoint, and
333
- # shortfall off the union clipped to the shot.
334
- covered = _union_length(ordered)
335
- # max(), not ordered[-1][1]: sorting (start, end) tuples orders by
336
- # start, so the last element is the latest-starting segment, whose end
337
- # can sit well inside an earlier segment that encloses it. A sustained
338
- # motion spanning the shot with beats inside it — exactly what
339
- # motion-recipe.md tells creators to mark `declares_overlap` — would
340
- # otherwise have its overrun read off one of the inner beats.
341
- last_end = max((end for _, end in ordered), default=0.0)
342
- inside = _union_length(
343
- [
344
- (max(start, 0.0), min(end, duration))
345
- for start, end in ordered
346
- if min(end, duration) > max(start, 0.0)
347
- ]
348
- )
349
-
350
- if last_end - duration > TOLERANCE_SECONDS:
351
- findings.append(
352
- _finding(
353
- "VID_EXPLICIT_TIMING_OVERFLOW",
354
- motion_id,
355
- f"explicit timing runs to {last_end}s on an accepted {duration}s shot; "
356
- "everything past the end is truncated",
357
- accepted_duration_seconds=duration,
358
- endpoint_seconds=round(last_end, 6),
359
- overflow_seconds=round(last_end - duration, 6),
360
- )
361
- )
362
- unallocated = duration - inside
363
- if unallocated > TOLERANCE_SECONDS:
364
- findings.append(
365
- _finding(
366
- "VID_EXPLICIT_TIMING_SHORTFALL",
367
- motion_id,
368
- f"explicit timing leaves {round(unallocated, 6)}s of an accepted "
369
- f"{duration}s shot unallocated; the written action may be stretched "
370
- "to fill it, so the written seconds may not be where it lands",
371
- accepted_duration_seconds=duration,
372
- allocated_seconds=round(inside, 6),
373
- unallocated_seconds=round(unallocated, 6),
374
- )
375
- )
376
-
377
- declared_total = (
378
- plan.get("declared_total_or_endpoint_seconds") if isinstance(plan, dict) else None
379
- )
380
- # The field is named `declared_total_or_endpoint_seconds`, so both
381
- # readings are legitimate and they differ whenever overlap is declared.
382
- # Matching either is a pass; insisting on one would make a correct plan
383
- # fail under the other spelling.
384
- declared_number = _finite_number(declared_total)
385
- if declared_total is not None and declared_number is None:
386
- findings.append(
387
- _finding(
388
- "VID_DECLARED_TOTAL_MISMATCH",
389
- motion_id,
390
- "timing_plan declared total must be a finite non-negative number",
391
- )
392
- )
393
- elif declared_number is not None and declared_number < 0:
394
- findings.append(
395
- _finding(
396
- "VID_DECLARED_TOTAL_MISMATCH",
397
- motion_id,
398
- "timing_plan declared total must be a finite non-negative number",
399
- declared_seconds=declared_number,
400
- )
401
- )
402
- elif declared_number is not None and not any(
403
- abs(declared_number - candidate) <= TOLERANCE_SECONDS
404
- for candidate in (covered, last_end)
405
- ):
406
- findings.append(
407
- _finding(
408
- "VID_DECLARED_TOTAL_MISMATCH",
409
- motion_id,
410
- f"timing_plan declares {declared_total}s, which is neither the "
411
- f"{round(covered, 6)}s its segments occupy nor their "
412
- f"{round(last_end, 6)}s endpoint",
413
- declared_seconds=declared_number,
414
- covered_seconds=round(covered, 6),
415
- endpoint_seconds=round(last_end, 6),
416
- )
417
- )
418
-
419
- return {
420
- "schema_version": SCHEMA_VERSION,
421
- "motion_specs": len(specs),
422
- "explicit_checked": checked,
423
- "relative_plans": relative,
424
- "unmeasured_specs": unmeasured,
425
- "findings": findings,
426
- "status": "pass" if not findings else "fail",
427
- }
428
-
429
-
430
- def build_parser() -> argparse.ArgumentParser:
431
- parser = argparse.ArgumentParser(
432
- description="Check explicit motion timing against accepted shot duration (VID-04)."
433
- )
434
- parser.add_argument("motion_specs", type=Path, help="the episode motion specs JSONL")
435
- parser.add_argument("--shots", type=Path, required=True)
436
- return parser
437
-
438
-
439
- def main(argv: list[str] | None = None) -> int:
440
- args = build_parser().parse_args(argv)
441
- try:
442
- result = check(_load_jsonl(args.motion_specs), _load_jsonl(args.shots))
443
- except CheckError as error:
444
- print(f"{type(error).__name__}: {error}", file=sys.stderr)
445
- return 2
446
- print(json.dumps(result, ensure_ascii=True, sort_keys=True, allow_nan=False))
447
- return 0 if result["status"] == "pass" else 1
448
-
449
-
450
- if __name__ == "__main__":
451
- raise SystemExit(main())