@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,553 @@
1
+ """R1–R8: mechanically keep source facts out of the transferable production reference.
2
+
3
+ `check` validates the agent's single file (reference_breakdown.json) against the cached
4
+ measurements and the optional understanding artifacts, computes `derived` in memory, and
5
+ returns {derived, errors, warnings}. `export` writes production_reference.json only when there
6
+ are zero errors, then re-scans the serialized result (R8).
7
+ """
8
+ import json
9
+ import math
10
+ import re
11
+ from pathlib import Path
12
+
13
+ from lib import read_json, write_json
14
+ from reference_measure import EDGE_S, MEASUREMENTS_FILE
15
+ from reference_profile import (
16
+ CUT_MATCH_S, OWNERS, apply_cut_fixes, build_production, derive, is_resolved, resolve)
17
+
18
+ BREAKDOWN_FILE = "reference_breakdown.json"
19
+ BREAKDOWN_SCHEMA = "video-reference.breakdown.v1"
20
+ DIMENSIONS = ("narrative_structure", "pacing", "shots_editing", "narration_subtitles", "audio_visual")
21
+ APPLIES_TO = ("story_plan", "visual_audio_board", "clip_plan", "narration", "style_card")
22
+ FUNCTIONS = ("hook", "setup", "turn", "escalation", "payoff")
23
+ NARRATION_JOBS = ("context", "causal_link", "foreshadow", "interpretation", "transition")
24
+ TARGET_ROOTS = ("shots", "loudness", "derived")
25
+ # Objects a target may export whole; every other target must be a numeric leaf. No list indexing,
26
+ # and first_original_at is exported as {fraction} only (its absolute `s` stays local).
27
+ _TARGET_OBJECTS = re.compile(
28
+ r"derived\.(?:structure|first_original_at|narration_jobs|by_owner\.[a-z_]+|by_section\.[a-z]+)")
29
+ GAP_S = 0.5
30
+ T_EPSILON_S = 0.05
31
+ CJK_RUN_MIN = 8
32
+ LATIN_RUN_MIN = 5
33
+
34
+ _KEYS = {
35
+ "top": {"schema", "labels", "source_facts", "methods", "skipped_dimensions"},
36
+ "labels": {"audio_spans", "sections", "subtitles", "basis", "cut_fixes"},
37
+ "cut_fixes": {"add", "remove"},
38
+ "span": {"start", "end", "owner", "narration_job"},
39
+ "section": {"start", "end", "function"},
40
+ "subtitles": {"burned", "max_lines", "marks_original", "evidence_t"},
41
+ "fact": {"id", "dimension", "statement", "t", "measure", "entities"},
42
+ "method": {"id", "dimension", "rule", "applies_when", "avoid_when", "applies_to", "evidence", "targets"},
43
+ "target": {"from"},
44
+ }
45
+ _REQUIRED = {
46
+ "top": {"schema", "labels"},
47
+ "labels": {"audio_spans", "sections"},
48
+ "span": {"start", "end", "owner"},
49
+ "section": {"start", "end", "function"},
50
+ "subtitles": {"burned", "max_lines"},
51
+ "fact": {"id", "dimension", "statement", "entities"},
52
+ "method": {"id", "dimension", "rule", "applies_to", "evidence"},
53
+ "target": {"from"},
54
+ }
55
+ METHOD_TEXT_FIELDS = ("rule", "applies_when", "avoid_when")
56
+ BANNED_EXPORT_KEYS = {"source_facts", "labels", "entities", "evidence", "statement", "from", "path"}
57
+ _CJK = re.compile(r"[㐀-鿿豈-﫿]+")
58
+ _WORD = re.compile(r"[A-Za-z0-9']+")
59
+ # Python's \b treats CJK as word characters, so "在1:23处" has no boundary before "1"; use
60
+ # digit/ASCII lookarounds instead. Path segments are ASCII so prose like "旁白/原声/音乐" stays legal.
61
+ _NUMERAL = "[0-9一二三四五六七八九十百零两]+"
62
+ _TIMECODE = re.compile(
63
+ rf"(?<![\d:])\d{{1,2}}:\d{{2}}(?::\d{{2}})?(?![\d:])|第\s*{_NUMERAL}\s*秒|{_NUMERAL}\s*分钟?\s*{_NUMERAL}\s*秒")
64
+ # One-segment absolute paths only for well-known roots, so prose like "原声/BGM" stays legal.
65
+ _ABS_PATH = re.compile(
66
+ r"(?<![A-Za-z0-9_.])/(?:[A-Za-z0-9_.-]+/)+[\w.-]*|~/[\w.-]+|(?<![A-Za-z0-9_])[A-Za-z]:[\\/][\w.-]+"
67
+ r"|(?<![A-Za-z0-9_.])/(?:tmp|Users|home|var|private|mnt|Volumes|opt|srv|root|data)(?![\w.-])")
68
+ _SPACE = re.compile(r"\s+")
69
+ # Short quoted terms in background research: titles, places, organisations, quoted lines.
70
+ _QUOTED = re.compile(r"《([^》]{2,8})》|「([^」]{2,8})」|“([^”]{2,8})”|‘([^’]{2,8})’|'([^'\s]{2,8})'")
71
+ _NUMBER_IN_RULE = re.compile(r"\d+(?:\.\d+)?\s*(?:秒|%|%|字)")
72
+
73
+
74
+ def _num(value):
75
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
76
+
77
+
78
+ def _closed(obj, kind, where, errors):
79
+ """R1 closed key set + required keys; returns True when `obj` is a dict."""
80
+ if not isinstance(obj, dict):
81
+ errors.append(f"R1 {where}: 必须是对象")
82
+ return False
83
+ for key in sorted(set(obj) - _KEYS[kind]):
84
+ errors.append(f"R1 {where}: 不允许的键 {key!r}")
85
+ for key in sorted(_REQUIRED.get(kind, set()) - set(obj)):
86
+ errors.append(f"R1 {where}: 缺少键 {key!r}")
87
+ return True
88
+
89
+
90
+ def _timed(items, kind, enum_key, enum, errors):
91
+ """R1 for spans/sections; returns the well-formed items for R2 and derive()."""
92
+ good = []
93
+ for i, item in enumerate(items if isinstance(items, list) else []):
94
+ where = f"labels.{'audio_spans' if kind == 'span' else 'sections'}[{i}]"
95
+ if not _closed(item, kind, where, errors):
96
+ continue
97
+ ok = _num(item.get("start")) and _num(item.get("end"))
98
+ if not ok:
99
+ errors.append(f"R1 {where}: start/end 必须是数字")
100
+ if item.get(enum_key) not in enum:
101
+ errors.append(f"R1 {where}: {enum_key} 必须是 {'|'.join(enum)},不是 {item.get(enum_key)!r}")
102
+ ok = False
103
+ job = item.get("narration_job")
104
+ if "narration_job" in item and (item.get("owner") != "narration" or job not in NARRATION_JOBS):
105
+ errors.append(f"R1 {where}: narration_job 只用于 narration 段,取值 {'|'.join(NARRATION_JOBS)}")
106
+ if ok:
107
+ good.append(item)
108
+ return good
109
+
110
+
111
+ def _check_cover(items, name, duration, errors):
112
+ """R2: sorted, no overlap, gaps <= 0.5 s, covering [0, duration] within 0.5 s."""
113
+ if not items:
114
+ errors.append(f"R2 labels.{name}: 不能为空")
115
+ return
116
+ for i, item in enumerate(items):
117
+ if item["start"] >= item["end"]:
118
+ errors.append(f"R2 labels.{name}[{i}]: start 必须小于 end")
119
+ if item["end"] > duration + GAP_S:
120
+ errors.append(f"R2 labels.{name}[{i}]: end 超出时长 {duration}")
121
+ if i:
122
+ prev = items[i - 1]
123
+ if item["start"] < prev["end"] - 1e-6:
124
+ errors.append(f"R2 labels.{name}[{i}]: 与上一段重叠或未按时间排序")
125
+ elif item["start"] - prev["end"] > GAP_S:
126
+ errors.append(f"R2 labels.{name}[{i}]: 与上一段间隙 {item['start'] - prev['end']:.2f}s > {GAP_S}s")
127
+ if items[0]["start"] > GAP_S or items[-1]["end"] < duration - GAP_S:
128
+ errors.append(f"R2 labels.{name}: 必须覆盖 [0, {duration}](首尾容差 {GAP_S}s)")
129
+
130
+
131
+ def _check_cut_fixes(fixes, cuts, duration, errors):
132
+ """R1/R2: removals name a detected cut, additions a cut the detector did not report."""
133
+ if not _closed(fixes, "cut_fixes", "labels.cut_fixes", errors):
134
+ return
135
+ for key in ("add", "remove"):
136
+ values = fixes.get(key, [])
137
+ if not isinstance(values, list) or not all(_num(v) for v in values):
138
+ errors.append(f"R1 labels.cut_fixes.{key}: 必须是秒数列表")
139
+ continue
140
+ for t in values:
141
+ near = any(abs(t - c) <= CUT_MATCH_S for c in cuts)
142
+ if not EDGE_S < t < duration - EDGE_S:
143
+ errors.append(f"R2 labels.cut_fixes.{key}: {t} 不在 (0, {duration}) 内")
144
+ elif key == "remove" and not near:
145
+ errors.append(f"R2 labels.cut_fixes.remove: {t} 附近 ±{CUT_MATCH_S}s 内没有测得的切点")
146
+ elif key == "add" and near:
147
+ errors.append(f"R2 labels.cut_fixes.add: {t} 已是测得的切点(±{CUT_MATCH_S}s)")
148
+
149
+
150
+ def _check_labels(labels, duration, errors, cuts=()):
151
+ if not _closed(labels, "labels", "labels", errors):
152
+ return [], []
153
+ spans = _timed(labels.get("audio_spans"), "span", "owner", OWNERS, errors)
154
+ sections = _timed(labels.get("sections"), "section", "function", FUNCTIONS, errors)
155
+ _check_cover(spans, "audio_spans", duration, errors)
156
+ _check_cover(sections, "sections", duration, errors)
157
+ if "cut_fixes" in labels:
158
+ _check_cut_fixes(labels["cut_fixes"], cuts, duration, errors)
159
+ subtitles = labels.get("subtitles")
160
+ if subtitles is not None and _closed(subtitles, "subtitles", "labels.subtitles", errors):
161
+ where = "R1 labels.subtitles"
162
+ if "burned" in subtitles and not isinstance(subtitles["burned"], bool):
163
+ errors.append(f"{where}.burned: 必须是 true/false")
164
+ lines = subtitles.get("max_lines")
165
+ if "max_lines" in subtitles and (not isinstance(lines, int) or isinstance(lines, bool) or lines < 1):
166
+ errors.append(f"{where}.max_lines: 必须是 ≥1 的整数")
167
+ if "marks_original" in subtitles and not isinstance(subtitles["marks_original"], str):
168
+ errors.append(f"{where}.marks_original: 必须是字符串")
169
+ evidence_t = subtitles.get("evidence_t", [])
170
+ if not isinstance(evidence_t, list):
171
+ errors.append(f"{where}.evidence_t: 必须是数字列表")
172
+ evidence_t = []
173
+ for t in evidence_t:
174
+ if not _num(t) or not 0 <= t <= duration:
175
+ errors.append(f"R2 labels.subtitles.evidence_t: {t!r} 不在 [0, {duration}] 内")
176
+ return spans, sections
177
+
178
+
179
+ def _check_ids(items, prefix, where, errors):
180
+ seen = set()
181
+ for i, item in enumerate(items):
182
+ ident = item.get("id")
183
+ if not isinstance(ident, str) or not re.fullmatch(rf"{prefix}\d+", ident):
184
+ errors.append(f"R1 {where}[{i}]: id 必须形如 {prefix}1")
185
+ elif ident in seen:
186
+ errors.append(f"R1 {where}[{i}]: id {ident} 重复")
187
+ seen.add(ident)
188
+ return seen
189
+
190
+
191
+ def _anchor_ok(path, measurements, derived):
192
+ """A `measure` anchor: resolves under shots/loudness/derived and is not a string leaf."""
193
+ if not isinstance(path, str) or path.split(".")[0] not in TARGET_ROOTS:
194
+ return False
195
+ value = resolve(path, measurements, derived)
196
+ return is_resolved(value) and not isinstance(value, str)
197
+
198
+
199
+ def _check_facts(facts, measurements, derived, duration, errors):
200
+ """R1 + R3: every fact is anchored to a time range or a resolvable measurement."""
201
+ for fact in facts:
202
+ where = f"source_facts.{fact.get('id')}"
203
+ if fact.get("dimension") not in DIMENSIONS:
204
+ errors.append(f"R1 {where}: dimension 必须是 {'|'.join(DIMENSIONS)}")
205
+ if not isinstance(fact.get("statement"), str) or not fact.get("statement").strip():
206
+ errors.append(f"R1 {where}: statement 不能为空")
207
+ if not isinstance(fact.get("entities"), list) or not all(isinstance(e, str) for e in fact.get("entities", [])):
208
+ errors.append(f"R3 {where}: 必须显式写 entities 列表(可为空)")
209
+ if ("t" in fact) == ("measure" in fact):
210
+ errors.append(f"R3 {where}: t 与 measure 必须二选一")
211
+ elif "t" in fact:
212
+ t = fact["t"]
213
+ if not (isinstance(t, list) and len(t) == 2 and all(_num(x) for x in t)
214
+ and 0 <= t[0] < t[1] <= duration + T_EPSILON_S):
215
+ errors.append(f"R3 {where}: t 必须是 [a, b] 且 0 ≤ a < b ≤ {duration}")
216
+ else:
217
+ paths = fact["measure"]
218
+ if not isinstance(paths, list) or not paths:
219
+ errors.append(f"R3 {where}: measure 必须是非空路径列表")
220
+ for path in paths if isinstance(paths, list) else []:
221
+ if not _anchor_ok(path, measurements, derived):
222
+ errors.append(f"R3 {where}: 测量路径 {path!r} 无法解析到 shots/loudness/derived 下的测量值")
223
+
224
+
225
+ def _target_ok(path, value):
226
+ if not isinstance(path, str) or not is_resolved(value) or value is None:
227
+ return False
228
+ parts = path.split(".")
229
+ if parts[0] not in TARGET_ROOTS or any(p.isdigit() for p in parts) or path == "derived.first_original_at.s":
230
+ return False
231
+ return _num(value) or _TARGET_OBJECTS.fullmatch(path) is not None
232
+
233
+
234
+ def _check_methods(methods, fact_ids, measurements, derived, errors):
235
+ """R1 + R4 + R5: closed method shape, real evidence, targets only name measurement paths."""
236
+ for method in methods:
237
+ where = f"methods.{method.get('id')}"
238
+ if method.get("dimension") not in DIMENSIONS:
239
+ errors.append(f"R1 {where}: dimension 必须是 {'|'.join(DIMENSIONS)}")
240
+ if method.get("applies_to") not in APPLIES_TO:
241
+ errors.append(f"R1 {where}: applies_to 必须是 {'|'.join(APPLIES_TO)}")
242
+ for field in METHOD_TEXT_FIELDS:
243
+ if field in method and (not isinstance(method[field], str) or not method[field].strip()):
244
+ errors.append(f"R1 {where}: {field} 必须是非空字符串")
245
+ evidence = method.get("evidence")
246
+ if not isinstance(evidence, list) or not evidence:
247
+ errors.append(f"R4 {where}: evidence 至少一条")
248
+ for item in evidence if isinstance(evidence, list) else []:
249
+ ok = item in fact_ids or (
250
+ isinstance(item, str) and item.startswith("measure:")
251
+ and _anchor_ok(item[len("measure:"):], measurements, derived))
252
+ if not ok:
253
+ errors.append(f"R4 {where}: evidence {item!r} 既不是已有 fact id,也不是可解析的 measure:路径")
254
+ targets = method.get("targets", {})
255
+ if not isinstance(targets, dict):
256
+ errors.append(f"R1 {where}: targets 必须是对象")
257
+ continue
258
+ for name, target in targets.items():
259
+ twhere = f"{where}.targets.{name}"
260
+ if not re.fullmatch(r"[a-z][a-z0-9_]*", str(name)):
261
+ errors.append(f"R1 {twhere}: 名字只用小写字母、数字和下划线")
262
+ if not _closed(target, "target", twhere, errors):
263
+ continue
264
+ if set(target) & {"value", "provenance"}:
265
+ errors.append(f"R5 {twhere}: 数值与 provenance 由 export 填写,只写 from")
266
+ path = target.get("from")
267
+ if "from" in target and not _target_ok(path, resolve(path, measurements, derived)):
268
+ errors.append(f"R5 {twhere}: from {path!r} 必须是 shots/loudness/derived 下的数值叶子或允许的派生对象")
269
+
270
+
271
+ def _ngrams(text, size):
272
+ """CJK n-grams per punctuation-free run, plus over all CJK joined (so「你可知道,我是…」still matches)."""
273
+ runs = _CJK.findall(text)
274
+ grams = set()
275
+ for run in [*runs, "".join(runs)]:
276
+ grams.update(run[i:i + size] for i in range(len(run) - size + 1))
277
+ return grams
278
+
279
+
280
+ def _word_grams(text, size):
281
+ words = [w.lower() for w in _WORD.findall(text)]
282
+ return {tuple(words[i:i + size]) for i in range(len(words) - size + 1)}
283
+
284
+
285
+ def _mention_names(value):
286
+ """Names one aliases/asr_mentions entry contributes.
287
+
288
+ The model writes plain strings; video-understanding's deterministic ASR/research fallback writes
289
+ {text, evidence_id, matched_aliases} dicts, whose names are the matched aliases (the text is the
290
+ ASR window, already in the source-text corpus). Anything else contributes nothing."""
291
+ if isinstance(value, str):
292
+ return [value]
293
+ if isinstance(value, dict):
294
+ matched = value.get("matched_aliases")
295
+ return [m for m in matched if isinstance(m, str)] if isinstance(matched, list) else []
296
+ return []
297
+
298
+
299
+ def _index_names(index):
300
+ """name / aliases / asr_mentions of characters, entities and research_glossary in understanding_index.json."""
301
+ names = set()
302
+ for key in ("characters", "entities", "research_glossary"):
303
+ items = index.get(key) if isinstance(index, dict) else None
304
+ for item in items if isinstance(items, list) else []:
305
+ if isinstance(item, dict):
306
+ if isinstance(item.get("name"), str):
307
+ names.add(item["name"])
308
+ for alias_key in ("aliases", "asr_mentions"):
309
+ values = item.get(alias_key)
310
+ for value in values if isinstance(values, list) else []:
311
+ names.update(_mention_names(value))
312
+ return names
313
+
314
+
315
+ def _strings(node):
316
+ if isinstance(node, str):
317
+ yield node
318
+ elif isinstance(node, dict):
319
+ for value in node.values():
320
+ yield from _strings(value)
321
+ elif isinstance(node, list):
322
+ for value in node:
323
+ yield from _strings(value)
324
+
325
+
326
+ def _string_items(values):
327
+ """The string entries of a JSON list; a non-list or a non-string entry contributes nothing."""
328
+ return [value for value in values if isinstance(value, str)] if isinstance(values, list) else []
329
+
330
+
331
+ def _research_names(research):
332
+ """Character names and aliases, short cultural-note items, and short quoted terms.
333
+
334
+ background_research.json is agent-written: a name or alias that is not a string (a nested
335
+ object, a list) is ignored rather than fed to the name set."""
336
+ names = set()
337
+ if not isinstance(research, dict):
338
+ return names
339
+ characters = research.get("characters")
340
+ if isinstance(characters, dict):
341
+ names.update(characters)
342
+ elif isinstance(characters, list):
343
+ names.update(_string_items([c.get("name") for c in characters if isinstance(c, dict)]))
344
+ details = research.get("character_details")
345
+ for name, info in details.items() if isinstance(details, dict) else []:
346
+ names.add(name)
347
+ names.update(_string_items(info.get("aliases") if isinstance(info, dict) else None))
348
+ notes = research.get("cultural_notes")
349
+ for note in notes if isinstance(notes, list) else []:
350
+ item = note.get("item") if isinstance(note, dict) else None
351
+ if isinstance(item, str) and _CJK.fullmatch(item) and len(item) <= 5:
352
+ names.add(item)
353
+ for text in _strings(research):
354
+ names.update(g for match in _QUOTED.findall(text) for g in match if g)
355
+ return names
356
+
357
+
358
+ def _grams(texts):
359
+ return (set().union(*(_ngrams(t, CJK_RUN_MIN) for t in texts)) if texts else set(),
360
+ set().union(*(_word_grams(t, LATIN_RUN_MIN) for t in texts)) if texts else set())
361
+
362
+
363
+ def leak_corpus(facts, asr_segments, asr_evidence, research, index=None):
364
+ """Entity names and source text that must never reach a transferable method.
365
+
366
+ Source text is the ASR (each window and each adjacent pair joined, so a line split by a window
367
+ boundary still matches) plus the background research prose; the agent's own fact statements
368
+ are kept apart so an error can say which side a shared phrase came from.
369
+ """
370
+ names = {e for f in facts for e in (f.get("entities") or []) if isinstance(e, str)}
371
+ names.update(_index_names(index))
372
+ research = research if isinstance(research, dict) else {}
373
+ asr_evidence = asr_evidence if isinstance(asr_evidence, dict) else {}
374
+ names.update(_research_names(research))
375
+ glossary = asr_evidence.get("glossary")
376
+ names.update(_string_items(glossary.get("names") if isinstance(glossary, dict) else None))
377
+ windows = sorted((s for s in asr_segments or [] if isinstance(s, dict)),
378
+ key=lambda s: float(s.get("start") or 0))
379
+ texts = [str(s.get("text") or "") for s in windows]
380
+ texts += [a + b for a, b in zip(texts, texts[1:])]
381
+ texts += list(_strings(research))
382
+ cjk, latin = _grams(texts)
383
+ fact_cjk, fact_latin = _grams([str(f.get("statement") or "") for f in facts])
384
+ return {
385
+ "names": sorted({_SPACE.sub("", n) for n in names if isinstance(n, str) and len(_SPACE.sub("", n)) >= 2},
386
+ key=len, reverse=True),
387
+ "cjk": cjk, "latin": latin, "fact_cjk": fact_cjk - cjk, "fact_latin": fact_latin - latin,
388
+ }
389
+
390
+
391
+ def leak_errors(text, where, corpus, rule="R6"):
392
+ """R6 a–d on one string; each error quotes the offending substring."""
393
+ errors = []
394
+ lowered = _SPACE.sub("", text).lower()
395
+ for name in corpus["names"]:
396
+ if name.lower() in lowered:
397
+ errors.append(f"{rule} {where}: 含原片实体名「{name}」")
398
+ cjk, words = _ngrams(text, CJK_RUN_MIN), _word_grams(text, LATIN_RUN_MIN)
399
+ for gram in sorted(cjk & corpus["cjk"])[:1]:
400
+ errors.append(f"{rule} {where}: 与原片台词/背景资料共有连续 {CJK_RUN_MIN}+ 字「{gram}」")
401
+ for gram in sorted(words & corpus["latin"])[:1]:
402
+ errors.append(f"{rule} {where}: 与原片台词/背景资料共有连续 {LATIN_RUN_MIN}+ 词「{' '.join(gram)}」")
403
+ for gram in sorted(cjk & corpus.get("fact_cjk", set()))[:1]:
404
+ errors.append(f"{rule} {where}: 与 source_facts 的描述共有连续 {CJK_RUN_MIN}+ 字「{gram}」"
405
+ "(若这是通用剪辑措辞,改写 fact 或方法任一侧)")
406
+ for gram in sorted(words & corpus.get("fact_latin", set()))[:1]:
407
+ errors.append(f"{rule} {where}: 与 source_facts 的描述共有连续 {LATIN_RUN_MIN}+ 词「{' '.join(gram)}」")
408
+ for match in _TIMECODE.finditer(text):
409
+ errors.append(f"{rule} {where}: 含绝对时间「{match.group(0)}」,改用相对位置或 targets")
410
+ for match in _ABS_PATH.finditer(text):
411
+ errors.append(f"{rule} {where}: 含绝对路径「{match.group(0)}」")
412
+ return errors
413
+
414
+
415
+ def _warnings(breakdown, methods, asr_segments, asr_status, research, *, index, asr_evidence, source,
416
+ review_windows=None):
417
+ warnings = []
418
+ for method in methods:
419
+ if not method.get("applies_when"):
420
+ warnings.append(f"methods.{method.get('id')}: 没写 applies_when,后续制作难判断何时套用")
421
+ if _NUMBER_IN_RULE.search(str(method.get("rule") or "")):
422
+ warnings.append(f"methods.{method.get('id')}: rule 正文含数字,建议把数值放进 targets")
423
+ if asr_status != "AVAILABLE_COARSE":
424
+ warnings.append(f"ASR 状态为 {asr_status},旁白语速与泄漏扫描的台词覆盖都不完整")
425
+ has_cjk = any(_CJK.search(str(s.get("text") or "")) for s in asr_segments if isinstance(s, dict))
426
+ if has_cjk and not ((_research_names(research) | _index_names(index)) - {None, ""}):
427
+ warnings.append("ASR 有中文对白,但 background_research.json 与 understanding_index.json 都没有给出名字:"
428
+ "泄漏扫描只认 fact entities 里写到的名字,台词里其他人名、地名、组织名会漏网")
429
+ asr_video = (asr_evidence if isinstance(asr_evidence, dict) else {}).get("source_video")
430
+ if isinstance(asr_video, dict) and {k: asr_video.get(k) for k in ("size", "mtime_ns")} != {
431
+ k: source.get(k) for k in ("size", "mtime_ns")}:
432
+ warnings.append("asr_timing_evidence.json 的 source_video 与测量的成片身份不一致:理解产物可能来自另一部视频,"
433
+ "语速与泄漏扫描会用错台词和人名")
434
+ labels = breakdown.get("labels") if isinstance(breakdown.get("labels"), dict) else {}
435
+ windows = review_windows or []
436
+ if windows and "cut_fixes" not in labels:
437
+ warnings.append(f"measure 压下了 {len(windows)} 个疑似切点窗口:用 `frames --review` 看图,再写 labels.cut_fixes"
438
+ "(看过且无需改动就写 {}),切点数与各段切点密度才算复核过")
439
+ if not str(labels.get("basis") or "").strip():
440
+ warnings.append("labels.basis 为空:写明标注依据(如 5s ASR 窗口 + 故事板)")
441
+ return warnings
442
+
443
+
444
+ def check_breakdown(breakdown, measurements, asr_segments=(), asr_evidence=None, research=None, index=None):
445
+ """Run R1–R7; return {derived, errors, warnings}."""
446
+ errors = []
447
+ duration = measurements["source"]["duration_s"]
448
+ if not _closed(breakdown, "top", "breakdown", errors):
449
+ return {"derived": None, "errors": errors, "warnings": []}
450
+ if breakdown.get("schema") != BREAKDOWN_SCHEMA:
451
+ errors.append(f"R1 breakdown.schema 必须是 {BREAKDOWN_SCHEMA}")
452
+ labels = breakdown.get("labels", {})
453
+ errors_before = len(errors)
454
+ spans, sections = _check_labels(labels, duration, errors, measurements["shots"]["cuts"])
455
+ fixes_ok = not any(e.startswith(("R1 labels.cut_fixes", "R2 labels.cut_fixes")) for e in errors[errors_before:])
456
+ measurements = apply_cut_fixes(measurements, labels) if fixes_ok and isinstance(labels, dict) else measurements
457
+ asr_segments = [s for s in (asr_segments or []) if isinstance(s, dict)]
458
+ asr_status = (asr_evidence if isinstance(asr_evidence, dict) else {}).get("status") or "UNKNOWN"
459
+ derived = derive({"audio_spans": spans, "sections": sections}, measurements, asr_segments, asr_status)
460
+
461
+ facts, methods = [], []
462
+ for key, kind, bucket in (("source_facts", "fact", facts), ("methods", "method", methods)):
463
+ items = breakdown.get(key, [])
464
+ if not isinstance(items, list):
465
+ errors.append(f"R1 breakdown.{key}: 必须是列表")
466
+ continue
467
+ bucket.extend(item for i, item in enumerate(items) if _closed(item, kind, f"{key}[{i}]", errors))
468
+ fact_ids = _check_ids(facts, "f", "source_facts", errors)
469
+ _check_ids(methods, "m", "methods", errors)
470
+ _check_facts(facts, measurements, derived, duration, errors)
471
+ _check_methods(methods, fact_ids, measurements, derived, errors)
472
+
473
+ corpus = leak_corpus(facts, asr_segments, asr_evidence, research, index)
474
+ for method in methods:
475
+ for field in METHOD_TEXT_FIELDS:
476
+ if isinstance(method.get(field), str):
477
+ errors.extend(leak_errors(method[field], f"methods.{method.get('id')}.{field}", corpus))
478
+
479
+ skipped = breakdown.get("skipped_dimensions", {})
480
+ if not isinstance(skipped, dict) or set(skipped) - set(DIMENSIONS):
481
+ errors.append(f"R1 skipped_dimensions: 键只能是 {'|'.join(DIMENSIONS)}")
482
+ skipped = {}
483
+ for dimension, reason in skipped.items():
484
+ if not (isinstance(reason, str) and reason.strip()):
485
+ errors.append(f"R1 skipped_dimensions.{dimension}: 原因必须是非空字符串")
486
+ else: # exported verbatim, so scanned now rather than only at export (R8)
487
+ errors.extend(leak_errors(reason, f"skipped_dimensions.{dimension}", corpus))
488
+ covered = {m.get("dimension") for m in methods}
489
+ for dimension in DIMENSIONS:
490
+ if dimension not in covered and dimension not in skipped:
491
+ errors.append(f"R7 {dimension}: 至少一条 method,或在 skipped_dimensions 写明原因")
492
+ warnings = _warnings(breakdown, methods, asr_segments, asr_status, research,
493
+ index=index, asr_evidence=asr_evidence, source=measurements["source"],
494
+ review_windows=measurements["shots"].get("review_windows"))
495
+ return {"derived": derived, "errors": errors, "warnings": warnings}
496
+
497
+
498
+ def _load(work_dir):
499
+ work_dir = Path(work_dir)
500
+ measurements = read_json(work_dir / MEASUREMENTS_FILE)
501
+ breakdown = read_json(work_dir / BREAKDOWN_FILE)
502
+ missing = [name for name, value in ((MEASUREMENTS_FILE, measurements), (BREAKDOWN_FILE, breakdown)) if value is None]
503
+ return measurements, breakdown, {
504
+ "asr_segments": read_json(work_dir / "asr_result.json", []),
505
+ "asr_evidence": read_json(work_dir / "asr_timing_evidence.json"),
506
+ "research": read_json(work_dir / "background_research.json"),
507
+ "index": read_json(work_dir / "understanding_index.json"),
508
+ }, missing
509
+
510
+
511
+ def run_check(work_dir):
512
+ measurements, breakdown, extra, missing = _load(work_dir)
513
+ if missing:
514
+ return {"derived": None, "errors": [f"缺少 {name}" for name in missing], "warnings": []}
515
+ return check_breakdown(breakdown, measurements, **extra)
516
+
517
+
518
+ def _walk(node, where="$"):
519
+ if isinstance(node, dict):
520
+ for key, value in node.items():
521
+ yield "key", str(key), f"{where}.{key}"
522
+ yield from _walk(value, f"{where}.{key}")
523
+ elif isinstance(node, list):
524
+ for i, value in enumerate(node):
525
+ yield from _walk(value, f"{where}[{i}]")
526
+ elif isinstance(node, str):
527
+ yield "str", node, where
528
+
529
+
530
+ def export_errors(production, corpus):
531
+ """R8: re-scan every key and string of the serialized export and reject source-only keys."""
532
+ errors = []
533
+ for kind, text, where in _walk(json.loads(json.dumps(production, ensure_ascii=False))):
534
+ if kind == "key" and text in BANNED_EXPORT_KEYS:
535
+ errors.append(f"R8 {where}: 导出物不能含键 {text!r}")
536
+ errors.extend(leak_errors(text, where, corpus, rule="R8"))
537
+ return errors
538
+
539
+
540
+ def run_export(work_dir, out_path):
541
+ """Check, project, re-scan, then write; returns the report (with `written` on success)."""
542
+ report = run_check(work_dir)
543
+ if report["errors"]:
544
+ return report
545
+ measurements, breakdown, extra, _ = _load(work_dir)
546
+ measurements = apply_cut_fixes(measurements, breakdown.get("labels"))
547
+ production = build_production(breakdown, measurements, report["derived"])
548
+ facts = [f for f in breakdown.get("source_facts") or [] if isinstance(f, dict)]
549
+ corpus = leak_corpus(facts, extra["asr_segments"], extra["asr_evidence"], extra["research"], extra["index"])
550
+ report["errors"] = export_errors(production, corpus)
551
+ if not report["errors"]:
552
+ report["written"] = str(write_json(out_path, production))
553
+ return report