@oh-story/dsh 0.1.11 → 0.1.13

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 (264) hide show
  1. package/README.md +17 -11
  2. package/lib/client.js +85 -35
  3. package/lib/client.js.map +3 -3
  4. package/lib/drama/manifest.json +41 -36
  5. package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +340 -13
  6. package/lib/drama/skills/short-drama-assets/references/voice-direction.md +4 -2
  7. package/lib/drama/skills/short-drama-edit/SKILL.md +32 -12
  8. package/lib/drama/skills/short-drama-edit/references/still-cuts.md +90 -0
  9. package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +1035 -145
  10. package/lib/drama/skills/short-drama-edit/scripts/selftest.py +48 -0
  11. package/lib/drama/skills/short-drama-produce/SKILL.md +6 -1
  12. package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +12 -0
  13. package/lib/drama/skills/short-drama-produce/scripts/production_tool.py +138 -9
  14. package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +6 -0
  15. package/lib/drama/skills/short-drama-storyboard/SKILL.md +2 -0
  16. package/lib/drama/skills/short-drama-storyboard/references/production-shot-grammar.md +3 -1
  17. package/lib/drama/skills/short-drama-video-prompts/SKILL.md +7 -0
  18. package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +14 -0
  19. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +19 -6
  20. package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +1 -0
  21. package/lib/drama/skills/short-drama-video-prompts/references/target-model-profile.md +5 -0
  22. package/lib/drama/skills/short-drama-video-prompts/references/wan-3.0.md +4 -1
  23. package/lib/index.js +16 -7
  24. package/lib/index.js.map +2 -2
  25. package/lib/novel-to-game/README.md +98 -44
  26. package/lib/novel-to-game/README_ZH.md +100 -46
  27. package/lib/novel-to-game/VERSION +1 -1
  28. package/lib/novel-to-game/examples/jin-ping-mei/build/BUILD_BRIEF.md +27 -202
  29. package/lib/novel-to-game/examples/jin-ping-mei/build/app/RUN.md +23 -115
  30. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/chamber.webp +0 -0
  31. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/court_night.webp +0 -0
  32. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/court_rain.webp +0 -0
  33. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/garden.webp +0 -0
  34. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/hall.webp +0 -0
  35. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/kitchen.webp +0 -0
  36. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/street.webp +0 -0
  37. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/street_dawn.webp +0 -0
  38. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/temple.webp +0 -0
  39. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/yue/moon.webp +0 -0
  40. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/angry.webp +0 -0
  41. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/blush.webp +0 -0
  42. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/neutral.webp +0 -0
  43. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/sad.webp +0 -0
  44. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/smile.webp +0 -0
  45. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/surprised.webp +0 -0
  46. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/angry.webp +0 -0
  47. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/blush.webp +0 -0
  48. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/neutral.webp +0 -0
  49. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/sad.webp +0 -0
  50. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/smile.webp +0 -0
  51. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/surprised.webp +0 -0
  52. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/angry.webp +0 -0
  53. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/blush.webp +0 -0
  54. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/neutral.webp +0 -0
  55. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/sad.webp +0 -0
  56. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/smile.webp +0 -0
  57. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/surprised.webp +0 -0
  58. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/angry.webp +0 -0
  59. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/blush.webp +0 -0
  60. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/neutral.webp +0 -0
  61. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/sad.webp +0 -0
  62. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/smile.webp +0 -0
  63. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/surprised.webp +0 -0
  64. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/angry.webp +0 -0
  65. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/blush.webp +0 -0
  66. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/neutral.webp +0 -0
  67. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/sad.webp +0 -0
  68. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/smile.webp +0 -0
  69. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/surprised.webp +0 -0
  70. package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/style.css +326 -2503
  71. package/lib/novel-to-game/examples/jin-ping-mei/build/app/index.html +71 -4
  72. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/adv.js +221 -0
  73. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/audio.js +113 -257
  74. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/main.js +511 -3026
  75. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/menus.js +277 -0
  76. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/petals.js +67 -0
  77. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/stage.js +180 -0
  78. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/store.js +70 -0
  79. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common.js +198 -0
  80. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common2.js +204 -0
  81. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common3.js +198 -0
  82. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/dates.js +186 -0
  83. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/index.js +122 -0
  84. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/meng.js +190 -0
  85. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/pan.js +212 -0
  86. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/pinger.js +198 -0
  87. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/xuee.js +199 -0
  88. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/yue.js +214 -0
  89. package/lib/novel-to-game/examples/jin-ping-mei/build/app/package.json +2 -2
  90. package/lib/novel-to-game/examples/jin-ping-mei/build/art/generated-art.json +182 -45
  91. package/lib/novel-to-game/examples/jin-ping-mei/qa/README.md +7 -17
  92. package/lib/novel-to-game/examples/jin-ping-mei/qa/verification.json +9 -9
  93. package/lib/novel-to-game/manifest.json +303 -78
  94. package/lib/novel-to-game/skills/game-art-direction/references/art-direction-method.md +7 -2
  95. package/lib/novel-to-game/skills/game-concept/references/concept-method.md +2 -0
  96. package/lib/novel-to-game/skills/game-world-design/references/narrative-design-method.md +2 -0
  97. package/lib/novel-to-game/skills/game-world-design/references/world-design-method.md +9 -0
  98. package/lib/types/client/drama-production-view.d.ts +11 -0
  99. package/lib/types/client/drama-production-view.d.ts.map +1 -1
  100. package/lib/types/client/file-activity.d.ts +1 -1
  101. package/lib/types/client/file-activity.d.ts.map +1 -1
  102. package/lib/types/client/production-prompts.d.ts +7 -1
  103. package/lib/types/client/production-prompts.d.ts.map +1 -1
  104. package/lib/types/client/production-runtime.d.ts +7 -2
  105. package/lib/types/client/production-runtime.d.ts.map +1 -1
  106. package/lib/types/drama-adapters.d.ts.map +1 -1
  107. package/lib/types/skill-provider.d.ts.map +1 -1
  108. package/lib/types/video-project.d.ts.map +1 -1
  109. package/lib/video-recap/CHANGELOG.md +108 -1
  110. package/lib/video-recap/README.en.md +18 -14
  111. package/lib/video-recap/README.md +18 -14
  112. package/lib/video-recap/manifest.json +297 -321
  113. package/lib/video-recap/skills/video-assemble/SKILL.md +18 -16
  114. package/lib/video-recap/skills/video-assemble/references/explicit-audio-mix.md +3 -1
  115. package/lib/video-recap/skills/video-assemble/references/jianying/SOURCE.md +5 -5
  116. package/lib/video-recap/skills/video-assemble/references/packaging.md +4 -4
  117. package/lib/video-recap/skills/video-assemble/references/subtitle-track.md +10 -10
  118. package/lib/video-recap/skills/video-assemble/scripts/adoption/audio_mix_binding.py +13 -2
  119. package/lib/video-recap/skills/video-assemble/scripts/adoption/av_clock.py +109 -0
  120. package/lib/video-recap/skills/video-assemble/scripts/adoption/strict_publish.py +4 -1
  121. package/lib/video-recap/skills/video-assemble/scripts/artifacts.py +2 -9
  122. package/lib/video-recap/skills/video-assemble/scripts/assemble.py +188 -51
  123. package/lib/video-recap/skills/video-assemble/scripts/assemble_constants.py +0 -1
  124. package/lib/video-recap/skills/video-assemble/scripts/assembly_contract.py +32 -75
  125. package/lib/video-recap/skills/video-assemble/scripts/assembly_settings.py +8 -15
  126. package/lib/video-recap/skills/video-assemble/scripts/audio_mix.py +202 -161
  127. package/lib/video-recap/skills/video-assemble/scripts/codec_peak.py +112 -0
  128. package/lib/video-recap/skills/video-assemble/scripts/export_jianying.py +10 -101
  129. package/lib/video-recap/skills/video-assemble/scripts/jianying/builders.py +44 -475
  130. package/lib/video-recap/skills/video-assemble/scripts/jianying/model.py +1 -22
  131. package/lib/video-recap/skills/video-assemble/scripts/jianying/templates.py +0 -3
  132. package/lib/video-recap/skills/video-assemble/scripts/jianying/timeline_contract.py +36 -103
  133. package/lib/video-recap/skills/video-assemble/scripts/jianying/tracks.py +0 -8
  134. package/lib/video-recap/skills/video-assemble/scripts/jianying/writer.py +28 -203
  135. package/lib/video-recap/skills/video-assemble/scripts/lib.py +20 -34
  136. package/lib/video-recap/skills/video-assemble/scripts/loudness.py +332 -0
  137. package/lib/video-recap/skills/video-assemble/scripts/media.py +158 -38
  138. package/lib/video-recap/skills/video-assemble/scripts/narration_audio.py +30 -16
  139. package/lib/video-recap/skills/video-assemble/scripts/render_preflight.py +49 -8
  140. package/lib/video-recap/skills/video-assemble/scripts/source_subtitles.py +17 -6
  141. package/lib/video-recap/skills/video-assemble/scripts/subtitles/core.py +24 -2
  142. package/lib/video-recap/skills/video-assemble/scripts/subtitles/track.py +2 -5
  143. package/lib/video-recap/skills/video-assemble/scripts/subtitles/track_binding.py +2 -9
  144. package/lib/video-recap/skills/video-assemble/scripts/timeline.py +12 -34
  145. package/lib/video-recap/skills/video-assemble/scripts/timeline_emit.py +3 -4
  146. package/lib/video-recap/skills/video-assemble/scripts/visual_render.py +28 -2
  147. package/lib/video-recap/skills/video-cut/SKILL.md +37 -11
  148. package/lib/video-recap/skills/video-cut/references/shot-review.md +1 -1
  149. package/lib/video-recap/skills/video-cut/scripts/cut.py +2 -34
  150. package/lib/video-recap/skills/video-cut/scripts/cut_cli.py +96 -124
  151. package/lib/video-recap/skills/video-cut/scripts/cut_contract.py +97 -182
  152. package/lib/video-recap/skills/video-cut/scripts/cut_render.py +177 -166
  153. package/lib/video-recap/skills/video-cut/scripts/frame_grid.py +249 -0
  154. package/lib/video-recap/skills/video-cut/scripts/lib.py +0 -3
  155. package/lib/video-recap/skills/video-cut/scripts/media_geometry.py +173 -7
  156. package/lib/video-recap/skills/video-cut/scripts/sentence_boundaries.py +234 -151
  157. package/lib/video-recap/skills/video-cut/scripts/sentence_gate.py +189 -0
  158. package/lib/video-recap/skills/video-cut/scripts/shot_review.py +2 -6
  159. package/lib/video-recap/skills/video-recap/SKILL.md +24 -27
  160. package/lib/video-recap/skills/video-recap/references/audio-routing.md +8 -6
  161. package/lib/video-recap/skills/video-recap/references/config-playbook.md +8 -11
  162. package/lib/video-recap/skills/video-recap/references/data-schema.md +131 -74
  163. package/lib/video-recap/skills/video-recap/references/resource-library.md +17 -1
  164. package/lib/video-recap/skills/video-recap/scripts/doctor.py +51 -268
  165. package/lib/video-recap/skills/video-recap/scripts/final_qc.py +89 -254
  166. package/lib/video-recap/skills/video-recap/scripts/lib.py +81 -70
  167. package/lib/video-recap/skills/video-recap/scripts/library.py +40 -4
  168. package/lib/video-recap/skills/video-recap/scripts/materials.py +13 -80
  169. package/lib/video-recap/skills/video-recap/scripts/recap_cli.py +49 -17
  170. package/lib/video-recap/skills/video-recap/scripts/recap_inspect.py +1 -3
  171. package/lib/video-recap/skills/video-recap/scripts/recap_runner.py +86 -116
  172. package/lib/video-recap/skills/video-recap/scripts/recap_runtime.py +87 -23
  173. package/lib/video-recap/skills/video-recap/scripts/recap_source.py +0 -39
  174. package/lib/video-recap/skills/video-recap/scripts/recap_stage_qc.py +37 -135
  175. package/lib/video-recap/skills/video-recap/scripts/recap_timeline.py +118 -127
  176. package/lib/video-recap/skills/video-recap/scripts/resources/__init__.py +4 -0
  177. package/lib/video-recap/skills/video-recap/scripts/{resource_lock.py → resources/lock.py} +1 -1
  178. package/lib/video-recap/skills/video-recap/scripts/{project_binding.py → resources/project_binding.py} +78 -17
  179. package/lib/video-recap/skills/video-reference/SKILL.md +108 -0
  180. package/lib/video-recap/skills/video-reference/references/reference-schema.md +139 -0
  181. package/lib/video-recap/skills/video-reference/scripts/lib.py +85 -0
  182. package/lib/video-recap/skills/video-reference/scripts/reference.py +96 -0
  183. package/lib/video-recap/skills/video-reference/scripts/reference_check.py +553 -0
  184. package/lib/video-recap/skills/video-reference/scripts/reference_frames.py +122 -0
  185. package/lib/video-recap/skills/video-reference/scripts/reference_measure.py +253 -0
  186. package/lib/video-recap/skills/video-reference/scripts/reference_profile.py +271 -0
  187. package/lib/video-recap/skills/video-script/SKILL.md +9 -7
  188. package/lib/video-recap/skills/video-script/scripts/agent_text.py +18 -267
  189. package/lib/video-recap/skills/video-script/scripts/deslop_qc.py +12 -30
  190. package/lib/video-recap/skills/video-script/scripts/entry_suggestion.py +125 -0
  191. package/lib/video-recap/skills/video-script/scripts/evidence_bundle.py +2 -26
  192. package/lib/video-recap/skills/video-script/scripts/lib.py +5 -15
  193. package/lib/video-recap/skills/video-script/scripts/lint_summary.py +184 -0
  194. package/lib/video-recap/skills/video-script/scripts/narration_lint.py +120 -100
  195. package/lib/video-recap/skills/video-script/scripts/review.py +3 -34
  196. package/lib/video-recap/skills/video-script/scripts/review_grounding.py +20 -46
  197. package/lib/video-recap/skills/video-script/scripts/review_response.py +120 -258
  198. package/lib/video-recap/skills/video-script/scripts/review_runner.py +2 -10
  199. package/lib/video-recap/skills/video-script/scripts/speech_ownership.py +89 -5
  200. package/lib/video-recap/skills/video-script/scripts/validate.py +26 -34
  201. package/lib/video-recap/skills/video-understanding/SKILL.md +29 -4
  202. package/lib/video-recap/skills/video-understanding/references/data-schema.md +5 -22
  203. package/lib/video-recap/skills/video-understanding/scripts/agent_text.py +24 -121
  204. package/lib/video-recap/skills/video-understanding/scripts/asr.py +11 -5
  205. package/lib/video-recap/skills/video-understanding/scripts/asr_timing_evidence.py +15 -24
  206. package/lib/video-recap/skills/video-understanding/scripts/briefing/builder.py +59 -25
  207. package/lib/video-recap/skills/video-understanding/scripts/briefing/context.py +39 -3
  208. package/lib/video-recap/skills/video-understanding/scripts/briefing/inputs.py +15 -27
  209. package/lib/video-recap/skills/video-understanding/scripts/briefing/timeline.py +136 -37
  210. package/lib/video-recap/skills/video-understanding/scripts/consolidate.py +121 -37
  211. package/lib/video-recap/skills/video-understanding/scripts/detect.py +118 -16
  212. package/lib/video-recap/skills/video-understanding/scripts/index_normalize.py +359 -0
  213. package/lib/video-recap/skills/video-understanding/scripts/lib.py +62 -21
  214. package/lib/video-recap/skills/video-understanding/scripts/storyboard.py +181 -51
  215. package/lib/video-recap/skills/video-understanding/scripts/timeline_fusion.py +0 -26
  216. package/lib/video-recap/skills/video-understanding/scripts/understanding_brief.py +37 -14
  217. package/lib/video-recap/skills/video-understanding/scripts/understanding_cache.py +40 -32
  218. package/lib/video-recap/skills/video-understanding/scripts/understanding_runner.py +100 -127
  219. package/lib/video-recap/skills/video-understanding/scripts/understanding_storyboard.py +182 -30
  220. package/lib/video-recap/skills/video-understanding/scripts/vlm.py +30 -27
  221. package/lib/video-recap/skills/video-voiceover/SKILL.md +19 -12
  222. package/lib/video-recap/skills/video-voiceover/references/index-tts.md +3 -3
  223. package/lib/video-recap/skills/video-voiceover/scripts/approved_text_policy.py +3 -1
  224. package/lib/video-recap/skills/video-voiceover/scripts/dub.py +60 -135
  225. package/lib/video-recap/skills/video-voiceover/scripts/lib.py +9 -34
  226. package/lib/video-recap/skills/video-voiceover/scripts/providers/fish_audio.py +6 -1
  227. package/lib/video-recap/skills/video-voiceover/scripts/tts_audio.py +64 -1
  228. package/lib/video-recap/skills/video-voiceover/scripts/tts_cache.py +99 -0
  229. package/lib/video-recap/skills/video-voiceover/scripts/voiceover.py +177 -200
  230. package/package.json +45 -45
  231. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/inner_court_accord_five.webp +0 -0
  232. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/public_day15.webp +0 -0
  233. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/household/li_jiaoer.webp +0 -0
  234. package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/new-guofeng.css +0 -1494
  235. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/assets.js +0 -147
  236. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/data.js +0 -7470
  237. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/engine.js +0 -13066
  238. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/text.js +0 -32
  239. package/lib/novel-to-game/examples/jin-ping-mei/qa/playable-model.json +0 -99
  240. package/lib/novel-to-game/examples/jin-ping-mei/qa/verify_playable_model.mjs +0 -152
  241. package/lib/video-recap/skills/video-assemble/references/foreground-compose.md +0 -118
  242. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_combination_segment.json +0 -84
  243. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_combination_video_material.json +0 -98
  244. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_draft.json +0 -204
  245. package/lib/video-recap/skills/video-assemble/references/pair-media.md +0 -74
  246. package/lib/video-recap/skills/video-assemble/scripts/compose_foreground.py +0 -293
  247. package/lib/video-recap/skills/video-assemble/scripts/pair_media.py +0 -216
  248. package/lib/video-recap/skills/video-recap/references/shift-left-qc-schema.md +0 -23
  249. package/lib/video-recap/skills/video-recap/scripts/mimo_qc.py +0 -33
  250. package/lib/video-recap/skills/video-recap/scripts/qc/__init__.py +0 -1
  251. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_client.py +0 -13
  252. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_contract.py +0 -9
  253. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_evidence.py +0 -287
  254. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_observations.py +0 -175
  255. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_payload.py +0 -206
  256. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_report.py +0 -318
  257. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_runner.py +0 -130
  258. package/lib/video-recap/skills/video-recap/scripts/qc_contract.py +0 -320
  259. package/lib/video-recap/skills/video-script/scripts/timeline_fusion.py +0 -108
  260. package/lib/video-recap/skills/video-understanding/scripts/brief.py +0 -9
  261. package/lib/video-recap/skills/video-understanding/scripts/deslop_qc.py +0 -211
  262. package/lib/video-recap/skills/video-understanding/scripts/narration_lint.py +0 -707
  263. package/lib/video-recap/skills/video-understanding/scripts/speech_ownership.py +0 -143
  264. /package/lib/video-recap/skills/video-cut/scripts/{narration_mapping.py → cut_qc.py} +0 -0
@@ -73,11 +73,6 @@ def _env_number(name, default, cast, minimum):
73
73
  return value
74
74
 
75
75
 
76
- def env_int(name, default, *, minimum=None):
77
- """Read an integer env var, rejecting malformed or below-minimum values."""
78
- return _env_number(name, default, int, minimum)
79
-
80
-
81
76
  def env_bool(name, default=False):
82
77
  """Read common boolean env var forms."""
83
78
  raw = os.environ.get(name)
@@ -120,9 +115,6 @@ CONFIG = {
120
115
  "quiet_overlap_min_ratio": 0.8, # 解说段至少多少比例落在安静窗口内才标记为非对白重叠
121
116
  "visual_beat_max_seconds": 18.0, # 单段解说超过该时长且跨多个帧锚点时给 lint 提醒
122
117
  "visual_beat_max_facts": 3, # 单段解说最多建议覆盖的 frame_facts 锚点数量
123
- "asr_chunk_min_chars": env_int("ASR_CHUNK_MIN_CHARS", 500, minimum=1), # brief 中 ASR 写作分块最小字数/词数
124
- "asr_chunk_max_chars": env_int("ASR_CHUNK_MAX_CHARS", 800, minimum=1), # brief 中 ASR 写作分块最大字数/词数
125
- "edit_mode": os.environ.get("EDIT_MODE", "full"), # full | cut
126
118
  }
127
119
 
128
120
  def log(msg):
@@ -165,9 +157,8 @@ def _sanitize_api_error(value, limit=500):
165
157
  text = _ERROR_KEY_RE.sub("<redacted-key>", text)
166
158
  return text[:limit]
167
159
 
168
- def _api_headers(api_provider=None, api_url=None, api_key=None):
160
+ def _api_headers(api_key=None):
169
161
  """Build MiMo auth headers (OpenAI-compatible chat/completions with an api-key header)."""
170
- del api_provider, api_url # MiMo is the only provider; signature kept for call sites
171
162
  key = CONFIG["api_key"] if api_key is None else api_key
172
163
  return {
173
164
  "Content-Type": "application/json",
@@ -175,9 +166,8 @@ def _api_headers(api_provider=None, api_url=None, api_key=None):
175
166
  "api-key": key,
176
167
  }
177
168
 
178
- def _prepare_api_payload(payload, api_provider=None, api_url=None):
169
+ def _prepare_api_payload(payload):
179
170
  """Normalize payload fields for MiMo's OpenAI-compatible chat/completions API."""
180
- del api_provider, api_url
181
171
  normalized = dict(payload)
182
172
  if "max_tokens" in normalized and "max_completion_tokens" not in normalized:
183
173
  normalized["max_completion_tokens"] = normalized.pop("max_tokens")
@@ -192,15 +182,15 @@ def _prepare_api_payload(payload, api_provider=None, api_url=None):
192
182
  normalized["thinking"] = {"type": "disabled"}
193
183
  return normalized
194
184
 
195
- def api_call(payload, max_retries=8, *, api_provider=None, api_url=None, api_key=None, api_env_var=None):
185
+ def api_call(payload, max_retries=8, *, api_url=None, api_key=None, api_env_var=None):
196
186
  """调用 OpenAI-compatible API,带重试。
197
187
 
198
188
  集群的 429 限流是常态而非错误,所以重试更耐心(更多次数 + 退避封顶 60s + 遵从 Retry-After),
199
189
  避免一次瞬时限流就中止整个阶段。配额窗口常以分钟计,所以 429 在没有 Retry-After 时也至少等 10s。
200
190
  """
201
191
  endpoint = normalize_api_url(api_url if api_url is not None else CONFIG["api_url"])
202
- headers = _api_headers(api_provider=api_provider, api_url=endpoint, api_key=api_key)
203
- data = json.dumps(_prepare_api_payload(payload, api_provider=api_provider, api_url=endpoint)).encode("utf-8")
192
+ headers = _api_headers(api_key=api_key)
193
+ data = json.dumps(_prepare_api_payload(payload)).encode("utf-8")
204
194
 
205
195
  for attempt in range(max_retries):
206
196
  try:
@@ -0,0 +1,184 @@
1
+ """Render a narration lint report as the compact lines the author reads on the console.
2
+
3
+ narration_lint.json keeps every field; this summary repeats, per flagged block, the
4
+ numbers needed to fix it without opening that file. Errors (failed lint) and warnings
5
+ (passed lint) use the same line format. Block numbers are 1-based ("段 N",
6
+ the block's position in narration.json); the report's `index` fields stay 0-based. A
7
+ deslop blocker found in original_subtitles.json carries `source: "original_subtitles"`
8
+ and an index into that file, so it is labelled "原声字幕第 N 条", never "段 N".
9
+ """
10
+
11
+ MAX_LISTED_ERRORS = 12
12
+
13
+ _HINTS = {
14
+ "over_budget": "缩短文字,或放宽/挪动时间窗",
15
+ "interrupts_source_sentence": "把整块(时长不变)挪到建议的时间窗;没有建议入点时缩短、挪走或删掉这块",
16
+ "source_sentence_anchors_unavailable": "挪走或删掉这块,或重新生成含句尾锚点的语音证据",
17
+ "time_overlap": "调整时间窗,让两块不重叠",
18
+ "out_of_order": "按 start 从小到大排列各块",
19
+ "outside_clip_plan": "把这块挪进某个选中片段的时间范围",
20
+ "ambiguous_source_clip": "给这块写 source_clip_id",
21
+ "em_dash": "删掉破折号,改用逗号、句号或把句子拆开",
22
+ "placeholder_leakage": "把示例占位(如【主角】)换成真实人名或内容",
23
+ "slot_too_short": "放宽时间窗,或把这块并进相邻块",
24
+ "incomplete_sentence": "用句号、问号或感叹号收尾",
25
+ "outside_scene": "核对时间,确认这块对应哪段画面",
26
+ "crosses_scene_boundary": "把这块收进所在场景,或在场景切换处拆成两块",
27
+ "visual_beat_too_broad": "拆块或收紧时间窗,让旁白贴着当前画面",
28
+ "crosses_clip_boundary": "把这块收进所在片段,超出部分会被裁掉",
29
+ "no_original_blocks": "在几个强原声时刻不写旁白,让原声满音量播放",
30
+ "under_narrated": "确认每个长空档都有意交给原声;只在有明确解说任务时加块",
31
+ "no_original_breaks": "在块之间留几个多秒的空档给原声",
32
+ "fragmented_beats": "把相邻、讲同一件事的短句并成一块",
33
+ }
34
+
35
+ _ORIGINAL_SUBTITLE_HINTS = {
36
+ "em_dash": "在 original_subtitles.json 里删掉这条的破折号(不是改 narration.json)",
37
+ "placeholder_leakage": "在 original_subtitles.json 里替换这条的示例占位(不是改 narration.json)",
38
+ }
39
+ _ORIGINAL_SUBTITLE_FALLBACK_HINT = "改 original_subtitles.json 里的这一条,不是 narration.json"
40
+
41
+
42
+ def block_label(index):
43
+ """'段 N' for a 0-based block index; '整体' for a file-level issue."""
44
+ return "整体" if index is None else f"段 {index + 1}"
45
+
46
+
47
+ def _label(e):
48
+ if e.get("source") == "original_subtitles":
49
+ index = e.get("index")
50
+ return "原声字幕" if index is None else f"原声字幕第 {index + 1} 条"
51
+ return block_label(e.get("index"))
52
+
53
+
54
+ def _hint(e):
55
+ code = e.get("code")
56
+ if e.get("source") == "original_subtitles":
57
+ return _ORIGINAL_SUBTITLE_HINTS.get(code, _ORIGINAL_SUBTITLE_FALLBACK_HINT)
58
+ return _HINTS.get(code)
59
+
60
+
61
+ def _seconds(value):
62
+ return f"{value:.2f}s" if isinstance(value, (int, float)) else "?"
63
+
64
+
65
+ def _window(e):
66
+ return f"{_seconds(e.get('start'))}-{_seconds(e.get('end'))}"
67
+
68
+
69
+ def _over_budget(e):
70
+ head = f"{_window(e)} 写了 {e.get('actual_chars')} 字,窗口约 {e.get('budget_chars')} 字"
71
+ if "limit_chars" in e: # the full-mode error
72
+ return f"{head}(硬上限 {e.get('limit_chars')},超出 {e.get('over_chars')} 字)"
73
+ return (
74
+ f"{head},估计读完 {_seconds(e.get('estimated_tts_seconds'))},"
75
+ f"可用 {_seconds(e.get('slot_seconds'))}"
76
+ )
77
+
78
+
79
+ def _interrupts(e):
80
+ text = f"入点 {_seconds(e.get('entry_time'))} 落在原声句子中间"
81
+ if e.get("suggested_start") is None:
82
+ shift = e.get("max_shift_seconds")
83
+ within = f"前后 {shift:g} 秒内" if isinstance(shift, (int, float)) else "附近"
84
+ return text + f",{within}没有能整块挪过去、留在前后两块之间又不与其他块重叠或相接的句尾锚点"
85
+ tail = e.get("source_text_tail")
86
+ tail = f",句尾「{tail}」" if tail else ""
87
+ target = _seconds(e["suggested_start"])
88
+ if e.get("suggested_end") is not None:
89
+ target += f"-{_seconds(e['suggested_end'])}"
90
+ return (
91
+ f"{text},建议入点 {target}"
92
+ f"(锚点 {e.get('anchor_confidence')}/{e.get('anchor_boundary_use')}{tail})"
93
+ )
94
+
95
+
96
+ def _ratio(value):
97
+ return f"{value:.0%}" if isinstance(value, (int, float)) else "?"
98
+
99
+
100
+ _WARNING_DETAILS = {
101
+ "slot_too_short": lambda e: f"{_window(e)} 只容得下约 {e.get('budget_chars')} 字,TTS 可能放不下",
102
+ "incomplete_sentence": lambda e: f"结尾「{e.get('text_tail', '')}」没有句末标点",
103
+ "outside_scene": lambda e: f"{_window(e)} 的中点不在任何检测到的场景里",
104
+ "crosses_scene_boundary": lambda e: (
105
+ f"{_window(e)} 超出所在场景 "
106
+ f"{_seconds(e.get('scene_start'))}-{_seconds(e.get('scene_end'))}"
107
+ ),
108
+ "visual_beat_too_broad": lambda e: (
109
+ f"{_window(e)} 共 {_seconds(e.get('duration'))},"
110
+ f"跨过 {e.get('frame_fact_count', '?')} 个画面锚点"
111
+ ),
112
+ "crosses_clip_boundary": lambda e: (
113
+ f"{_window(e)} 超出所在片段 {_seconds(e.get('clip_start'))}-{_seconds(e.get('clip_end'))}"
114
+ ),
115
+ "no_original_blocks": lambda e: (
116
+ f"旁白覆盖率 {_ratio(e.get('narration_coverage'))},超过上限 {_ratio(e.get('coverage_max'))}"
117
+ ),
118
+ "under_narrated": lambda e: (
119
+ f"旁白覆盖率 {_ratio(e.get('narration_coverage'))},低于下限 {_ratio(e.get('coverage_min'))}"
120
+ ),
121
+ "no_original_breaks": lambda e: (
122
+ f"块与块之间没有 ≥{_seconds(e.get('original_block_min_seconds'))} 的原声段"
123
+ ),
124
+ "fragmented_beats": lambda e: (
125
+ f"平均每块 {e.get('avg_block_chars')} 字,少于 {e.get('block_min_chars')} 字"
126
+ ),
127
+ }
128
+
129
+
130
+ def _details(e):
131
+ code = e.get("code")
132
+ if code == "over_budget" and "budget_chars" in e:
133
+ return _over_budget(e)
134
+ if code == "interrupts_source_sentence":
135
+ return _interrupts(e)
136
+ if code == "source_sentence_anchors_unavailable":
137
+ return f"入点 {_seconds(e.get('entry_time'))} 压在原声上,但没有已验证的句尾锚点"
138
+ if code == "time_overlap":
139
+ return (
140
+ f"{_seconds(e.get('start'))}-{_seconds(e.get('end'))} 与"
141
+ f"{block_label(e.get('previous_index'))}(结束于 {_seconds(e.get('previous_end'))})重叠"
142
+ )
143
+ if code == "out_of_order":
144
+ return f"start {_seconds(e.get('start'))} 早于上一块的 {_seconds(e.get('previous_start'))}"
145
+ if code in _WARNING_DETAILS:
146
+ return _WARNING_DETAILS[code](e)
147
+ return str(e.get("message", "")).strip()
148
+
149
+
150
+ def _issue_line(e):
151
+ line = f"- {_label(e)} {e.get('code')}:{_details(e)}"
152
+ hint = _hint(e)
153
+ if hint:
154
+ line += f"。改法:{hint}"
155
+ return line
156
+
157
+
158
+ def _warning_lines(report):
159
+ """One line per warning, every warning listed: they never block, so none is cut."""
160
+ return [_issue_line(w) for w in report.get("warnings", [])]
161
+
162
+
163
+ def format_lint_warnings(report, lint_path=None):
164
+ """The console summary of a lint that passed with warnings."""
165
+ lines = [f"narration lint:通过,{len(report.get('warnings', []))} 个 warning(不阻塞)"]
166
+ lines.extend(_warning_lines(report))
167
+ if lint_path is not None:
168
+ lines.append(f"完整报告:{lint_path}")
169
+ return "\n".join(lines)
170
+
171
+
172
+ def format_lint_failure(report, lint_path=None):
173
+ errors = report.get("errors", [])
174
+ lines = [f"narration.json 预检失败:{len(errors)} 个 error,修改后重跑 validate。"]
175
+ lines.extend(_issue_line(e) for e in errors[:MAX_LISTED_ERRORS])
176
+ if len(errors) > MAX_LISTED_ERRORS:
177
+ lines.append(f"- …另有 {len(errors) - MAX_LISTED_ERRORS} 个 error 未列出")
178
+ warnings = _warning_lines(report)
179
+ if warnings:
180
+ lines.append(f"另有 {len(warnings)} 个 warning(不阻塞):")
181
+ lines.extend(warnings)
182
+ if lint_path is not None:
183
+ lines.append(f"完整报告(含全部字段):{lint_path}")
184
+ return "\n".join(lines)
@@ -5,21 +5,26 @@ timing and text checks live here, and every later consumer trusts its result.
5
5
  """
6
6
 
7
7
  import json
8
- import math
9
8
  from pathlib import Path
10
9
 
11
10
  from lib import CONFIG, log
12
11
  from agent_text import (
13
- _clean_narration_punctuation,
12
+ TTS_UTTERANCE_OVERHEAD_SECONDS,
14
13
  _find_scene_for_midpoint,
15
- _normalise_narration_segment,
16
- _post_dedup_narration,
17
- _recommended_char_budget,
14
+ _lint_char_budget,
18
15
  _scene_available_seconds,
19
16
  _text_char_count,
20
- _truncate_at_sentence,
21
17
  )
22
18
  from deslop_qc import analyze_deslop_qc
19
+ from entry_suggestion import (
20
+ SUGGESTED_ANCHOR_MAX_SHIFT_SECONDS,
21
+ _has_connected_predecessor,
22
+ _is_number,
23
+ _nearest_safe_anchor,
24
+ _other_block_spans,
25
+ _suggestion_window,
26
+ )
27
+ from lint_summary import format_lint_failure, format_lint_warnings
23
28
  from speech_ownership import (
24
29
  entry_overlaps_source_speech,
25
30
  load_source_sentence_evidence,
@@ -32,14 +37,6 @@ def _lint_issue(level, index, code, message, **extra):
32
37
  return issue
33
38
 
34
39
 
35
- def _is_number(value):
36
- return (
37
- isinstance(value, (int, float))
38
- and not isinstance(value, bool)
39
- and math.isfinite(value)
40
- )
41
-
42
-
43
40
  def _visual_overlay_issues(index, seg):
44
41
  """Shape-check the renderer handoff recap reads verbatim from a validated segment.
45
42
 
@@ -134,11 +131,16 @@ def _clip_matches_for_segment(seg, clip_plan, requested_clip_id):
134
131
  ]
135
132
 
136
133
 
137
- def _source_sentence_entry_issue(index, start, anchors, speech_owned):
134
+ def _source_sentence_entry_issue(
135
+ index, start, end, anchors, speech_owned, other_spans=(), window=(None, None),
136
+ ):
138
137
  """Return a blocking issue when narration enters midway through source speech.
139
138
 
140
139
  The validator reports a correction instead of silently moving audio. Source
141
140
  sentence integrity is invariant; an editorial policy string cannot bypass it.
141
+ `other_spans` are the other blocks' (start, end) and `window` the open range between
142
+ the neighbours (see _nearest_safe_anchor): the suggested entry never moves this block
143
+ onto, flush against, or past one of them.
142
144
  """
143
145
  if not speech_owned:
144
146
  return None
@@ -165,45 +167,81 @@ def _source_sentence_entry_issue(index, start, anchors, speech_owned):
165
167
  if pause_start - 0.05 <= start <= when + 0.08:
166
168
  return None
167
169
 
168
- suggested = next(
169
- (anchor for anchor in anchors if anchor["time"] > start + 0.08), None
170
- )
170
+ suggested = _nearest_safe_anchor(start, end, anchors, other_spans, window)
171
171
  return _lint_issue(
172
172
  "error",
173
173
  index,
174
174
  "interrupts_source_sentence",
175
- "Narration enters before the source sentence finishes. Move this block to the suggested "
176
- "sentence-end anchor, or shorten/move/remove it when there is no later verified anchor, "
177
- "then rerun lint before TTS. Source sentence interruption has no override.",
175
+ "Narration enters before the source sentence finishes. Move the whole block (same "
176
+ "duration) to the suggested sentence-end anchor, or shorten/move/remove it when no "
177
+ f"anchor within {SUGGESTED_ANCHOR_MAX_SHIFT_SECONDS:g}s keeps it between its neighbours "
178
+ "and clear of the other blocks, then rerun lint before TTS. The window stays inside the "
179
+ "block's clip (cut) or the output duration (cut_output); full mode does not know the "
180
+ "video's end, so keep it inside the video. Source sentence interruption has no override.",
178
181
  entry_time=round(start, 3),
179
182
  suggested_start=round(suggested["time"], 3) if suggested else None,
183
+ suggested_end=round(suggested["time"] + end - start, 3) if suggested else None,
184
+ max_shift_seconds=SUGGESTED_ANCHOR_MAX_SHIFT_SECONDS,
180
185
  source_text_tail=str(suggested.get("text_tail", "")).strip() if suggested else "",
181
186
  anchor_confidence=suggested["confidence"] if suggested else None,
187
+ anchor_boundary_use=(
188
+ suggested.get("boundary_use")
189
+ or ("unverified" if suggested["confidence"] in {"high", "medium"} else "none")
190
+ )
191
+ if suggested
192
+ else None,
182
193
  )
183
194
 
184
195
 
185
- def _has_connected_predecessor(narration, idx, start):
186
- """A back-to-back narration handoff (<=150ms gap) does not expose the source track,
187
- so the next TTS block is not a new source-speech entry."""
188
- for other_idx, other in enumerate(narration):
189
- if other_idx == idx or not isinstance(other, dict):
190
- continue
191
- other_start, other_end = other.get("start"), other.get("end")
192
- if not _is_number(other_start) or not _is_number(other_end):
193
- continue
194
- if other_start < start and -0.001 <= start - other_end <= 0.15:
195
- return True
196
- return False
196
+ def _suggestion_limits(seg, clip_plan, mode, output_duration):
197
+ """Closed range a suggested move must stay in: the block's one clip (cut mode, source
198
+ time), or [0, output_duration] when that is known (cut_output)."""
199
+ if mode == "cut" and clip_plan:
200
+ requested = seg.get("source_clip_id")
201
+ try:
202
+ requested = None if requested is None else int(requested)
203
+ except (TypeError, ValueError):
204
+ return None, None
205
+ matches = _clip_matches_for_segment(seg, clip_plan, requested)
206
+ return _clip_span(matches[0]) if len(matches) == 1 else (None, None)
207
+ usable = _is_number(output_duration) and output_duration > 0
208
+ return 0.0, output_duration if usable else None
209
+
210
+
211
+ # Full-mode hard text budget: a segment longer than this multiple of its recommended
212
+ # character budget cannot be spoken in its window, so lint returns it to the author
213
+ # instead of anything downstream shortening it.
214
+ OVER_BUDGET_ERROR_RATIO = 1.25
215
+
216
+
217
+ def _over_budget_error(index, start, end, char_count, budget):
218
+ limit = int(budget * OVER_BUDGET_ERROR_RATIO)
219
+ return _lint_issue(
220
+ "error",
221
+ index,
222
+ "over_budget",
223
+ f"段 {index + 1} [{start:.2f}-{end:.2f}s] has {char_count} chars; the window holds "
224
+ f"about {budget} (hard limit {limit}), {char_count - limit} over the limit. "
225
+ "Shorten the text or widen/move the window, then rerun validate; "
226
+ "validation never shortens approved text.",
227
+ start=start,
228
+ end=end,
229
+ budget_chars=budget,
230
+ limit_chars=limit,
231
+ actual_chars=char_count,
232
+ over_chars=char_count - limit,
233
+ tts_overhead_seconds=TTS_UTTERANCE_OVERHEAD_SECONDS,
234
+ )
197
235
 
198
236
 
199
237
  def lint_narration(
200
238
  narration, scenes_analysis=None, *, clip_plan=None, mode="full", work_dir=None,
201
- require_chronological=False,
239
+ output_duration=None,
202
240
  ):
203
241
  """Preflight-check agent narration before TTS; write narration_lint.json when work_dir is set.
204
242
 
205
- Segments are sorted by start for the timing checks; ``require_chronological`` (the
206
- --preserve-approved-text contract) additionally rejects input that is not already in order."""
243
+ Segments must already be in chronological order; validation never reorders them.
244
+ `output_duration` (cut_output) bounds where interrupt suggestions may move a block."""
207
245
  scenes_analysis = scenes_analysis or []
208
246
  errors = []
209
247
  warnings = []
@@ -221,6 +259,8 @@ def lint_narration(
221
259
  )
222
260
  else:
223
261
  previous_start = None
262
+ # Suggested (start, end) of blocks already told to move; later blocks keep clear.
263
+ suggested_spans = {}
224
264
  for idx, seg in enumerate(narration):
225
265
  if not isinstance(seg, dict):
226
266
  errors.append(
@@ -240,7 +280,7 @@ def lint_narration(
240
280
  )
241
281
  )
242
282
  continue
243
- if require_chronological and previous_start is not None and start < previous_start:
283
+ if previous_start is not None and start < previous_start:
244
284
  errors.append(
245
285
  _lint_issue(
246
286
  "error",
@@ -304,12 +344,18 @@ def lint_narration(
304
344
  continue
305
345
 
306
346
  char_count = _text_char_count(text)
307
- budget = _recommended_char_budget(start, end)
347
+ budget = _lint_char_budget(start, end)
308
348
  # estimate at the REAL playback rate (after the narration_speed atempo); otherwise a
309
- # beat sized to its 1.3x-sped slot looks "over budget" when it actually fits.
349
+ # beat sized to its 1.3x-sped slot looks "over budget" when it actually fits. The
350
+ # utterance's TTS edge silence takes room too.
310
351
  play_rate = CONFIG["speech_rate"] * CONFIG["narration_speed"]
311
- estimated_tts_seconds = char_count / play_rate
352
+ estimated_tts_seconds = char_count / play_rate + TTS_UTTERANCE_OVERHEAD_SECONDS
312
353
  slot_seconds = _scene_available_seconds(start, end)
354
+ over_limit = (
355
+ mode == "full" and char_count > budget * OVER_BUDGET_ERROR_RATIO
356
+ )
357
+ if over_limit:
358
+ errors.append(_over_budget_error(idx, start, end, char_count, budget))
313
359
  if budget < 5:
314
360
  warnings.append(
315
361
  _lint_issue(
@@ -322,7 +368,9 @@ def lint_narration(
322
368
  budget_chars=budget,
323
369
  )
324
370
  )
325
- elif estimated_tts_seconds > slot_seconds:
371
+ # Independent of slot_too_short: a short slot can also be overfilled, and the
372
+ # author needs both numbers.
373
+ if not over_limit and estimated_tts_seconds > slot_seconds:
326
374
  warnings.append(
327
375
  _lint_issue(
328
376
  "warning",
@@ -352,15 +400,25 @@ def lint_narration(
352
400
  entry_issue = _source_sentence_entry_issue(
353
401
  idx,
354
402
  start,
403
+ end,
355
404
  source_sentence_anchors,
356
405
  entry_overlaps_source_speech(
357
406
  start,
358
407
  source_evidence,
359
408
  authored_overlap=seg.get("overlaps_speech", True),
360
409
  ),
410
+ _other_block_spans(narration, idx),
411
+ _suggestion_window(
412
+ narration, idx, suggested_spans,
413
+ _suggestion_limits(seg, clip_plan, mode, output_duration),
414
+ ),
361
415
  )
362
416
  if entry_issue:
363
417
  errors.append(entry_issue)
418
+ if entry_issue.get("suggested_end") is not None:
419
+ suggested_spans[idx] = (
420
+ entry_issue["suggested_start"], entry_issue["suggested_end"],
421
+ )
364
422
 
365
423
  scene_bounds = _scene_bounds_for_midpoint(scenes_analysis, start, end)
366
424
  if scenes_analysis and not scene_bounds:
@@ -404,6 +462,7 @@ def lint_narration(
404
462
  end=end,
405
463
  duration=round(end - start, 2),
406
464
  frame_fact_times=[round(ts, 2) for ts in frame_fact_times[:8]],
465
+ frame_fact_count=len(frame_fact_times),
407
466
  )
408
467
  )
409
468
 
@@ -603,11 +662,9 @@ def lint_narration(
603
662
  )
604
663
  )
605
664
 
665
+ # Unfiltered, so narration-sourced blocker indices are narration.json positions.
606
666
  deslop_qc = analyze_deslop_qc(
607
- [seg for seg in narration if isinstance(seg, dict)]
608
- if isinstance(narration, list)
609
- else [],
610
- work_dir=work_dir,
667
+ narration if isinstance(narration, list) else [], work_dir=work_dir,
611
668
  )
612
669
  for blocker in deslop_qc["blockers"]:
613
670
  errors.append(
@@ -632,76 +689,39 @@ def lint_narration(
632
689
  "warnings": warnings,
633
690
  }
634
691
  if work_dir is not None:
635
- Path(work_dir, "deslop_qc.json").write_text(
636
- json.dumps(deslop_qc, ensure_ascii=False, indent=2), encoding="utf-8"
637
- )
638
692
  Path(work_dir, "narration_lint.json").write_text(
639
693
  json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
640
694
  )
641
695
  return report
642
696
 
643
697
 
698
+ class NarrationLintError(ValueError):
699
+ """Lint found blocking errors: an ordinary result for the author to fix, not a crash.
700
+
701
+ str() is the compact per-block summary; `report` is the full lint report."""
702
+
703
+ def __init__(self, report, lint_path=None):
704
+ self.report = report
705
+ super().__init__(format_lint_failure(report, lint_path))
706
+
707
+
644
708
  def validate_narration_or_raise(
645
709
  narration, scenes_analysis=None, *, clip_plan=None, mode="full", work_dir=None,
646
- require_chronological=False,
710
+ output_duration=None,
647
711
  ):
648
712
  report = lint_narration(
649
713
  narration, scenes_analysis, clip_plan=clip_plan, mode=mode, work_dir=work_dir,
650
- require_chronological=require_chronological,
714
+ output_duration=output_duration,
651
715
  )
652
716
  if report["errors"]:
653
- sample = "; ".join(
654
- f"#{e['index']}: {e['code']}" for e in report["errors"][:3]
717
+ raise NarrationLintError(
718
+ report, None if work_dir is None else Path(work_dir, "narration_lint.json")
655
719
  )
656
- raise ValueError(f"narration.json 预检失败: {sample}; 详见 narration_lint.json")
657
720
  if report["warnings"]:
658
- log(
659
- f"narration lint: {len(report['warnings'])} warnings (see narration_lint.json)"
660
- )
721
+ log(format_lint_warnings(
722
+ report, None if work_dir is None else Path(work_dir, "narration_lint.json")
723
+ ))
661
724
  else:
662
- log("narration lint: ok")
725
+ log("narration lint:通过")
663
726
  return report
664
727
 
665
-
666
- def _validate_narration_budget(narration, scenes_analysis):
667
- """Trim lint-validated narration to its timing budgets; drop what cannot be spoken."""
668
- del scenes_analysis # scene boundaries are advisory (lint warns); authored timing is kept
669
- cleaned = []
670
- for raw in narration:
671
- item = _normalise_narration_segment(raw)
672
- max_chars = _recommended_char_budget(item["start"], item["end"])
673
- if max_chars < 5:
674
- log(f" 丢弃过短解说段 {item['start']:.1f}-{item['end']:.1f}s")
675
- continue
676
- if _text_char_count(item["narration"]) > max_chars * 1.25:
677
- truncated = _truncate_at_sentence(item["narration"], max_chars)
678
- if truncated and _text_char_count(truncated) >= 5:
679
- log(f" 解说超预算,已截短: {item['start']:.1f}-{item['end']:.1f}s")
680
- item["narration"] = truncated
681
- else:
682
- log(
683
- f" 解说超预算且无法安全截断,已丢弃: {item['start']:.1f}-{item['end']:.1f}s"
684
- )
685
- continue
686
- item["narration"] = _clean_narration_punctuation(item["narration"])
687
- stripped = item["narration"].strip()
688
- if stripped and stripped[-1] in ",:、;,—":
689
- item["narration"] = stripped.rstrip(",:、;,—") + "。"
690
- cleaned.append(item)
691
-
692
- cleaned.sort(key=lambda n: n["start"])
693
- deduped = []
694
- for item in cleaned:
695
- if deduped and item["start"] < deduped[-1]["end"]:
696
- prev = deduped[-1]
697
- log(
698
- f" 解说时间重叠: {item['start']:.1f}-{item['end']:.1f}s vs "
699
- f"{prev['start']:.1f}-{prev['end']:.1f}s"
700
- )
701
- if _text_char_count(item["narration"]) > _text_char_count(
702
- prev["narration"]
703
- ):
704
- deduped[-1] = item
705
- else:
706
- deduped.append(item)
707
- return _post_dedup_narration(deduped)
@@ -1,40 +1,9 @@
1
1
  #!/usr/bin/env python3
2
- """Public API and CLI entrypoint for narration review."""
2
+ """CLI entrypoint for narration review."""
3
3
 
4
- from evidence_bundle import (
5
- build_evidence_bundle,
6
- build_review_coverage_metadata,
7
- coverage_policy_v1,
8
- filter_evidence_by_ranges,
9
- render_evidence_bundle,
10
- )
11
- from review_grounding import remap_grounding_to_output_timeline
12
- from review_response import (
13
- build_grounding_qc,
14
- build_review_messages,
15
- format_review_md,
16
- merge_review_findings,
17
- parse_review_response,
18
- write_grounding_qc,
19
- )
20
- from review_runner import main, review_narration
4
+ from review_runner import main
21
5
 
22
- __all__ = [
23
- "build_evidence_bundle",
24
- "build_grounding_qc",
25
- "build_review_coverage_metadata",
26
- "build_review_messages",
27
- "coverage_policy_v1",
28
- "filter_evidence_by_ranges",
29
- "format_review_md",
30
- "main",
31
- "merge_review_findings",
32
- "parse_review_response",
33
- "remap_grounding_to_output_timeline",
34
- "render_evidence_bundle",
35
- "review_narration",
36
- "write_grounding_qc",
37
- ]
6
+ __all__ = ["main"]
38
7
 
39
8
  if __name__ == "__main__":
40
9
  main()