@oh-story/dsh 0.1.9 → 0.1.10

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 (197) hide show
  1. package/README.md +12 -12
  2. package/lib/client.js +204 -72
  3. package/lib/client.js.map +3 -3
  4. package/lib/drama/manifest.json +84 -74
  5. package/lib/drama/skills/short-drama/SKILL.md +2 -2
  6. package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +14 -0
  7. package/lib/drama/skills/short-drama-assets/references/character-and-look.md +10 -0
  8. package/lib/drama/skills/short-drama-assets/references/continuity-lock.md +6 -9
  9. package/lib/drama/skills/short-drama-assets/references/voice-direction.md +28 -0
  10. package/lib/drama/skills/short-drama-edit/SKILL.md +27 -29
  11. package/lib/drama/skills/short-drama-edit/assets//345/211/252/350/276/221/345/215/225.md +2 -2
  12. package/lib/drama/skills/short-drama-edit/references/cut-craft.md +24 -67
  13. package/lib/drama/skills/short-drama-edit/references/delivery-verify.md +27 -56
  14. package/lib/drama/skills/short-drama-edit/references/generated-footage.md +37 -79
  15. package/lib/drama/skills/short-drama-edit/references/sound-and-subtitles.md +39 -92
  16. package/lib/drama/skills/short-drama-edit/references/stage-contract.md +12 -16
  17. package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +126 -11
  18. package/lib/drama/skills/short-drama-edit/scripts/selftest.py +64 -0
  19. package/lib/drama/skills/short-drama-image-prompts/SKILL.md +2 -0
  20. package/lib/drama/skills/short-drama-image-prompts/references/common-recipe.md +11 -0
  21. package/lib/drama/skills/short-drama-produce/SKILL.md +5 -1
  22. package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +14 -20
  23. package/lib/drama/skills/short-drama-produce/references/providers/minimax-h3-video.md +8 -26
  24. package/lib/drama/skills/short-drama-produce/references/providers/minimax-speech.md +41 -0
  25. package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +142 -1
  26. package/lib/drama/skills/short-drama-review/SKILL.md +2 -3
  27. package/lib/drama/skills/short-drama-review/references/production-quality-gates.md +6 -19
  28. package/lib/drama/skills/short-drama-review/references/rubric-visual-motion.md +9 -35
  29. package/lib/drama/skills/short-drama-storyboard/SKILL.md +10 -6
  30. package/lib/drama/skills/short-drama-storyboard/references/lighting-craft.md +39 -0
  31. package/lib/drama/skills/short-drama-storyboard/references/shot-craft.md +58 -21
  32. package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +1 -1
  33. package/lib/drama/skills/short-drama-video-prompts/SKILL.md +10 -9
  34. package/lib/drama/skills/short-drama-video-prompts/references/camera-audio-continuity.md +17 -0
  35. package/lib/drama/skills/short-drama-video-prompts/references/minimax-h3.md +12 -38
  36. package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +4 -7
  37. package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +8 -22
  38. package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +6 -19
  39. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.0.md +2 -3
  40. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +34 -9
  41. package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +2 -2
  42. package/lib/drama/skills/short-drama-write/SKILL.md +2 -4
  43. package/lib/index.js +289 -91
  44. package/lib/index.js.map +4 -4
  45. package/lib/novel-to-game/README.md +299 -59
  46. package/lib/novel-to-game/README_ZH.md +299 -56
  47. package/lib/novel-to-game/VERSION +1 -1
  48. package/lib/novel-to-game/examples/jin-ping-mei/build/BUILD_BRIEF.md +3 -1
  49. package/lib/novel-to-game/examples/jin-ping-mei/build/app/RUN.md +11 -3
  50. package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/new-guofeng.css +397 -8
  51. package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/style.css +0 -31
  52. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/data.js +71 -73
  53. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/engine.js +78 -89
  54. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/main.js +37 -14
  55. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/text.js +5 -5
  56. package/lib/novel-to-game/examples/jin-ping-mei/build/app/package.json +2 -1
  57. package/lib/novel-to-game/examples/jin-ping-mei/qa/README.md +11 -16
  58. package/lib/novel-to-game/examples/jin-ping-mei/qa/verification.json +11 -11
  59. package/lib/novel-to-game/examples/jin-ping-mei/qa/verify_playable_model.mjs +23 -0
  60. package/lib/novel-to-game/manifest.json +77 -87
  61. package/lib/novel-to-game/skills/game-art-direction/SKILL.md +7 -12
  62. package/lib/novel-to-game/skills/game-art-direction/references/art-direction-method.md +27 -106
  63. package/lib/novel-to-game/skills/game-build/SKILL.md +13 -14
  64. package/lib/novel-to-game/skills/game-build/references/blender-asset-workflow.md +24 -0
  65. package/lib/novel-to-game/skills/game-build/references/build-brief-contract.md +45 -82
  66. package/lib/novel-to-game/skills/game-build/references/generative-media-pipeline.md +14 -45
  67. package/lib/novel-to-game/skills/game-build/references/playable-model-contract.md +30 -65
  68. package/lib/novel-to-game/skills/game-build/references/tts-production-contract.md +13 -27
  69. package/lib/novel-to-game/skills/game-concept/SKILL.md +13 -15
  70. package/lib/novel-to-game/skills/game-concept/references/concept-method.md +28 -75
  71. package/lib/novel-to-game/skills/game-qa/SKILL.md +6 -7
  72. package/lib/novel-to-game/skills/game-qa/references/test-design-method.md +9 -5
  73. package/lib/novel-to-game/skills/game-world-design/SKILL.md +12 -16
  74. package/lib/novel-to-game/skills/game-world-design/references/narrative-design-method.md +23 -72
  75. package/lib/novel-to-game/skills/game-world-design/references/numeric-design-method.md +14 -48
  76. package/lib/novel-to-game/skills/game-world-design/references/world-design-method.md +36 -103
  77. package/lib/novel-to-game/skills/novel-game-analyze/SKILL.md +4 -10
  78. package/lib/novel-to-game/skills/novel-game-analyze/references/gameability-protocol.md +4 -4
  79. package/lib/novel-to-game/skills/novel-to-game/SKILL.md +9 -21
  80. package/lib/novel-to-game/skills/novel-to-game/references/intake-method.md +5 -4
  81. package/lib/novel-to-game/skills/novel-to-game/references/pipeline-contract.md +5 -8
  82. package/lib/oh-story/manifest.json +230 -185
  83. package/lib/oh-story/roles/chapter-extractor.md +177 -237
  84. package/lib/oh-story/roles/consistency-checker.md +4 -4
  85. package/lib/oh-story/roles/narrative-writer.md +24 -25
  86. package/lib/oh-story/roles/story-architect.md +5 -5
  87. package/lib/oh-story/roles/story-explorer.md +11 -11
  88. package/lib/oh-story/skills/story/SKILL.md +13 -7
  89. package/lib/oh-story/skills/story/VERSION +1 -1
  90. package/lib/oh-story/skills/story/references/author-memory.md +54 -24
  91. package/lib/oh-story/skills/story/scripts/author_memory_commit.py +934 -170
  92. package/lib/oh-story/skills/story-cover/SKILL.md +10 -1
  93. package/lib/oh-story/skills/story-deslop/SKILL.md +40 -53
  94. package/lib/oh-story/skills/story-deslop/references/anti-ai-writing.md +11 -10
  95. package/lib/oh-story/skills/story-deslop/references/author-memory.md +54 -24
  96. package/lib/oh-story/skills/story-deslop/references/deslop-gates.md +1 -1
  97. package/lib/oh-story/skills/story-deslop/scripts/author_memory_commit.py +934 -170
  98. package/lib/oh-story/skills/story-import/SKILL.md +77 -81
  99. package/lib/oh-story/skills/story-import/references/character-state-reverse.md +1 -1
  100. package/lib/oh-story/skills/story-import/references/state-tracking.md +4 -69
  101. package/lib/oh-story/skills/story-import/references/structure-mapping-long.md +10 -8
  102. package/lib/oh-story/skills/story-import/references/tracking-transaction.md +8 -6
  103. package/lib/oh-story/skills/story-import/scripts/check-outline-contract.js +34 -16
  104. package/lib/oh-story/skills/story-import/scripts/tracking_commit.py +210 -2
  105. package/lib/oh-story/skills/story-import/scripts/wordcount_core.py +3 -0
  106. package/lib/oh-story/skills/story-long-analyze/SKILL.md +91 -258
  107. package/lib/oh-story/skills/story-long-analyze/references/author-facing.md +245 -0
  108. package/lib/oh-story/skills/story-long-analyze/references/deconstruction-notes.md +2 -2
  109. package/lib/oh-story/skills/story-long-analyze/references/inspiration-library.md +105 -0
  110. package/lib/oh-story/skills/story-long-analyze/references/material-decomposition.md +113 -105
  111. package/lib/oh-story/skills/story-long-analyze/references/output-templates.md +169 -125
  112. package/lib/oh-story/skills/story-long-analyze/references/pipeline-ops.md +172 -64
  113. package/lib/oh-story/skills/story-long-analyze/references/semantic-acceptance-fixtures.md +84 -0
  114. package/lib/oh-story/skills/story-long-analyze/references/style-profile-generator.md +42 -132
  115. package/lib/oh-story/skills/story-long-analyze/references/style-profile-protocol.md +5 -5
  116. package/lib/oh-story/skills/story-long-analyze/scripts/build_chapter_index.py +466 -0
  117. package/lib/oh-story/skills/story-long-analyze/scripts/inspect_existing_assets.py +702 -0
  118. package/lib/oh-story/skills/story-long-analyze/scripts/inspiration_index.py +864 -0
  119. package/lib/oh-story/skills/story-long-analyze/scripts/manage_analysis_run.py +1106 -0
  120. package/lib/oh-story/skills/story-long-analyze/scripts/render_relation_chart.py +425 -0
  121. package/lib/oh-story/skills/story-long-scan/SKILL.md +4 -1
  122. package/lib/oh-story/skills/story-long-write/SKILL.md +38 -29
  123. package/lib/oh-story/skills/story-long-write/references/anti-ai-writing.md +11 -10
  124. package/lib/oh-story/skills/story-long-write/references/artifact-protocols.md +25 -20
  125. package/lib/oh-story/skills/story-long-write/references/author-memory.md +54 -24
  126. package/lib/oh-story/skills/story-long-write/references/benchmark-recall.md +3 -3
  127. package/lib/oh-story/skills/story-long-write/references/cross-book-recall.md +26 -0
  128. package/lib/oh-story/skills/story-long-write/references/dialogue-mastery.md +4 -0
  129. package/lib/oh-story/skills/story-long-write/references/emotional-methods.md +2 -2
  130. package/lib/oh-story/skills/story-long-write/references/long-chapter-quality.md +4 -13
  131. package/lib/oh-story/skills/story-long-write/references/outline-structure-theory.md +1 -1
  132. package/lib/oh-story/skills/story-long-write/references/project-files.md +9 -7
  133. package/lib/oh-story/skills/story-long-write/references/reader-contract-and-progression.md +12 -4
  134. package/lib/oh-story/skills/story-long-write/references/reference-index.md +1 -2
  135. package/lib/oh-story/skills/story-long-write/references/tracking-transaction.md +8 -6
  136. package/lib/oh-story/skills/story-long-write/references/workflow-chapter.md +86 -115
  137. package/lib/oh-story/skills/story-long-write/references/workflow-daily.md +55 -120
  138. package/lib/oh-story/skills/story-long-write/references/workflow-revision.md +15 -11
  139. package/lib/oh-story/skills/story-long-write/references/workflow-setup.md +66 -68
  140. package/lib/oh-story/skills/story-long-write/references/writer-prompt-fallback.md +3 -3
  141. package/lib/oh-story/skills/story-long-write/references/writing-craft.md +4 -12
  142. package/lib/oh-story/skills/story-long-write/scripts/author_memory_commit.py +934 -170
  143. package/lib/oh-story/skills/story-long-write/scripts/build_writer_prompt.py +139 -18
  144. package/lib/oh-story/skills/story-long-write/scripts/check-outline-contract.js +34 -16
  145. package/lib/oh-story/skills/story-long-write/scripts/outline_view.py +5 -3
  146. package/lib/oh-story/skills/story-long-write/scripts/storyctl.py +58 -0
  147. package/lib/oh-story/skills/story-long-write/scripts/tracking_commit.py +210 -2
  148. package/lib/oh-story/skills/story-long-write/scripts/wordcount_core.py +3 -0
  149. package/lib/oh-story/skills/story-review/SKILL.md +68 -85
  150. package/lib/oh-story/skills/story-review/references/anti-ai-writing.md +11 -10
  151. package/lib/oh-story/skills/story-review/references/author-memory.md +54 -24
  152. package/lib/oh-story/skills/story-review/references/dialogue-mastery.md +4 -0
  153. package/lib/oh-story/skills/story-review/references/review-quality.md +4 -10
  154. package/lib/oh-story/skills/story-review/references/tracking-transaction.md +8 -6
  155. package/lib/oh-story/skills/story-review/scripts/author_memory_commit.py +934 -170
  156. package/lib/oh-story/skills/story-review/scripts/tracking_commit.py +210 -2
  157. package/lib/oh-story/skills/story-review/scripts/wordcount_core.py +3 -0
  158. package/lib/oh-story/skills/story-setup/SKILL.md +50 -42
  159. package/lib/oh-story/skills/story-setup/references/agent-references/anti-ai-writing.md +11 -10
  160. package/lib/oh-story/skills/story-setup/references/agent-references/deslop-gates.md +1 -1
  161. package/lib/oh-story/skills/story-setup/references/agent-references/dialogue-mastery.md +4 -0
  162. package/lib/oh-story/skills/story-setup/references/agent-references/long-emotional-methods.md +2 -2
  163. package/lib/oh-story/skills/story-setup/references/agent-references/long-quality.md +1 -1
  164. package/lib/oh-story/skills/story-setup/references/agent-references/writing-craft.md +4 -12
  165. package/lib/oh-story/skills/story-setup/references/diagnostics.md +1 -1
  166. package/lib/oh-story/skills/story-short-analyze/SKILL.md +22 -3
  167. package/lib/oh-story/skills/story-short-analyze/references/material-decomposition.md +5 -1
  168. package/lib/oh-story/skills/story-short-scan/SKILL.md +5 -1
  169. package/lib/oh-story/skills/story-short-write/SKILL.md +6 -147
  170. package/lib/oh-story/skills/story-short-write/references/author-memory.md +54 -24
  171. package/lib/oh-story/skills/story-short-write/references/dialogue-mastery.md +4 -0
  172. package/lib/oh-story/skills/story-short-write/references/reference-index.md +2 -0
  173. package/lib/oh-story/skills/story-short-write/references/workflow-design.md +3 -2
  174. package/lib/oh-story/skills/story-short-write/references/workflow-draft.md +126 -0
  175. package/lib/oh-story/skills/story-short-write/references/workflow-revision.md +32 -0
  176. package/lib/oh-story/skills/story-short-write/scripts/author_memory_commit.py +934 -170
  177. package/lib/types/client/drama-production-view.d.ts.map +1 -1
  178. package/lib/types/client/file-activity.d.ts +19 -1
  179. package/lib/types/client/file-activity.d.ts.map +1 -1
  180. package/lib/types/client/index.d.ts.map +1 -1
  181. package/lib/types/client/production-prompts.d.ts +7 -0
  182. package/lib/types/client/production-prompts.d.ts.map +1 -1
  183. package/lib/types/client/production-runtime.d.ts +16 -0
  184. package/lib/types/client/production-runtime.d.ts.map +1 -1
  185. package/lib/types/drama-adapters.d.ts +5 -3
  186. package/lib/types/drama-adapters.d.ts.map +1 -1
  187. package/lib/types/native-hooks.d.ts +52 -0
  188. package/lib/types/native-hooks.d.ts.map +1 -1
  189. package/lib/types/role-identity.d.ts +17 -0
  190. package/lib/types/role-identity.d.ts.map +1 -0
  191. package/lib/types/role-tool.d.ts.map +1 -1
  192. package/lib/types/skill-provider.d.ts.map +1 -1
  193. package/package.json +45 -45
  194. package/lib/novel-to-game/skills/game-world-design/references/dialogue-design-method.md +0 -58
  195. package/lib/novel-to-game/skills/game-world-design/references/game-writing-craft.md +0 -53
  196. package/lib/novel-to-game/skills/novel-to-game/references/intake-benchmark-reference.md +0 -38
  197. package/lib/oh-story/skills/story-long-write/references/state-tracking.md +0 -108
@@ -91,6 +91,10 @@ PICTURE_LIMITS = {
91
91
  "warmth": (-30.0, 30.0),
92
92
  }
93
93
  TOLERANCE = 0.005
94
+ # ffmpeg's `noise` strength runs to 100, which is snow, not grain. The useful
95
+ # band for a finished film is single digits; the cap keeps a typo from shipping
96
+ # a broken-signal look that measures perfectly fine.
97
+ GRAIN_LIMIT = 20.0
94
98
 
95
99
 
96
100
  class EditError(Exception):
@@ -116,6 +120,7 @@ class Delivery(NamedTuple):
116
120
  burn_subtitles: bool
117
121
  frame_size: Optional[tuple[int, int]]
118
122
  fps: Optional[float]
123
+ grain: Optional[float]
119
124
 
120
125
 
121
126
  def _seconds(raw: str, *, field: str, line: int) -> float:
@@ -131,6 +136,7 @@ def _parse_delivery(lines: Sequence[str]) -> Delivery:
131
136
  burn = True
132
137
  frame_size: Optional[tuple[int, int]] = None
133
138
  fps: Optional[float] = None
139
+ grain: Optional[float] = None
134
140
  for raw in lines:
135
141
  match = FIELD.match(raw)
136
142
  if not match:
@@ -146,6 +152,23 @@ def _parse_delivery(lines: Sequence[str]) -> Delivery:
146
152
  loudness = float(found.group(0))
147
153
  elif name == "字幕":
148
154
  burn = "无" != value.strip() and "不烧" not in value
155
+ elif name == "颗粒":
156
+ # Grain belongs to the delivery spec rather than to a cut, for the
157
+ # same reason loudness does: applied per cut it would become one
158
+ # more thing that differs between segments, which is the defect it
159
+ # is here to cover.
160
+ if value.strip() in {"无", "不加"}:
161
+ grain = None
162
+ else:
163
+ found = re.search(r"[0-9]+(?:\.[0-9]+)?", value)
164
+ if found:
165
+ amount = float(found.group(0))
166
+ if not 0.0 <= amount <= GRAIN_LIMIT:
167
+ raise EditError(
168
+ f"{CUT_LIST_NAME}: 颗粒 {amount} 超出 0–{GRAIN_LIMIT:g};"
169
+ "这一档以上不再像胶片,像信号故障"
170
+ )
171
+ grain = amount or None
149
172
  elif name == "画幅与帧率":
150
173
  size = re.search(r"([0-9]{2,5})\s*[×x*]\s*([0-9]{2,5})", value)
151
174
  if size:
@@ -153,7 +176,7 @@ def _parse_delivery(lines: Sequence[str]) -> Delivery:
153
176
  rate = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*fps", value, re.I)
154
177
  if rate:
155
178
  fps = float(rate.group(1))
156
- return Delivery(target, loudness, burn, frame_size, fps)
179
+ return Delivery(target, loudness, burn, frame_size, fps, grain)
157
180
 
158
181
 
159
182
  def parse_cut_list(path: Path) -> tuple[Delivery, list[Cut], list[str]]:
@@ -371,8 +394,31 @@ def probe_stream(media: Path) -> dict[str, Any]:
371
394
  }
372
395
 
373
396
 
397
+ def _unaccounted_shots(known: set[str], cuts: Sequence[Cut], unused: Sequence[str]) -> list[str]:
398
+ """Require each source to be used or explicitly omitted with a reason."""
399
+
400
+ used = {cut.motion for cut in cuts}
401
+ excused = set()
402
+ for note in unused:
403
+ match = re.fullmatch(r"(MOTION-[\w-]+)\s*[((]理由[::]\s*(.+?)[))]", note.strip())
404
+ if match and match.group(2).strip():
405
+ excused.add(match.group(1))
406
+ missing = sorted(known - used - excused)
407
+ if not missing:
408
+ return []
409
+ return [
410
+ f"{CUT_LIST_NAME}: 以下镜头未采用,且缺少「未采用镜头」及理由:"
411
+ + "、".join(missing)
412
+ ]
413
+
414
+
374
415
  def check_cuts(
375
- episode: Path, cuts: Sequence[Cut], project_root: Path, *, probe: bool
416
+ episode: Path,
417
+ cuts: Sequence[Cut],
418
+ project_root: Path,
419
+ *,
420
+ probe: bool,
421
+ unused: Sequence[str] = (),
376
422
  ) -> list[str]:
377
423
  """Every mechanical cross-check the cut list can be held to. Returns findings."""
378
424
 
@@ -403,6 +449,7 @@ def check_cuts(
403
449
  f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的来源 "
404
450
  f"{cut.motion} 不在《{MOTION_DOCUMENT}》中"
405
451
  )
452
+ findings.extend(_unaccounted_shots(known, cuts, unused))
406
453
  else:
407
454
  findings.append(f"没有 {motion_path},来源 MOTION 无法核对")
408
455
 
@@ -411,6 +458,7 @@ def check_cuts(
411
458
  if screenplay_path.is_file():
412
459
  screenplay = screenplay_path.read_text(encoding="utf-8")
413
460
 
461
+ media_format: Optional[tuple[Any, Any, float]] = None
414
462
  for cut in cuts:
415
463
  span = cut.end - cut.start
416
464
  if cut.start < 0:
@@ -430,7 +478,17 @@ def check_cuts(
430
478
  f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的素材不存在: {cut.media}"
431
479
  )
432
480
  elif probe:
433
- available = probe_duration(media)
481
+ stream = probe_stream(media)
482
+ available = stream["duration"]
483
+ current_format = (stream["width"], stream["height"], stream["fps"])
484
+ if media_format is None:
485
+ media_format = current_format
486
+ elif current_format != media_format:
487
+ findings.append(
488
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的画幅或帧率 "
489
+ f"{current_format} 与首段 {media_format} 不一致;"
490
+ "先在外部统一素材规格,再更新来源路径与入出点"
491
+ )
434
492
  if cut.end > available + TOLERANCE:
435
493
  findings.append(
436
494
  f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 出点 {cut.end:.2f} "
@@ -598,6 +656,17 @@ def render(
598
656
  )
599
657
  filters.append(f"ass='{escaped}'")
600
658
 
659
+ # Grain goes on last, over the whole assembled film, so one texture sits
660
+ # across every cut. `t` makes it move frame to frame — static noise reads
661
+ # as dirt on the lens, not as film.
662
+ grain = (
663
+ f"noise=alls={delivery.grain:g}:allf=t+u"
664
+ if delivery.grain is not None
665
+ else None
666
+ )
667
+ if grain is not None:
668
+ filters.append(grain)
669
+
601
670
  final = output_root / "成片.mp4"
602
671
  command = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y", "-i", str(joined)]
603
672
  if overlay_path is not None:
@@ -605,9 +674,15 @@ def render(
605
674
  # to be named: the default one drops it and the overlay arrives as
606
675
  # an opaque black rectangle.
607
676
  command += ["-c:v", "libvpx", "-i", str(overlay_path)]
677
+ # The overlay branch owns its own chain, so grain is spliced in
678
+ # after the composite rather than left in `filters`, which this
679
+ # branch never reads.
680
+ chain = "[0:v][1:v]overlay=0:0:format=auto"
681
+ if grain is not None:
682
+ chain += f",{grain}"
608
683
  command += [
609
684
  "-filter_complex",
610
- "[0:v][1:v]overlay=0:0:format=auto,format=yuv420p[v]",
685
+ f"{chain},format=yuv420p[v]",
611
686
  "-map", "[v]", "-map", "0:a",
612
687
  "-c:v", "libx264", "-preset", "medium", "-crf", "18",
613
688
  ]
@@ -618,7 +693,7 @@ def render(
618
693
  command += ["-c:v", "copy"]
619
694
  if delivery.loudness_lufs is not None:
620
695
  command += ["-af", _loudnorm_filter(ffmpeg, joined, delivery.loudness_lufs)]
621
- command += ["-c:a", "aac", "-b:a", "192k"]
696
+ command += ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"]
622
697
  else:
623
698
  command += ["-c:a", "copy"]
624
699
  command.append(str(final))
@@ -663,11 +738,10 @@ def _shot_match_filter(cut: Cut) -> str:
663
738
 
664
739
 
665
740
  def _loudnorm_filter(ffmpeg: str, media: Path, target: float) -> str:
666
- """Two-pass EBU R128.
741
+ """Measure EBU R128 before normalization; verify the encoded output afterward.
667
742
 
668
- One pass is a dynamic normalizer that lands several dB from the target; the
669
- delivered loudness would then be a number nobody chose. Measure first, feed
670
- the measurement back, and the second pass is linear.
743
+ FFmpeg can fall back to dynamic processing when linear gain would exceed
744
+ the peak or loudness-range target. Two passes do not guarantee target LUFS.
671
745
  """
672
746
 
673
747
  common = f"I={target}:TP=-1.5:LRA=11"
@@ -894,6 +968,40 @@ def _timecode(seconds: float) -> str:
894
968
  return f"{hours:02d}:{minutes:02d}:{whole:02d},{milliseconds:03d}"
895
969
 
896
970
 
971
+ def _segment_colour(ffmpeg: str, path: Path) -> Optional[tuple[float, float]]:
972
+ """Mean luma and blue-red difference of one segment, on a 0-255 scale."""
973
+
974
+ result = subprocess.run(
975
+ [ffmpeg, "-v", "error", "-i", str(path), "-vf", "fps=2,scale=96:-1",
976
+ "-pix_fmt", "rgb24", "-f", "rawvideo", "-"],
977
+ capture_output=True,
978
+ )
979
+ raw = result.stdout
980
+ if result.returncode != 0 or len(raw) < 3:
981
+ return None
982
+ count = len(raw) // 3
983
+ red = sum(raw[i * 3] for i in range(count)) / count
984
+ green = sum(raw[i * 3 + 1] for i in range(count)) / count
985
+ blue = sum(raw[i * 3 + 2] for i in range(count)) / count
986
+ return 0.299 * red + 0.587 * green + 0.114 * blue, blue - red
987
+
988
+
989
+ def _segment_colours(ffmpeg: str, output_root: Path, cuts: Sequence[Cut]) -> list[dict[str, Any]]:
990
+ """Report current cut segments individually, without imposing a shared grade."""
991
+
992
+ rows: list[dict[str, Any]] = []
993
+ for cut in cuts:
994
+ path = output_root / SEGMENT_DIRECTORY / f"{cut.cut_id}.mp4"
995
+ colour = _segment_colour(ffmpeg, path) if path.is_file() else None
996
+ row: dict[str, Any] = {"分段": path.name}
997
+ if colour is None:
998
+ row["测量"] = "未测(分段缺失或不可读)"
999
+ else:
1000
+ row.update({"平均亮度": round(colour[0], 1), "蓝减红": round(colour[1], 1)})
1001
+ rows.append(row)
1002
+ return rows
1003
+
1004
+
897
1005
  def verify(episode: Path, cuts: Sequence[Cut], delivery: Delivery) -> dict[str, Any]:
898
1006
  """Measure the rendered film. Every entry is a number or an honest 未测."""
899
1007
 
@@ -931,6 +1039,10 @@ def verify(episode: Path, cuts: Sequence[Cut], delivery: Delivery) -> dict[str,
931
1039
  else:
932
1040
  measurements["实测响度 LUFS"] = "未测(loudnorm 没有返回可解析的测量结果)"
933
1041
  measurements["交付响度目标"] = delivery.loudness_lufs
1042
+ measurements["分段色彩观测"] = _segment_colours(ffmpeg, episode / OUTPUT_DIRECTORY, cuts)
1043
+ measurements["画内可读文字"] = (
1044
+ "未测(抽有画内文字的帧,逐字对《剧本.md》的「画面文字」与提示词声明的内容)"
1045
+ )
934
1046
  measurements["台词完整性"] = "未测(本工具不做转写;在成片上转写后逐句对《剧本.md》原文)"
935
1047
  measurements["边界帧"] = "未测(抽剪辑点前后各一帧目视核对黑场/白场/半渲染帧)"
936
1048
  return measurements
@@ -1004,7 +1116,8 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
1004
1116
  delivery, cuts, unused = parse_cut_list(episode / CUT_LIST_NAME)
1005
1117
  if arguments.command == "check":
1006
1118
  findings = check_cuts(
1007
- episode, cuts, project_root, probe=_which("ffprobe") is not None
1119
+ episode, cuts, project_root,
1120
+ probe=_which("ffprobe") is not None, unused=unused,
1008
1121
  )
1009
1122
  payload: dict[str, Any] = {
1010
1123
  "段数": len(cuts),
@@ -1018,7 +1131,9 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
1018
1131
  _emit(payload)
1019
1132
  return 1 if findings else 0
1020
1133
  if arguments.command == "render":
1021
- findings = check_cuts(episode, cuts, project_root, probe=True)
1134
+ findings = check_cuts(
1135
+ episode, cuts, project_root, probe=True, unused=unused
1136
+ )
1022
1137
  if findings:
1023
1138
  _emit({"findings": findings, "已渲染": False})
1024
1139
  return 1
@@ -15,6 +15,7 @@ from pathlib import Path
15
15
  sys.path.insert(0, str(Path(__file__).resolve().parent))
16
16
 
17
17
  from edit_tool import ( # noqa: E402
18
+ _unaccounted_shots,
18
19
  DEFAULT_REMOTION_CONCURRENCY,
19
20
  REMOTION_SOURCE,
20
21
  REMOTION_SOURCE_FILES,
@@ -377,6 +378,67 @@ def check_remotion_concurrency_is_capped() -> None:
377
378
  require("--concurrency=" in source, "render 必须显式传 --concurrency,不能用 Remotion 的默认值")
378
379
 
379
380
 
381
+ def check_grain_is_a_delivery_wide_decision() -> None:
382
+ """Grain is declared once for the film, and refused when it is not grain.
383
+
384
+ Per cut it would become one more thing that differs between segments, which
385
+ is the defect it exists to cover.
386
+ """
387
+
388
+ with tempfile.TemporaryDirectory() as scratch:
389
+ root = Path(scratch)
390
+ episode = build(root, CUT_LIST)
391
+ path = episode / "剪辑单.md"
392
+ baseline = path.read_text(encoding="utf-8")
393
+
394
+ delivery, _, _ = parse_cut_list(path)
395
+ require(delivery.grain is None, "没声明颗粒时应当是 None")
396
+
397
+ anchor = "- 交付响度:"
398
+ require(anchor in baseline, "夹具里应当有交付响度那一行")
399
+ path.write_text(baseline.replace(anchor, "- 颗粒:6\n" + anchor, 1), encoding="utf-8")
400
+ delivery, _, _ = parse_cut_list(path)
401
+ require(delivery.grain == 6.0, f"颗粒没有解析出来: {delivery.grain}")
402
+
403
+ path.write_text(baseline.replace(anchor, "- 颗粒:无\n" + anchor, 1), encoding="utf-8")
404
+ delivery, _, _ = parse_cut_list(path)
405
+ require(delivery.grain is None, "「无」应当解析成不加颗粒")
406
+
407
+ path.write_text(baseline.replace(anchor, "- 颗粒:80\n" + anchor, 1), encoding="utf-8")
408
+ try:
409
+ parse_cut_list(path)
410
+ except EditError as error:
411
+ require("超出" in str(error), f"越界报错没说清: {error}")
412
+ else:
413
+ raise AssertionError("颗粒 80 应当被拒绝")
414
+
415
+
416
+ def check_missing_shots_are_reported() -> None:
417
+ """A shot absent from the film must be absent on purpose.
418
+
419
+ Absence is invisible in a finished film — nobody watching can tell that an
420
+ episode's opening seven shots were never made. The only place it can be
421
+ caught is here, against the document that lists them.
422
+ """
423
+
424
+ known = {f"MOTION-EP006-{i:03d}" for i in range(1, 5)}
425
+
426
+ class Stub:
427
+ def __init__(self, motion): self.motion = motion
428
+
429
+ used = [Stub("MOTION-EP006-003"), Stub("MOTION-EP006-004")]
430
+ findings = _unaccounted_shots(known, used, [])
431
+ require(findings, "少了两镜却没有任何 finding")
432
+ require("001" in findings[0] and "002" in findings[0], f"没点名缺的镜头: {findings}")
433
+
434
+ excused = _unaccounted_shots(
435
+ known, used, ["MOTION-EP006-001(理由:质量不可用)", "MOTION-EP006-002(理由:文件缺失)"]
436
+ )
437
+ require(not excused, f"写进未采用的镜头不该再报: {excused}")
438
+
439
+ require(not _unaccounted_shots(known, [Stub(m) for m in known], []), "全采用时不该报")
440
+
441
+
380
442
  def main() -> int:
381
443
  with tempfile.TemporaryDirectory() as scratch:
382
444
  root = Path(scratch)
@@ -446,6 +508,8 @@ def main() -> int:
446
508
  check_ass_escaping()
447
509
  check_multi_subtitle()
448
510
  check_stale_window()
511
+ check_missing_shots_are_reported()
512
+ check_grain_is_a_delivery_wide_decision()
449
513
  check_remotion_sources_all_shipped()
450
514
  check_remotion_concurrency_is_capped()
451
515
  print("short-drama-edit self-tests passed")
@@ -20,6 +20,8 @@ license: MIT
20
20
 
21
21
  有当前视觉设定即可直接开始;剧本只在提示词需要确认剧情状态时读取。Look Development 是可选分支。
22
22
  先确认用途:身份板、造型/状态变体、地点板、道具板、组合 production sheet 或比较风格帧。
23
+ 分镜需要生产尾帧时,可把已接受的「收尾关键帧提示词」落实为独立 `IMG-...` 状态图条目:
24
+ 说明来源 SHOT 与终点,沿用视觉事实和参考范围,不重新设计动作。实际生成以该 IMG 条目为来源。
23
25
 
24
26
  创作者可读说明跟随项目语言;可复制正文跟随 `short-drama.json#/format/prompt_language`。没有
25
27
  `short-drama.json` 时正文默认
@@ -203,6 +203,17 @@
203
203
  - **`taste_option`**:写实度、媒介、颗粒、色调和镜头感由视觉风格或创作者决定。
204
204
  - 禁止用固定词表或形容词比例阻断交付;描述是否空泛,由审查者指出具体缺了什么。
205
205
 
206
+ ### 光学与表面描述
207
+
208
+ **`craft_default`**:对需要明确透视或写实质感的画面,写明机位、取景距离和可见表面特征。
209
+ 焦段可辅助表达,但不能据此承诺模型会精确模拟光学。
210
+
211
+ 根据画面尺度选择毛孔、布料折痕或金属使用痕迹;「干净」不必等同于没有任何纹理和折痕。
212
+ 不要给每张图添加相同的相机参数、瑕疵清单或排除词。
213
+
214
+ **`taste_option`**:表面是否平滑、是否修饰,以及写实程度由已接受的视觉风格决定。
215
+ 结果用对照图检查;像素纹理指标只作辅助,不能代替人物与材质的视觉判断。
216
+
206
217
  ## 5. 输出、常见问题与检查
207
218
 
208
219
  ### 输出
@@ -16,7 +16,8 @@ license: MIT
16
16
  取出本次提示词,建立一个有边界的运行 job。creator-first job 的 `source` 必须指向拥有这条提示词的
17
17
  当前 Markdown,`source_entry` 必须点名该文档允许的二级标题:`图片提示词.md` 用 `IMG-*`,
18
18
  `视频提示词.md` 用 `MOTION-*`,`分镜.md` 用 `SHOT-*`(modality 为 `image`,正文取该镜的
19
- `### 冻结关键帧提示词`)。分镜这一路是本套件里唯一能把某一镜的起始画面渲染成文件的入口,但不是创作者获得起始帧的唯一办法——
19
+ `### 冻结关键帧提示词`)。此选择器只提取首帧;尾帧需先由图片提示词阶段建立独立 `IMG-*` 状态图条目,
20
+ 再从 `图片提示词.md` 准备 image job。创作者也可以提供已有起始帧——
20
21
  在自己的工具里出图再放进项目同样成立,图片始终不进项目时走分镜的 `PLAN-...`;
21
22
  产出落在 `剧集/<EP>/制作成果/images/` 后,分镜 owner 才能把它绑成 `用途:起始帧` 的 `REF-...`。存在真实参考图时,
22
23
  还必须逐张填写 `reference_bindings` 的槽位、顺序、路径、中文名、用途以及允许/禁止控制范围;
@@ -126,6 +127,9 @@ adapter 配置必须在项目外,只包含 argv 命令和超时;凭据由 ad
126
127
  固定高保真引用并校验尺寸、格式与透明背景限制。
127
128
  - [MiniMax Music](references/providers/minimax-music.md):使用 `music-3.0` 与 hex 结果,区分主题曲
128
129
  和纯配乐,不伪造时长请求字段。
130
+ - [MiniMax 语音](references/providers/minimax-speech.md):`tts` 模态;模型与 `voice_id` 都必须
131
+ 由账号与文档显式给出,不内置音色清单。只从预置音色合成,不做声音克隆——克隆是授权问题,
132
+ 创作者授权的参考录音按普通创作者输入走 `输入/`。
129
133
  - [MiniMax H3 视频](references/providers/minimax-h3-video.md):模型 ID、分辨率集合与时长区间必须
130
134
  由账号显式配置;提示词进 `content` 的 text 项,参考图按显式 role 绑定,本地参考在没有可信上传时
131
135
  fail closed。该模型与画面同一次生成声音,写法影响见视频提示词技能的目标模型能力档案。
@@ -99,6 +99,12 @@ Keep this file outside the project:
99
99
  `command` is an argv array, never a shell string. Timeout is 1–3600 seconds. Do not put credentials in this file;
100
100
  let the adapter read its environment or operating-system credential store.
101
101
 
102
+ ## Capability sources
103
+
104
+ Use the provider's current API reference for roles, limits and supported combinations.
105
+ A relay can expose a different subset, reject inputs itself or transform requests.
106
+ Record the tested execution path and distinguish its observations from the native API contract.
107
+
102
108
  ## Adapter stdin
103
109
 
104
110
  The adapter receives one UTF-8 JSON document as raw stdin bytes. Read the
@@ -121,26 +127,14 @@ on the machine locale. The document contains the confirmed job plus:
121
127
  adapter must **not submit anything**; it polls and downloads that existing
122
128
  task and returns its outputs as usual.
123
129
 
124
- ## Why the handle exists
125
-
126
- A video task is billed the moment it is submitted, not when its result is
127
- collected. Everything after submission — polling for minutes, downloading — can
128
- be interrupted by a killed process, a dropped connection, or a sleeping laptop.
129
- Without a durable id the attempt record says `failed` while the provider's task
130
- is alive and already paid for, and the only way forward is to submit again and
131
- pay a second time for the same shot.
132
-
133
- So: write the handle before the first poll, and treat failing to write it as
134
- non-fatal — a submitted task must never be failed because its id could not be
135
- recorded locally. The tool copies the handle onto the attempt record on both the
136
- success and the failure path, `audit` reports any unfinished attempt that
137
- carries one as `orphaned_provider_job` with `action: collect_before_retry`, and
138
- `production_tool.py collect` fetches it.
139
-
140
- `collect` deliberately sits outside the confirmation gate. That gate exists to
141
- prevent an unintended charge; collecting spends nothing because the charge
142
- already happened. Requiring a fresh confirmation would make paying again the
143
- cheapest way out of an interruption — the exact outcome the gate is for.
130
+ ## Recovering a submitted task
131
+
132
+ Record the provider task ID before polling so an interrupted task can be collected without resubmission.
133
+ A local handle-write failure is non-fatal after submission. The tool copies the handle into the attempt
134
+ record on success or failure; `audit` reports unfinished tasks as `orphaned_provider_job` with
135
+ `action: collect_before_retry`.
136
+
137
+ `production_tool.py collect` retrieves the existing task without new submission or confirmation.
144
138
 
145
139
  It may translate provider-neutral parameters into its chosen SDK/API. Optional
146
140
  provider adapters under `scripts/` document and implement known translations;
@@ -74,31 +74,13 @@ structured: base/first-frame/first-last-frame tasks use `integrated_multimodal_d
74
74
  form. Chinese dialogue remains exact inside `<d>[Chinese] ...</d>`. Full details live in the
75
75
  video-prompt skill's `references/minimax-h3.md`.
76
76
 
77
- Audio generation is a **capability**, not a style:
78
- what changes upstream is only which axes of the target-model profile a project declares — see the
79
- video-prompt skill's target-model profile. Two consequences are worth stating here because they
80
- show up as production defects rather than as API errors:
81
-
82
- - an unstated **music** channel is not a silent one. Probing this suite's own prompts against H3, a
83
- body carrying `non_diegetic_music: N/A` came back without a score in 14 of 14 runs, while a body
84
- that said nothing about music came back with an invented score in 2 of 3. State the music intent.
85
- - the **speech** channel did not behave the same way in those probes: no run invented dialogue,
86
- including 8 runs whose body carried a contradictory `No dialogue` exclusion. Treat "an unstated
87
- vocal channel gets filled" as unverified rather than as a known failure, and write the shot's
88
- sound intent because the body should say what this shot sounds like — not on the strength of a
89
- mechanism this suite has not observed.
90
- - readable on-screen text was **not** produced unmasked in those probes either: across 44 sampled
91
- frames from 11 clips — including dialogue clips carrying no exclusion sentence, on H3 and on
92
- Seedance 2.0/2.5 — no burned-in subtitle or caption appeared. The exclusion sentence this suite
93
- mandates is therefore precautionary **for video** rather than evidence-driven. Keep writing it:
94
- a one-line exclusion costs nothing, these probes are small enough that they only show the failure
95
- was not produced here, and generator behaviour changes between versions. What should change is the
96
- justification — do not defend it as a fix for an observed defect. Note the asymmetry before carrying this reasoning anywhere else:
97
- the same probe run against `gpt-image-2` found the opposite — an unconstrained still came back
98
- covered in invented readable text, and the exclusion sentence cleaned it up. The image stages
99
- keep their text policy on evidence; only the video stage's copy of it is precautionary.
100
-
101
- Neither is written here as a fixed phrase, and this suite never injects one. The wording belongs to
102
- the shot and to the project's declared prompt language.
77
+ State the intended sound layers in the prompt. For no non-diegetic music, the
78
+ H3 dialect uses `non_diegetic_music: N/A`; small probes observed unwanted music
79
+ when this layer was omitted. They did not establish that omitted speech or
80
+ negative clauses cause extra dialogue.
81
+
82
+ Follow the project's subtitle and in-scene-text policy. Prompt wording belongs
83
+ to the video-prompt skill and the declared language, not to adapter-generated
84
+ boilerplate.
103
85
 
104
86
  Protocol reference: [MiniMax video generation API](https://platform.minimax.io/docs/api-reference/video-generation-v2-create).
@@ -0,0 +1,41 @@
1
+ # MiniMax Speech adapter
2
+
3
+ Adapter command:
4
+
5
+ ```json
6
+ {"command": ["python3", "/absolute/path/provider_adapters.py", "minimax-speech"], "timeout_seconds": 600}
7
+ ```
8
+
9
+ Required environment: `MINIMAX_API_KEY`. `MINIMAX_BASE_URL` optionally overrides the default
10
+ `https://api.minimax.io/v1` and must remain HTTPS.
11
+
12
+ The job uses the suite's `tts` modality and must have exactly one output. The production prompt is the
13
+ line to be spoken, verbatim — not a description of it. Supported public parameters are `model`,
14
+ `voice_id`, `emotion`, `speed`, `vol`, `pitch`, `sample_rate`, `bitrate`, and `format`. The requested
15
+ format must match the target extension and be `mp3` or `wav`.
16
+
17
+ `model` and `voice_id` are both required and neither has a default. The model is an account-enabled
18
+ endpoint, exactly as for the video providers. The voice is a creative decision recorded in
19
+ `视觉设定.md` under the character's 声音方向, and it reaches the adapter through the confirmed job.
20
+
21
+ ## Voice selection
22
+
23
+ Read the provider's voice listing and confirm availability for the chosen model and account.
24
+ The adapter validates the ID format, not catalogue membership. Record the selected ID in the
25
+ character's 声音方向 and pass it through the confirmed job.
26
+
27
+ This workflow selects preset voices. The adapter does not enrol or clone a voice;
28
+ any supplied recording retains its documented authorization and usage scope.
29
+
30
+ ## Request shape
31
+
32
+ The request is non-streaming with `output_format: hex`, `POST /t2a_v2`, and carries
33
+ `voice_setting` (`voice_id`, and any of `emotion`, `speed`, `vol`, `pitch`) alongside `audio_setting`
34
+ (`sample_rate`, `bitrate`, `format`). The adapter validates `base_resp.status_code`, decodes
35
+ `data.audio` as hexadecimal bytes, and writes it to a private temporary file.
36
+
37
+ `emotion` accepts the provider's seven values: `happy`, `sad`, `angry`, `fearful`, `disgusted`,
38
+ `surprised`, `neutral`. Anything outside that set is refused rather than passed through — a rejected
39
+ emotion is a typo caught before it is paid for.
40
+
41
+ Protocol reference: [MiniMax Text to Speech](https://platform.minimax.io/docs/api-reference/speech-t2a-http).