@oh-story/dsh 0.1.12 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/README.md +17 -11
  2. package/lib/client.js +85 -35
  3. package/lib/client.js.map +3 -3
  4. package/lib/drama/manifest.json +41 -36
  5. package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +340 -13
  6. package/lib/drama/skills/short-drama-assets/references/voice-direction.md +4 -2
  7. package/lib/drama/skills/short-drama-edit/SKILL.md +32 -12
  8. package/lib/drama/skills/short-drama-edit/references/still-cuts.md +90 -0
  9. package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +1035 -145
  10. package/lib/drama/skills/short-drama-edit/scripts/selftest.py +48 -0
  11. package/lib/drama/skills/short-drama-produce/SKILL.md +6 -1
  12. package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +12 -0
  13. package/lib/drama/skills/short-drama-produce/scripts/production_tool.py +138 -9
  14. package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +6 -0
  15. package/lib/drama/skills/short-drama-storyboard/SKILL.md +2 -0
  16. package/lib/drama/skills/short-drama-storyboard/references/production-shot-grammar.md +3 -1
  17. package/lib/drama/skills/short-drama-video-prompts/SKILL.md +7 -0
  18. package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +14 -0
  19. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +19 -6
  20. package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +1 -0
  21. package/lib/drama/skills/short-drama-video-prompts/references/target-model-profile.md +5 -0
  22. package/lib/drama/skills/short-drama-video-prompts/references/wan-3.0.md +4 -1
  23. package/lib/index.js +16 -7
  24. package/lib/index.js.map +2 -2
  25. package/lib/novel-to-game/README.md +98 -44
  26. package/lib/novel-to-game/README_ZH.md +100 -46
  27. package/lib/novel-to-game/VERSION +1 -1
  28. package/lib/novel-to-game/examples/jin-ping-mei/build/BUILD_BRIEF.md +27 -202
  29. package/lib/novel-to-game/examples/jin-ping-mei/build/app/RUN.md +23 -115
  30. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/chamber.webp +0 -0
  31. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/court_night.webp +0 -0
  32. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/court_rain.webp +0 -0
  33. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/garden.webp +0 -0
  34. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/hall.webp +0 -0
  35. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/kitchen.webp +0 -0
  36. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/street.webp +0 -0
  37. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/street_dawn.webp +0 -0
  38. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/temple.webp +0 -0
  39. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/yue/moon.webp +0 -0
  40. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/angry.webp +0 -0
  41. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/blush.webp +0 -0
  42. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/neutral.webp +0 -0
  43. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/sad.webp +0 -0
  44. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/smile.webp +0 -0
  45. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/surprised.webp +0 -0
  46. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/angry.webp +0 -0
  47. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/blush.webp +0 -0
  48. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/neutral.webp +0 -0
  49. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/sad.webp +0 -0
  50. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/smile.webp +0 -0
  51. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/surprised.webp +0 -0
  52. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/angry.webp +0 -0
  53. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/blush.webp +0 -0
  54. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/neutral.webp +0 -0
  55. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/sad.webp +0 -0
  56. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/smile.webp +0 -0
  57. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/surprised.webp +0 -0
  58. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/angry.webp +0 -0
  59. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/blush.webp +0 -0
  60. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/neutral.webp +0 -0
  61. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/sad.webp +0 -0
  62. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/smile.webp +0 -0
  63. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/surprised.webp +0 -0
  64. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/angry.webp +0 -0
  65. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/blush.webp +0 -0
  66. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/neutral.webp +0 -0
  67. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/sad.webp +0 -0
  68. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/smile.webp +0 -0
  69. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/surprised.webp +0 -0
  70. package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/style.css +326 -2503
  71. package/lib/novel-to-game/examples/jin-ping-mei/build/app/index.html +71 -4
  72. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/adv.js +221 -0
  73. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/audio.js +113 -257
  74. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/main.js +511 -3026
  75. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/menus.js +277 -0
  76. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/petals.js +67 -0
  77. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/stage.js +180 -0
  78. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/store.js +70 -0
  79. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common.js +198 -0
  80. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common2.js +204 -0
  81. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common3.js +198 -0
  82. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/dates.js +186 -0
  83. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/index.js +122 -0
  84. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/meng.js +190 -0
  85. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/pan.js +212 -0
  86. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/pinger.js +198 -0
  87. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/xuee.js +199 -0
  88. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/yue.js +214 -0
  89. package/lib/novel-to-game/examples/jin-ping-mei/build/app/package.json +2 -2
  90. package/lib/novel-to-game/examples/jin-ping-mei/build/art/generated-art.json +182 -45
  91. package/lib/novel-to-game/examples/jin-ping-mei/qa/README.md +7 -17
  92. package/lib/novel-to-game/examples/jin-ping-mei/qa/verification.json +9 -9
  93. package/lib/novel-to-game/manifest.json +303 -78
  94. package/lib/novel-to-game/skills/game-art-direction/references/art-direction-method.md +7 -2
  95. package/lib/novel-to-game/skills/game-concept/references/concept-method.md +2 -0
  96. package/lib/novel-to-game/skills/game-world-design/references/narrative-design-method.md +2 -0
  97. package/lib/novel-to-game/skills/game-world-design/references/world-design-method.md +9 -0
  98. package/lib/types/client/drama-production-view.d.ts +11 -0
  99. package/lib/types/client/drama-production-view.d.ts.map +1 -1
  100. package/lib/types/client/file-activity.d.ts +1 -1
  101. package/lib/types/client/file-activity.d.ts.map +1 -1
  102. package/lib/types/client/production-prompts.d.ts +7 -1
  103. package/lib/types/client/production-prompts.d.ts.map +1 -1
  104. package/lib/types/client/production-runtime.d.ts +7 -2
  105. package/lib/types/client/production-runtime.d.ts.map +1 -1
  106. package/lib/types/drama-adapters.d.ts.map +1 -1
  107. package/lib/types/skill-provider.d.ts.map +1 -1
  108. package/lib/types/video-project.d.ts.map +1 -1
  109. package/lib/video-recap/CHANGELOG.md +108 -1
  110. package/lib/video-recap/README.en.md +18 -14
  111. package/lib/video-recap/README.md +18 -14
  112. package/lib/video-recap/manifest.json +297 -321
  113. package/lib/video-recap/skills/video-assemble/SKILL.md +18 -16
  114. package/lib/video-recap/skills/video-assemble/references/explicit-audio-mix.md +3 -1
  115. package/lib/video-recap/skills/video-assemble/references/jianying/SOURCE.md +5 -5
  116. package/lib/video-recap/skills/video-assemble/references/packaging.md +4 -4
  117. package/lib/video-recap/skills/video-assemble/references/subtitle-track.md +10 -10
  118. package/lib/video-recap/skills/video-assemble/scripts/adoption/audio_mix_binding.py +13 -2
  119. package/lib/video-recap/skills/video-assemble/scripts/adoption/av_clock.py +109 -0
  120. package/lib/video-recap/skills/video-assemble/scripts/adoption/strict_publish.py +4 -1
  121. package/lib/video-recap/skills/video-assemble/scripts/artifacts.py +2 -9
  122. package/lib/video-recap/skills/video-assemble/scripts/assemble.py +188 -51
  123. package/lib/video-recap/skills/video-assemble/scripts/assemble_constants.py +0 -1
  124. package/lib/video-recap/skills/video-assemble/scripts/assembly_contract.py +32 -75
  125. package/lib/video-recap/skills/video-assemble/scripts/assembly_settings.py +8 -15
  126. package/lib/video-recap/skills/video-assemble/scripts/audio_mix.py +202 -161
  127. package/lib/video-recap/skills/video-assemble/scripts/codec_peak.py +112 -0
  128. package/lib/video-recap/skills/video-assemble/scripts/export_jianying.py +10 -101
  129. package/lib/video-recap/skills/video-assemble/scripts/jianying/builders.py +44 -475
  130. package/lib/video-recap/skills/video-assemble/scripts/jianying/model.py +1 -22
  131. package/lib/video-recap/skills/video-assemble/scripts/jianying/templates.py +0 -3
  132. package/lib/video-recap/skills/video-assemble/scripts/jianying/timeline_contract.py +36 -103
  133. package/lib/video-recap/skills/video-assemble/scripts/jianying/tracks.py +0 -8
  134. package/lib/video-recap/skills/video-assemble/scripts/jianying/writer.py +28 -203
  135. package/lib/video-recap/skills/video-assemble/scripts/lib.py +20 -34
  136. package/lib/video-recap/skills/video-assemble/scripts/loudness.py +332 -0
  137. package/lib/video-recap/skills/video-assemble/scripts/media.py +158 -38
  138. package/lib/video-recap/skills/video-assemble/scripts/narration_audio.py +30 -16
  139. package/lib/video-recap/skills/video-assemble/scripts/render_preflight.py +49 -8
  140. package/lib/video-recap/skills/video-assemble/scripts/source_subtitles.py +17 -6
  141. package/lib/video-recap/skills/video-assemble/scripts/subtitles/core.py +24 -2
  142. package/lib/video-recap/skills/video-assemble/scripts/subtitles/track.py +2 -5
  143. package/lib/video-recap/skills/video-assemble/scripts/subtitles/track_binding.py +2 -9
  144. package/lib/video-recap/skills/video-assemble/scripts/timeline.py +12 -34
  145. package/lib/video-recap/skills/video-assemble/scripts/timeline_emit.py +3 -4
  146. package/lib/video-recap/skills/video-assemble/scripts/visual_render.py +28 -2
  147. package/lib/video-recap/skills/video-cut/SKILL.md +37 -11
  148. package/lib/video-recap/skills/video-cut/references/shot-review.md +1 -1
  149. package/lib/video-recap/skills/video-cut/scripts/cut.py +2 -34
  150. package/lib/video-recap/skills/video-cut/scripts/cut_cli.py +96 -124
  151. package/lib/video-recap/skills/video-cut/scripts/cut_contract.py +97 -182
  152. package/lib/video-recap/skills/video-cut/scripts/cut_render.py +177 -166
  153. package/lib/video-recap/skills/video-cut/scripts/frame_grid.py +249 -0
  154. package/lib/video-recap/skills/video-cut/scripts/lib.py +0 -3
  155. package/lib/video-recap/skills/video-cut/scripts/media_geometry.py +173 -7
  156. package/lib/video-recap/skills/video-cut/scripts/sentence_boundaries.py +234 -151
  157. package/lib/video-recap/skills/video-cut/scripts/sentence_gate.py +189 -0
  158. package/lib/video-recap/skills/video-cut/scripts/shot_review.py +2 -6
  159. package/lib/video-recap/skills/video-recap/SKILL.md +24 -27
  160. package/lib/video-recap/skills/video-recap/references/audio-routing.md +8 -6
  161. package/lib/video-recap/skills/video-recap/references/config-playbook.md +8 -11
  162. package/lib/video-recap/skills/video-recap/references/data-schema.md +131 -74
  163. package/lib/video-recap/skills/video-recap/references/resource-library.md +17 -1
  164. package/lib/video-recap/skills/video-recap/scripts/doctor.py +51 -268
  165. package/lib/video-recap/skills/video-recap/scripts/final_qc.py +89 -254
  166. package/lib/video-recap/skills/video-recap/scripts/lib.py +81 -70
  167. package/lib/video-recap/skills/video-recap/scripts/library.py +40 -4
  168. package/lib/video-recap/skills/video-recap/scripts/materials.py +13 -80
  169. package/lib/video-recap/skills/video-recap/scripts/recap_cli.py +49 -17
  170. package/lib/video-recap/skills/video-recap/scripts/recap_inspect.py +1 -3
  171. package/lib/video-recap/skills/video-recap/scripts/recap_runner.py +86 -116
  172. package/lib/video-recap/skills/video-recap/scripts/recap_runtime.py +87 -23
  173. package/lib/video-recap/skills/video-recap/scripts/recap_source.py +0 -39
  174. package/lib/video-recap/skills/video-recap/scripts/recap_stage_qc.py +37 -135
  175. package/lib/video-recap/skills/video-recap/scripts/recap_timeline.py +118 -127
  176. package/lib/video-recap/skills/video-recap/scripts/resources/__init__.py +4 -0
  177. package/lib/video-recap/skills/video-recap/scripts/{resource_lock.py → resources/lock.py} +1 -1
  178. package/lib/video-recap/skills/video-recap/scripts/{project_binding.py → resources/project_binding.py} +78 -17
  179. package/lib/video-recap/skills/video-reference/SKILL.md +108 -0
  180. package/lib/video-recap/skills/video-reference/references/reference-schema.md +139 -0
  181. package/lib/video-recap/skills/video-reference/scripts/lib.py +85 -0
  182. package/lib/video-recap/skills/video-reference/scripts/reference.py +96 -0
  183. package/lib/video-recap/skills/video-reference/scripts/reference_check.py +553 -0
  184. package/lib/video-recap/skills/video-reference/scripts/reference_frames.py +122 -0
  185. package/lib/video-recap/skills/video-reference/scripts/reference_measure.py +253 -0
  186. package/lib/video-recap/skills/video-reference/scripts/reference_profile.py +271 -0
  187. package/lib/video-recap/skills/video-script/SKILL.md +9 -7
  188. package/lib/video-recap/skills/video-script/scripts/agent_text.py +18 -267
  189. package/lib/video-recap/skills/video-script/scripts/deslop_qc.py +12 -30
  190. package/lib/video-recap/skills/video-script/scripts/entry_suggestion.py +125 -0
  191. package/lib/video-recap/skills/video-script/scripts/evidence_bundle.py +2 -26
  192. package/lib/video-recap/skills/video-script/scripts/lib.py +5 -15
  193. package/lib/video-recap/skills/video-script/scripts/lint_summary.py +184 -0
  194. package/lib/video-recap/skills/video-script/scripts/narration_lint.py +120 -100
  195. package/lib/video-recap/skills/video-script/scripts/review.py +3 -34
  196. package/lib/video-recap/skills/video-script/scripts/review_grounding.py +20 -46
  197. package/lib/video-recap/skills/video-script/scripts/review_response.py +120 -258
  198. package/lib/video-recap/skills/video-script/scripts/review_runner.py +2 -10
  199. package/lib/video-recap/skills/video-script/scripts/speech_ownership.py +89 -5
  200. package/lib/video-recap/skills/video-script/scripts/validate.py +26 -34
  201. package/lib/video-recap/skills/video-understanding/SKILL.md +29 -4
  202. package/lib/video-recap/skills/video-understanding/references/data-schema.md +5 -22
  203. package/lib/video-recap/skills/video-understanding/scripts/agent_text.py +24 -121
  204. package/lib/video-recap/skills/video-understanding/scripts/asr.py +11 -5
  205. package/lib/video-recap/skills/video-understanding/scripts/asr_timing_evidence.py +15 -24
  206. package/lib/video-recap/skills/video-understanding/scripts/briefing/builder.py +59 -25
  207. package/lib/video-recap/skills/video-understanding/scripts/briefing/context.py +39 -3
  208. package/lib/video-recap/skills/video-understanding/scripts/briefing/inputs.py +15 -27
  209. package/lib/video-recap/skills/video-understanding/scripts/briefing/timeline.py +136 -37
  210. package/lib/video-recap/skills/video-understanding/scripts/consolidate.py +121 -37
  211. package/lib/video-recap/skills/video-understanding/scripts/detect.py +118 -16
  212. package/lib/video-recap/skills/video-understanding/scripts/index_normalize.py +359 -0
  213. package/lib/video-recap/skills/video-understanding/scripts/lib.py +62 -21
  214. package/lib/video-recap/skills/video-understanding/scripts/storyboard.py +181 -51
  215. package/lib/video-recap/skills/video-understanding/scripts/timeline_fusion.py +0 -26
  216. package/lib/video-recap/skills/video-understanding/scripts/understanding_brief.py +37 -14
  217. package/lib/video-recap/skills/video-understanding/scripts/understanding_cache.py +40 -32
  218. package/lib/video-recap/skills/video-understanding/scripts/understanding_runner.py +100 -127
  219. package/lib/video-recap/skills/video-understanding/scripts/understanding_storyboard.py +182 -30
  220. package/lib/video-recap/skills/video-understanding/scripts/vlm.py +30 -27
  221. package/lib/video-recap/skills/video-voiceover/SKILL.md +19 -12
  222. package/lib/video-recap/skills/video-voiceover/references/index-tts.md +3 -3
  223. package/lib/video-recap/skills/video-voiceover/scripts/approved_text_policy.py +3 -1
  224. package/lib/video-recap/skills/video-voiceover/scripts/dub.py +60 -135
  225. package/lib/video-recap/skills/video-voiceover/scripts/lib.py +9 -34
  226. package/lib/video-recap/skills/video-voiceover/scripts/providers/fish_audio.py +6 -1
  227. package/lib/video-recap/skills/video-voiceover/scripts/tts_audio.py +64 -1
  228. package/lib/video-recap/skills/video-voiceover/scripts/tts_cache.py +99 -0
  229. package/lib/video-recap/skills/video-voiceover/scripts/voiceover.py +177 -200
  230. package/package.json +45 -45
  231. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/inner_court_accord_five.webp +0 -0
  232. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/public_day15.webp +0 -0
  233. package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/household/li_jiaoer.webp +0 -0
  234. package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/new-guofeng.css +0 -1494
  235. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/assets.js +0 -147
  236. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/data.js +0 -7470
  237. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/engine.js +0 -13066
  238. package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/text.js +0 -32
  239. package/lib/novel-to-game/examples/jin-ping-mei/qa/playable-model.json +0 -99
  240. package/lib/novel-to-game/examples/jin-ping-mei/qa/verify_playable_model.mjs +0 -152
  241. package/lib/video-recap/skills/video-assemble/references/foreground-compose.md +0 -118
  242. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_combination_segment.json +0 -84
  243. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_combination_video_material.json +0 -98
  244. package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_draft.json +0 -204
  245. package/lib/video-recap/skills/video-assemble/references/pair-media.md +0 -74
  246. package/lib/video-recap/skills/video-assemble/scripts/compose_foreground.py +0 -293
  247. package/lib/video-recap/skills/video-assemble/scripts/pair_media.py +0 -216
  248. package/lib/video-recap/skills/video-recap/references/shift-left-qc-schema.md +0 -23
  249. package/lib/video-recap/skills/video-recap/scripts/mimo_qc.py +0 -33
  250. package/lib/video-recap/skills/video-recap/scripts/qc/__init__.py +0 -1
  251. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_client.py +0 -13
  252. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_contract.py +0 -9
  253. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_evidence.py +0 -287
  254. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_observations.py +0 -175
  255. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_payload.py +0 -206
  256. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_report.py +0 -318
  257. package/lib/video-recap/skills/video-recap/scripts/qc/mimo_runner.py +0 -130
  258. package/lib/video-recap/skills/video-recap/scripts/qc_contract.py +0 -320
  259. package/lib/video-recap/skills/video-script/scripts/timeline_fusion.py +0 -108
  260. package/lib/video-recap/skills/video-understanding/scripts/brief.py +0 -9
  261. package/lib/video-recap/skills/video-understanding/scripts/deslop_qc.py +0 -211
  262. package/lib/video-recap/skills/video-understanding/scripts/narration_lint.py +0 -707
  263. package/lib/video-recap/skills/video-understanding/scripts/speech_ownership.py +0 -143
  264. /package/lib/video-recap/skills/video-cut/scripts/{narration_mapping.py → cut_qc.py} +0 -0
@@ -198,13 +198,12 @@ def _compact_ffmpeg_error(stderr, limit=400):
198
198
  return "…" + text[-limit:]
199
199
 
200
200
  def annotate_quiet_windows_with_asr(periods, asr_result=None, *, video_duration=None, configured_segment_seconds=None):
201
- """Pure helper: annotate quiet windows with ASR-overlap confidence and QC.
201
+ """Pure helper: annotate quiet windows with ASR-overlap confidence.
202
202
 
203
203
  Coarse grid ASR (large synthetic windows from chunking) must not turn every quiet
204
- window into speech. The return value is (annotated_periods, qc). Inputs are copied.
204
+ window into speech. Returns the annotated periods; inputs are copied.
205
205
  """
206
206
  out = [dict(p) for p in (periods or [])]
207
- qc = {"coarse_asr_windows": 0, "low_confidence_speech_flags": 0, "asr_granularity": "none"}
208
207
  for qp in out:
209
208
  qp.setdefault("speech_overlap_ratio", 0.0)
210
209
  qp.setdefault("asr_overlap_seconds", 0.0)
@@ -212,7 +211,7 @@ def annotate_quiet_windows_with_asr(periods, asr_result=None, *, video_duration=
212
211
  qp.setdefault("has_speech", False)
213
212
  qp.setdefault("has_speech_reason", "no_asr_overlap")
214
213
  if not asr_result:
215
- return out, qc
214
+ return out
216
215
 
217
216
  valid_segments = []
218
217
  for seg in asr_result:
@@ -232,8 +231,6 @@ def annotate_quiet_windows_with_asr(periods, asr_result=None, *, video_duration=
232
231
  (avg_seg_dur >= configured * 0.9 and asr_coverage > float(video_duration) * 0.7)
233
232
  )
234
233
  granularity = "coarse_grid" if coarse_asr else "segment"
235
- qc["asr_granularity"] = granularity
236
- qc["avg_asr_segment_seconds"] = round(avg_seg_dur, 3)
237
234
  for qp in out:
238
235
  overlap_seconds = 0.0
239
236
  for ss, se in valid_segments:
@@ -245,8 +242,6 @@ def annotate_quiet_windows_with_asr(periods, asr_result=None, *, video_duration=
245
242
  if coarse_asr:
246
243
  qp["has_speech"] = False
247
244
  qp["has_speech_reason"] = "coarse_asr_overlap_ignored" if overlap_seconds > 0 else "coarse_asr_no_overlap"
248
- if overlap_seconds > 0:
249
- qc["coarse_asr_windows"] += 1
250
245
  continue
251
246
  if ratio >= 0.3:
252
247
  qp["has_speech"] = True
@@ -254,11 +249,10 @@ def annotate_quiet_windows_with_asr(periods, asr_result=None, *, video_duration=
254
249
  elif overlap_seconds > 0:
255
250
  qp["has_speech"] = False
256
251
  qp["has_speech_reason"] = "asr_overlap_low_confidence_quiet"
257
- qc["low_confidence_speech_flags"] += 1
258
252
  else:
259
253
  qp["has_speech"] = False
260
254
  qp["has_speech_reason"] = "no_asr_overlap"
261
- return out, qc
255
+ return out
262
256
 
263
257
 
264
258
  def detect_silence_periods(video_path, work_dir, asr_result=None):
@@ -343,16 +337,13 @@ def detect_silence_periods(video_path, work_dir, asr_result=None):
343
337
  periods = [p for p in merged if p["duration"] >= quiet_min]
344
338
 
345
339
  # 与 ASR 交叉验证:标记有语音的窗口,并记录可观测 confidence/reason。
346
- periods, qc = annotate_quiet_windows_with_asr(
340
+ periods = annotate_quiet_windows_with_asr(
347
341
  periods,
348
342
  asr_result,
349
343
  video_duration=get_video_duration(str(audio_path)) if asr_result else None,
350
344
  configured_segment_seconds=CONFIG["asr_segment_seconds"],
351
345
  )
352
346
 
353
- (work_dir / "silence_periods.qc.json").write_text(
354
- json.dumps(qc, ensure_ascii=False, indent=2), encoding="utf-8")
355
-
356
347
  # 保存
357
348
  (work_dir / "silence_periods.json").write_text(
358
349
  json.dumps(periods, ensure_ascii=False, indent=2), encoding="utf-8")
@@ -363,6 +354,106 @@ def detect_silence_periods(video_path, work_dir, asr_result=None):
363
354
  return periods
364
355
 
365
356
 
357
+ SPEECH_BOUNDARY_ANCHORS_SCHEMA_VERSION = 2
358
+
359
+
360
+ def anchors_current(work_dir):
361
+ """True when speech_boundary_anchors.json exists and has the current schema.
362
+
363
+ Schema 1 labelled coarse-ASR anchors `high` from a proportional position guess; a cached
364
+ schema-1 artifact must be regenerated rather than trusted.
365
+ """
366
+ path = Path(work_dir) / "speech_boundary_anchors.json"
367
+ try:
368
+ payload = json.loads(path.read_text(encoding="utf-8"))
369
+ except (OSError, ValueError, TypeError):
370
+ return False
371
+ return (
372
+ isinstance(payload, dict)
373
+ and payload.get("schema_version") == SPEECH_BOUNDARY_ANCHORS_SCHEMA_VERSION
374
+ )
375
+
376
+
377
+ def _anchor_confidence(seconds):
378
+ return "high" if seconds <= 0.6 else ("medium" if seconds <= 1.2 else "low")
379
+
380
+
381
+ def _classify_anchor(timing_bound, error):
382
+ """(confidence, boundary_use) from the ASR-window bound and the pause-snap distance."""
383
+ confidence = _anchor_confidence(max(timing_bound, error))
384
+ if confidence in {"high", "medium"}:
385
+ return confidence, "verified"
386
+ return confidence, ("unverified" if error <= 1.2 else "none")
387
+
388
+
389
+ def _upgrade_schema1_anchors(payload, asr_result):
390
+ """Relabel a schema-1 payload as schema 2 without audio, or return None.
391
+
392
+ Schema 1 already stores each anchor's pause, snap error and ASR window index, so the
393
+ schema-2 labels can be recomputed from `asr_result` alone. Anchor times are unchanged.
394
+ Returns None when any anchor cannot be mapped back to its ASR window.
395
+ """
396
+ segments = list(asr_result or [])
397
+ upgraded = []
398
+ for anchor in payload.get("sentence_anchors") or []:
399
+ try:
400
+ segment = segments[int(anchor["asr_segment_index"])]
401
+ seg_start = float(segment["start"])
402
+ seg_end = float(segment["end"])
403
+ pause_start = float(anchor["pause_start"])
404
+ error = float(anchor["alignment_error"])
405
+ except (KeyError, IndexError, TypeError, ValueError):
406
+ return None
407
+ tail = str(anchor.get("text_tail") or "").strip()
408
+ if seg_end <= seg_start or (tail and tail not in str(segment.get("text", ""))):
409
+ return None
410
+ timing_bound = max(pause_start - seg_start, seg_end - pause_start, 0.0)
411
+ confidence, boundary_use = _classify_anchor(timing_bound, error)
412
+ upgraded.append({
413
+ **anchor,
414
+ "timing_bound_seconds": round(timing_bound, 3),
415
+ "timing_basis": "asr_window",
416
+ "confidence": confidence,
417
+ "boundary_use": boundary_use,
418
+ })
419
+ return {
420
+ **payload,
421
+ "schema_version": SPEECH_BOUNDARY_ANCHORS_SCHEMA_VERSION,
422
+ "sentence_anchors": upgraded,
423
+ "upgraded_from_schema": payload.get("schema_version"),
424
+ }
425
+
426
+
427
+ def ensure_speech_boundary_anchors(work_dir, asr_result):
428
+ """Bring speech_boundary_anchors.json to the current schema without losing anchors.
429
+
430
+ With `audio.wav` present the anchors are re-detected. Without it (a material-library
431
+ restore copies the anchors but not the audio) a schema-1 file is relabelled in place from
432
+ `asr_result`; if that is impossible the file is left untouched, because consumers read
433
+ schema-1 high/medium as before. Usable anchors are never replaced by an `unavailable`
434
+ artifact. Only a missing file with no audio falls through to the detector's
435
+ `unavailable` report, as before.
436
+ """
437
+ work_dir = Path(work_dir)
438
+ if anchors_current(work_dir):
439
+ return
440
+ path = work_dir / "speech_boundary_anchors.json"
441
+ if (work_dir / "audio.wav").exists() or not path.exists():
442
+ detect_speech_boundary_anchors(work_dir, asr_result)
443
+ return
444
+ try:
445
+ payload = json.loads(path.read_text(encoding="utf-8"))
446
+ except (OSError, ValueError, TypeError):
447
+ return
448
+ if not isinstance(payload, dict):
449
+ return
450
+ upgraded = _upgrade_schema1_anchors(payload, asr_result)
451
+ if upgraded is None:
452
+ log("⚠️ speech_boundary_anchors.json 为旧格式且无法从 asr_result 重新标注,保持原样")
453
+ return
454
+ path.write_text(json.dumps(upgraded, ensure_ascii=False, indent=2), encoding="utf-8")
455
+
456
+
366
457
  def detect_speech_boundary_anchors(work_dir, asr_result):
367
458
  """Write sentence-end entry anchors by aligning ASR punctuation to short pauses.
368
459
 
@@ -370,13 +461,20 @@ def detect_speech_boundary_anchors(work_dir, asr_result):
370
461
  punctuation time from its character position inside the ASR window, then snap it to the
371
462
  closest short acoustic pause. The output is guidance + a deterministic pre-TTS gate; it
372
463
  never rewrites narration timing on its own.
464
+
465
+ The character-position estimate is a guess, so `confidence` is not taken from the snap
466
+ distance alone: the sentence end can be anywhere inside the ASR window, which bounds the
467
+ real error by `timing_bound_seconds` (the farther window edge from the pause). Only a
468
+ narrow window can yield `high`/`medium` (`boundary_use: verified`); a wide window yields
469
+ `low`, and an anchor that still snapped within the alignment limit is `unverified`
470
+ (usable by gates, labelled as an estimate) rather than dropped.
373
471
  """
374
472
  work_dir = Path(work_dir)
375
473
  audio_path = work_dir / "audio.wav"
376
474
  out_path = work_dir / "speech_boundary_anchors.json"
377
475
  segments = list(asr_result or [])
378
476
  report = {
379
- "schema_version": 1,
477
+ "schema_version": SPEECH_BOUNDARY_ANCHORS_SCHEMA_VERSION,
380
478
  "artifact": "speech_boundary_anchors.json",
381
479
  "status": "completed",
382
480
  "detector": {
@@ -448,14 +546,18 @@ def detect_speech_boundary_anchors(work_dir, asr_result):
448
546
  used.add(pause["index"])
449
547
  last_midpoint = pause["midpoint"]
450
548
  error = abs(pause["midpoint"] - expected)
451
- confidence = "high" if error <= 0.6 else ("medium" if error <= 1.2 else "low")
549
+ timing_bound = max(pause["start"] - seg_start, seg_end - pause["start"], 0.0)
550
+ confidence, boundary_use = _classify_anchor(timing_bound, error)
452
551
  anchors.append({
453
552
  "time": pause["end"],
454
553
  "pause_start": pause["start"],
455
554
  "pause_end": pause["end"],
456
555
  "expected_time": round(expected, 3),
457
556
  "alignment_error": round(error, 3),
557
+ "timing_bound_seconds": round(timing_bound, 3),
558
+ "timing_basis": "asr_window",
458
559
  "confidence": confidence,
560
+ "boundary_use": boundary_use,
459
561
  "punctuation": match.group(0),
460
562
  "text_tail": text[max(0, match.end() - 32):match.end()],
461
563
  "asr_segment_index": asr_index,
@@ -0,0 +1,359 @@
1
+ """Deterministic clean-up of the model-written understanding index (consolidate.py Pass B).
2
+
3
+ Two defects seen in real indexes are repaired here, after the model reply is parsed:
4
+
5
+ - `plot_points[*].time` written as "00:95" (seconds >= 60, i.e. raw scene seconds poured into
6
+ the ss field). The time is re-read as minutes * 60 + seconds and rewritten as canonical
7
+ "MM:SS" ("H:MM:SS" past an hour). A time that cannot be parsed, or that lies past the end
8
+ of the analysed scenes, is dropped instead of kept as a wrong timestamp.
9
+ - The same character split into two `characters` entries where one's name is the other's name
10
+ or alias (a shared alias alone is not enough, and an entry that would bridge two different
11
+ characters is left on its own). Which entries merge does not depend on their order; the
12
+ merged character sits at the first occurrence and keeps the first name any member has, the
13
+ other names become aliases, and relationships are re-pointed at the surviving name.
14
+
15
+ Pure functions only: no I/O, no model calls.
16
+ """
17
+
18
+ import json
19
+ import re
20
+
21
+ _TIME_RE = re.compile(r"^\s*(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)\s*$")
22
+ _APPROX_RE = re.compile(r"^(?:大约|约)\s*")
23
+ # "01:20左右" / "3分钟前后" / "80秒许": approximation words, dropped wherever they sit.
24
+ _APPROX_WORD_RE = re.compile(r"\s*(?:左右|前后|许)")
25
+ # "第3分钟" / "第95秒" / "第2小时": an ordinal names the Nth unit, read as its start
26
+ # ((N-1) units, "第1分钟" is 00:00). Only a lone whole unit: "第3分20秒" is the time 03:20.
27
+ _ORDINAL_UNIT_RE = re.compile(
28
+ r"^第\s*(\d+)\s*(小时|分钟|分|秒)(?=\s*(?:$|[-~~—–至到]))"
29
+ )
30
+ _ORDINAL_UNIT_SECONDS = {"小时": 3600, "分钟": 60, "分": 60, "秒": 1}
31
+ # Any other ordinal prefix on a time ("第01:20", "第3分20秒") reads as that time.
32
+ _ORDINAL_RE = re.compile(r"^第\s*(?=\d)")
33
+ # A range starts with a digit, so a leading "-" (a negative time) is not a range separator.
34
+ _RANGE_RE = re.compile(r"^(\d[\d:.]*?)\s*(?:秒|s|S)?\s*(?:-|~|~|—|–|至|到)\s*\d")
35
+ _UNIT_RE = re.compile(r"\s*(?:秒|s|S)$")
36
+ # "3分20秒" / "1小时2分3秒" / "3分钟" / "1.5分": rewritten as seconds ("200.0" / "3723.0" /
37
+ # "180.0" / "90.0") before parsing.
38
+ _CN_TIME_RE = re.compile(
39
+ r"(?:(\d+)\s*(?:小时|时)\s*)?(\d+(?:\.\d+)?)\s*分(?:钟)?\s*(?:(\d+(?:\.\d+)?)\s*秒?)?"
40
+ )
41
+ _DURATION_SLACK_SECONDS = 1.0
42
+ _CONFIDENCE_RANK = {"high": 3, "medium": 2, "low": 1}
43
+ _LIST_FIELDS = ("aliases", "visual_descriptions", "asr_mentions", "evidence_ids")
44
+
45
+
46
+ def _ordinal_unit(match):
47
+ count, unit = match.groups()
48
+ return repr(max(int(count) - 1, 0) * _ORDINAL_UNIT_SECONDS[unit])
49
+
50
+
51
+ def _cn_time(match):
52
+ hours, minutes, secs = match.groups()
53
+ return repr(int(hours or 0) * 3600 + float(minutes) * 60 + float(secs or 0))
54
+
55
+
56
+ def _plot_time_text(value):
57
+ """Strip the wrappers models put around a time: 约/大约/左右/前后/许, an ordinal 第, a
58
+ trailing 秒/s, a range tail.
59
+
60
+ A full-width colon ("00:95") is read as ":", minute/second words ("3分20秒", "1.5分")
61
+ are rewritten as seconds, an ordinal unit ("第3分钟") as the start of that unit."""
62
+ text = str(value or "").strip().replace(":", ":")
63
+ text = _APPROX_WORD_RE.sub("", _APPROX_RE.sub("", text))
64
+ text = _ORDINAL_UNIT_RE.sub(_ordinal_unit, text)
65
+ text = _ORDINAL_RE.sub("", text)
66
+ text = _CN_TIME_RE.sub(_cn_time, text)
67
+ match = _RANGE_RE.match(text)
68
+ if match:
69
+ text = match.group(1)
70
+ return _UNIT_RE.sub("", text).strip()
71
+
72
+
73
+ def parse_plot_time(value):
74
+ """Seconds for "MM:SS" / "H:MM:SS" / a bare number, or None when unreadable.
75
+
76
+ Fields are not range-checked: "00:95" is 95 s, "1:75" is 135 s (the model wrote scene
77
+ seconds into the seconds field). "约01:20", "01:20左右", "12.5秒", "00:95", "3分20秒",
78
+ "1.5分", an ordinal unit ("第3分钟" is the third minute, read as its start 02:00; "第1分钟"
79
+ and "第0分钟" are 00:00; "第95秒" is 94 s; "第2小时" is 1:00:00) and the start of a range
80
+ ("01:20-01:45") are read too.
81
+ Negative or non-finite values are unreadable."""
82
+ if isinstance(value, bool):
83
+ return None
84
+ if isinstance(value, (int, float)):
85
+ seconds = float(value)
86
+ else:
87
+ text = _plot_time_text(value)
88
+ match = _TIME_RE.match(text)
89
+ if match:
90
+ hours, minutes, secs = match.groups()
91
+ seconds = int(hours or 0) * 3600 + int(minutes) * 60 + float(secs)
92
+ else:
93
+ try:
94
+ seconds = float(text)
95
+ except ValueError:
96
+ return None
97
+ if seconds != seconds or seconds in (float("inf"), float("-inf")) or seconds < 0:
98
+ return None
99
+ return seconds
100
+
101
+
102
+ def format_plot_time(seconds):
103
+ whole = int(seconds)
104
+ hours, rest = divmod(whole, 3600)
105
+ minutes, secs = divmod(rest, 60)
106
+ return f"{hours}:{minutes:02d}:{secs:02d}" if hours else f"{minutes:02d}:{secs:02d}"
107
+
108
+
109
+ def normalize_plot_times(plot_points, duration=None):
110
+ """Rewrite each object plot point's `time` as canonical text; drop unusable times.
111
+
112
+ Returns (plot_points, dropped_count). Bare-string plot points pass through unchanged."""
113
+ out = []
114
+ dropped = 0
115
+ for point in plot_points or []:
116
+ if not isinstance(point, dict) or "time" not in point:
117
+ out.append(point)
118
+ continue
119
+ point = dict(point)
120
+ seconds = parse_plot_time(point["time"])
121
+ if seconds is None or (
122
+ duration is not None and seconds > duration + _DURATION_SLACK_SECONDS
123
+ ):
124
+ point.pop("time")
125
+ dropped += 1
126
+ else:
127
+ point["time"] = format_plot_time(seconds)
128
+ out.append(point)
129
+ return out, dropped
130
+
131
+
132
+ def _term_key(value):
133
+ return " ".join(str(value or "").split()).casefold()
134
+
135
+
136
+ def _aliases(character):
137
+ """`aliases` as a list; a bare string is one alias, not a sequence of characters."""
138
+ value = character.get("aliases")
139
+ if value is None or value == "":
140
+ return []
141
+ if isinstance(value, (list, tuple)):
142
+ return list(value)
143
+ return [value]
144
+
145
+
146
+ def _alias_keys(character):
147
+ return {key for key in (_term_key(alias) for alias in _aliases(character)) if key}
148
+
149
+
150
+ def _stable_union(*lists):
151
+ seen = set()
152
+ out = []
153
+ for values in lists:
154
+ for value in values or []:
155
+ key = json.dumps(value, ensure_ascii=False, sort_keys=True)
156
+ if key not in seen:
157
+ seen.add(key)
158
+ out.append(value)
159
+ return out
160
+
161
+
162
+ def _merge_into(target, other):
163
+ other_name = str(other.get("name", "")).strip()
164
+ aliases = _aliases(other)
165
+ if other_name and _term_key(other_name) != _term_key(target.get("name")):
166
+ aliases = [other_name] + aliases
167
+ for field in _LIST_FIELDS:
168
+ if field == "aliases":
169
+ target[field] = _stable_union(_aliases(target), aliases)
170
+ else:
171
+ target[field] = _stable_union(target.get(field), other.get(field))
172
+ target["aliases"] = [
173
+ alias for alias in target["aliases"]
174
+ if _term_key(alias) != _term_key(target.get("name"))
175
+ ]
176
+ for field in ("description", "research_role"):
177
+ if not str(target.get(field) or "").strip() and str(other.get(field) or "").strip():
178
+ target[field] = other[field]
179
+ ranks = [
180
+ _CONFIDENCE_RANK.get(str(item.get("confidence")), 0) for item in (target, other)
181
+ ]
182
+ if ranks[1] > ranks[0]:
183
+ target["confidence"] = other["confidence"]
184
+
185
+
186
+ def _character_nodes(characters):
187
+ """Pool dict entries by name key: entries with the same name are one character.
188
+
189
+ Returns nodes as {"members": [input positions], "name": key, "aliases": alias keys}; an
190
+ entry without a name is a node of its own."""
191
+ nodes = []
192
+ by_name = {}
193
+ for position, item in enumerate(characters):
194
+ if not isinstance(item, dict):
195
+ continue
196
+ name = _term_key(item.get("name"))
197
+ aliases = _alias_keys(item)
198
+ if name and name in by_name:
199
+ node = nodes[by_name[name]]
200
+ node["members"].append(position)
201
+ node["aliases"] |= aliases
202
+ continue
203
+ if name:
204
+ by_name[name] = len(nodes)
205
+ nodes.append({"members": [position], "name": name, "aliases": set(aliases)})
206
+ return nodes
207
+
208
+
209
+ def _linked(x, y):
210
+ """True when one node's name is the other's alias. Two aliases alone never link: a
211
+ generic alias the model hands two people (男子, 老板) must not fold them into one."""
212
+ return bool(
213
+ (x["name"] and x["name"] in y["aliases"]) or (y["name"] and y["name"] in x["aliases"])
214
+ )
215
+
216
+
217
+ def _character_groups(nodes):
218
+ """Partition nodes into characters, independent of their order.
219
+
220
+ A node whose linked neighbours include two nodes not linked to each other is a bridge
221
+ (a 男子 extra named by an alias two leads share, or one entry carrying two people's
222
+ names): it stays on its own and links nothing. The other nodes are unioned along their
223
+ links. Returns groups as sorted member-position lists, ordered by first occurrence."""
224
+ count = len(nodes)
225
+ links = [
226
+ [j for j in range(count) if j != i and _linked(nodes[i], nodes[j])]
227
+ for i in range(count)
228
+ ]
229
+ bridge = [
230
+ any(
231
+ not _linked(nodes[j], nodes[k])
232
+ for a, j in enumerate(neighbours)
233
+ for k in neighbours[a + 1:]
234
+ )
235
+ for neighbours in links
236
+ ]
237
+ parent = list(range(count))
238
+
239
+ def root(i):
240
+ while parent[i] != i:
241
+ parent[i] = parent[parent[i]]
242
+ i = parent[i]
243
+ return i
244
+
245
+ for i in range(count):
246
+ if bridge[i]:
247
+ continue
248
+ for j in links[i]:
249
+ if not bridge[j]:
250
+ parent[root(i)] = root(j)
251
+ members = {}
252
+ for i, node in enumerate(nodes):
253
+ members.setdefault(root(i), []).extend(node["members"])
254
+ return sorted((sorted(group) for group in members.values()), key=lambda group: group[0])
255
+
256
+ def merge_characters(characters):
257
+ """Merge character entries that are the same person by name (case/space-insensitive).
258
+
259
+ Entries with the same name are one character. Two characters merge when one's name is
260
+ the other's alias; a shared alias alone does not merge, and an entry that links two
261
+ characters not linked to each other is kept on its own instead of folding them together.
262
+ The partition does not depend on input order: each merged character sits at its first
263
+ entry's position and keeps the name of its first entry that has one, the others' names
264
+ become aliases. Returns (characters, renames) where renames maps every merged name key
265
+ to the surviving name. Non-dict entries pass through."""
266
+ characters = list(characters or [])
267
+ groups = _character_groups(_character_nodes(characters))
268
+ first_of = {group[0]: group for group in groups}
269
+ merged = []
270
+ renames = {}
271
+ for position, item in enumerate(characters):
272
+ if not isinstance(item, dict):
273
+ merged.append(item)
274
+ continue
275
+ group = first_of.get(position)
276
+ if group is None:
277
+ continue # folded into an earlier entry
278
+ entry = dict(item)
279
+ if not _term_key(entry.get("name")):
280
+ # A nameless first entry keeps its position but takes the first member name.
281
+ named = next(
282
+ (characters[m] for m in group if _term_key(characters[m].get("name"))), None
283
+ )
284
+ if named is not None:
285
+ entry = {"name": named["name"], **{k: v for k, v in entry.items() if k != "name"}}
286
+ for other in group[1:]:
287
+ _merge_into(entry, characters[other])
288
+ name = _term_key(characters[other].get("name"))
289
+ if name:
290
+ renames[name] = entry.get("name")
291
+ merged.append(entry)
292
+ return merged, renames
293
+
294
+
295
+ def repoint_relationships(relationships, renames):
296
+ """Point relationships at surviving character names; drop self-loops and duplicates."""
297
+ out = []
298
+ by_key = {}
299
+ for rel in relationships or []:
300
+ if not isinstance(rel, dict):
301
+ out.append(rel)
302
+ continue
303
+ rel = dict(rel)
304
+ renamed = False
305
+ for side in ("a", "b"):
306
+ key = _term_key(rel.get(side))
307
+ if key in renames:
308
+ rel[side] = renames[key]
309
+ renamed = True
310
+ if renamed and _term_key(rel.get("a")) == _term_key(rel.get("b")):
311
+ continue # "X — 同一人 — X" left over from the split entries
312
+ key = (_term_key(rel.get("a")), _term_key(rel.get("b")), _term_key(rel.get("relation")))
313
+ if key in by_key:
314
+ existing = by_key[key]
315
+ existing["evidence_ids"] = _stable_union(
316
+ existing.get("evidence_ids"), rel.get("evidence_ids")
317
+ )
318
+ continue
319
+ by_key[key] = rel
320
+ out.append(rel)
321
+ return out
322
+
323
+
324
+ def scenes_end(scenes):
325
+ """Latest scene end (source seconds), or None when no scene carries a usable end."""
326
+ ends = []
327
+ for scene in scenes or []:
328
+ try:
329
+ ends.append(float(scene["end"]))
330
+ except (KeyError, TypeError, ValueError):
331
+ continue
332
+ return max(ends) if ends else None
333
+
334
+
335
+ def coerce_character_aliases(characters):
336
+ """Rewrite a non-list `aliases` on each character as a list (a string is one alias)."""
337
+ return [
338
+ dict(item, aliases=_aliases(item))
339
+ if isinstance(item, dict) and "aliases" in item and not isinstance(item["aliases"], list)
340
+ else item
341
+ for item in characters or []
342
+ ]
343
+
344
+
345
+ def normalize_index(index, duration=None):
346
+ """Apply both repairs to a parsed index; returns (index, report)."""
347
+ index = dict(index)
348
+ before = len(index.get("characters") or [])
349
+ characters, renames = merge_characters(coerce_character_aliases(index.get("characters")))
350
+ renames = {
351
+ key: name for key, name in renames.items() if key != _term_key(name)
352
+ }
353
+ index["characters"] = characters
354
+ index["relationships"] = repoint_relationships(index.get("relationships"), renames)
355
+ index["plot_points"], dropped = normalize_plot_times(index.get("plot_points"), duration)
356
+ return index, {
357
+ "merged_characters": before - len(characters),
358
+ "dropped_plot_times": dropped,
359
+ }