@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
@@ -0,0 +1,122 @@
1
+ """Contact sheets for looking at a finished video: suppressed cut candidates, longest shots, or a span.
2
+
3
+ The measurement can miss cuts (dissolves, dark scenes, rapid montage) or keep a few false ones,
4
+ and when no understanding run exists the agent has no pictures at all. Each sheet row is one
5
+ ffmpeg seek tiled to CELLS frames; rows are stacked into pages under U/reference_frames/ and the
6
+ printed legend gives every cell's time, so no drawtext/font support is needed.
7
+ """
8
+ import math
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+ from lib import ffprobe, run_cmd
13
+
14
+ FRAMES_DIR = "reference_frames"
15
+ CELLS = 12
16
+ CELL_WIDTH = 160
17
+ ROWS_PER_PAGE = 8
18
+ LONGEST_EDGE_S = 0.1
19
+
20
+
21
+ def _row(label, start, end, cells):
22
+ """Evenly spaced cell times over [start, end]; a single time when the range is a point."""
23
+ cells = max(1, min(CELLS, cells))
24
+ step = (end - start) / (cells - 1) if cells > 1 else 0.0
25
+ return {"label": label, "times": [round(start + i * step, 2) for i in range(cells)], "step": step}
26
+
27
+
28
+ def review_rows(measurements, fps):
29
+ """Every frame of each suppressed-candidate window, CELLS frames per row."""
30
+ frame_s = 1.0 / fps if fps else 0.04
31
+ rows = []
32
+ for start, end in measurements["shots"].get("review_windows") or []:
33
+ for row in span_rows(start, end, frame_s):
34
+ rows.append({**row, "label": f"待复核 {start}–{end}s"})
35
+ return rows
36
+
37
+
38
+ def longest_rows(measurements, count):
39
+ """One row per longest measured shot: a long 'shot' is where a missed cut hides."""
40
+ duration = measurements["source"]["duration_s"]
41
+ bounds = [0.0, *measurements["shots"]["cuts"], duration]
42
+ shots = sorted(zip(bounds, bounds[1:]), key=lambda s: s[1] - s[0], reverse=True)[:count]
43
+ return [_row(f"镜头 {a:.2f}–{b:.2f}s({b - a:.1f}s)", a + LONGEST_EDGE_S, b - LONGEST_EDGE_S, CELLS)
44
+ for a, b in sorted(shots) if b - a > 2 * LONGEST_EDGE_S]
45
+
46
+
47
+ def span_rows(start, end, step):
48
+ """Cells every `step` seconds over [start, end], CELLS per row."""
49
+ if step <= 0 or end <= start:
50
+ raise ValueError("--span 需要 A<B,--step 必须为正数")
51
+ times = [round(start + i * step, 3) for i in range(int(math.floor((end - start) / step + 1e-9)) + 1)]
52
+ return [{"label": f"{chunk[0]:g}–{chunk[-1]:g}s,每 {step:g}s", "times": chunk, "step": step}
53
+ for chunk in (times[i:i + CELLS] for i in range(0, len(times), CELLS))]
54
+
55
+
56
+ def _render_row(video, row, out_path):
57
+ times, step = row["times"], row["step"]
58
+ # Cell i is the first frame at or after times[0] + i * step (the unrounded step). The fps filter
59
+ # would instead pick the frame nearest each slot, which can be a frame of the *next* shot and hide
60
+ # where a cut is. Only len(times) frames are picked: a short row leaves its other cells blank.
61
+ pick = f"select='gte(t\\,selected_n*{step:.6f}-0.001)*lt(selected_n\\,{len(times)})'"
62
+ cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-ss", f"{times[0]:.3f}",
63
+ "-t", f"{step * (len(times) - 1) + 0.2:.3f}", "-i", video,
64
+ "-vf", f"{pick},scale={CELL_WIDTH}:-2,tile={CELLS}x1", "-frames:v", "1", out_path]
65
+ result = run_cmd(cmd)
66
+ if result.returncode != 0 or not Path(out_path).is_file():
67
+ raise RuntimeError(f"ffmpeg 取帧失败({row['label']}): {result.stderr.strip()[-400:]}")
68
+
69
+
70
+ def render(video, work_dir, rows, name):
71
+ """Write pages <name>_<n>.jpg under work_dir/reference_frames; return [(page, rows)]."""
72
+ out_dir = Path(work_dir) / FRAMES_DIR
73
+ out_dir.mkdir(parents=True, exist_ok=True)
74
+ pages = []
75
+ with tempfile.TemporaryDirectory(prefix="reference-frames-") as scratch:
76
+ for page_index in range(0, len(rows), ROWS_PER_PAGE):
77
+ page_rows = rows[page_index:page_index + ROWS_PER_PAGE]
78
+ parts = []
79
+ for i, row in enumerate(page_rows):
80
+ part = Path(scratch) / f"row_{page_index + i}.jpg"
81
+ _render_row(video, row, part)
82
+ parts.append(str(part))
83
+ page = out_dir / f"{name}_{page_index // ROWS_PER_PAGE + 1}.jpg"
84
+ if len(parts) == 1:
85
+ cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", parts[0], str(page)]
86
+ else:
87
+ cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y"]
88
+ for part in parts:
89
+ cmd += ["-i", part]
90
+ cmd += ["-filter_complex", f"vstack=inputs={len(parts)}", str(page)]
91
+ result = run_cmd(cmd)
92
+ if result.returncode != 0:
93
+ raise RuntimeError(f"ffmpeg 拼页失败: {result.stderr.strip()[-400:]}")
94
+ pages.append((page, page_rows))
95
+ return pages
96
+
97
+
98
+ def legend(pages):
99
+ """Printable mapping from page/row/cell to seconds."""
100
+ lines = []
101
+ for page, rows in pages:
102
+ lines.append(str(page))
103
+ for i, row in enumerate(rows, 1):
104
+ lines.append(f" 第 {i} 行 {row['label']}:" + " ".join(f"{t:g}" for t in row["times"]))
105
+ return "\n".join(lines)
106
+
107
+
108
+ def frames(video, work_dir, measurements, *, mode, longest=5, span=None, step=2.0):
109
+ """Build the rows for `mode` (review | longest | span) and render them."""
110
+ if mode == "span":
111
+ start, end = span
112
+ rows = span_rows(start, end, step)
113
+ else:
114
+ if measurements is None:
115
+ raise ValueError("先运行 measure:review / longest 需要 reference_measurements.json")
116
+ if mode == "review":
117
+ rows = review_rows(measurements, measurements["source"].get("fps") or ffprobe(video)["fps"])
118
+ else:
119
+ rows = longest_rows(measurements, longest)
120
+ if not rows:
121
+ return []
122
+ return render(str(video), work_dir, rows, mode)
@@ -0,0 +1,253 @@
1
+ """One ffmpeg pass over a finished video: hard-cut times (scdet) and loudness (ebur128).
2
+
3
+ scdet reports a per-frame score; a fixed score threshold is wrong in both directions on real
4
+ recaps (threshold 10 missed about a third of the cuts in a dark scene; threshold 6 counted a
5
+ fast-moving single shot as nine cuts). So every frame scoring >= SCORE_FLOOR is kept and a cut is
6
+ a frame that scores >= hard, or >= soft while scoring at least ISOLATION_RATIO times every other
7
+ frame within ISOLATION_WINDOW_S (the adjacent frames excluded: they carry the cut's own
8
+ after-image). Suppressed candidates are grouped into `shots.review_windows` for the agent to look
9
+ at; the agent's corrections live in the breakdown (labels.cut_fixes), never in this file.
10
+
11
+ The score is min(mafd, |mafd - previous mafd|), so a cut out of fast motion scores near zero (a
12
+ dark hard cut after a fight scored 0.9). Every cut is still a one-sided peak of mafd itself: at
13
+ least every other frame within the window and ISOLATION_RATIO times every frame on one side.
14
+ Such peaks (`mafd_peaks`) join the review windows; they never become cuts on their own.
15
+
16
+ The result is cached in `reference_measurements.json` by the video's {size, mtime_ns}; the raw
17
+ scores are cached too, so changing the score settings re-derives the cuts without decoding again.
18
+ Shot statistics are computed from these cuts directly; an understanding run's scene list merges
19
+ short shots and would inflate the median shot length.
20
+ """
21
+ import bisect
22
+ import math
23
+ import re
24
+ from pathlib import Path
25
+
26
+ from lib import ffprobe, file_identity, log, read_json, run_cmd, write_json
27
+
28
+ MEASUREMENTS_FILE = "reference_measurements.json"
29
+ MEASUREMENTS_SCHEMA = "video-reference.measurements.v3"
30
+ DETECTOR = "scdet-isolated-v1"
31
+ DEFAULT_HARD_SCORE = 10.0 # always a cut
32
+ DEFAULT_SOFT_SCORE = 4.0 # a cut only when isolated
33
+ ISOLATION_RATIO = 2.0
34
+ ISOLATION_WINDOW_S = 0.3
35
+ SCORE_FLOOR = 2.0 # below soft / ratio a frame can neither be a cut nor suppress one;
36
+ # also the least mafd a review peak needs
37
+ REVIEW_PAD_S = 0.2
38
+ REVIEW_JOIN_S = 0.5
39
+ CURVE_WINDOW_S = 10.0
40
+ EDGE_S = 0.05 # cuts this close to either end are not cuts between two shots
41
+ SILENT_LUFS = -70.0 # ebur128 reports -120.7 for digital silence / warm-up blocks
42
+
43
+ _SCDET = re.compile(
44
+ r"lavfi\.scd\.score\s*[:=]\s*(-?\d+(?:\.\d+)?)\s*,?\s*lavfi\.scd\.time\s*[:=]\s*(-?\d+(?:\.\d+)?)")
45
+ _MAFD = re.compile(r"\[Parsed_metadata[^\]]*\]\s*(?:frame:.*?pts_time:\s*(\S+)|lavfi\.scd\.mafd=(\S+))")
46
+ _EBU_FRAME = re.compile(r"\bt:\s*(\d+(?:\.\d+)?)\s.*?\bS:\s*(-?\d+(?:\.\d+)?|-?inf|nan)")
47
+ _SUMMARY_I = re.compile(r"\bI:\s*(-?\d+(?:\.\d+)?)\s*LUFS")
48
+ _SUMMARY_LRA = re.compile(r"\bLRA:\s*(-?\d+(?:\.\d+)?)\s*LU\b")
49
+ _SUMMARY_PEAK = re.compile(r"True peak:\s*\n\s*Peak:\s*(-?\d+(?:\.\d+)?|-inf)")
50
+
51
+
52
+ def parse_scdet(stderr, duration=None):
53
+ """Sorted [time, score] pairs from scdet log lines (`score: 4.2, time: 2.6` or `score=4.2 time=2.6`).
54
+
55
+ Frames within EDGE_S of either end are dropped: they are not cuts between two shots.
56
+ """
57
+ scores = {}
58
+ for match in _SCDET.finditer(stderr or ""):
59
+ score, t = float(match.group(1)), round(float(match.group(2)), 3)
60
+ if t <= EDGE_S or (duration is not None and t >= duration - EDGE_S):
61
+ continue
62
+ scores[t] = max(score, scores.get(t, score))
63
+ return [[t, round(scores[t], 3)] for t in sorted(scores)]
64
+
65
+
66
+ def _sides(series, times, i, adjacent):
67
+ """Highest value before and after series[i] within ISOLATION_WINDOW_S, adjacent frames excluded."""
68
+ t = series[i][0]
69
+ lo = bisect.bisect_left(times, t - ISOLATION_WINDOW_S - 1e-9)
70
+ hi = bisect.bisect_right(times, t + ISOLATION_WINDOW_S + 1e-9)
71
+ return (max((v for u, v in series[lo:i] if t - u > adjacent), default=0.0),
72
+ max((v for u, v in series[i + 1:hi] if u - t > adjacent), default=0.0))
73
+
74
+
75
+ def mafd_peaks(stderr, fps, duration=None):
76
+ """[time, mafd] of frames whose mafd (metadata print) is a one-sided peak >= SCORE_FLOOR."""
77
+ series, t = [], None
78
+ for time_text, value in _MAFD.findall(stderr or ""):
79
+ if time_text:
80
+ t = _number(time_text)
81
+ elif t is not None:
82
+ series.append((round(t, 3), _number(value) or 0.0))
83
+ t = None
84
+ adjacent = 1.5 / fps if fps else 0.06
85
+ times = [u for u, _ in series]
86
+ peaks = []
87
+ for i, (t, value) in enumerate(series):
88
+ if value < SCORE_FLOOR or t <= EDGE_S or (duration is not None and t >= duration - EDGE_S):
89
+ continue
90
+ before, after = _sides(series, times, i, adjacent)
91
+ if value >= max(before, after) and value >= ISOLATION_RATIO * min(before, after):
92
+ peaks.append([t, round(value, 3)])
93
+ return peaks
94
+
95
+
96
+ def detect_cuts(scores, fps, *, hard=DEFAULT_HARD_SCORE, soft=DEFAULT_SOFT_SCORE, peaks=()):
97
+ """(cuts, review_windows) from [time, score] pairs and mafd peaks; see the module docstring."""
98
+ frame_s = 1.0 / fps if fps else 0.04
99
+ adjacent = 1.5 * frame_s
100
+ times = [t for t, _ in scores]
101
+ cuts, suppressed = [], [t for t, _ in peaks]
102
+ for i, (t, score) in enumerate(scores):
103
+ if score < soft:
104
+ continue
105
+ if score < hard and score < ISOLATION_RATIO * max(_sides(scores, times, i, adjacent)):
106
+ suppressed.append(t)
107
+ continue
108
+ if cuts and t - cuts[-1][0] <= adjacent: # one cut spread over two frames
109
+ if score > cuts[-1][1]:
110
+ cuts[-1] = (t, score)
111
+ continue
112
+ cuts.append((t, score))
113
+ cut_times = [t for t, _ in cuts]
114
+ windows = []
115
+ for t in sorted(set(suppressed)):
116
+ if any(abs(t - c) <= adjacent for c in cut_times):
117
+ continue
118
+ if windows and t - windows[-1][1] <= REVIEW_JOIN_S:
119
+ windows[-1][1] = t
120
+ else:
121
+ windows.append([t, t])
122
+ return cut_times, [[round(max(0.0, a - REVIEW_PAD_S), 2), round(b + REVIEW_PAD_S, 2)] for a, b in windows]
123
+
124
+
125
+ def _number(text):
126
+ try:
127
+ value = float(text)
128
+ except ValueError:
129
+ return None
130
+ return value if math.isfinite(value) else None
131
+
132
+
133
+ def parse_ebur128(stderr, duration):
134
+ """Integrated loudness, LRA, true peak (Summary block) and a 1 s short-term series."""
135
+ stderr = stderr or ""
136
+ head, marker, summary = stderr.rpartition("Summary:")
137
+ if not marker:
138
+ return None
139
+ seconds = max(1, math.ceil(duration))
140
+ series = [None] * seconds
141
+ for match in _EBU_FRAME.finditer(head):
142
+ bucket = int(float(match.group(1)))
143
+ value = _number(match.group(2))
144
+ if 0 <= bucket < seconds:
145
+ series[bucket] = None if value is None or value < SILENT_LUFS else round(value, 1)
146
+
147
+ def pick(pattern):
148
+ match = pattern.search(summary)
149
+ return None if match is None else _number(match.group(1))
150
+
151
+ return {
152
+ "integrated_lufs": pick(_SUMMARY_I),
153
+ "lra_lu": pick(_SUMMARY_LRA),
154
+ "true_peak_dbtp": pick(_SUMMARY_PEAK),
155
+ "short_term_1s": series,
156
+ }
157
+
158
+
159
+ def _percentile(sorted_values, q):
160
+ if not sorted_values:
161
+ return None
162
+ position = (len(sorted_values) - 1) * q
163
+ low = math.floor(position)
164
+ high = min(low + 1, len(sorted_values) - 1)
165
+ return sorted_values[low] + (sorted_values[high] - sorted_values[low]) * (position - low)
166
+
167
+
168
+ def shot_stats(cuts, duration):
169
+ """Shot-length distribution and cut density for cuts inside [0, duration]."""
170
+ bounds = [0.0, *cuts, duration]
171
+ lengths = sorted(b - a for a, b in zip(bounds, bounds[1:]) if b > a)
172
+ count = len(lengths)
173
+ minutes = duration / 60.0
174
+ windows = max(1, math.ceil(duration / CURVE_WINDOW_S))
175
+ curve = [0] * windows
176
+ for cut in cuts:
177
+ curve[min(windows - 1, int(cut // CURVE_WINDOW_S))] += 1
178
+ # Scale each window by its own length: the last one is usually partial (6.66 s on a 126.66 s
179
+ # video). Floor at 1 s so a sliver of a window cannot turn one cut into hundreds per minute.
180
+ per_min = [60.0 / max(1.0, min(CURVE_WINDOW_S, duration - i * CURVE_WINDOW_S)) for i in range(windows)]
181
+ return {
182
+ "cuts": list(cuts),
183
+ "count": count,
184
+ "mean_s": round(sum(lengths) / count, 3) if count else None,
185
+ "median_s": round(_percentile(lengths, 0.5), 3) if count else None,
186
+ "p10_s": round(_percentile(lengths, 0.1), 3) if count else None,
187
+ "p90_s": round(_percentile(lengths, 0.9), 3) if count else None,
188
+ "share_under_1s": round(sum(1 for x in lengths if x < 1.0) / count, 3) if count else None,
189
+ "share_over_8s": round(sum(1 for x in lengths if x > 8.0) / count, 3) if count else None,
190
+ "cuts_per_min": round(len(cuts) / minutes, 2) if minutes > 0 else None,
191
+ "curve": {"window_s": CURVE_WINDOW_S, "values": [round(c * k, 1) for c, k in zip(curve, per_min)]},
192
+ }
193
+
194
+
195
+ def _ffmpeg_command(video, scaled, has_audio):
196
+ video_filter = f"scdet=threshold={SCORE_FLOOR:g},metadata=mode=print:key=lavfi.scd.mafd"
197
+ if scaled:
198
+ video_filter = "scale=320:-2," + video_filter
199
+ cmd = ["ffmpeg", "-hide_banner", "-nostats", "-i", video, "-map", "0:v:0", "-vf", video_filter]
200
+ if has_audio:
201
+ cmd += ["-map", "0:a:0", "-af", "ebur128=peak=true:framelog=info"]
202
+ return cmd + ["-f", "null", "-"]
203
+
204
+
205
+ def _reusable(cached, identity, scaled):
206
+ """The cached decode (scores + loudness) is valid for this file and scaling."""
207
+ if not isinstance(cached, dict) or cached.get("schema") != MEASUREMENTS_SCHEMA:
208
+ return False
209
+ source = cached.get("source") or {}
210
+ settings = cached.get("settings") or {}
211
+ same_file = {k: source.get(k) for k in identity} == identity
212
+ return same_file and settings.get("scaled") == scaled and settings.get("score_floor") == SCORE_FLOOR
213
+
214
+
215
+ def measure(video, work_dir, *, hard=DEFAULT_HARD_SCORE, soft=DEFAULT_SOFT_SCORE, scaled=True):
216
+ """Measure `video` into work_dir/reference_measurements.json; reuse the decode when the file is unchanged."""
217
+ if not SCORE_FLOOR * ISOLATION_RATIO <= soft <= hard:
218
+ raise ValueError(f"需要 {SCORE_FLOOR * ISOLATION_RATIO:g} ≤ soft ≤ hard,收到 soft={soft} hard={hard}")
219
+ video = Path(video)
220
+ out_path = Path(work_dir) / MEASUREMENTS_FILE
221
+ identity = file_identity(video)
222
+ settings = {"detector": DETECTOR, "hard_score": float(hard), "soft_score": float(soft),
223
+ "isolation_ratio": ISOLATION_RATIO, "isolation_window_s": ISOLATION_WINDOW_S,
224
+ "score_floor": SCORE_FLOOR, "scaled": bool(scaled)}
225
+ cached = read_json(out_path)
226
+ if _reusable(cached, identity, bool(scaled)):
227
+ if cached.get("settings") == settings:
228
+ log(f"复用 {MEASUREMENTS_FILE}(成片与设置未变)")
229
+ return cached
230
+ log("成片未变,只按新的分数设置重算切点(不重新解码)")
231
+ source, scores, loudness = cached["source"], cached["scdet_scores"], cached.get("loudness")
232
+ peaks = cached.get("mafd_peaks") or []
233
+ else:
234
+ source = {**identity, **ffprobe(video)}
235
+ result = run_cmd(_ffmpeg_command(video, scaled, source["audio_streams"] > 0))
236
+ if result.returncode != 0:
237
+ raise RuntimeError(f"ffmpeg 测量失败: {result.stderr.strip()[-600:]}")
238
+ scores = parse_scdet(result.stderr, source["duration_s"])
239
+ peaks = mafd_peaks(result.stderr, source.get("fps"), source["duration_s"])
240
+ loudness = parse_ebur128(result.stderr, source["duration_s"]) if source["audio_streams"] else None
241
+ cuts, review_windows = detect_cuts(scores, source.get("fps"), hard=hard, soft=soft, peaks=peaks)
242
+ payload = {
243
+ "schema": MEASUREMENTS_SCHEMA,
244
+ "source": source,
245
+ "settings": settings,
246
+ "shots": {**shot_stats(cuts, source["duration_s"]), "review_windows": review_windows},
247
+ "loudness": loudness,
248
+ "scdet_scores": scores,
249
+ "mafd_peaks": peaks,
250
+ }
251
+ write_json(out_path, payload)
252
+ log(f"写入 {out_path}:{len(cuts)} 个切点,{len(review_windows)} 个待复核窗口,时长 {source['duration_s']:.1f}s")
253
+ return payload
@@ -0,0 +1,271 @@
1
+ """Pure functions: derive labeled metrics from labels + measurements, and project the export.
2
+
3
+ `derived` only ever lives in memory: `check` prints it so the agent can write methods against
4
+ it, `export` copies the needed values into production_reference.json. The agent never types a
5
+ number — a method target names a measurement path and the export fills the value in.
6
+
7
+ The agent's cut review (labels.cut_fixes) is applied first: every shot number, derived cut
8
+ density and export value is computed from the reviewed cut list, never from the raw detector.
9
+ """
10
+ import statistics
11
+
12
+ from reference_measure import shot_stats
13
+
14
+ PRODUCTION_SCHEMA = "video-reference.production.v1"
15
+ CUT_MATCH_S = 0.1 # a removal or addition this close to a detected cut refers to that cut
16
+ OWNERS = ("narration", "original_dialogue", "action_sound", "ambience", "music", "silence")
17
+ SWITCH_TOLERANCE_S = 0.25
18
+ MIN_WINDOW_COVERAGE = 0.8
19
+ NULL_ASR_STATUSES = ("EXPLICITLY_SKIPPED",)
20
+
21
+
22
+ def cut_fix_counts(labels):
23
+ fixes = (labels or {}).get("cut_fixes") or {}
24
+ return len(fixes.get("add") or []), len(fixes.get("remove") or [])
25
+
26
+
27
+ def apply_cut_fixes(measurements, labels):
28
+ """Measurements whose `shots` come from the reviewed cut list (unchanged without fixes)."""
29
+ fixes = (labels or {}).get("cut_fixes") or {}
30
+ if not any(cut_fix_counts(labels)):
31
+ return measurements
32
+ removed = fixes.get("remove") or []
33
+ cuts = [c for c in measurements["shots"]["cuts"] if not any(abs(c - r) <= CUT_MATCH_S for r in removed)]
34
+ cuts = sorted({*cuts, *(round(float(t), 3) for t in fixes.get("add") or [])})
35
+ shots = shot_stats(cuts, measurements["source"]["duration_s"])
36
+ shots["review_windows"] = measurements["shots"].get("review_windows") or []
37
+ return {**measurements, "shots": shots}
38
+
39
+
40
+ def _overlap(a0, a1, b0, b1):
41
+ return max(0.0, min(a1, b1) - max(a0, b0))
42
+
43
+
44
+ def _seconds_in(spans, start, end):
45
+ return sum(_overlap(start, end, s["start"], s["end"]) for s in spans)
46
+
47
+
48
+ def _cuts_inside(cuts, start, end):
49
+ return sum(1 for c in cuts if start < c < end)
50
+
51
+
52
+ def _per_min(count, seconds):
53
+ return round(count * 60.0 / seconds, 2) if seconds > 0 else None
54
+
55
+
56
+ def _r(value, digits=3):
57
+ return None if value is None else round(value, digits)
58
+
59
+
60
+ def _merged_blocks(spans, owner):
61
+ """Contiguous runs of one owner (adjacent spans with different narration_job join)."""
62
+ blocks = []
63
+ for span in spans:
64
+ if span["owner"] != owner:
65
+ continue
66
+ if blocks and abs(blocks[-1][1] - span["start"]) < 1e-6:
67
+ blocks[-1][1] = span["end"]
68
+ else:
69
+ blocks.append([span["start"], span["end"]])
70
+ return blocks
71
+
72
+
73
+ def _mean_lufs(series, blocks):
74
+ values = [
75
+ series[i] for i in range(len(series or []))
76
+ if series[i] is not None and any(a <= i + 0.5 < b for a, b in blocks)
77
+ ]
78
+ return _r(sum(values) / len(values), 1) if values else None
79
+
80
+
81
+ def _by_owner(spans, cuts, duration, series):
82
+ result = {}
83
+ for owner in OWNERS:
84
+ blocks = _merged_blocks(spans, owner)
85
+ if not blocks:
86
+ continue
87
+ seconds = sum(b - a for a, b in blocks)
88
+ result[owner] = {
89
+ "seconds": _r(seconds, 2),
90
+ "share": _r(seconds / duration),
91
+ "blocks": len(blocks),
92
+ "block_median_s": _r(statistics.median(b - a for a, b in blocks), 2),
93
+ "cuts_per_min": _per_min(sum(_cuts_inside(cuts, a, b) for a, b in blocks), seconds),
94
+ "mean_short_term_lufs": _mean_lufs(series, blocks),
95
+ }
96
+ return result
97
+
98
+
99
+ def _visible_chars(text):
100
+ return sum(1 for ch in str(text or "") if ch.isalnum())
101
+
102
+
103
+ def narration_rate(spans, asr_windows, asr_status):
104
+ """Chars/s over ASR windows that narration spans cover by >= 80%; null when unusable."""
105
+ narration = [s for s in spans if s["owner"] == "narration"]
106
+ usable = asr_status is None or not (
107
+ asr_status.startswith("FAILED_") or asr_status in NULL_ASR_STATUSES
108
+ )
109
+ used, chars, seconds, lengths = 0, 0, 0.0, []
110
+ for window in asr_windows if usable else []:
111
+ start, end = float(window.get("start", 0)), float(window.get("end", 0))
112
+ if end <= start:
113
+ continue
114
+ lengths.append(end - start)
115
+ if _seconds_in(narration, start, end) / (end - start) >= MIN_WINDOW_COVERAGE:
116
+ used += 1
117
+ chars += _visible_chars(window.get("text"))
118
+ seconds += end - start
119
+ return {
120
+ "value": _r(chars / seconds, 2) if seconds > 0 and chars > 0 else None,
121
+ "windows_used": used,
122
+ "window_s": _r(statistics.median(lengths), 2) if lengths else None,
123
+ "precision": "coarse_asr_windows",
124
+ }
125
+
126
+
127
+ def switch_on_cut_share(spans, cuts):
128
+ """Share of audio-owner switch points that land within ±0.25 s of a picture cut."""
129
+ switches = [b["start"] for a, b in zip(spans, spans[1:]) if a["owner"] != b["owner"]]
130
+ if not switches:
131
+ return None
132
+ hits = sum(1 for t in switches if any(abs(t - c) <= SWITCH_TOLERANCE_S for c in cuts))
133
+ return _r(hits / len(switches))
134
+
135
+
136
+ def _lead_owner(spans, start, end):
137
+ totals = {}
138
+ for span in spans:
139
+ totals[span["owner"]] = totals.get(span["owner"], 0.0) + _overlap(
140
+ start, end, span["start"], span["end"])
141
+ return max(totals, key=totals.get) if totals and max(totals.values()) > 0 else None
142
+
143
+
144
+ def derive(labels, measurements, asr_windows=(), asr_status=None):
145
+ """All labeled metrics from well-formed labels; see references/reference-schema.md."""
146
+ duration = measurements["source"]["duration_s"]
147
+ cuts = measurements["shots"]["cuts"]
148
+ series = (measurements.get("loudness") or {}).get("short_term_1s") or []
149
+ spans = sorted(labels.get("audio_spans") or [], key=lambda s: s["start"])
150
+ sections = sorted(labels.get("sections") or [], key=lambda s: s["start"])
151
+ narration = [s for s in spans if s["owner"] == "narration"]
152
+
153
+ by_section = {}
154
+ for section in sections:
155
+ entry = by_section.setdefault(section["function"], {"seconds": 0.0, "cuts": 0, "narr": 0.0})
156
+ entry["seconds"] += section["end"] - section["start"]
157
+ entry["cuts"] += _cuts_inside(cuts, section["start"], section["end"])
158
+ entry["narr"] += _seconds_in(narration, section["start"], section["end"])
159
+ first_original = next((s for s in spans if s["owner"] == "original_dialogue"), None)
160
+ derived = {
161
+ "by_owner": _by_owner(spans, cuts, duration, series),
162
+ "narration_chars_per_s": narration_rate(spans, asr_windows, asr_status),
163
+ "switch_on_cut_share": switch_on_cut_share(spans, cuts),
164
+ "by_section": {
165
+ function: {
166
+ "seconds": _r(e["seconds"], 2),
167
+ "cuts_per_min": _per_min(e["cuts"], e["seconds"]),
168
+ "narration_share": _r(e["narr"] / e["seconds"]) if e["seconds"] > 0 else None,
169
+ }
170
+ for function, e in by_section.items()
171
+ },
172
+ "structure": [
173
+ {
174
+ "function": s["function"],
175
+ "at": [_r(s["start"] / duration), _r(s["end"] / duration)],
176
+ "lead_owner": _lead_owner(spans, s["start"], s["end"]),
177
+ }
178
+ for s in sections
179
+ ],
180
+ "first_original_at": None if first_original is None else {
181
+ "s": _r(first_original["start"], 2),
182
+ "fraction": _r(first_original["start"] / duration),
183
+ },
184
+ }
185
+ jobs = {}
186
+ for span in narration:
187
+ if span.get("narration_job"):
188
+ jobs[span["narration_job"]] = jobs.get(span["narration_job"], 0.0) + span["end"] - span["start"]
189
+ narration_seconds = sum(s["end"] - s["start"] for s in narration)
190
+ if jobs and narration_seconds > 0:
191
+ derived["narration_jobs"] = {job: _r(sec / narration_seconds) for job, sec in jobs.items()}
192
+ return derived
193
+
194
+
195
+ _MISSING = object()
196
+
197
+
198
+ def resolve(path, measurements, derived):
199
+ """Value at a dot path in measurements (or in derived for `derived.*`); _MISSING if absent."""
200
+ parts = str(path).split(".")
201
+ node = {"derived": derived} if parts[0] == "derived" else measurements
202
+ for part in parts:
203
+ if isinstance(node, dict) and part in node:
204
+ node = node[part]
205
+ elif isinstance(node, list) and part.isdigit() and int(part) < len(node):
206
+ node = node[int(part)]
207
+ else:
208
+ return _MISSING
209
+ return node
210
+
211
+
212
+ def is_resolved(value):
213
+ return value is not _MISSING
214
+
215
+
216
+ def _metric(value, provenance, **extra):
217
+ return {"value": value, "provenance": provenance, **extra}
218
+
219
+
220
+ def build_production(breakdown, measurements, derived):
221
+ """production_reference.json: methods + numbers only; no facts, labels, evidence or paths."""
222
+ labels = breakdown["labels"]
223
+ shots = measurements["shots"]
224
+ loudness = measurements.get("loudness") or {}
225
+ added, removed = cut_fix_counts(labels)
226
+ shot_provenance = "reviewed" if "cut_fixes" in labels else "measured" # {} = looked, no change
227
+ profile = {
228
+ "shot_median_s": _metric(shots.get("median_s"), shot_provenance),
229
+ "cuts_per_min": _metric(shots.get("cuts_per_min"), shot_provenance),
230
+ "integrated_lufs": _metric(loudness.get("integrated_lufs"), "measured"),
231
+ "narration_share": _metric(
232
+ (derived["by_owner"].get("narration") or {}).get("share", 0.0), "labeled"),
233
+ "narration_chars_per_s": _metric(
234
+ derived["narration_chars_per_s"]["value"], "labeled", precision="coarse_asr_windows"),
235
+ "switch_on_cut_share": _metric(derived["switch_on_cut_share"], "labeled"),
236
+ "first_original_at": _metric(
237
+ None if derived["first_original_at"] is None
238
+ else {"fraction": derived["first_original_at"]["fraction"]},
239
+ "labeled"),
240
+ }
241
+ methods = []
242
+ for method in breakdown.get("methods") or []:
243
+ entry = {key: method[key] for key in (
244
+ "id", "dimension", "rule", "applies_when", "avoid_when", "applies_to") if key in method}
245
+ targets = {}
246
+ for name, target in (method.get("targets") or {}).items():
247
+ source = target["from"]
248
+ provenance = ("labeled" if source.startswith("derived.")
249
+ else shot_provenance if source.startswith("shots.") else "measured")
250
+ value = resolve(source, measurements, derived)
251
+ if source == "derived.first_original_at": # the absolute `s` stays local, as in the profile
252
+ value = {"fraction": value["fraction"]}
253
+ targets[name] = _metric(value, provenance)
254
+ if targets:
255
+ entry["targets"] = targets
256
+ methods.append(entry)
257
+ subtitles = {k: v for k, v in (labels.get("subtitles") or {}).items() if k != "evidence_t"}
258
+ settings = measurements.get("settings") or {}
259
+ cut_detection = {key: settings.get(key) for key in (
260
+ "detector", "hard_score", "soft_score", "isolation_ratio", "isolation_window_s", "scaled")}
261
+ return {
262
+ "schema": PRODUCTION_SCHEMA,
263
+ "duration_s": measurements["source"]["duration_s"],
264
+ "canvas": measurements["source"]["canvas"],
265
+ "cut_detection": {**cut_detection, "agent_added": added, "agent_removed": removed},
266
+ "profile": {name: metric for name, metric in profile.items() if metric["value"] is not None},
267
+ "structure": derived["structure"],
268
+ "subtitles": subtitles,
269
+ "methods": methods,
270
+ "skipped_dimensions": dict(breakdown.get("skipped_dimensions") or {}),
271
+ }