@oh-story/dsh 0.1.12 → 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
@@ -94,8 +94,8 @@ def env_float(name, default, *, minimum=None):
94
94
  return _env_number(name, default, float, minimum)
95
95
 
96
96
 
97
- # Single MiMo credential powers ASR + VLM + TTS. Per-capability overrides
98
- # (MIMO_VIDEO_API_KEY / MIMO_TTS_API_KEY / MIMO_ASR_API_KEY and their *_API_URL forms)
97
+ # Single MiMo credential powers ASR + VLM. Per-capability overrides
98
+ # (MIMO_VIDEO_API_KEY / MIMO_ASR_API_KEY and their *_API_URL forms)
99
99
  # are optional and fall back to MIMO_API_KEY / MIMO_API_URL. Token-Plan keys (tp-*) auto-
100
100
  # route to the Token-Plan cluster base URL; pay-as-you-go keys use api.xiaomimimo.com.
101
101
  _mimo_api_key = os.environ.get("MIMO_API_KEY", "")
@@ -121,6 +121,7 @@ CONFIG = {
121
121
  "mimo_api_key": _mimo_api_key,
122
122
  "mimo_video_api_url": normalize_api_url(_raw_mimo_video_api_url),
123
123
  "mimo_video_api_key": _mimo_video_api_key,
124
+ "mimo_video_env_var": "MIMO_VIDEO_API_KEY" if os.environ.get("MIMO_VIDEO_API_KEY") else "MIMO_API_KEY",
124
125
  "mimo_asr_api_url": normalize_api_url(_raw_mimo_asr_api_url),
125
126
  "mimo_asr_api_key": _mimo_asr_api_key,
126
127
  "mimo_asr_env_var": "MIMO_ASR_API_KEY" if os.environ.get("MIMO_ASR_API_KEY") else "MIMO_API_KEY",
@@ -164,19 +165,12 @@ CONFIG = {
164
165
  # 生成解说时使用 speech_rate * safety_margin 作为约束
165
166
  "speech_rate": env_float("SPEECH_RATE", 3.9, minimum=0.5), # 旧值 3.5 系统性偏低 ~10-17%
166
167
  "speech_safety_margin": env_float("SPEECH_SAFETY_MARGIN", 0.85, minimum=0.1), # 保守系数:TTS 实际语速有 ±20% 波动
167
- # Block-coverage lint thresholds — promoted from inline .get() literals to real CONFIG keys (tunable; defaults unchanged)
168
+ # Block-coverage targets the brief derives its first-draft block count from
168
169
  "narration_coverage_target": 0.7, # rough first-draft/diagnostic fallback; content-led audio decisions may differ (not a quota)
169
- "narration_coverage_max": 0.85, # above this coverage → no_original_blocks (narration is wall-to-wall)
170
- "narration_coverage_min": 0.5, # below this coverage → under_narrated
171
170
  "narration_block_seconds": 9.0, # block cadence used to derive target block count
172
- "original_block_min_seconds": 2.5, # a deliberate original-audio gap must be at least this long
173
- "narration_block_min_chars": 16, # below this avg block size → fragmented_beats
174
171
  "breath_ms": 250, # 段间呼吸空间(ms);block recap 块内连贯、块间留原声呼吸
175
172
  "narration_speed": env_float("NARRATION_SPEED", 1.15, minimum=0.5), # 解说整体提速(atempo),默认回到可懂区间;长片可设 1.0
176
173
  "narration_tail_pad_seconds": 0.1, # 解说尾部最少留白;短 slot 会自动压低 delay 避免截断
177
- "quiet_overlap_min_ratio": 0.8, # 解说段至少多少比例落在安静窗口内才标记为非对白重叠
178
- "visual_beat_max_seconds": 18.0, # 单段解说超过该时长且跨多个帧锚点时给 lint 提醒
179
- "visual_beat_max_facts": 3, # 单段解说最多建议覆盖的 frame_facts 锚点数量
180
174
  "asr_chunk_min_chars": env_int("ASR_CHUNK_MIN_CHARS", 500, minimum=1), # brief 中 ASR 写作分块最小字数/词数
181
175
  "asr_chunk_max_chars": env_int("ASR_CHUNK_MAX_CHARS", 800, minimum=1), # brief 中 ASR 写作分块最大字数/词数
182
176
  "silence_noise_threshold": "-25dB", # ffmpeg silencedetect 噪声阈值
@@ -250,6 +244,57 @@ def load_background_research(work_dir):
250
244
  raise ValueError("background_research.json 顶层必须是 JSON 对象({...})")
251
245
  return data
252
246
 
247
+ # MiMo refusal detection, defined once for this skill. Two deliberately different tests:
248
+ #
249
+ # - Narrow (`is_moderation_refusal`): ASR windows and per-scene VLM replies. MiMo answers a
250
+ # moderated request with the English sentence "The request was rejected because it was
251
+ # considered high risk" as the reply content. Only the provider's own wording is matched:
252
+ # a false positive here blanks real dialogue or a real scene description.
253
+ # - Broad (`is_usable_overview_chunk`): only the optional MiMo video overview. A false
254
+ # positive there just retries the chunk and falls back to the frame description, so it also
255
+ # rejects generic refusal phrasing ("违规", "content policy", ...) that would be too
256
+ # aggressive for transcripts.
257
+ MODERATION_REFUSAL_MARKERS = ("request was rejected", "considered high risk")
258
+ OVERVIEW_REJECTION_MARKERS = MODERATION_REFUSAL_MARKERS + (
259
+ "high risk", "content policy", "cannot process", "无法处理", "内容审核", "违规",
260
+ )
261
+
262
+
263
+ def is_moderation_refusal(text):
264
+ """True when a MiMo reply is a moderation refusal, not a transcript or description.
265
+
266
+ Both markers must appear in a short reply: one of them alone ("my request was rejected")
267
+ is plausible English dialogue, and a 15 s transcript window must not be blanked for it.
268
+ """
269
+ low = " ".join(str(text or "").lower().split())
270
+ return len(low) <= 200 and all(marker in low for marker in MODERATION_REFUSAL_MARKERS)
271
+
272
+
273
+ def is_usable_overview_chunk(content):
274
+ """A MiMo overview chunk is usable only if it is non-empty and carries no refusal phrasing."""
275
+ low = str(content or "").strip().lower()
276
+ return bool(low) and not any(marker in low for marker in OVERVIEW_REJECTION_MARKERS)
277
+
278
+
279
+ def offline_ignored_settings(api_key, endpoint_key):
280
+ """Cache-settings keys not compared when no credential is set.
281
+
282
+ The default endpoint URL is derived from the key prefix (tp-* → Token Plan cluster), so
283
+ unsetting the key changes it although the provider output did not change, and nothing can
284
+ be recomputed without a key anyway: an offline rerun must reuse the paid artifact, not
285
+ discard it. With a key every setting is compared."""
286
+ return () if api_key else (endpoint_key,)
287
+
288
+
289
+ def settings_match(cached, current, ignore=()):
290
+ """True when two cache-settings dicts are equal outside the `ignore` keys."""
291
+ if not isinstance(cached, dict) or not isinstance(current, dict):
292
+ return False
293
+ return {k: v for k, v in cached.items() if k not in ignore} == {
294
+ k: v for k, v in current.items() if k not in ignore
295
+ }
296
+
297
+
253
298
  def file_identity(path):
254
299
  """{"size", "mtime_ns"} of a file: the cache identity recorded instead of content hashes."""
255
300
  st = os.stat(os.fspath(path))
@@ -286,9 +331,8 @@ def _sanitize_api_error(value, limit=500):
286
331
  text = _ERROR_KEY_RE.sub("<redacted-key>", text)
287
332
  return text[:limit]
288
333
 
289
- def _api_headers(api_provider=None, api_url=None, api_key=None):
334
+ def _api_headers(api_key=None):
290
335
  """Build MiMo auth headers (OpenAI-compatible chat/completions with an api-key header)."""
291
- del api_provider, api_url # MiMo is the only provider; signature kept for call sites
292
336
  key = CONFIG["api_key"] if api_key is None else api_key
293
337
  return {
294
338
  "Content-Type": "application/json",
@@ -296,9 +340,8 @@ def _api_headers(api_provider=None, api_url=None, api_key=None):
296
340
  "api-key": key,
297
341
  }
298
342
 
299
- def _prepare_api_payload(payload, api_provider=None, api_url=None):
343
+ def _prepare_api_payload(payload):
300
344
  """Normalize payload fields for MiMo's OpenAI-compatible chat/completions API."""
301
- del api_provider, api_url
302
345
  normalized = dict(payload)
303
346
  if "max_tokens" in normalized and "max_completion_tokens" not in normalized:
304
347
  normalized["max_completion_tokens"] = normalized.pop("max_tokens")
@@ -314,10 +357,9 @@ def _prepare_api_payload(payload, api_provider=None, api_url=None):
314
357
  return normalized
315
358
 
316
359
  def _mimo_endpoint(kind):
317
- """Return per-capability MiMo endpoint settings (video understanding / TTS / ASR)."""
360
+ """Return per-capability MiMo endpoint settings (video understanding / ASR)."""
318
361
  by_kind = {
319
362
  "video": ("mimo_video_api_url", "mimo_video_api_key", "mimo_video_env_var"),
320
- "tts": ("mimo_tts_api_url", "mimo_tts_api_key", "mimo_tts_env_var"),
321
363
  "asr": ("mimo_asr_api_url", "mimo_asr_api_key", "mimo_asr_env_var"),
322
364
  }
323
365
  if kind not in by_kind:
@@ -326,7 +368,7 @@ def _mimo_endpoint(kind):
326
368
  return {
327
369
  "api_url": CONFIG.get(url_key) or CONFIG.get("mimo_api_url"),
328
370
  "api_key": CONFIG.get(key_key) or CONFIG.get("mimo_api_key"),
329
- "api_env_var": CONFIG.get(src_key, "MIMO_API_KEY"),
371
+ "api_env_var": CONFIG[src_key],
330
372
  }
331
373
 
332
374
  def _call_mimo_endpoint(kind, payload, max_retries=10):
@@ -334,7 +376,6 @@ def _call_mimo_endpoint(kind, payload, max_retries=10):
334
376
  return api_call(
335
377
  payload,
336
378
  max_retries=max_retries,
337
- api_provider="mimo",
338
379
  api_url=settings["api_url"],
339
380
  api_key=settings["api_key"],
340
381
  api_env_var=settings["api_env_var"],
@@ -348,7 +389,7 @@ def mimo_asr_api_call(payload, max_retries=10):
348
389
  """Call the MiMo speech-recognition (ASR) endpoint."""
349
390
  return _call_mimo_endpoint("asr", payload, max_retries=max_retries)
350
391
 
351
- def api_call(payload, max_retries=8, *, api_provider=None, api_url=None, api_key=None, api_env_var=None):
392
+ def api_call(payload, max_retries=8, *, api_url=None, api_key=None, api_env_var=None):
352
393
  """调用 OpenAI-compatible API,带重试。
353
394
 
354
395
  长视频理解会发出数百次 VLM/ASR 调用,集群的 429 限流是常态而非错误,所以重试更耐心
@@ -356,8 +397,8 @@ def api_call(payload, max_retries=8, *, api_provider=None, api_url=None, api_key
356
397
  集群的配额窗口常以分钟计,所以 429 在没有 Retry-After 时也至少等 10s,给窗口时间复位。
357
398
  """
358
399
  endpoint = normalize_api_url(api_url if api_url is not None else CONFIG["api_url"])
359
- headers = _api_headers(api_provider=api_provider, api_url=endpoint, api_key=api_key)
360
- data = json.dumps(_prepare_api_payload(payload, api_provider=api_provider, api_url=endpoint)).encode("utf-8")
400
+ headers = _api_headers(api_key=api_key)
401
+ data = json.dumps(_prepare_api_payload(payload)).encode("utf-8")
361
402
 
362
403
  for attempt in range(max_retries):
363
404
  try:
@@ -8,8 +8,9 @@ one image instead of opening dozens of frames:
8
8
  - edited_storyboard.{jpg,json} — one row per kept clip over the cut OUTPUT timeline,
9
9
  each tile dual-labelled (output time / source time).
10
10
 
11
- Both reuse the frames already extracted by understand.py (frames/frame_*.jpg at CONFIG["fps"]).
12
- Nothing here re-extracts video. Every function returns dict|None and degrades to
11
+ Both reuse the frames already extracted by understand.py (frames/frame_*.jpg at CONFIG["fps"];
12
+ a multi-source edited sheet reads each source's own work_dir frames at the fps its frames
13
+ manifest records). Nothing here re-extracts video. Every function returns dict|None and degrades to
13
14
  None + log(...) on ANY failure (no frames, ffmpeg missing/non-zero, font probe raises),
14
15
  so a storyboard quirk can NEVER block the pipeline (Principle 1: advisory, never blocking).
15
16
 
@@ -19,6 +20,7 @@ when no font is available (or drawtext errors) the sheet is still produced UNLAB
19
20
  """
20
21
  import json
21
22
  import math
23
+ import re
22
24
  import shutil
23
25
  import subprocess
24
26
  from pathlib import Path
@@ -156,30 +158,82 @@ def _scene_anchor_timestamps(scenes, max_tiles):
156
158
  return anchors
157
159
 
158
160
 
159
- def _labelled_frame(frame_path, label, font_path, scratch_dir, out_name):
160
- """Burn `label` onto a copy of frame_path via drawtext; return the labelled path or None.
161
+ def _frame_size(frame_path):
162
+ """(width, height) of one extracted frame via ffprobe, or None on any failure."""
163
+ cmd = [
164
+ "ffprobe", "-v", "error", "-select_streams", "v:0",
165
+ "-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", str(frame_path),
166
+ ]
167
+ try:
168
+ result = run_cmd(cmd)
169
+ width, height = (int(v) for v in result.stdout.strip().split("x"))
170
+ except Exception: # noqa: BLE001 - an unreadable size only means "normalise this tile"
171
+ return None
172
+ if result.returncode != 0 or width <= 0 or height <= 0:
173
+ return None
174
+ return width, height
175
+
161
176
 
162
- None signals the caller to fall back to the original frame (and flip labels_burned off).
163
- A drawtext failure here is non-fatal: the unlabelled frame still tiles fine.
177
+ def _prepared_frame(frame_path, label, font_path, fit, scratch_dir, out_name):
178
+ """Copy frame_path through ffmpeg, letterboxed into `fit` and/or labelled; path or None.
179
+
180
+ `fit` = (w, h) letterboxes a frame into one tile size and pixel format. A sheet mixing
181
+ sources needs both: the tile filter's input is re-initialised whenever frame size or
182
+ pixel format changes mid-sequence (a 4:4:4 source next to a 4:2:0 one), which silently
183
+ drops the tiles already placed, and mixed sizes would otherwise be squashed to the first.
184
+ `font_path=None` skips the label. None signals the caller to degrade; a failure here is
185
+ never fatal to the pipeline.
164
186
  """
187
+ filters = []
188
+ if fit is not None:
189
+ width, height = fit
190
+ filters.append(
191
+ f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
192
+ f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,format=yuvj420p"
193
+ )
194
+ if font_path:
195
+ safe_label = label.replace("\\", "\\\\").replace(":", "\\:").replace("'", "’")
196
+ filters.append(
197
+ f"drawtext=fontfile='{font_path}':text='{safe_label}':"
198
+ "x=8:y=8:fontsize=28:fontcolor=white:"
199
+ "box=1:boxcolor=black@0.55:boxborderw=6"
200
+ )
165
201
  out_path = scratch_dir / out_name
166
- safe_label = label.replace("\\", "\\\\").replace(":", "\\:").replace("'", "’")
167
- drawtext = (
168
- f"drawtext=fontfile='{font_path}':text='{safe_label}':"
169
- "x=8:y=8:fontsize=28:fontcolor=white:"
170
- "box=1:boxcolor=black@0.55:boxborderw=6"
171
- )
172
- cmd = ["ffmpeg", "-y", "-i", str(frame_path), "-vf", drawtext, "-frames:v", "1", str(out_path)]
202
+ cmd = [
203
+ "ffmpeg", "-y", "-i", str(frame_path), "-vf", ",".join(filters),
204
+ "-frames:v", "1", str(out_path),
205
+ ]
173
206
  try:
174
207
  result = run_cmd(cmd)
175
- except Exception as exc: # noqa: BLE001 - a label render must never abort the sheet
176
- log(f"storyboard drawtext 异常(降级为不烧时间戳): {exc}")
208
+ except Exception as exc: # noqa: BLE001 - a tile render must never abort the sheet
209
+ log(f"storyboard 帧处理异常(降级): {exc}")
177
210
  return None
178
211
  if result.returncode != 0 or not out_path.exists():
179
212
  return None
180
213
  return out_path
181
214
 
182
215
 
216
+ def _prepare_frames(tiles, font_path, scratch_dir, out_stem):
217
+ """Frames to tile, labelled when font_path is set; None as soon as one render fails.
218
+
219
+ A tile with neither a label nor a `fit` uses its extracted frame as-is (no ffmpeg).
220
+ """
221
+ frames = []
222
+ for idx, tile in enumerate(tiles):
223
+ fit = tile.get("fit")
224
+ if not font_path and fit is None:
225
+ frames.append(Path(tile["frame_file"]))
226
+ continue
227
+ prepared = _prepared_frame(
228
+ Path(tile["frame_file"]), tile["label"], font_path, fit, scratch_dir,
229
+ f"lbl_{out_stem}_{idx:05d}.jpg",
230
+ )
231
+ if prepared is None:
232
+ return None
233
+ frames.append(prepared)
234
+ return frames
235
+
236
+
183
237
  def _tile_pages(frame_paths, columns, out_dir, out_stem, scratch_dir):
184
238
  """Tile frame_paths into one or more contact-sheet pages; return the list of page paths.
185
239
 
@@ -225,11 +279,24 @@ def _tile_pages(frame_paths, columns, out_dir, out_stem, scratch_dir):
225
279
  return pages
226
280
 
227
281
 
282
+ _PATH_SEPARATORS = re.compile(r"[\\/]")
283
+
284
+
285
+ def work_dir_relative_pages(pages):
286
+ """Page images as work_dir-relative POSIX paths ("storyboard/<name>").
287
+
288
+ Pages always live in <work_dir>/storyboard/, so only the file name is kept: an absolute
289
+ path would keep naming the original work_dir after the directory is copied or moved."""
290
+ # Split on both separators: a sidecar written on Windows may be read on POSIX and back.
291
+ return ["storyboard/" + _PATH_SEPARATORS.split(str(page))[-1] for page in pages or []]
292
+
293
+
228
294
  def _render_storyboard(work_dir, tiles, out_stem):
229
295
  """Shared render path: optionally burn labels, tile to pages, return (page_paths, labels_burned).
230
296
 
231
- `tiles` is a list of dicts that ALREADY carry a resolved `frame_file` (absolute path) and a
232
- `label` string. Returns (None, _) on hard failure so callers degrade to None.
297
+ `tiles` is a list of dicts that ALREADY carry a resolved `frame_file` (absolute path), a
298
+ `label` string and optionally a `fit` tile size. Returns (None, _) on hard failure so
299
+ callers degrade to None.
233
300
  """
234
301
  if not _ffmpeg_available():
235
302
  log("storyboard 跳过:未找到 ffmpeg")
@@ -242,27 +309,22 @@ def _render_storyboard(work_dir, tiles, out_stem):
242
309
  scratch_dir.mkdir(parents=True, exist_ok=True)
243
310
 
244
311
  font_path = _probe_font()
245
- labels_burned = bool(font_path)
246
- render_frames = []
247
- if labels_burned:
248
- for idx, tile in enumerate(tiles):
249
- labelled = _labelled_frame(
250
- Path(tile["frame_file"]), tile["label"], font_path, scratch_dir,
251
- f"lbl_{out_stem}_{idx:05d}.jpg",
252
- )
253
- if labelled is None:
254
- # First failure → abandon labelling entirely so the WHOLE sheet is consistent
255
- # (no half-labelled pages). The JSON sidecar still carries every timestamp.
256
- labels_burned = False
257
- break
258
- render_frames.append(labelled)
259
- if not labels_burned:
260
- render_frames = [Path(tile["frame_file"]) for tile in tiles]
261
-
262
312
  try:
263
- pages = _tile_pages(
264
- render_frames, CONFIG["storyboard_columns"],
265
- storyboard_dir, out_stem, scratch_dir,
313
+ render_frames = (
314
+ _prepare_frames(tiles, font_path, scratch_dir, out_stem) if font_path else None
315
+ )
316
+ labels_burned = render_frames is not None
317
+ if render_frames is None:
318
+ # First label failure → abandon labelling for the WHOLE sheet (no half-labelled
319
+ # pages). The JSON sidecar still carries every timestamp.
320
+ render_frames = _prepare_frames(tiles, None, scratch_dir, out_stem)
321
+ pages = (
322
+ _tile_pages(
323
+ render_frames, CONFIG["storyboard_columns"],
324
+ storyboard_dir, out_stem, scratch_dir,
325
+ )
326
+ if render_frames is not None
327
+ else []
266
328
  )
267
329
  finally:
268
330
  shutil.rmtree(scratch_dir, ignore_errors=True)
@@ -320,7 +382,7 @@ def build_source_storyboard(work_dir, video_path, scenes, fps):
320
382
  "video_path": str(video_path),
321
383
  "fps": float(fps) if fps else None,
322
384
  "labels_burned": labels_burned,
323
- "page_images": [str(p) for p in pages],
385
+ "page_images": work_dir_relative_pages(pages),
324
386
  "sample_policy": {
325
387
  "max_tiles": max_tiles,
326
388
  "columns": columns,
@@ -354,7 +416,23 @@ def _source_to_output(source_time, clip):
354
416
  return round(max(out_start, min(mapped, out_end)), 3)
355
417
 
356
418
 
357
- def build_edited_storyboard(work_dir, source_video_path, clip_plan_validated, fps):
419
+ def _multi_source_tile_size(frame_sets):
420
+ """One tile size for a multi-source sheet: the first source's frame size rounded down to
421
+ even numbers (640x360 when no frame can be probed). Every multi-source tile is re-encoded
422
+ into it — see _prepared_frame. Odd sizes are real (frames keep storage resolution, e.g.
423
+ 853x480) and ffmpeg's scale rounds a 4:2:0 frame up to even, so an odd pad target would
424
+ be smaller than its input and every tile would fail."""
425
+ for entry in frame_sets.values():
426
+ size = _frame_size(entry["paths"][0])
427
+ if size:
428
+ width, height = size
429
+ return max(2, width - width % 2), max(2, height - height % 2)
430
+ return 640, 360
431
+
432
+
433
+ def build_edited_storyboard(
434
+ work_dir, source_video_path, clip_plan_validated, fps, *, source_frames=None
435
+ ):
358
436
  """OUTPUT-timeline contact sheet: one row per kept clip over the cut. Returns dict|None.
359
437
 
360
438
  For each kept clip, samples source start / mid / (end − 0.5s), maps each to the nearest
@@ -362,13 +440,37 @@ def build_edited_storyboard(work_dir, source_video_path, clip_plan_validated, fp
362
440
  dedupes so a ≤1s clip yields 1-2 tiles (not 3 identical). Each tile is dual-labelled with
363
441
  both `output_timestamp` and `source_timestamp` (+ `source_clip_id`). Writes
364
442
  storyboard/edited_storyboard.json. Returns None + log on any failure.
443
+
444
+ Single-source plans read work_dir/frames at `fps`. A multi-source plan passes
445
+ `source_frames` = {source_id: {"paths", "numbers", "fps"[, "source_path"]}} built from each
446
+ source's own work_dir; its tiles also carry `source_id` and a short `S<n>` label listed
447
+ under `sources`, and `frame_file` keeps the full path (frame names repeat across sources).
365
448
  """
366
449
  try:
367
450
  work_dir = Path(work_dir)
368
- paths, numbers = _frame_index(work_dir)
369
- if not paths:
370
- log("storyboard 跳过 edited:frames/ 为空或缺失")
371
- return None
451
+ if source_frames is None:
452
+ paths, numbers = _frame_index(work_dir)
453
+ if not paths:
454
+ log("storyboard 跳过 edited:frames/ 为空或缺失")
455
+ return None
456
+ frame_sets = {None: {"paths": paths, "numbers": numbers, "fps": fps}}
457
+ tile_size = None
458
+ else:
459
+ # Only sources a clip uses: an unused one must not take an S<n> label, be listed
460
+ # under `sources`, or set the tile size.
461
+ used = {
462
+ clip.get("source_id") for clip in clip_plan_validated["clips"]
463
+ if isinstance(clip, dict)
464
+ }
465
+ frame_sets = {
466
+ sid: entry for sid, entry in source_frames.items()
467
+ if entry["paths"] and sid in used
468
+ }
469
+ if not frame_sets:
470
+ log("storyboard 跳过 edited:剪辑用到的来源都没有 frames/")
471
+ return None
472
+ tile_size = _multi_source_tile_size(frame_sets)
473
+ short_labels = {sid: f"S{n}" for n, sid in enumerate(frame_sets, start=1)}
372
474
  clips = clip_plan_validated["clips"]
373
475
  if not clips:
374
476
  log("storyboard 跳过 edited:clip_plan_validated 无 clips")
@@ -388,10 +490,16 @@ def build_edited_storyboard(work_dir, source_video_path, clip_plan_validated, fp
388
490
  continue
389
491
  if source_end <= source_start:
390
492
  continue
493
+ source_id = clip.get("source_id") if source_frames is not None else None
494
+ frame_set = frame_sets.get(source_id)
495
+ if frame_set is None:
496
+ continue # this source has no extracted frames (e.g. a material restore)
391
497
  mid = (source_start + source_end) / 2.0
392
498
  end_sample = max(source_start, source_end - 0.5)
393
499
  for src_ts in (source_start, mid, end_sample):
394
- frame = _nearest_existing_frame(src_ts, fps, paths, numbers)
500
+ frame = _nearest_existing_frame(
501
+ src_ts, frame_set["fps"], frame_set["paths"], frame_set["numbers"]
502
+ )
395
503
  if frame is None:
396
504
  continue
397
505
  key = (clip_id, str(frame))
@@ -399,14 +507,20 @@ def build_edited_storyboard(work_dir, source_video_path, clip_plan_validated, fp
399
507
  continue # same clip resolving to the same frame → drop the duplicate tile
400
508
  seen_frames.add(key)
401
509
  out_ts = _source_to_output(src_ts, clip)
402
- tiles.append({
510
+ src_label = "src" if source_id is None else short_labels[source_id]
511
+ tile = {
403
512
  "tile_id": tile_id,
404
513
  "output_timestamp": out_ts,
405
514
  "source_timestamp": round(float(src_ts), 3),
406
515
  "source_clip_id": clip_id,
407
- "label": f"out {_fmt_mmss(out_ts)} / src {_fmt_mmss(src_ts)}",
516
+ "label": f"out {_fmt_mmss(out_ts)} / {src_label} {_fmt_mmss(src_ts)}",
408
517
  "frame_file": str(frame),
409
- })
518
+ }
519
+ if source_id is not None:
520
+ tile["source_id"] = source_id
521
+ if tile_size is not None:
522
+ tile["fit"] = tile_size
523
+ tiles.append(tile)
410
524
  tile_id += 1
411
525
  if not tiles:
412
526
  log("storyboard 跳过 edited:未解析到任何帧")
@@ -420,15 +534,20 @@ def build_edited_storyboard(work_dir, source_video_path, clip_plan_validated, fp
420
534
  return None
421
535
 
422
536
  for tile in tiles:
423
- tile["frame_file"] = Path(tile["frame_file"]).name
537
+ tile["frame_file"] = (
538
+ Path(tile["frame_file"]).name
539
+ if source_frames is None
540
+ else str(tile["frame_file"])
541
+ )
542
+ tile.pop("fit", None)
424
543
  edited_source = Path(work_dir) / "edited_source.mp4"
425
544
  payload = {
426
545
  "schema_version": 1,
427
546
  "timeline": "output",
428
- "source_video_path": str(source_video_path),
429
- "edited_video_path": str(edited_source) if edited_source.exists() else None,
547
+ "source_video_path": str(source_video_path) if source_frames is None else None,
548
+ "edited_video_path": edited_source.name if edited_source.exists() else None,
430
549
  "labels_burned": labels_burned,
431
- "page_images": [str(p) for p in pages],
550
+ "page_images": work_dir_relative_pages(pages),
432
551
  "sample_policy": {
433
552
  "max_tiles": max_tiles,
434
553
  "columns": columns,
@@ -436,6 +555,17 @@ def build_edited_storyboard(work_dir, source_video_path, clip_plan_validated, fp
436
555
  },
437
556
  "tiles": tiles,
438
557
  }
558
+ if source_frames is not None:
559
+ on_sheet = {tile.get("source_id") for tile in tiles}
560
+ payload["sources"] = [
561
+ {
562
+ "label": short_labels[sid],
563
+ "source_id": sid,
564
+ "source_path": frame_sets[sid].get("source_path"),
565
+ }
566
+ for sid in frame_sets
567
+ if sid in on_sheet
568
+ ]
439
569
  json_path = work_dir / "storyboard" / "edited_storyboard.json"
440
570
  json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
441
571
  log(f"storyboard edited: {len(tiles)} tiles → {len(pages)} page(s), labels_burned={labels_burned}")
@@ -1,38 +1,12 @@
1
1
  """Fuse scenes, ASR, and quiet windows for narration planning."""
2
2
 
3
- from lib import CONFIG
4
3
  from agent_text import _overlap_seconds, _recommended_char_budget
5
- from narration_lint import _validate_narration_budget
6
4
 
7
5
 
8
6
  def _quiet_windows(silence_periods):
9
7
  return [qp for qp in silence_periods if not qp["has_speech"]]
10
8
 
11
9
 
12
- def _align_narration_to_quiet(narration, scenes_analysis, silence_periods):
13
- """Recompute overlaps_speech from real quiet windows; keep the agent's timing.
14
-
15
- The dense continuous-bed design places narration ON the pictured beat over a
16
- ducked original bed, so segments are never relocated into silence gaps. Only
17
- the overlaps_speech flag that the ducking stage consumes is corrected, leaving
18
- the agent's start/end (and text) intact.
19
-
20
- Budget/dedup runs FIRST so a dedup-merged beat's overlaps_speech reflects its
21
- extended timing, not its original shorter span.
22
- """
23
- aligned = _validate_narration_budget(narration, scenes_analysis)
24
- quiet_windows = _quiet_windows(silence_periods)
25
- quiet_ratio_min = CONFIG["quiet_overlap_min_ratio"]
26
- for n in aligned:
27
- seg_dur = n["end"] - n["start"]
28
- quiet_overlap = sum(
29
- _overlap_seconds(n["start"], n["end"], qw["start"], qw["end"])
30
- for qw in quiet_windows
31
- )
32
- n["overlaps_speech"] = quiet_overlap < max(0.3, seg_dur * quiet_ratio_min)
33
- return aligned
34
-
35
-
36
10
  def _scene_asr_lines(asr_result, scene):
37
11
  lines = []
38
12
  for seg in asr_result:
@@ -8,10 +8,11 @@ from lib import CONFIG, log, load_background_research
8
8
  from asr_timing_evidence import asr_evidence_summary_for_brief
9
9
 
10
10
 
11
- from detect import detect_speech_boundary_anchors
11
+ from detect import ensure_speech_boundary_anchors
12
12
 
13
13
 
14
- from brief import build_agent_brief, assess_understanding_substrate
14
+ from briefing.builder import build_agent_brief
15
+ from briefing.context import assess_understanding_substrate
15
16
 
16
17
 
17
18
  from understanding_cache import _load_json, _merge_overview_into_scenes
@@ -136,23 +137,44 @@ def _write_brief_from_existing_artifacts(video, work_dir, args, video_duration):
136
137
  scenes, asr_result, silence_periods = _load_understanding_artifacts_for_brief(
137
138
  work_dir
138
139
  )
139
- if not (Path(work_dir) / "speech_boundary_anchors.json").exists():
140
- detect_speech_boundary_anchors(work_dir, asr_result)
140
+ ensure_speech_boundary_anchors(work_dir, asr_result)
141
141
  overview_path = Path(work_dir) / "mimo_video_overview.json"
142
142
  if CONFIG["mimo_video_overview"]:
143
143
  scenes = _merge_overview_into_scenes(scenes, overview_path)
144
144
 
145
+ _finish_brief(
146
+ video, work_dir, args, video_duration,
147
+ storyboard_scenes=scenes, brief_scenes=scenes,
148
+ asr_result=asr_result, silence_periods=silence_periods,
149
+ status="brief_only", done_label="brief-only 完成", force=False,
150
+ )
151
+
152
+
153
+ def _finish_brief(
154
+ video, work_dir, args, video_duration, *, storyboard_scenes, brief_scenes,
155
+ asr_result, silence_periods, status, done_label, force,
156
+ ):
157
+ """Storyboards, substrate banner, writing brief and the JSON status line.
158
+
159
+ Shared by the full analysis run and --brief-only. The full run passes the detected
160
+ scenes to the storyboard and the VLM analysis to the brief; --brief-only has only the
161
+ VLM analysis, so it passes that for both.
162
+ """
163
+ work_dir = Path(work_dir)
164
+ # Storyboard contact sheets (advisory, never blocking). Source uses scene anchors over the
165
+ # source timeline; edited is gated on clip_plan_validated.json file-presence (NOT edit_mode —
166
+ # recap.py forwards --edit-mode cut in BOTH passes, so the validated plan is the only reliable
167
+ # pass2 signal). Both cache via _write_stage_meta/_stage_cache_valid with fps + frame-set in the key.
145
168
  source_storyboard = None
146
- edited_storyboard = None
147
- scenes_json = Path(work_dir) / "scenes.json"
169
+ scenes_json = work_dir / "scenes.json"
148
170
  if scenes_json.exists():
149
171
  source_storyboard = _generate_source_storyboard(
150
- work_dir, Path(video), scenes, scenes_json, force=False
172
+ work_dir, Path(video), storyboard_scenes, scenes_json, force=force
151
173
  )
152
- edited_storyboard = _generate_edited_storyboard(work_dir, video, force=False)
153
- cut_mode = (Path(work_dir) / "clip_plan_validated.json").exists()
174
+ edited_storyboard = _generate_edited_storyboard(work_dir, video, force=force)
175
+ cut_mode = (work_dir / "clip_plan_validated.json").exists()
154
176
 
155
- substrate = assess_understanding_substrate(scenes, asr_result)
177
+ substrate = assess_understanding_substrate(brief_scenes, asr_result)
156
178
  if substrate["level"] != "rich":
157
179
  banner = "理解素材为空" if substrate["level"] == "empty" else "理解素材偏薄"
158
180
  log(
@@ -160,7 +182,7 @@ def _write_brief_from_existing_artifacts(video, work_dir, args, video_duration):
160
182
  f"带 frame_facts 的场景 {substrate['scenes_with_frame_facts']} | 平均画面描述 {substrate['avg_description_len']} 字"
161
183
  )
162
184
  brief_path = build_agent_brief(
163
- scenes,
185
+ brief_scenes,
164
186
  asr_result,
165
187
  silence_periods,
166
188
  video_duration,
@@ -170,19 +192,20 @@ def _write_brief_from_existing_artifacts(video, work_dir, args, video_duration):
170
192
  mimo_overview_video_path=video,
171
193
  asr_evidence=asr_evidence_summary_for_brief(work_dir, video),
172
194
  )
195
+ # C1: prepend a storyboard header to the returned brief file, pointing the agent at the sheet(s).
173
196
  _prepend_storyboard_brief_header(
174
197
  brief_path, source_storyboard, edited_storyboard, cut_mode=cut_mode
175
198
  )
176
199
  log("=" * 50)
177
- log(f"brief-only 完成。写作 brief: {brief_path}")
200
+ log(f"{done_label}。写作 brief: {brief_path}")
178
201
  print(
179
202
  json.dumps(
180
203
  {
181
- "status": "brief_only",
204
+ "status": status,
182
205
  "work_dir": str(work_dir),
183
206
  "brief": str(brief_path),
184
207
  "substrate": substrate["level"],
185
- "scenes": len(scenes),
208
+ "scenes": len(storyboard_scenes),
186
209
  "asr_segments": len(asr_result),
187
210
  },
188
211
  ensure_ascii=False,