@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,249 @@
1
+ """Frame-grid facts and edge snapping so edited_source.mp4 is constant frame rate.
2
+
3
+ A clip edge between two source frames leaves the concat filter a segment whose audio is a
4
+ fraction of a frame longer than its video, so the next segment starts off the frame grid and
5
+ the encoded file loses one frame slot per join (an 80 ms hold at 25 fps). Every clip therefore
6
+ starts on its source's frame grid and lasts a whole number of output frames.
7
+ """
8
+
9
+ import math
10
+ from fractions import Fraction
11
+
12
+ # The NTSC rates ffprobe reports as N/1001; canvas fps buckets store them rounded.
13
+ _NTSC_RATES = {
14
+ 23.976: Fraction(24000, 1001),
15
+ 29.97: Fraction(30000, 1001),
16
+ 47.952: Fraction(48000, 1001),
17
+ 59.94: Fraction(60000, 1001),
18
+ }
19
+ # An edge "inside" a pause window is judged without the gate's 50 ms tolerance: a half-frame
20
+ # move must not pull an edge that sits on a pause boundary back over the spoken word.
21
+ _INSIDE_EPSILON = 0.001
22
+
23
+
24
+ def parse_frame_rate(text):
25
+ """An ffprobe 'N/D' rate as a Fraction; None when unknown or implausible.
26
+
27
+ Rates above 120 fps are container timebases of variable-rate sources, not a frame grid.
28
+ """
29
+ num, _, den = str(text or "").partition("/")
30
+ try:
31
+ rate = Fraction(num) / Fraction(den or 1)
32
+ except (ValueError, ZeroDivisionError):
33
+ return None
34
+ return rate if 0 < rate <= 120 else None
35
+
36
+
37
+ def canvas_frame_rate(fps):
38
+ """Exact rate for a canvas fps bucket (29.97 is 30000/1001, not 2997/100)."""
39
+ return _NTSC_RATES.get(round(float(fps), 3)) or Fraction(float(fps)).limit_denominator(1001)
40
+
41
+
42
+ def output_frame_rate(rows, canvas_fps):
43
+ """The frame rate edited_source.mp4 is rendered at.
44
+
45
+ One source keeps its own exact rate; several sources are resampled to the canvas bucket.
46
+ """
47
+ if len(rows) == 1:
48
+ rate = parse_frame_rate(rows[0].get("frame_rate"))
49
+ if rate:
50
+ return rate
51
+ return canvas_frame_rate(canvas_fps)
52
+
53
+
54
+ def frame_count(duration, rate):
55
+ """Whole output frames a clip of `duration` seconds renders to (at least one)."""
56
+ return max(1, int(round(duration * rate)))
57
+
58
+
59
+ def source_frame_grids(geometry_qc):
60
+ """{source_path: grid} from the selected output geometry, for snap_edges_to_frames."""
61
+ out_rate = Fraction(geometry_qc["frame_rate"])
62
+ return {
63
+ row["path"]: {
64
+ "source_rate": parse_frame_rate(row.get("frame_rate")),
65
+ "origin": float(row.get("video_start_offset", 0.0)),
66
+ "output_rate": out_rate,
67
+ }
68
+ for row in geometry_qc["sources"]
69
+ }
70
+
71
+
72
+ # Gate statuses from sentence_boundaries' edge classifier, safest first.
73
+ _GATE_ORDER = {"safe": 0, "unchecked": 1, "blocking": 2}
74
+
75
+
76
+ def _rank(edge, ts, windows, classify):
77
+ """(gate status, 0 strictly inside a sentence/quiet window else 1).
78
+
79
+ `classify(edge, ts)` is the sentence-boundary gate itself, so a sub-frame move never
80
+ turns an edge the gate passes into one it blocks.
81
+ """
82
+ strict = any(w["start"] - _INSIDE_EPSILON <= ts <= w["end"] + _INSIDE_EPSILON
83
+ for w in windows)
84
+ return _GATE_ORDER[classify(edge, ts)[0]], 0 if strict else 1
85
+
86
+
87
+ def _drops_kept_edge(edge, original, ts, keep_ranges):
88
+ """Whether moving a clip edge from `original` to `ts` cuts into a required range.
89
+
90
+ Required-evidence coverage is exact, so a start that moves past a node's start (or an
91
+ end that moves before a node's end) the clip used to cover would lose that node.
92
+ """
93
+ if edge == "start":
94
+ return any(original <= start < ts for start, _ in keep_ranges)
95
+ return any(ts < end <= original for _, end in keep_ranges)
96
+
97
+
98
+ # How far a detected shot change may sit from a frame boundary and still count as on it
99
+ # (ffmpeg pts times and clip edges are both rounded to the millisecond).
100
+ _CUT_EPSILON = 0.002
101
+
102
+
103
+ def _straddles_cut(edge, ts, candidates, scene_cuts):
104
+ """Whether choosing `ts` keeps a sliver of the other shot across a source hard cut.
105
+
106
+ A start before a cut that lies up to the later candidate opens on the old shot (a flash
107
+ frame before the cut); an end after a cut that lies from the earlier candidate on closes on
108
+ the next shot. The candidate at or past the cut (start) or before it (end) does not.
109
+ """
110
+ if edge == "start":
111
+ last = max(candidates)
112
+ return any(ts + _CUT_EPSILON < cut <= last + _CUT_EPSILON for cut in scene_cuts)
113
+ first = min(candidates)
114
+ return any(first - _CUT_EPSILON <= cut < ts - _CUT_EPSILON for cut in scene_cuts)
115
+
116
+
117
+ def _pick(edge, original, candidates, windows, classify, keep_ranges, scene_cuts=()):
118
+ """The candidate the gate likes best (then one strictly inside a pause window), then one
119
+ that keeps required ranges, then one that does not straddle a detected source hard cut
120
+ (no flash frame), then the nearest, then the earlier (a deterministic tie-break)."""
121
+ return min(
122
+ candidates,
123
+ key=lambda t: (
124
+ *_rank(edge, t, windows, classify),
125
+ _drops_kept_edge(edge, original, t, keep_ranges),
126
+ _straddles_cut(edge, t, candidates, scene_cuts),
127
+ abs(t - original),
128
+ t,
129
+ ),
130
+ )
131
+
132
+
133
+ def _overlaps(clips, idx, start, end):
134
+ return any(
135
+ start < other["source_end"] and end > other["source_start"]
136
+ for j, other in enumerate(clips)
137
+ if j != idx
138
+ )
139
+
140
+
141
+ def snap_edges_to_frames(clips, grid, windows, classify, source_duration, *,
142
+ allow_overlap, joined_to_previous):
143
+ """Snap every clip of ONE source onto the frame grid; returns (clips, events).
144
+
145
+ The start moves to the nearest source frame boundary and the end to the nearest whole
146
+ number of output frames after it, each choosing between the two neighbouring candidates
147
+ the one the sentence-boundary gate `classify(edge, ts) -> (status, reason)` rates safest
148
+ (safe, then unchecked, then blocking), preferring one strictly inside a pause window.
149
+ A start that continues the previous clip's source range exactly follows that clip's
150
+ snapped end, so a lossless join stays lossless. Between equally safe candidates the one
151
+ that still covers every `grid["keep_ranges"]` (required-evidence nodes) edge it covered
152
+ wins, then the one that does not straddle a `grid["scene_cuts"]` source hard cut (a start
153
+ at or after the cut, an end at or before it, so no frame of the other shot flashes).
154
+ Without a usable source rate the start is left alone, but the length is still a whole
155
+ number of output frames.
156
+ """
157
+ clips = [dict(c) for c in clips]
158
+ rate, origin, out_rate = grid["source_rate"], grid["origin"], grid["output_rate"]
159
+ keep_ranges = grid.get("keep_ranges", ())
160
+ scene_cuts = grid.get("scene_cuts", ())
161
+ events = []
162
+ for idx, clip in enumerate(clips):
163
+ start, end = clip["source_start"], clip["source_end"]
164
+ if joined_to_previous[idx] and idx > 0:
165
+ new_start, start_reason = clips[idx - 1]["source_end"], "continuous_source_join"
166
+ elif rate is None:
167
+ new_start, start_reason = start, "source_frame_rate_unknown"
168
+ else:
169
+ pos = (start - origin) * rate
170
+ ks = {max(0, math.floor(pos)), max(0, math.ceil(pos))}
171
+ options = [round(origin + float(k / rate), 3) for k in sorted(ks)]
172
+ safe = [t for t in options if t >= start or allow_overlap
173
+ or not _overlaps(clips, idx, t, end)]
174
+ new_start = _pick("start", start, safe or [max(options)], windows, classify,
175
+ keep_ranges, scene_cuts)
176
+ start_reason = "frame_grid"
177
+ span = (end - new_start) * out_rate
178
+ counts = sorted({max(1, math.floor(span)), max(1, math.ceil(span))})
179
+ options = [
180
+ (n, round(new_start + float(n / out_rate), 3)) for n in counts
181
+ if new_start + float(n / out_rate) <= source_duration + _INSIDE_EPSILON
182
+ ] or [(counts[0], round(new_start + float(counts[0] / out_rate), 3))]
183
+ safe = [(n, t) for n, t in options if t <= end or allow_overlap
184
+ or not _overlaps(clips, idx, new_start, t)] or options[:1]
185
+ new_end = _pick("end", end, [t for _, t in safe], windows, classify, keep_ranges,
186
+ scene_cuts)
187
+ frames = next(n for n, t in safe if t == new_end)
188
+ clip["source_start"], clip["source_end"] = new_start, new_end
189
+ events.append({
190
+ "clip_id": clip["clip_id"],
191
+ "source_id": clip.get("source_id"),
192
+ "original_start": round(start, 3),
193
+ "original_end": round(end, 3),
194
+ "new_start": new_start,
195
+ "new_end": new_end,
196
+ "start_reason": start_reason,
197
+ "frame_count": frames,
198
+ })
199
+ return clips, events
200
+
201
+
202
+ def edge_frame_snapper(grid, windows, classify, source_duration):
203
+ """frame_snap(edge, clip, ts): where snap_edges_to_frames puts `clip`'s edge moved to ts.
204
+
205
+ The gate uses it to check a suggested edge time after frame alignment, not before.
206
+ """
207
+ def frame_snap(edge, clip, when):
208
+ key = "source_start" if edge == "start" else "source_end"
209
+ moved = {**clip, key: when}
210
+ if moved["source_end"] <= moved["source_start"]:
211
+ return when
212
+ snapped, _ = snap_edges_to_frames([moved], grid, windows, classify, source_duration,
213
+ allow_overlap=True, joined_to_previous=[False])
214
+ return snapped[0][key]
215
+
216
+ return frame_snap
217
+
218
+
219
+ def record_frame_grid(plan, geometry_qc):
220
+ """Stamp each clip's frame_count and qc.frame_grid; the render draws exactly these frames.
221
+
222
+ The output timeline (output_start/output_end, total_duration) is rewritten from the
223
+ cumulative frame count, so it matches the render instead of drifting by the per-clip
224
+ millisecond rounding (a 29.97 fps frame is 33.367 ms).
225
+ """
226
+ out_rate = Fraction(geometry_qc["frame_rate"])
227
+ total = 0
228
+ for clip in plan["clips"]:
229
+ clip["frame_count"] = frame_count(clip["duration"], out_rate)
230
+ clip["output_start"] = round(float(total / out_rate), 3)
231
+ total += clip["frame_count"]
232
+ clip["output_end"] = round(float(total / out_rate), 3)
233
+ plan["total_duration"] = round(float(total / out_rate), 3)
234
+ plan.setdefault("qc", {})["frame_grid"] = {
235
+ "output_frame_rate": geometry_qc["frame_rate"],
236
+ "frame_count": total,
237
+ "duration": round(float(total / out_rate), 3),
238
+ "sources": [
239
+ {
240
+ "source_id": row["source_id"],
241
+ "path": row["path"],
242
+ "frame_rate": row.get("frame_rate"),
243
+ "video_start_offset": row.get("video_start_offset", 0.0),
244
+ "start_snapped": parse_frame_rate(row.get("frame_rate")) is not None,
245
+ }
246
+ for row in geometry_qc["sources"]
247
+ ],
248
+ }
249
+ return plan
@@ -46,9 +46,6 @@ CONFIG = {
46
46
  "clip_start_snap_max_prepend": env_float("CLIP_START_SNAP_MAX_PREPEND", 1.8, min_val=0.0),
47
47
  "clip_start_snap_max_trim": env_float("CLIP_START_SNAP_MAX_TRIM", 0.35, min_val=0.0),
48
48
  "clip_join_audio_fade_ms": env_float("CLIP_JOIN_AUDIO_FADE_MS", 30.0, min_val=0.0),
49
- # video-cut is the only skill that implements clip padding, so it is the only skill that
50
- # may declare the knob. cut_cli reads this as the default for --clip-padding.
51
- "clip_padding": env_float("CLIP_PADDING", 0.0, min_val=0.0),
52
49
  # Keep clip boundaries off the ORIGINAL footage's hard cuts: a clip that opens/closes a few
53
50
  # tenths of a second from a source shot-change shows a brief sliver of the adjacent shot that
54
51
  # then hard-cuts again — a visible 闪烁/flicker at the edit point. Snap source_start forward
@@ -2,6 +2,7 @@
2
2
 
3
3
  import json
4
4
 
5
+ from frame_grid import canvas_frame_rate, output_frame_rate, parse_frame_rate
5
6
  from lib import run_cmd
6
7
 
7
8
 
@@ -57,7 +58,38 @@ def _fps_from_rate(rate):
57
58
  return float(num) / float(den) if float(den) > 0 else 0.0
58
59
 
59
60
 
60
- def _geometry_from_stream(stream):
61
+ def _frame_rate_text(stream):
62
+ """The stream's real frame grid as an 'N/D' string.
63
+
64
+ Interlaced streams often report the field rate as r_frame_rate (50/1 for 25 frames a
65
+ second); when r_frame_rate is exactly twice avg_frame_rate the average is the grid.
66
+ A variable-rate phone clip can report r_frame_rate well above its real average (60/1
67
+ for ~29.6 frames a second); rendering that at 60 fps CFR would double every frame, so
68
+ the common rate nearest the average is used instead, as it is when r_frame_rate is
69
+ unusable (`0/0`) but the average is not.
70
+ """
71
+ r_rate = stream.get("r_frame_rate", "0/0")
72
+ avg = stream.get("avg_frame_rate", "0/0")
73
+ avg_fps = _fps_from_rate(avg)
74
+ if 0 < avg_fps <= 120:
75
+ ratio = _fps_from_rate(r_rate) / avg_fps
76
+ if abs(ratio - 2) < 0.01:
77
+ return avg
78
+ if ratio > 1.5 or parse_frame_rate(r_rate) is None:
79
+ rate = canvas_frame_rate(_fps_bucket(avg_fps))
80
+ return f"{rate.numerator}/{rate.denominator}"
81
+ return r_rate
82
+
83
+
84
+ def _video_start_offset(stream, format_start):
85
+ """Seconds from the file start (ffmpeg's input zero) to the first video frame."""
86
+ try:
87
+ return round(max(0.0, float(stream.get("start_time")) - float(format_start or 0.0)), 6)
88
+ except (TypeError, ValueError):
89
+ return 0.0
90
+
91
+
92
+ def _geometry_from_stream(stream, format_start=0.0):
61
93
  coded_width, coded_height = stream["width"], stream["height"]
62
94
  parsed_sar = _parse_ratio(stream.get("sample_aspect_ratio"))
63
95
  dar = _parse_ratio(stream.get("display_aspect_ratio"))
@@ -80,9 +112,9 @@ def _geometry_from_stream(stream):
80
112
  display_width, display_height = display_height, display_width
81
113
 
82
114
  width, height = _clamp_even_geometry(round(display_width), round(display_height))
83
- fps = _fps_from_rate(stream["r_frame_rate"]) or _fps_from_rate(
84
- stream.get("avg_frame_rate", "0/0")
85
- )
115
+ frame_rate = _frame_rate_text(stream)
116
+ # The canvas fps bucket follows the same frame grid (not an interlaced field rate).
117
+ fps = _fps_from_rate(frame_rate) or _fps_from_rate(stream.get("avg_frame_rate", "0/0"))
86
118
  if not 0 < fps <= 120:
87
119
  fps = 30.0
88
120
  facts = {
@@ -91,6 +123,9 @@ def _geometry_from_stream(stream):
91
123
  "width": width,
92
124
  "height": height,
93
125
  "fps": round(fps, 3),
126
+ # Exact rate and first-frame time: the grid video-cut snaps clip edges onto.
127
+ "frame_rate": frame_rate,
128
+ "video_start_offset": _video_start_offset(stream, format_start),
94
129
  "sample_aspect_ratio": stream.get("sample_aspect_ratio", "1:1"),
95
130
  "sample_aspect_ratio_float": round(sar, 6),
96
131
  "display_aspect_ratio": stream.get("display_aspect_ratio"),
@@ -118,7 +153,8 @@ def _probe_video_geometry(video_path):
118
153
  "-select_streams",
119
154
  "v:0",
120
155
  "-show_entries",
121
- "stream=width,height,r_frame_rate,avg_frame_rate,sample_aspect_ratio,display_aspect_ratio:stream_tags=rotate:stream_side_data=rotation",
156
+ "stream=width,height,r_frame_rate,avg_frame_rate,start_time,sample_aspect_ratio,"
157
+ "display_aspect_ratio:stream_tags=rotate:stream_side_data=rotation:format=start_time",
122
158
  "-of",
123
159
  "json",
124
160
  str(video_path),
@@ -126,10 +162,11 @@ def _probe_video_geometry(video_path):
126
162
  result = run_cmd(cmd)
127
163
  if result.returncode != 0:
128
164
  raise RuntimeError(f"ffprobe 无法读取视频几何信息: {video_path}: {result.stderr.strip()}")
129
- streams = json.loads(result.stdout).get("streams", [])
165
+ payload = json.loads(result.stdout)
166
+ streams = payload.get("streams", [])
130
167
  if not streams:
131
168
  raise RuntimeError(f"没有视频流: {video_path}")
132
- return _geometry_from_stream(streams[0])
169
+ return _geometry_from_stream(streams[0], payload.get("format", {}).get("start_time"))
133
170
 
134
171
 
135
172
  def _orientation(width, height):
@@ -179,6 +216,8 @@ def _select_output_geometry(source_paths, clips):
179
216
  "display_height": facts["display_height"],
180
217
  "area": width * height,
181
218
  "fps": fps,
219
+ "frame_rate": facts["frame_rate"],
220
+ "video_start_offset": facts["video_start_offset"],
182
221
  "fps_bucket": _fps_bucket(fps),
183
222
  "orientation": _orientation(width, height),
184
223
  "rotation": facts["rotation"],
@@ -220,6 +259,8 @@ def _select_output_geometry(source_paths, clips):
220
259
  "width": width,
221
260
  "height": height,
222
261
  "fps": round(fps, 3),
262
+ # The exact rate edited_source.mp4 is rendered at (one source keeps its own rate).
263
+ "frame_rate": str(output_frame_rate(rows, fps)),
223
264
  "reason": "weighted_orientation_area_fps",
224
265
  "source_id": selected["source_id"],
225
266
  "source_path": selected["path"],
@@ -236,3 +277,128 @@ def _select_output_geometry(source_paths, clips):
236
277
  "sources": rows,
237
278
  }
238
279
  return width, height, round(fps, 3), reason
280
+
281
+
282
+ # Delivered-picture format. `_probe_video_format`, `_output_color_tags`,
283
+ # `_color_tag_filter` and `_color_tag_args` are intentional function-level copies shared
284
+ # with the final render (tests/orchestrator/test_render_format_parity.py keeps them identical).
285
+ _COLOR_FIELDS = (
286
+ ("color_space", "colorspace"),
287
+ ("color_primaries", "color_primaries"),
288
+ ("color_transfer", "color_trc"),
289
+ )
290
+ _UNTAGGED_COLOR = {None, "", "unknown", "unspecified", "reserved", "N/A"}
291
+ # ffprobe's colour space for an RGB picture (PNG, libx264rgb, FFV1 RGB). ffmpeg rejects
292
+ # `-colorspace gbr`, and the delivered picture is YUV anyway, so it is never written.
293
+ _RGB_COLOR_SPACE = "gbr"
294
+ # Pixel-format name prefixes of ffmpeg's RGB family (packed, planar `gbr*`, paletted, Bayer).
295
+ # Some RGB sources report no colour space at all (QuickTime RLE `argb`, raw `bgr24`, GIF
296
+ # `pal8`), so the pixel format decides too; tests/assemble/test_render_delivery.py checks
297
+ # the prefixes against every format a real ffprobe lists.
298
+ _RGB_PIX_FMT_PREFIXES = (
299
+ "rgb", "bgr", "gbr", "argb", "abgr", "0rgb", "0bgr", "x2rgb", "x2bgr", "pal8", "bayer_",
300
+ )
301
+ # Names ffprobe prints that setparams and the -colorspace/-color_primaries/-color_trc
302
+ # output options accept and libx264 writes back (tests/assemble/test_render_delivery.py
303
+ # runs each through a real ffmpeg); anything else is not written explicitly.
304
+ _KNOWN_COLOR_VALUES = {
305
+ "colorspace": {
306
+ "bt709", "fcc", "bt470bg", "smpte170m", "smpte240m", "ycgco", "bt2020nc", "bt2020c",
307
+ "smpte2085", "chroma-derived-nc", "chroma-derived-c", "ictcp",
308
+ },
309
+ "color_primaries": {
310
+ "bt709", "bt470m", "bt470bg", "smpte170m", "smpte240m", "film", "bt2020",
311
+ "smpte428", "smpte431", "smpte432",
312
+ },
313
+ "color_trc": {
314
+ "bt709", "bt470m", "bt470bg", "smpte170m", "smpte240m", "linear", "log100",
315
+ "log316", "iec61966-2-4", "bt1361e", "iec61966-2-1", "bt2020-10", "bt2020-12",
316
+ "smpte2084", "smpte428", "arib-std-b67",
317
+ },
318
+ }
319
+ # setparams only knows the BT.470 transfers by their ffprobe names, while ffmpeg 8's
320
+ # -color_trc output option only knows them as gamma22/gamma28 (ffmpeg 9 takes both).
321
+ _COLOR_OPTION_SPELLING = {"color_trc": {"bt470m": "gamma22", "bt470bg": "gamma28"}}
322
+
323
+
324
+ def _probe_video_format(video_path):
325
+ """Codec, pixel format, size and colour tags of the first video stream ({} if unreadable)."""
326
+ result = run_cmd([
327
+ "ffprobe", "-v", "error", "-select_streams", "v:0",
328
+ "-show_entries",
329
+ "stream=codec_name,pix_fmt,width,height,color_space,color_primaries,color_transfer,color_range",
330
+ "-of", "json", str(video_path),
331
+ ])
332
+ if result.returncode != 0:
333
+ return {}
334
+ try:
335
+ streams = json.loads(result.stdout).get("streams") or []
336
+ except (ValueError, AttributeError):
337
+ return {}
338
+ return streams[0] if streams and isinstance(streams[0], dict) else {}
339
+
340
+
341
+ def _output_color_tags(stream):
342
+ """Colour tags to write on the delivered picture, as {ffmpeg option: value}.
343
+
344
+ An untagged or BT.709 source is labelled BT.709, which is how players already decode
345
+ untagged web video. Any other declared colour space passes through as declared. The
346
+ range follows the source: full range stays `pc`, everything else is limited `tv`.
347
+ For a YUV source nothing here converts pixels; it only fixes the labels.
348
+
349
+ An RGB source (colour space `gbr`, or an RGB pixel format with or without a colour
350
+ space) has no YUV matrix to keep: it is converted to BT.709 limited range, and
351
+ `from_rgb` tells `_color_tag_filter` to do that conversion explicitly instead of
352
+ letting ffmpeg pick a matrix.
353
+ """
354
+ declared = {option: stream.get(key) for key, option in _COLOR_FIELDS}
355
+ from_rgb = (
356
+ declared["colorspace"] == _RGB_COLOR_SPACE
357
+ or str(stream.get("pix_fmt") or "").startswith(_RGB_PIX_FMT_PREFIXES)
358
+ )
359
+ if from_rgb:
360
+ declared["colorspace"] = "bt709"
361
+ if all(value in _UNTAGGED_COLOR or value == "bt709" for value in declared.values()):
362
+ tags = {option: "bt709" for _, option in _COLOR_FIELDS}
363
+ else:
364
+ tags = {
365
+ option: value for option, value in declared.items()
366
+ if value in _KNOWN_COLOR_VALUES[option]
367
+ }
368
+ if from_rgb:
369
+ tags["color_range"] = "tv"
370
+ tags["from_rgb"] = True
371
+ else:
372
+ tags["color_range"] = "pc" if stream.get("color_range") == "pc" else "tv"
373
+ return tags
374
+
375
+
376
+ def _color_tag_filter(tags):
377
+ """setparams filter that stamps the tags on every frame before the encoder.
378
+
379
+ Output options alone are not enough when re-encoding: ffmpeg 8/9 let the (untagged)
380
+ frame properties win for primaries and transfer, so the bitstream would stay untagged.
381
+ An RGB frame is first converted to BT.709 yuv420p explicitly: stamping a YUV colour
382
+ space on an RGB frame makes ffmpeg's own later conversion use the BT.601 matrix
383
+ while the file says BT.709.
384
+ """
385
+ parts = [f"{option}={tags[option]}" for _, option in _COLOR_FIELDS if option in tags]
386
+ parts.append(f"range={tags['color_range']}")
387
+ setparams = "setparams=" + ":".join(parts)
388
+ if tags.get("from_rgb"):
389
+ return (
390
+ f"scale=out_color_matrix=bt709:out_range={tags['color_range']},"
391
+ f"format=yuv420p,{setparams}"
392
+ )
393
+ return setparams
394
+
395
+
396
+ def _color_tag_args(tags):
397
+ """Output options that write the tags into the container (and encoder) metadata."""
398
+ args = []
399
+ for _, option in _COLOR_FIELDS:
400
+ if option in tags:
401
+ value = _COLOR_OPTION_SPELLING.get(option, {}).get(tags[option], tags[option])
402
+ args += [f"-{option}", value]
403
+ return args + ["-color_range", tags["color_range"]]
404
+