@oh-story/dsh 0.1.8 → 0.1.9

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 (272) hide show
  1. package/README.md +11 -11
  2. package/lib/client.js +35 -14
  3. package/lib/client.js.map +2 -2
  4. package/lib/drama/manifest.json +165 -69
  5. package/lib/drama/skills/short-drama/SKILL.md +5 -1
  6. package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +126 -9
  7. package/lib/drama/skills/short-drama/scripts/project_tool.py +11 -0
  8. package/lib/drama/skills/short-drama-assets/references/character-and-look.md +18 -0
  9. package/lib/drama/skills/short-drama-assets/references/continuity-lock.md +10 -1
  10. package/lib/drama/skills/short-drama-edit/SKILL.md +148 -0
  11. package/lib/drama/skills/short-drama-edit/agents/openai.yaml +4 -0
  12. package/lib/drama/skills/short-drama-edit/assets/remotion/README.md +60 -0
  13. package/lib/drama/skills/short-drama-edit/assets/remotion/package.json +15 -0
  14. package/lib/drama/skills/short-drama-edit/assets/remotion/remotion.config.ts +6 -0
  15. package/lib/drama/skills/short-drama-edit/assets/remotion/src/Root.tsx +26 -0
  16. package/lib/drama/skills/short-drama-edit/assets/remotion/src/Subtitles.tsx +88 -0
  17. package/lib/drama/skills/short-drama-edit/assets/remotion/src/font.ts +46 -0
  18. package/lib/drama/skills/short-drama-edit/assets/remotion/src/index.ts +4 -0
  19. package/lib/drama/skills/short-drama-edit/assets/remotion/src/schema.ts +32 -0
  20. package/lib/drama/skills/short-drama-edit/assets/remotion/tsconfig.json +13 -0
  21. package/lib/drama/skills/short-drama-edit/assets//345/211/252/350/276/221/345/215/225.md +43 -0
  22. package/lib/drama/skills/short-drama-edit/references/cut-craft.md +81 -0
  23. package/lib/drama/skills/short-drama-edit/references/delivery-verify.md +58 -0
  24. package/lib/drama/skills/short-drama-edit/references/generated-footage.md +96 -0
  25. package/lib/drama/skills/short-drama-edit/references/sound-and-subtitles.md +113 -0
  26. package/lib/drama/skills/short-drama-edit/references/stage-contract.md +55 -0
  27. package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +1041 -0
  28. package/lib/drama/skills/short-drama-edit/scripts/selftest.py +456 -0
  29. package/lib/drama/skills/short-drama-image-prompts/assets/image-prompts.md +5 -0
  30. package/lib/drama/skills/short-drama-image-prompts/assets/lookdev-prompts.md +3 -0
  31. package/lib/drama/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +2 -2
  32. package/lib/drama/skills/short-drama-novel-analyze/references/chapter-extraction.md +4 -1
  33. package/lib/drama/skills/short-drama-produce/SKILL.md +24 -3
  34. package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +28 -0
  35. package/lib/drama/skills/short-drama-produce/references/providers/minimax-h3-video.md +24 -5
  36. package/lib/drama/skills/short-drama-produce/references/providers/seedance.md +7 -0
  37. package/lib/drama/skills/short-drama-produce/scripts/fixture_adapter.py +18 -1
  38. package/lib/drama/skills/short-drama-produce/scripts/production_tool.py +181 -2
  39. package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +57 -2
  40. package/lib/drama/skills/short-drama-review/SKILL.md +3 -0
  41. package/lib/drama/skills/short-drama-review/references/production-quality-gates.md +20 -0
  42. package/lib/drama/skills/short-drama-review/references/rubric-visual-motion.md +36 -0
  43. package/lib/drama/skills/short-drama-storyboard/SKILL.md +7 -0
  44. package/lib/drama/skills/short-drama-storyboard/assets/keyframe-prompts.md +4 -0
  45. package/lib/drama/skills/short-drama-storyboard/references/shot-craft.md +39 -0
  46. package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +2 -0
  47. package/lib/drama/skills/short-drama-video-prompts/SKILL.md +7 -1
  48. package/lib/drama/skills/short-drama-video-prompts/references/minimax-h3.md +101 -6
  49. package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +7 -1
  50. package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +35 -0
  51. package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +3 -1
  52. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.0.md +13 -3
  53. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +35 -4
  54. package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +1 -1
  55. package/lib/drama/skills/short-drama-write/SKILL.md +13 -2
  56. package/lib/drama/skills/short-drama-write/references/screenplay-format.md +6 -0
  57. package/lib/drama/skills/short-drama-write/references/stage-contract.md +1 -0
  58. package/lib/index.js +4 -2
  59. package/lib/index.js.map +2 -2
  60. package/lib/novel-to-game/VERSION +1 -1
  61. package/lib/novel-to-game/manifest.json +28 -28
  62. package/lib/novel-to-game/skills/game-art-direction/SKILL.md +6 -27
  63. package/lib/novel-to-game/skills/game-build/SKILL.md +12 -29
  64. package/lib/novel-to-game/skills/game-concept/SKILL.md +10 -26
  65. package/lib/novel-to-game/skills/game-qa/SKILL.md +5 -15
  66. package/lib/novel-to-game/skills/game-qa/references/test-design-method.md +1 -1
  67. package/lib/novel-to-game/skills/game-world-design/SKILL.md +19 -28
  68. package/lib/novel-to-game/skills/game-world-design/references/dialogue-design-method.md +1 -2
  69. package/lib/novel-to-game/skills/novel-game-analyze/SKILL.md +6 -9
  70. package/lib/novel-to-game/skills/novel-to-game/SKILL.md +6 -11
  71. package/lib/novel-to-game/skills/novel-to-game/references/intake-benchmark-reference.md +0 -4
  72. package/lib/novel-to-game/skills/novel-to-game/references/intake-method.md +1 -3
  73. package/lib/novel-to-game/skills/novel-to-game/references/pipeline-contract.md +3 -5
  74. package/lib/oh-story/manifest.json +292 -172
  75. package/lib/oh-story/roles/character-designer.md +1 -1
  76. package/lib/oh-story/roles/narrative-writer.md +56 -242
  77. package/lib/oh-story/roles/story-architect.md +1 -1
  78. package/lib/oh-story/skills/story/SKILL.md +1 -1
  79. package/lib/oh-story/skills/story/VERSION +1 -1
  80. package/lib/oh-story/skills/story/references/author-memory.md +10 -8
  81. package/lib/oh-story/skills/story-deslop/SKILL.md +20 -182
  82. package/lib/oh-story/skills/story-deslop/references/anti-ai-writing.md +19 -47
  83. package/lib/oh-story/skills/story-deslop/references/author-memory.md +10 -8
  84. package/lib/oh-story/skills/story-deslop/references/banned-words.md +3 -1
  85. package/lib/oh-story/skills/story-deslop/references/deslop-gates.md +154 -0
  86. package/lib/oh-story/skills/story-deslop/references/style-resolution.md +27 -0
  87. package/lib/oh-story/skills/story-deslop/scripts/check-ai-patterns.js +6 -1
  88. package/lib/oh-story/skills/story-deslop/scripts/check-degeneration.js +39 -20
  89. package/lib/oh-story/skills/story-deslop/scripts/normalize-punctuation.js +10 -5
  90. package/lib/oh-story/skills/story-deslop/scripts/style-whitelist.js +39 -0
  91. package/lib/oh-story/skills/story-import/SKILL.md +2 -2
  92. package/lib/oh-story/skills/story-import/references/format-and-structure.md +2 -2
  93. package/lib/oh-story/skills/story-import/references/tracking-initialization.md +31 -0
  94. package/lib/oh-story/skills/story-import/references/tracking-transaction.md +2 -29
  95. package/lib/oh-story/skills/story-import/scripts/check-outline-contract.js +176 -12
  96. package/lib/oh-story/skills/story-long-analyze/SKILL.md +1 -1
  97. package/lib/oh-story/skills/story-long-write/SKILL.md +20 -193
  98. package/lib/oh-story/skills/story-long-write/references/anti-ai-writing.md +19 -47
  99. package/lib/oh-story/skills/story-long-write/references/artifact-protocols.md +62 -7
  100. package/lib/oh-story/skills/story-long-write/references/author-memory.md +10 -8
  101. package/lib/oh-story/skills/story-long-write/references/banned-words.md +3 -1
  102. package/lib/oh-story/skills/story-long-write/references/benchmark-recall.md +20 -0
  103. package/lib/oh-story/skills/story-long-write/references/dialogue-mastery.md +6 -4
  104. package/lib/oh-story/skills/story-long-write/references/long-chapter-quality.md +2 -2
  105. package/lib/oh-story/skills/story-long-write/references/long-format.md +2 -0
  106. package/lib/oh-story/skills/story-long-write/references/outline-structure-theory.md +2 -0
  107. package/lib/oh-story/skills/story-long-write/references/project-files.md +108 -0
  108. package/lib/oh-story/skills/story-long-write/references/reference-index.md +87 -0
  109. package/lib/oh-story/skills/story-long-write/references/style-combat-face.md +1 -1
  110. package/lib/oh-story/skills/story-long-write/references/style-craft.md +9 -29
  111. package/lib/oh-story/skills/story-long-write/references/style-genre-modules.md +1 -1
  112. package/lib/oh-story/skills/story-long-write/references/style-resolution.md +27 -0
  113. package/lib/oh-story/skills/story-long-write/references/tracking-initialization.md +31 -0
  114. package/lib/oh-story/skills/story-long-write/references/tracking-transaction.md +2 -29
  115. package/lib/oh-story/skills/story-long-write/references/workflow-chapter.md +51 -52
  116. package/lib/oh-story/skills/story-long-write/references/workflow-daily.md +8 -7
  117. package/lib/oh-story/skills/story-long-write/references/workflow-setup.md +36 -15
  118. package/lib/oh-story/skills/story-long-write/references/writer-prompt-fallback.md +22 -0
  119. package/lib/oh-story/skills/story-long-write/references/writing-craft.md +69 -2
  120. package/lib/oh-story/skills/story-long-write/scripts/build_writer_prompt.py +410 -0
  121. package/lib/oh-story/skills/story-long-write/scripts/check-ai-patterns.js +6 -1
  122. package/lib/oh-story/skills/story-long-write/scripts/check-degeneration.js +39 -20
  123. package/lib/oh-story/skills/story-long-write/scripts/check-outline-contract.js +176 -12
  124. package/lib/oh-story/skills/story-long-write/scripts/check-outline-copy.js +90 -23
  125. package/lib/oh-story/skills/story-long-write/scripts/normalize-punctuation.js +10 -5
  126. package/lib/oh-story/skills/story-long-write/scripts/outline_view.py +283 -0
  127. package/lib/oh-story/skills/story-long-write/scripts/style-whitelist.js +39 -0
  128. package/lib/oh-story/skills/story-review/SKILL.md +13 -3
  129. package/lib/oh-story/skills/story-review/references/anti-ai-writing.md +19 -47
  130. package/lib/oh-story/skills/story-review/references/author-memory.md +10 -8
  131. package/lib/oh-story/skills/story-review/references/banned-words.md +3 -1
  132. package/lib/oh-story/skills/story-review/references/dialogue-mastery.md +6 -4
  133. package/lib/oh-story/skills/story-review/references/review-quality.md +2 -2
  134. package/lib/oh-story/skills/story-review/references/style-resolution.md +27 -0
  135. package/lib/oh-story/skills/story-review/references/tracking-initialization.md +31 -0
  136. package/lib/oh-story/skills/story-review/references/tracking-transaction.md +2 -29
  137. package/lib/oh-story/skills/story-review/scripts/check-ai-patterns.js +6 -1
  138. package/lib/oh-story/skills/story-review/scripts/check-degeneration.js +39 -20
  139. package/lib/oh-story/skills/story-review/scripts/normalize-punctuation.js +10 -5
  140. package/lib/oh-story/skills/story-review/scripts/style-whitelist.js +39 -0
  141. package/lib/oh-story/skills/story-setup/SKILL.md +17 -10
  142. package/lib/oh-story/skills/story-setup/references/agent-references/anti-ai-writing.md +19 -47
  143. package/lib/oh-story/skills/story-setup/references/agent-references/banned-words.md +3 -1
  144. package/lib/oh-story/skills/story-setup/references/agent-references/deslop-gates.md +154 -0
  145. package/lib/oh-story/skills/story-setup/references/agent-references/dialogue-mastery.md +6 -4
  146. package/lib/oh-story/skills/story-setup/references/agent-references/format-and-structure.md +2 -2
  147. package/lib/oh-story/skills/story-setup/references/agent-references/short-genre-formulas.md +1 -1
  148. package/lib/oh-story/skills/story-setup/references/agent-references/style-combat-face.md +1 -1
  149. package/lib/oh-story/skills/story-setup/references/agent-references/style-genre-modules.md +1 -1
  150. package/lib/oh-story/skills/story-setup/references/agent-references/style-resolution.md +27 -0
  151. package/lib/oh-story/skills/story-setup/references/agent-references/writing-craft.md +69 -2
  152. package/lib/oh-story/skills/story-setup/references/diagnostics.md +38 -0
  153. package/lib/oh-story/skills/story-short-analyze/references/analysis-writing-techniques.md +1 -1
  154. package/lib/oh-story/skills/story-short-analyze/references/source-story-quality.md +2 -2
  155. package/lib/oh-story/skills/story-short-write/SKILL.md +18 -186
  156. package/lib/oh-story/skills/story-short-write/references/author-memory.md +10 -8
  157. package/lib/oh-story/skills/story-short-write/references/banned-words.md +3 -1
  158. package/lib/oh-story/skills/story-short-write/references/benchmark-recall.md +60 -0
  159. package/lib/oh-story/skills/story-short-write/references/dialogue-mastery.md +6 -4
  160. package/lib/oh-story/skills/story-short-write/references/genre-styles//344/270/226/346/203/205/346/211/223/350/204/270.md +2 -2
  161. package/lib/oh-story/skills/story-short-write/references/genre-styles//345/217/214/347/224/267/344/270/273.md +1 -1
  162. package/lib/oh-story/skills/story-short-write/references/genre-styles//345/244/215/344/273/207/346/211/223/350/204/270.md +3 -3
  163. package/lib/oh-story/skills/story-short-write/references/genre-styles//345/256/205/346/226/227/345/256/253/346/226/227.md +3 -3
  164. package/lib/oh-story/skills/story-short-write/references/genre-styles//346/200/273/350/243/201/350/261/252/351/227/250.md +3 -3
  165. package/lib/oh-story/skills/story-short-write/references/genre-styles//346/202/254/347/226/221.md +5 -5
  166. package/lib/oh-story/skills/story-short-write/references/genre-styles//346/260/221/344/277/227/346/200/252/350/260/210.md +4 -4
  167. package/lib/oh-story/skills/story-short-write/references/genre-styles//347/224/234/345/256/240.md +5 -5
  168. package/lib/oh-story/skills/story-short-write/references/genre-styles//350/277/275/345/246/273/347/201/253/350/221/254/345/234/272.md +3 -3
  169. package/lib/oh-story/skills/story-short-write/references/genre-writing-formulas.md +1 -1
  170. package/lib/oh-story/skills/story-short-write/references/genre-writing-techniques.md +1 -1
  171. package/lib/oh-story/skills/story-short-write/references/reference-index.md +53 -0
  172. package/lib/oh-story/skills/story-short-write/references/short-craft.md +12 -14
  173. package/lib/oh-story/skills/story-short-write/references/short-deslop.md +10 -10
  174. package/lib/oh-story/skills/story-short-write/references/short-format.md +3 -1
  175. package/lib/oh-story/skills/story-short-write/references/short-prose-quality.md +2 -2
  176. package/lib/oh-story/skills/story-short-write/references/style-resolution.md +27 -0
  177. package/lib/oh-story/skills/story-short-write/references/workflow-design.md +77 -0
  178. package/lib/oh-story/skills/story-short-write/references/writing-workflow.md +5 -5
  179. package/lib/oh-story/skills/story-short-write/scripts/check-ai-patterns.js +6 -1
  180. package/lib/oh-story/skills/story-short-write/scripts/check-degeneration.js +39 -20
  181. package/lib/oh-story/skills/story-short-write/scripts/check-delivery-contract.js +34 -5
  182. package/lib/oh-story/skills/story-short-write/scripts/check-outline-copy.js +90 -23
  183. package/lib/oh-story/skills/story-short-write/scripts/normalize-punctuation.js +10 -5
  184. package/lib/oh-story/skills/story-short-write/scripts/style-whitelist.js +39 -0
  185. package/lib/types/client/drama-production-view.d.ts.map +1 -1
  186. package/lib/types/client/index.d.ts.map +1 -1
  187. package/lib/types/client/production-prompts.d.ts.map +1 -1
  188. package/lib/types/client/production-runtime.d.ts +17 -0
  189. package/lib/types/client/production-runtime.d.ts.map +1 -1
  190. package/lib/types/skill-provider.d.ts.map +1 -1
  191. package/lib/video-recap/CHANGELOG.md +31 -1
  192. package/lib/video-recap/manifest.json +162 -162
  193. package/lib/video-recap/skills/video-assemble/scripts/artifacts.py +4 -9
  194. package/lib/video-recap/skills/video-assemble/scripts/assemble.py +47 -60
  195. package/lib/video-recap/skills/video-assemble/scripts/assembly_contract.py +77 -115
  196. package/lib/video-recap/skills/video-assemble/scripts/assembly_settings.py +41 -40
  197. package/lib/video-recap/skills/video-assemble/scripts/audio_automation.py +22 -32
  198. package/lib/video-recap/skills/video-assemble/scripts/audio_mix.py +102 -192
  199. package/lib/video-recap/skills/video-assemble/scripts/jianying_builders.py +14 -35
  200. package/lib/video-recap/skills/video-assemble/scripts/jianying_model.py +14 -12
  201. package/lib/video-recap/skills/video-assemble/scripts/jianying_optional.py +8 -9
  202. package/lib/video-recap/skills/video-assemble/scripts/jianying_schema.py +3 -14
  203. package/lib/video-recap/skills/video-assemble/scripts/jianying_timeline_contract.py +23 -12
  204. package/lib/video-recap/skills/video-assemble/scripts/jianying_tracks.py +1 -4
  205. package/lib/video-recap/skills/video-assemble/scripts/jianying_writer.py +24 -25
  206. package/lib/video-recap/skills/video-assemble/scripts/lib.py +31 -85
  207. package/lib/video-recap/skills/video-assemble/scripts/media.py +100 -144
  208. package/lib/video-recap/skills/video-assemble/scripts/narration_audio.py +101 -195
  209. package/lib/video-recap/skills/video-assemble/scripts/render_preflight.py +10 -21
  210. package/lib/video-recap/skills/video-assemble/scripts/source_subtitles.py +90 -169
  211. package/lib/video-recap/skills/video-assemble/scripts/subtitle_core.py +69 -122
  212. package/lib/video-recap/skills/video-assemble/scripts/subtitle_render.py +10 -17
  213. package/lib/video-recap/skills/video-assemble/scripts/timeline.py +33 -48
  214. package/lib/video-recap/skills/video-assemble/scripts/timeline_emit.py +14 -20
  215. package/lib/video-recap/skills/video-assemble/scripts/visual_render.py +135 -213
  216. package/lib/video-recap/skills/video-cut/scripts/cut_cli.py +49 -57
  217. package/lib/video-recap/skills/video-cut/scripts/cut_contract.py +38 -74
  218. package/lib/video-recap/skills/video-cut/scripts/cut_render.py +46 -95
  219. package/lib/video-recap/skills/video-cut/scripts/lib.py +23 -25
  220. package/lib/video-recap/skills/video-cut/scripts/media_geometry.py +70 -173
  221. package/lib/video-recap/skills/video-cut/scripts/narration_mapping.py +28 -53
  222. package/lib/video-recap/skills/video-cut/scripts/sentence_boundaries.py +162 -322
  223. package/lib/video-recap/skills/video-recap/references/timeline-and-jianying.md +6 -7
  224. package/lib/video-recap/skills/video-recap/scripts/doctor.py +104 -124
  225. package/lib/video-recap/skills/video-recap/scripts/final_qc.py +255 -412
  226. package/lib/video-recap/skills/video-recap/scripts/lib.py +30 -52
  227. package/lib/video-recap/skills/video-recap/scripts/materials.py +97 -120
  228. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_client.py +7 -12
  229. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_evidence.py +46 -97
  230. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_observations.py +18 -36
  231. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_payload.py +29 -47
  232. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_report.py +43 -76
  233. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_runner.py +2 -1
  234. package/lib/video-recap/skills/video-recap/scripts/qc_contract.py +2 -1
  235. package/lib/video-recap/skills/video-recap/scripts/recap_cli.py +2 -2
  236. package/lib/video-recap/skills/video-recap/scripts/recap_inspect.py +129 -145
  237. package/lib/video-recap/skills/video-recap/scripts/recap_review.py +27 -20
  238. package/lib/video-recap/skills/video-recap/scripts/recap_runner.py +20 -31
  239. package/lib/video-recap/skills/video-recap/scripts/recap_runtime.py +31 -106
  240. package/lib/video-recap/skills/video-recap/scripts/recap_stage_qc.py +43 -113
  241. package/lib/video-recap/skills/video-recap/scripts/recap_timeline.py +196 -377
  242. package/lib/video-recap/skills/video-script/scripts/agent_brief.py +1 -1
  243. package/lib/video-recap/skills/video-script/scripts/agent_text.py +63 -172
  244. package/lib/video-recap/skills/video-script/scripts/brief_context.py +80 -135
  245. package/lib/video-recap/skills/video-script/scripts/brief_inputs.py +125 -167
  246. package/lib/video-recap/skills/video-script/scripts/brief_timeline.py +93 -208
  247. package/lib/video-recap/skills/video-script/scripts/deslop_qc.py +20 -46
  248. package/lib/video-recap/skills/video-script/scripts/lib.py +30 -26
  249. package/lib/video-recap/skills/video-script/scripts/narration_lint.py +145 -140
  250. package/lib/video-recap/skills/video-script/scripts/review_response.py +12 -42
  251. package/lib/video-recap/skills/video-script/scripts/review_runner.py +0 -6
  252. package/lib/video-recap/skills/video-script/scripts/speech_ownership.py +39 -100
  253. package/lib/video-recap/skills/video-script/scripts/timeline_fusion.py +33 -124
  254. package/lib/video-recap/skills/video-understanding/scripts/agent_brief.py +1 -1
  255. package/lib/video-recap/skills/video-understanding/scripts/agent_text.py +63 -172
  256. package/lib/video-recap/skills/video-understanding/scripts/asr.py +1 -2
  257. package/lib/video-recap/skills/video-understanding/scripts/brief_context.py +80 -135
  258. package/lib/video-recap/skills/video-understanding/scripts/brief_inputs.py +125 -167
  259. package/lib/video-recap/skills/video-understanding/scripts/brief_timeline.py +93 -208
  260. package/lib/video-recap/skills/video-understanding/scripts/consolidate.py +1 -1
  261. package/lib/video-recap/skills/video-understanding/scripts/deslop_qc.py +20 -46
  262. package/lib/video-recap/skills/video-understanding/scripts/lib.py +31 -26
  263. package/lib/video-recap/skills/video-understanding/scripts/narration_lint.py +145 -140
  264. package/lib/video-recap/skills/video-understanding/scripts/speech_ownership.py +39 -100
  265. package/lib/video-recap/skills/video-understanding/scripts/timeline_fusion.py +33 -124
  266. package/lib/video-recap/skills/video-understanding/scripts/vlm.py +1 -4
  267. package/lib/video-recap/skills/video-voiceover/scripts/dub.py +7 -13
  268. package/lib/video-recap/skills/video-voiceover/scripts/fish_audio.py +12 -30
  269. package/lib/video-recap/skills/video-voiceover/scripts/lib.py +98 -131
  270. package/lib/video-recap/skills/video-voiceover/scripts/tts_audio.py +17 -28
  271. package/lib/video-recap/skills/video-voiceover/scripts/voiceover.py +151 -240
  272. package/package.json +41 -41
@@ -0,0 +1,1041 @@
1
+ #!/usr/bin/env python3
2
+ """Assemble a short-drama episode from its cut list.
3
+
4
+ Three subcommands, deliberately separated so a report can never borrow one's
5
+ evidence for another's claim:
6
+
7
+ ``check`` parse and cross-check ``剪辑单.md`` against the project. No rendering.
8
+ ``render`` cut, join, burn subtitles and normalize loudness into 制作成果/成片/.
9
+ ``verify`` measure an already-rendered film and print the numbers.
10
+
11
+ ``verify`` prints measurements, never verdicts. Whether the film is any good is
12
+ a question for review or for the creator, and no number here answers it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import re
20
+ import shutil
21
+ import subprocess
22
+ import sys
23
+ import tempfile
24
+ from pathlib import Path
25
+ from typing import Any, NamedTuple, Optional, Sequence
26
+
27
+ MINIMUM_PYTHON = (3, 9)
28
+ if sys.version_info < MINIMUM_PYTHON:
29
+ raise SystemExit(
30
+ "short-drama-edit needs Python {}.{} or newer; this interpreter is {}.{}".format(
31
+ *MINIMUM_PYTHON, sys.version_info.major, sys.version_info.minor
32
+ )
33
+ )
34
+
35
+ CUT_LIST_NAME = "剪辑单.md"
36
+ MOTION_DOCUMENT = "视频提示词.md"
37
+ SCREENPLAY_DOCUMENT = "剧本.md"
38
+ OUTPUT_DIRECTORY = Path("制作成果") / "成片"
39
+ SEGMENT_DIRECTORY = "分段"
40
+ # A CJK subtitle needs a font that actually has the glyphs. libass falls back
41
+ # through fontconfig when this one is absent, which is the right behaviour: a
42
+ # missing font shows as a different typeface, never as empty boxes.
43
+ SUBTITLE_FONT = "Noto Sans CJK SC"
44
+ # The optional Remotion route ships as source only. Its `node_modules` is
45
+ # hundreds of megabytes of third-party code that is neither creative content nor
46
+ # part of the skill, so the runnable copy lives outside the project entirely --
47
+ # the same rule the suite applies to adapter configuration and credentials.
48
+ REMOTION_SOURCE = Path(__file__).resolve().parent.parent / "assets" / "remotion"
49
+ REMOTION_SOURCE_FILES = (
50
+ "package.json",
51
+ "tsconfig.json",
52
+ "remotion.config.ts",
53
+ "src/index.ts",
54
+ "src/schema.ts",
55
+ "src/Root.tsx",
56
+ "src/Subtitles.tsx",
57
+ "src/font.ts",
58
+ )
59
+ DEFAULT_REMOTION_WORKSPACE = Path.home() / ".cache" / "short-drama-edit" / "remotion"
60
+ REMOTION_COMPOSITION = "Subtitles"
61
+ # Remotion defaults its worker count to the machine's core count, and each worker
62
+ # is a browser holding a full frame. On a 10-core / 8 GB laptop that took the
63
+ # machine down mid-render. Raise this only after measuring the host.
64
+ DEFAULT_REMOTION_CONCURRENCY = 2
65
+ SUBTITLE_RENDERERS = ("ffmpeg", "remotion")
66
+
67
+ CUT_HEADING = re.compile(r"^##\s+(CUT-[^\s·]+)\s*(?:·\s*(.*))?$")
68
+ MOTION_HEADING = re.compile(r"^##\s+(MOTION-[^\s·]+)")
69
+ FIELD = re.compile(r"^-\s*([^:]+):\s*(.*)$")
70
+ UNUSED_LINE = re.compile(r"^-\s*未采用镜头:\s*(.*)$")
71
+ # A source line is "MOTION-... · relative/path". The separator is the same
72
+ # middle dot the storyboard uses for reference slots, so the two documents read
73
+ # alike; a plain slash would collide with the path itself.
74
+ SOURCE = re.compile(r"^(MOTION-\S+)\s*·\s*(.+?)\s*$")
75
+ SUBTITLE_WINDOW = re.compile(r"^\s*([0-9.]+)\s*[-–~]\s*([0-9.]+)\s*$")
76
+ # One shot can carry several lines: an exchange of three is one shot, not three.
77
+ # Numbered fields keep them ordered and let each one state its own window.
78
+ SUBTITLE_FIELD = re.compile(r"^字幕(?:\s*(\d+))?$")
79
+ SUBTITLE_CUE = re.compile(r"^\s*([0-9.]+)\s*[-–~]\s*([0-9.]+)\s+(.+?)\s*$")
80
+ # A shot-match is three numbers and nothing else. Anything richer belongs in a
81
+ # grading tool, and anything implicit belongs nowhere: a correction the cut list
82
+ # does not state is a correction no reviewer can see.
83
+ PICTURE_KEYS = {"亮度": "brightness", "饱和": "saturation", "色温": "warmth"}
84
+ PICTURE_TERM = re.compile(r"(亮度|饱和|色温)\s*([+-]?[0-9]*\.?[0-9]+)")
85
+ # Each term's real range, not a symmetric magnitude: ffmpeg's `eq` takes
86
+ # saturation in 0..3, so a negative value that passes an abs() bound is accepted
87
+ # by the document and then rejected by the renderer, halfway through a render.
88
+ PICTURE_LIMITS = {
89
+ "brightness": (-0.25, 0.25),
90
+ "saturation": (0.0, 2.0),
91
+ "warmth": (-30.0, 30.0),
92
+ }
93
+ TOLERANCE = 0.005
94
+
95
+
96
+ class EditError(Exception):
97
+ """A defect in the cut list or its inputs, reported rather than raised through."""
98
+
99
+
100
+ class Cut(NamedTuple):
101
+ cut_id: str
102
+ title: str
103
+ motion: str
104
+ media: str
105
+ start: float
106
+ end: float
107
+ declared: float
108
+ subtitles: tuple[tuple[Optional[float], Optional[float], str], ...]
109
+ picture: dict[str, float]
110
+ line_number: int
111
+
112
+
113
+ class Delivery(NamedTuple):
114
+ target_seconds: Optional[float]
115
+ loudness_lufs: Optional[float]
116
+ burn_subtitles: bool
117
+ frame_size: Optional[tuple[int, int]]
118
+ fps: Optional[float]
119
+
120
+
121
+ def _seconds(raw: str, *, field: str, line: int) -> float:
122
+ try:
123
+ return float(raw.strip())
124
+ except ValueError as error:
125
+ raise EditError(f"{CUT_LIST_NAME}:{line}: {field} 不是秒数: {raw!r}") from error
126
+
127
+
128
+ def _parse_delivery(lines: Sequence[str]) -> Delivery:
129
+ target = None
130
+ loudness = None
131
+ burn = True
132
+ frame_size: Optional[tuple[int, int]] = None
133
+ fps: Optional[float] = None
134
+ for raw in lines:
135
+ match = FIELD.match(raw)
136
+ if not match:
137
+ continue
138
+ name, value = match.group(1).strip(), match.group(2).strip()
139
+ if name == "成片目标时长":
140
+ found = re.search(r"[0-9]+(?:\.[0-9]+)?", value)
141
+ if found:
142
+ target = float(found.group(0))
143
+ elif name == "交付响度":
144
+ found = re.search(r"-?[0-9]+(?:\.[0-9]+)?", value)
145
+ if found:
146
+ loudness = float(found.group(0))
147
+ elif name == "字幕":
148
+ burn = "无" != value.strip() and "不烧" not in value
149
+ elif name == "画幅与帧率":
150
+ size = re.search(r"([0-9]{2,5})\s*[×x*]\s*([0-9]{2,5})", value)
151
+ if size:
152
+ frame_size = (int(size.group(1)), int(size.group(2)))
153
+ rate = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*fps", value, re.I)
154
+ if rate:
155
+ fps = float(rate.group(1))
156
+ return Delivery(target, loudness, burn, frame_size, fps)
157
+
158
+
159
+ def parse_cut_list(path: Path) -> tuple[Delivery, list[Cut], list[str]]:
160
+ """Read 剪辑单.md into a delivery spec, ordered cuts, and unused-material notes."""
161
+
162
+ if not path.is_file():
163
+ raise EditError(f"没有 {path}")
164
+ lines = path.read_text(encoding="utf-8").splitlines()
165
+ preamble: list[str] = []
166
+ unused: list[str] = []
167
+ cuts: list[Cut] = []
168
+ current: Optional[dict[str, Any]] = None
169
+
170
+ def close(pending: Optional[dict[str, Any]]) -> None:
171
+ if pending is None:
172
+ return
173
+ cuts.append(_finish_cut(pending))
174
+
175
+ for number, raw in enumerate(lines, start=1):
176
+ heading = CUT_HEADING.match(raw)
177
+ if heading:
178
+ close(current)
179
+ current = {
180
+ "cut_id": heading.group(1),
181
+ "title": (heading.group(2) or "").strip(),
182
+ "line": number,
183
+ "fields": {},
184
+ }
185
+ continue
186
+ if raw.startswith("## "):
187
+ close(current)
188
+ current = None
189
+ continue
190
+ if current is None:
191
+ unused_match = UNUSED_LINE.match(raw)
192
+ if unused_match:
193
+ unused.extend(
194
+ item.strip() for item in unused_match.group(1).split(";") if item.strip()
195
+ )
196
+ preamble.append(raw)
197
+ continue
198
+ field = FIELD.match(raw)
199
+ if field:
200
+ current["fields"][field.group(1).strip()] = (field.group(2).strip(), number)
201
+ close(current)
202
+ return _parse_delivery(preamble), cuts, unused
203
+
204
+
205
+ def _finish_cut(pending: dict[str, Any]) -> Cut:
206
+ fields: dict[str, tuple[str, int]] = pending["fields"]
207
+ cut_id: str = pending["cut_id"]
208
+ line: int = pending["line"]
209
+
210
+ def required(name: str) -> tuple[str, int]:
211
+ if name not in fields:
212
+ raise EditError(f"{CUT_LIST_NAME}:{line}: {cut_id} 缺少「{name}」")
213
+ return fields[name]
214
+
215
+ source_raw, source_line = required("来源")
216
+ source = SOURCE.match(source_raw)
217
+ if not source:
218
+ raise EditError(
219
+ f"{CUT_LIST_NAME}:{source_line}: {cut_id} 的来源要写成 "
220
+ f"「MOTION-... · 项目相对路径」,当前是 {source_raw!r}"
221
+ )
222
+ start_raw, start_line = required("入点")
223
+ end_raw, end_line = required("出点")
224
+ declared_raw, declared_line = required("时长")
225
+ subtitles = _parse_subtitles(fields, cut_id=cut_id, line=line)
226
+ picture: dict[str, float] = {}
227
+ picture_raw = fields.get("画面")
228
+ if picture_raw is not None:
229
+ text, where = picture_raw
230
+ if text.strip() not in {"", "无", "不校"}:
231
+ for label, value in PICTURE_TERM.findall(text):
232
+ key = PICTURE_KEYS[label]
233
+ number = float(value)
234
+ low, high = PICTURE_LIMITS[key]
235
+ if not low <= number <= high:
236
+ raise EditError(
237
+ f"{CUT_LIST_NAME}:{where}: {cut_id} 的画面「{label}」超出允许范围"
238
+ f"({low:g} 到 {high:g}):{number}"
239
+ )
240
+ picture[key] = number
241
+ if not picture:
242
+ raise EditError(
243
+ f"{CUT_LIST_NAME}:{where}: {cut_id} 的画面写了内容但没有可执行的项;"
244
+ "只认「亮度 <数>」「饱和 <数>」「色温 <数>」,不需要校正时写「无」"
245
+ )
246
+ return Cut(
247
+ cut_id=cut_id,
248
+ title=pending["title"],
249
+ motion=source.group(1),
250
+ media=source.group(2),
251
+ start=_seconds(start_raw, field="入点", line=start_line),
252
+ end=_seconds(end_raw, field="出点", line=end_line),
253
+ declared=_seconds(declared_raw, field="时长", line=declared_line),
254
+ subtitles=subtitles,
255
+ picture=picture,
256
+ line_number=line,
257
+ )
258
+
259
+
260
+ def _parse_subtitles(
261
+ fields: dict[str, tuple[str, int]], *, cut_id: str, line: int
262
+ ) -> tuple[tuple[Optional[float], Optional[float], str], ...]:
263
+ """Read every subtitle a cut declares, in written order.
264
+
265
+ A shot is not one line. The exchange "就是什么 / 就是少了点东西 / 少了什么" is a
266
+ single over-shoulder shot carrying three, and a cut list that can hold only
267
+ one of them silently drops the other two — the film then plays lines that
268
+ never reach the screen, and nothing reports it.
269
+ """
270
+
271
+ numbered: list[tuple[int, str, int]] = []
272
+ plain: Optional[tuple[str, int]] = None
273
+ for key, (value, where) in fields.items():
274
+ match = SUBTITLE_FIELD.match(key.strip())
275
+ if not match:
276
+ continue
277
+ if match.group(1) is None:
278
+ plain = (value, where)
279
+ else:
280
+ numbered.append((int(match.group(1)), value, where))
281
+
282
+ if numbered and plain is not None:
283
+ raise EditError(
284
+ f"{CUT_LIST_NAME}:{line}: {cut_id} 同时写了「字幕」和「字幕 N」;"
285
+ "一段用一种写法——一句用「字幕」,多句全部编号"
286
+ )
287
+
288
+ if numbered:
289
+ numbered.sort()
290
+ expected = list(range(1, len(numbered) + 1))
291
+ if [item[0] for item in numbered] != expected:
292
+ raise EditError(
293
+ f"{CUT_LIST_NAME}:{line}: {cut_id} 的字幕编号要从 1 连续排到 "
294
+ f"{len(numbered)},当前是 {[item[0] for item in numbered]}"
295
+ )
296
+ cues: list[tuple[Optional[float], Optional[float], str]] = []
297
+ for index, value, where in numbered:
298
+ found = SUBTITLE_CUE.match(value)
299
+ if not found:
300
+ raise EditError(
301
+ f"{CUT_LIST_NAME}:{where}: {cut_id} 的「字幕 {index}」要写成"
302
+ "「<起>-<止> <台词>」,多句必须各自带时间"
303
+ )
304
+ cues.append((float(found.group(1)), float(found.group(2)), found.group(3)))
305
+ for earlier, later in zip(cues, cues[1:]):
306
+ if later[0] is not None and earlier[1] is not None and later[0] < earlier[1]:
307
+ raise EditError(
308
+ f"{CUT_LIST_NAME}:{line}: {cut_id} 的字幕时间重叠:两句不能同时在屏上"
309
+ )
310
+ return tuple(cues)
311
+
312
+ if plain is None or plain[0].strip() == "无":
313
+ return ()
314
+ window_raw = fields.get("字幕时间")
315
+ if window_raw is None:
316
+ return ((None, None, plain[0].strip()),)
317
+ found = SUBTITLE_WINDOW.match(window_raw[0])
318
+ if not found:
319
+ raise EditError(
320
+ f"{CUT_LIST_NAME}:{window_raw[1]}: {cut_id} 的字幕时间要写成「起-止」秒数"
321
+ )
322
+ return ((float(found.group(1)), float(found.group(2)), plain[0].strip()),)
323
+
324
+
325
+ def _which(name: str) -> Optional[str]:
326
+ return shutil.which(name)
327
+
328
+
329
+ def _require(name: str) -> str:
330
+ found = _which(name)
331
+ if found is None:
332
+ raise EditError(
333
+ f"PATH 上没有 {name}。本阶段要渲染和测量真实媒体,没有它就没有可报告的事实;"
334
+ "先安装 ffmpeg,不用别的手段近似。"
335
+ )
336
+ return found
337
+
338
+
339
+ def probe_duration(media: Path) -> float:
340
+ probe = _require("ffprobe")
341
+ result = subprocess.run(
342
+ [probe, "-v", "error", "-show_entries", "format=duration",
343
+ "-of", "default=nw=1:nk=1", str(media)],
344
+ capture_output=True, text=True, check=False,
345
+ )
346
+ if result.returncode != 0:
347
+ raise EditError(f"ffprobe 读不出时长: {media} ({result.stderr.strip()})")
348
+ return float(result.stdout.strip())
349
+
350
+
351
+ def probe_stream(media: Path) -> dict[str, Any]:
352
+ probe = _require("ffprobe")
353
+ result = subprocess.run(
354
+ [probe, "-v", "error", "-select_streams", "v:0", "-show_entries",
355
+ "stream=width,height,r_frame_rate", "-show_entries", "format=duration",
356
+ "-of", "json", str(media)],
357
+ capture_output=True, text=True, check=False,
358
+ )
359
+ if result.returncode != 0:
360
+ raise EditError(f"ffprobe 读不出流信息: {media} ({result.stderr.strip()})")
361
+ document = json.loads(result.stdout)
362
+ stream = (document.get("streams") or [{}])[0]
363
+ rate = stream.get("r_frame_rate", "0/1")
364
+ numerator, _, denominator = rate.partition("/")
365
+ fps = float(numerator) / float(denominator) if float(denominator or 0) else 0.0
366
+ return {
367
+ "width": stream.get("width"),
368
+ "height": stream.get("height"),
369
+ "fps": round(fps, 3),
370
+ "duration": float(document.get("format", {}).get("duration", 0.0)),
371
+ }
372
+
373
+
374
+ def check_cuts(
375
+ episode: Path, cuts: Sequence[Cut], project_root: Path, *, probe: bool
376
+ ) -> list[str]:
377
+ """Every mechanical cross-check the cut list can be held to. Returns findings."""
378
+
379
+ findings: list[str] = []
380
+ if not cuts:
381
+ findings.append(f"{CUT_LIST_NAME}: 没有 CUT 条目")
382
+ return findings
383
+
384
+ seen: set[str] = set()
385
+ for cut in cuts:
386
+ if cut.cut_id in seen:
387
+ findings.append(f"{CUT_LIST_NAME}:{cut.line_number}: CUT ID 重复: {cut.cut_id}")
388
+ seen.add(cut.cut_id)
389
+
390
+ motion_path = episode / MOTION_DOCUMENT
391
+ if motion_path.is_file():
392
+ known = {
393
+ match.group(1)
394
+ for match in (
395
+ MOTION_HEADING.match(line)
396
+ for line in motion_path.read_text(encoding="utf-8").splitlines()
397
+ )
398
+ if match
399
+ }
400
+ for cut in cuts:
401
+ if cut.motion not in known:
402
+ findings.append(
403
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的来源 "
404
+ f"{cut.motion} 不在《{MOTION_DOCUMENT}》中"
405
+ )
406
+ else:
407
+ findings.append(f"没有 {motion_path},来源 MOTION 无法核对")
408
+
409
+ screenplay = ""
410
+ screenplay_path = episode / SCREENPLAY_DOCUMENT
411
+ if screenplay_path.is_file():
412
+ screenplay = screenplay_path.read_text(encoding="utf-8")
413
+
414
+ for cut in cuts:
415
+ span = cut.end - cut.start
416
+ if cut.start < 0:
417
+ findings.append(f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 入点为负")
418
+ if span <= 0:
419
+ findings.append(
420
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 出点不晚于入点"
421
+ )
422
+ elif abs(span - cut.declared) > TOLERANCE:
423
+ findings.append(
424
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 出点 - 入点 = "
425
+ f"{span:.2f},与「时长:{cut.declared:.2f}」不符"
426
+ )
427
+ media = _resolve_media(episode, project_root, cut.media)
428
+ if media is None:
429
+ findings.append(
430
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的素材不存在: {cut.media}"
431
+ )
432
+ elif probe:
433
+ available = probe_duration(media)
434
+ if cut.end > available + TOLERANCE:
435
+ findings.append(
436
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 出点 {cut.end:.2f} "
437
+ f"超过素材实际时长 {available:.2f}"
438
+ )
439
+ for window_start, window_end, text in cut.subtitles:
440
+ if screenplay and _normalize(text) not in _normalize(screenplay):
441
+ findings.append(
442
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的字幕在《"
443
+ f"{SCREENPLAY_DOCUMENT}》里找不到原文: {text}"
444
+ )
445
+ if window_start is None or window_end is None:
446
+ continue
447
+ if not 0 <= window_start < window_end <= span + TOLERANCE:
448
+ findings.append(
449
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的字幕时间超出本段区间"
450
+ f": {window_start}-{window_end}"
451
+ )
452
+
453
+ findings.extend(_overlap_findings(cuts))
454
+ findings.extend(_stale_window_findings(episode, project_root, cuts))
455
+ return findings
456
+
457
+
458
+ def _stale_window_findings(
459
+ episode: Path, project_root: Path, cuts: Sequence[Cut]
460
+ ) -> list[str]:
461
+ """Material newer than the cut list means the timings were measured on something else.
462
+
463
+ Regenerating one shot is routine — the take was hazy, the action was wrong.
464
+ What is easy to forget is that every subtitle window bound to it was reverse-
465
+ engineered from the *old* take's audio. Nothing else notices: the numbers are
466
+ still in range, the render still succeeds, and the subtitle simply appears at
467
+ a moment when nobody is speaking. This is the one form of staleness the file
468
+ system can see, so it is reported rather than trusted to memory.
469
+ """
470
+
471
+ listing = episode / CUT_LIST_NAME
472
+ try:
473
+ authored = listing.stat().st_mtime
474
+ except OSError:
475
+ return []
476
+ findings: list[str] = []
477
+ for cut in cuts:
478
+ if not any(start is not None for start, _, _ in cut.subtitles):
479
+ continue
480
+ media = _resolve_media(episode, project_root, cut.media)
481
+ if media is None:
482
+ continue
483
+ try:
484
+ changed = media.stat().st_mtime
485
+ except OSError:
486
+ continue
487
+ if changed > authored + 1.0:
488
+ findings.append(
489
+ f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的素材比剪辑单新"
490
+ f"({cut.media});这一段的字幕时间是按旧素材的发声区间反推的,"
491
+ "重出之后必须重测再改,不能沿用"
492
+ )
493
+ return findings
494
+
495
+
496
+ def _overlap_findings(cuts: Sequence[Cut]) -> list[str]:
497
+ """Two cuts drawn from one file must not reuse the same frames (EDT-06)."""
498
+
499
+ findings: list[str] = []
500
+ by_media: dict[str, list[Cut]] = {}
501
+ for cut in cuts:
502
+ by_media.setdefault(cut.media, []).append(cut)
503
+ for media, group in by_media.items():
504
+ ordered = sorted(group, key=lambda item: item.start)
505
+ for earlier, later in zip(ordered, ordered[1:]):
506
+ if later.start < earlier.end - TOLERANCE:
507
+ findings.append(
508
+ f"{CUT_LIST_NAME}: {earlier.cut_id} 与 {later.cut_id} 在同一素材 "
509
+ f"{media} 上区间重叠({later.start:.2f} < {earlier.end:.2f})"
510
+ )
511
+ return findings
512
+
513
+
514
+ def _normalize(text: str) -> str:
515
+ """Compare dialogue ignoring punctuation and whitespace, never ignoring characters."""
516
+
517
+ return re.sub(r"[\s,。!?、;:…—·\-“”‘’\"'()()]", "", text)
518
+
519
+
520
+ def _resolve_media(episode: Path, project_root: Path, relative: str) -> Optional[Path]:
521
+ for base in (episode, project_root):
522
+ candidate = (base / relative).resolve()
523
+ if candidate.is_file():
524
+ return candidate
525
+ return None
526
+
527
+
528
+ def render(
529
+ episode: Path,
530
+ project_root: Path,
531
+ cuts: Sequence[Cut],
532
+ delivery: Delivery,
533
+ *,
534
+ burn_subtitles: bool,
535
+ renderer: str = "ffmpeg",
536
+ remotion_workspace: Path = DEFAULT_REMOTION_WORKSPACE,
537
+ remotion_concurrency: int = DEFAULT_REMOTION_CONCURRENCY,
538
+ ) -> dict[str, Any]:
539
+ ffmpeg = _require("ffmpeg")
540
+ _require("ffprobe")
541
+ output_root = episode / OUTPUT_DIRECTORY
542
+ segments_root = output_root / SEGMENT_DIRECTORY
543
+ segments_root.mkdir(parents=True, exist_ok=True)
544
+
545
+ segments: list[Path] = []
546
+ spans: list[float] = []
547
+ for cut in cuts:
548
+ media = _resolve_media(episode, project_root, cut.media)
549
+ if media is None:
550
+ raise EditError(f"{cut.cut_id} 的素材不存在: {cut.media}")
551
+ segment = segments_root / f"{cut.cut_id}.mp4"
552
+ command = [
553
+ ffmpeg, "-hide_banner", "-loglevel", "error", "-y",
554
+ "-ss", f"{cut.start:.3f}", "-t", f"{cut.end - cut.start:.3f}", "-i", str(media),
555
+ ]
556
+ match = _shot_match_filter(cut)
557
+ if match:
558
+ command += ["-vf", match]
559
+ command += [
560
+ "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p",
561
+ "-c:a", "aac", "-b:a", "192k", str(segment),
562
+ ]
563
+ _run(command)
564
+ segments.append(segment)
565
+ spans.append(probe_duration(segment))
566
+
567
+ with tempfile.TemporaryDirectory() as scratch:
568
+ listing = Path(scratch) / "segments.txt"
569
+ listing.write_text(
570
+ "".join(f"file '{segment.as_posix()}'\n" for segment in segments), encoding="utf-8"
571
+ )
572
+ joined = output_root / "成片-未混音.mp4"
573
+ _run([ffmpeg, "-hide_banner", "-loglevel", "error", "-y",
574
+ "-f", "concat", "-safe", "0", "-i", str(listing), "-c", "copy", str(joined)])
575
+
576
+ filters: list[str] = []
577
+ subtitle_path = None
578
+ styled_path = None
579
+ overlay_path = None
580
+ if burn_subtitles and any(cut.subtitles for cut in cuts):
581
+ canvas = probe_stream(segments[0])
582
+ cues = _subtitle_cues(cuts, spans)
583
+ subtitle_path = output_root / "字幕.srt"
584
+ subtitle_path.write_text(_build_srt(cues), encoding="utf-8")
585
+ if renderer == "remotion":
586
+ overlay_path = _render_remotion_overlay(
587
+ output_root, cues, canvas, sum(spans), remotion_workspace,
588
+ concurrency=remotion_concurrency,
589
+ )
590
+ else:
591
+ styled = styled_path = output_root / "字幕.ass"
592
+ styled.write_text(
593
+ _build_ass(cues, canvas["width"] or 1080, canvas["height"] or 1920),
594
+ encoding="utf-8",
595
+ )
596
+ escaped = (
597
+ str(styled).replace("\\", "/").replace(":", r"\:").replace("'", r"\'")
598
+ )
599
+ filters.append(f"ass='{escaped}'")
600
+
601
+ final = output_root / "成片.mp4"
602
+ command = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y", "-i", str(joined)]
603
+ if overlay_path is not None:
604
+ # VP8 carries its alpha as WebM block additions, so the decoder has
605
+ # to be named: the default one drops it and the overlay arrives as
606
+ # an opaque black rectangle.
607
+ command += ["-c:v", "libvpx", "-i", str(overlay_path)]
608
+ command += [
609
+ "-filter_complex",
610
+ "[0:v][1:v]overlay=0:0:format=auto,format=yuv420p[v]",
611
+ "-map", "[v]", "-map", "0:a",
612
+ "-c:v", "libx264", "-preset", "medium", "-crf", "18",
613
+ ]
614
+ elif filters:
615
+ command += ["-vf", ",".join(filters), "-c:v", "libx264",
616
+ "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p"]
617
+ else:
618
+ command += ["-c:v", "copy"]
619
+ if delivery.loudness_lufs is not None:
620
+ command += ["-af", _loudnorm_filter(ffmpeg, joined, delivery.loudness_lufs)]
621
+ command += ["-c:a", "aac", "-b:a", "192k"]
622
+ else:
623
+ command += ["-c:a", "copy"]
624
+ command.append(str(final))
625
+ _run(command)
626
+
627
+ return {
628
+ "成片": str(final),
629
+ "分段": [str(segment) for segment in segments],
630
+ "字幕": str(subtitle_path) if subtitle_path else None,
631
+ "压制字幕": str(styled_path) if styled_path else None,
632
+ "字幕叠层": str(overlay_path) if overlay_path else None,
633
+ "字幕渲染": renderer if subtitle_path else None,
634
+ "段数": len(segments),
635
+ "各段时长之和": round(sum(cut.end - cut.start for cut in cuts), 2),
636
+ }
637
+
638
+
639
+ def _shot_match_filter(cut: Cut) -> str:
640
+ """Match one cut to its neighbours, using only what the cut list declared.
641
+
642
+ Generated shots drift: two takes of the same person at the same table come
643
+ back a stop apart and half a step of white balance away from each other, and
644
+ the join reads as a mistake rather than a cut. The correction stays a stated
645
+ creator decision — the tool never measures a clip and adjusts it on its own,
646
+ because a correction nobody wrote down is one nobody can review.
647
+ """
648
+
649
+ stages: list[str] = []
650
+ eq = [
651
+ f"{name}={cut.picture[key]}"
652
+ for key, name in (("brightness", "brightness"), ("saturation", "saturation"))
653
+ if key in cut.picture
654
+ ]
655
+ if eq:
656
+ stages.append("eq=" + ":".join(eq))
657
+ warmth = cut.picture.get("warmth")
658
+ if warmth:
659
+ # Positive is warmer: lift red, drop blue, by the same small amount.
660
+ amount = warmth / 100.0
661
+ stages.append(f"colorbalance=rm={amount:.4f}:bm={-amount:.4f}")
662
+ return ",".join(stages)
663
+
664
+
665
+ def _loudnorm_filter(ffmpeg: str, media: Path, target: float) -> str:
666
+ """Two-pass EBU R128.
667
+
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.
671
+ """
672
+
673
+ common = f"I={target}:TP=-1.5:LRA=11"
674
+ result = subprocess.run(
675
+ [ffmpeg, "-hide_banner", "-nostats", "-i", str(media),
676
+ "-af", f"loudnorm={common}:print_format=json", "-f", "null", "-"],
677
+ capture_output=True, text=True, check=False,
678
+ )
679
+ measured = _last_json_object(result.stderr)
680
+ required = ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")
681
+ if not measured or not all(key in measured for key in required):
682
+ # Say so rather than silently delivering a single-pass approximation.
683
+ raise EditError(
684
+ "loudnorm 第一遍没有返回可用的测量结果,无法做两遍响度标准化;"
685
+ "检查成片音轨是否为空"
686
+ )
687
+ return (
688
+ f"loudnorm={common}:measured_I={measured['input_i']}"
689
+ f":measured_TP={measured['input_tp']}:measured_LRA={measured['input_lra']}"
690
+ f":measured_thresh={measured['input_thresh']}"
691
+ f":offset={measured['target_offset']}:linear=true:print_format=summary"
692
+ )
693
+
694
+
695
+ def _sync_remotion_workspace(workspace: Path) -> Path:
696
+ """Copy the shipped composition into a runnable workspace outside the project.
697
+
698
+ Editing the composition means editing the skill's own source; the workspace
699
+ is a build directory that is rewritten on every run, so a change here can
700
+ never be quietly lost, and `node_modules` never lands inside the repository.
701
+ """
702
+
703
+ workspace = workspace.expanduser().resolve()
704
+ (workspace / "src").mkdir(parents=True, exist_ok=True)
705
+ for name in REMOTION_SOURCE_FILES:
706
+ source = REMOTION_SOURCE / name
707
+ if not source.is_file():
708
+ raise EditError(f"技能里缺少 Remotion 源文件: {name}")
709
+ shutil.copyfile(source, workspace / name)
710
+ return workspace
711
+
712
+
713
+ def _render_remotion_overlay(
714
+ output_root: Path,
715
+ cues: Sequence[tuple[float, float, str]],
716
+ canvas: dict[str, Any],
717
+ duration: float,
718
+ workspace_root: Path,
719
+ concurrency: int = DEFAULT_REMOTION_CONCURRENCY,
720
+ ) -> Path:
721
+ """Render the subtitle layer as a transparent video with Remotion.
722
+
723
+ The picture is never re-drawn by the browser: only the type is, onto an
724
+ empty frame, and ffmpeg composites that over untouched footage. So the
725
+ route costs one overlay pass and buys real typography — weight, rim, safe
726
+ area, wrapping and an entry animation — expressed once in CSS instead of in
727
+ a subtitle format whose own scaling has to be reasoned about.
728
+
729
+ Remotion is a separate project with its own licence: free for individuals
730
+ and small companies, paid above that. It is opt-in for exactly that reason,
731
+ and nothing installs it behind the creator's back.
732
+ """
733
+
734
+ workspace = _sync_remotion_workspace(workspace_root)
735
+ if not (workspace / "node_modules").is_dir():
736
+ raise EditError(
737
+ f"Remotion 还没安装。先运行一次:\n"
738
+ f" cd {workspace} && npm install\n"
739
+ "或改用默认的 ffmpeg 字幕(去掉 --subtitles remotion)。\n"
740
+ "注意 Remotion 有自己的许可证:个人与小团队免费,超出规模需要商业授权;"
741
+ "本工具不会替你安装它。"
742
+ )
743
+ npx = _which("npx")
744
+ if npx is None:
745
+ raise EditError(
746
+ "PATH 上没有 npx,无法运行 Remotion;装好 Node.js,或改用默认的 ffmpeg 字幕。"
747
+ )
748
+ props = output_root / "字幕.props.json"
749
+ props.write_text(
750
+ json.dumps(
751
+ {
752
+ "cues": [
753
+ {"start": round(start, 3), "end": round(end, 3), "text": text}
754
+ for start, end, text in cues
755
+ ],
756
+ "width": canvas["width"] or 1080,
757
+ "height": canvas["height"] or 1920,
758
+ "fps": canvas["fps"] or 24,
759
+ "durationInSeconds": round(duration, 3),
760
+ "fontScale": 0.034,
761
+ "bottomScale": 0.055,
762
+ "fontFamily": (
763
+ '"PingFang SC", "Noto Sans CJK SC", "Source Han Sans SC", '
764
+ '"Microsoft YaHei", sans-serif'
765
+ ),
766
+ },
767
+ ensure_ascii=False,
768
+ indent=2,
769
+ ),
770
+ encoding="utf-8",
771
+ )
772
+ overlay = output_root / "字幕叠层.webm"
773
+ result = subprocess.run(
774
+ [npx, "remotion", "render", REMOTION_COMPOSITION, str(overlay),
775
+ f"--props={props}", "--log=error",
776
+ f"--concurrency={max(1, concurrency)}"],
777
+ cwd=str(workspace), capture_output=True, text=True, check=False,
778
+ )
779
+ if result.returncode != 0 or not overlay.is_file():
780
+ raise EditError(
781
+ "Remotion 渲染失败:\n" + (result.stderr or result.stdout or "").strip()[-2000:]
782
+ )
783
+ return overlay
784
+
785
+
786
+ def _frame_geometry(media: Path) -> str:
787
+ stream = probe_stream(media)
788
+ return f"{stream['width']}x{stream['height']}"
789
+
790
+
791
+ def _subtitle_cues(
792
+ cuts: Sequence[Cut], spans: Sequence[float]
793
+ ) -> list[tuple[float, float, str]]:
794
+ """Place each line in output time, measuring the segments that were written.
795
+
796
+ A segment lands on a frame boundary, so it is a few milliseconds longer than
797
+ the cut list declares. Accumulating the declared numbers instead drifts —
798
+ a third of a second by the end of eight cuts here — and the subtitle leaves
799
+ before the actor stops speaking. The rendered files are the timeline.
800
+ """
801
+
802
+ cues: list[tuple[float, float, str]] = []
803
+ cursor = 0.0
804
+ for cut, span in zip(cuts, spans):
805
+ declared = cut.end - cut.start
806
+ # The windows were authored against the declared span; hold them in
807
+ # place proportionally rather than letting the tail slip out.
808
+ scale = span / declared if declared > 0 else 1.0
809
+ for window_start, window_end, text in cut.subtitles:
810
+ start = 0.0 if window_start is None else window_start
811
+ end = declared if window_end is None else window_end
812
+ cues.append((cursor + start * scale, cursor + end * scale, text))
813
+ cursor += span
814
+ return cues
815
+
816
+
817
+ def _build_srt(cues: Sequence[tuple[float, float, str]]) -> str:
818
+ """The text is the screenplay's, verbatim; only the timing is ours."""
819
+
820
+ return "\n".join(
821
+ f"{index}\n{_timecode(start)} --> {_timecode(end)}\n{text}\n"
822
+ for index, (start, end, text) in enumerate(cues, start=1)
823
+ )
824
+
825
+
826
+ def _build_ass(
827
+ cues: Sequence[tuple[float, float, str]], width: int, height: int
828
+ ) -> str:
829
+ """Author the ASS directly so the type size is stated in frame pixels.
830
+
831
+ Letting ffmpeg convert the SRT hands libass a 384-high canvas, and every
832
+ size in the style is then scaled by height/384 on the way to the frame.
833
+ Computing a FontSize from the real height on top of that scales it twice:
834
+ the first attempt here produced type a third of the frame wide, sitting in
835
+ the middle of the picture with both ends of the line cut off. Declaring
836
+ PlayRes as the frame removes the conversion, so one unit is one pixel.
837
+ """
838
+
839
+ font_size = max(18, round(height * 0.034))
840
+ outline = max(2, round(height * 0.0022))
841
+ margin_v = max(24, round(height * 0.055))
842
+ margin_h = max(24, round(width * 0.06))
843
+ header = (
844
+ "[Script Info]\n"
845
+ "ScriptType: v4.00+\n"
846
+ "WrapStyle: 2\n"
847
+ "ScaledBorderAndShadow: yes\n"
848
+ f"PlayResX: {width}\n"
849
+ f"PlayResY: {height}\n"
850
+ "\n[V4+ Styles]\n"
851
+ "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, "
852
+ "OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, "
853
+ "ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, "
854
+ "MarginL, MarginR, MarginV, Encoding\n"
855
+ f"Style: 正片,{SUBTITLE_FONT},{font_size},&H00FFFFFF,&H00FFFFFF,"
856
+ f"&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,{outline},1,2,"
857
+ f"{margin_h},{margin_h},{margin_v},1\n"
858
+ "\n[Events]\n"
859
+ "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, "
860
+ "Effect, Text\n"
861
+ )
862
+ lines = [
863
+ f"Dialogue: 0,{_ass_time(start)},{_ass_time(end)},正片,,0,0,0,,{_ass_text(text)}"
864
+ for start, end, text in cues
865
+ ]
866
+ return header + "\n".join(lines) + "\n"
867
+
868
+
869
+ def _ass_text(text: str) -> str:
870
+ """Escape a line so ASS renders its characters instead of reading them.
871
+
872
+ `{`…`}` is an override block in ASS and `\\` starts an escape, so a quoted
873
+ line that happens to contain either would lose characters on screen with no
874
+ error anywhere — and EDT-05 exists precisely to keep the burned text equal
875
+ to the screenplay's, character for character.
876
+ """
877
+
878
+ return text.replace("\\", "\\\\").replace("{", "\\{").replace("}", "\\}")
879
+
880
+
881
+ def _ass_time(seconds: float) -> str:
882
+ hundredths = int(round(seconds * 100))
883
+ hours, hundredths = divmod(hundredths, 360000)
884
+ minutes, hundredths = divmod(hundredths, 6000)
885
+ whole, hundredths = divmod(hundredths, 100)
886
+ return f"{hours:d}:{minutes:02d}:{whole:02d}.{hundredths:02d}"
887
+
888
+
889
+ def _timecode(seconds: float) -> str:
890
+ milliseconds = int(round(seconds * 1000))
891
+ hours, milliseconds = divmod(milliseconds, 3_600_000)
892
+ minutes, milliseconds = divmod(milliseconds, 60_000)
893
+ whole, milliseconds = divmod(milliseconds, 1000)
894
+ return f"{hours:02d}:{minutes:02d}:{whole:02d},{milliseconds:03d}"
895
+
896
+
897
+ def verify(episode: Path, cuts: Sequence[Cut], delivery: Delivery) -> dict[str, Any]:
898
+ """Measure the rendered film. Every entry is a number or an honest 未测."""
899
+
900
+ ffmpeg = _require("ffmpeg")
901
+ final = episode / OUTPUT_DIRECTORY / "成片.mp4"
902
+ if not final.is_file():
903
+ raise EditError(f"没有 {final};先运行 render")
904
+ stream = probe_stream(final)
905
+ expected = sum(cut.end - cut.start for cut in cuts)
906
+ measurements: dict[str, Any] = {
907
+ "成片": str(final),
908
+ "实测时长": round(stream["duration"], 2),
909
+ "各段时长之和": round(expected, 2),
910
+ "时长差": round(stream["duration"] - expected, 2),
911
+ "画幅": f"{stream['width']}×{stream['height']}",
912
+ "画幅是否等于交付规格": _matches_frame_size(delivery, stream),
913
+ "帧率": stream["fps"],
914
+ "帧率是否等于交付规格": _matches_fps(delivery, stream),
915
+ "目标时长": delivery.target_seconds,
916
+ "与目标时长的差": (
917
+ round(stream["duration"] - delivery.target_seconds, 2)
918
+ if delivery.target_seconds is not None
919
+ else "未测(剪辑单没有声明目标时长)"
920
+ ),
921
+ }
922
+ result = subprocess.run(
923
+ [ffmpeg, "-hide_banner", "-nostats", "-i", str(final),
924
+ "-af", "loudnorm=print_format=json", "-f", "null", "-"],
925
+ capture_output=True, text=True, check=False,
926
+ )
927
+ measured = _last_json_object(result.stderr)
928
+ if measured and "input_i" in measured:
929
+ measurements["实测响度 LUFS"] = float(measured["input_i"])
930
+ measurements["实测真峰 dBTP"] = float(measured["input_tp"])
931
+ else:
932
+ measurements["实测响度 LUFS"] = "未测(loudnorm 没有返回可解析的测量结果)"
933
+ measurements["交付响度目标"] = delivery.loudness_lufs
934
+ measurements["台词完整性"] = "未测(本工具不做转写;在成片上转写后逐句对《剧本.md》原文)"
935
+ measurements["边界帧"] = "未测(抽剪辑点前后各一帧目视核对黑场/白场/半渲染帧)"
936
+ return measurements
937
+
938
+
939
+ def _matches_frame_size(delivery: Delivery, stream: dict[str, Any]) -> Any:
940
+ if delivery.frame_size is None:
941
+ return "未测(剪辑单没有声明画幅)"
942
+ return (stream["width"], stream["height"]) == delivery.frame_size
943
+
944
+
945
+ def _matches_fps(delivery: Delivery, stream: dict[str, Any]) -> Any:
946
+ if delivery.fps is None:
947
+ return "未测(剪辑单没有声明帧率)"
948
+ return abs(stream["fps"] - delivery.fps) < 0.05
949
+
950
+
951
+ def _last_json_object(text: str) -> Optional[dict[str, Any]]:
952
+ """ffmpeg prints its own tail after the loudnorm block, so decode a prefix."""
953
+
954
+ decoder = json.JSONDecoder()
955
+ start = text.rfind("{")
956
+ while start != -1:
957
+ try:
958
+ document, _ = decoder.raw_decode(text[start:].lstrip())
959
+ except json.JSONDecodeError:
960
+ start = text.rfind("{", 0, start)
961
+ continue
962
+ if isinstance(document, dict):
963
+ return document
964
+ start = text.rfind("{", 0, start)
965
+ return None
966
+
967
+
968
+ def _run(command: Sequence[str]) -> None:
969
+ result = subprocess.run(command, capture_output=True, text=True, check=False)
970
+ if result.returncode != 0:
971
+ raise EditError(f"命令失败: {' '.join(command[:6])}…\n{result.stderr.strip()}")
972
+
973
+
974
+ def _emit(payload: dict[str, Any]) -> None:
975
+ print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=False))
976
+
977
+
978
+ def main(argv: Optional[Sequence[str]] = None) -> int:
979
+ parser = argparse.ArgumentParser(description="短剧剪辑:核对剪辑单、渲染成片、测量成片")
980
+ parser.add_argument("command", choices=("check", "render", "verify"))
981
+ parser.add_argument("episode", help="剧集/<EP> 目录")
982
+ parser.add_argument("--project-root", default=".", help="项目根目录(解析素材相对路径)")
983
+ parser.add_argument("--no-subtitles", action="store_true", help="render 时不烧字幕")
984
+ parser.add_argument(
985
+ "--subtitles", choices=SUBTITLE_RENDERERS, default="ffmpeg",
986
+ help="字幕渲染方式:ffmpeg(默认,无外部依赖)或 remotion(需先安装,排版更好)",
987
+ )
988
+ parser.add_argument(
989
+ "--remotion-workspace", type=Path, default=DEFAULT_REMOTION_WORKSPACE,
990
+ help=f"Remotion 运行工作区(默认 {DEFAULT_REMOTION_WORKSPACE}),必须在项目之外",
991
+ )
992
+ parser.add_argument(
993
+ "--remotion-concurrency", type=int, default=DEFAULT_REMOTION_CONCURRENCY,
994
+ help=(
995
+ f"Remotion 的并发无头浏览器数(默认 {DEFAULT_REMOTION_CONCURRENCY})。"
996
+ "每个都持有一整帧,调高很容易把内存吃满"
997
+ ),
998
+ )
999
+ arguments = parser.parse_args(argv)
1000
+
1001
+ episode = Path(arguments.episode).resolve()
1002
+ project_root = Path(arguments.project_root).resolve()
1003
+ try:
1004
+ delivery, cuts, unused = parse_cut_list(episode / CUT_LIST_NAME)
1005
+ if arguments.command == "check":
1006
+ findings = check_cuts(
1007
+ episode, cuts, project_root, probe=_which("ffprobe") is not None
1008
+ )
1009
+ payload: dict[str, Any] = {
1010
+ "段数": len(cuts),
1011
+ "各段时长之和": round(sum(cut.end - cut.start for cut in cuts), 2),
1012
+ "目标时长": delivery.target_seconds,
1013
+ "未采用镜头": unused,
1014
+ "findings": findings,
1015
+ }
1016
+ if _which("ffprobe") is None:
1017
+ payload["未测"] = ["区间是否超过素材实际时长(PATH 上没有 ffprobe)"]
1018
+ _emit(payload)
1019
+ return 1 if findings else 0
1020
+ if arguments.command == "render":
1021
+ findings = check_cuts(episode, cuts, project_root, probe=True)
1022
+ if findings:
1023
+ _emit({"findings": findings, "已渲染": False})
1024
+ return 1
1025
+ _emit(render(
1026
+ episode, project_root, cuts, delivery,
1027
+ burn_subtitles=delivery.burn_subtitles and not arguments.no_subtitles,
1028
+ renderer=arguments.subtitles,
1029
+ remotion_workspace=arguments.remotion_workspace,
1030
+ remotion_concurrency=arguments.remotion_concurrency,
1031
+ ))
1032
+ return 0
1033
+ _emit(verify(episode, cuts, delivery))
1034
+ return 0
1035
+ except EditError as error:
1036
+ print(str(error), file=sys.stderr)
1037
+ return 2
1038
+
1039
+
1040
+ if __name__ == "__main__":
1041
+ raise SystemExit(main())