@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.
- package/README.md +17 -11
- package/lib/client.js +85 -35
- package/lib/client.js.map +3 -3
- package/lib/drama/manifest.json +41 -36
- package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +340 -13
- package/lib/drama/skills/short-drama-assets/references/voice-direction.md +4 -2
- package/lib/drama/skills/short-drama-edit/SKILL.md +32 -12
- package/lib/drama/skills/short-drama-edit/references/still-cuts.md +90 -0
- package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +1035 -145
- package/lib/drama/skills/short-drama-edit/scripts/selftest.py +48 -0
- package/lib/drama/skills/short-drama-produce/SKILL.md +6 -1
- package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +12 -0
- package/lib/drama/skills/short-drama-produce/scripts/production_tool.py +138 -9
- package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +6 -0
- package/lib/drama/skills/short-drama-storyboard/SKILL.md +2 -0
- package/lib/drama/skills/short-drama-storyboard/references/production-shot-grammar.md +3 -1
- package/lib/drama/skills/short-drama-video-prompts/SKILL.md +7 -0
- package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +14 -0
- package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +19 -6
- package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +1 -0
- package/lib/drama/skills/short-drama-video-prompts/references/target-model-profile.md +5 -0
- package/lib/drama/skills/short-drama-video-prompts/references/wan-3.0.md +4 -1
- package/lib/index.js +16 -7
- package/lib/index.js.map +2 -2
- package/lib/novel-to-game/README.md +98 -44
- package/lib/novel-to-game/README_ZH.md +100 -46
- package/lib/novel-to-game/VERSION +1 -1
- package/lib/novel-to-game/examples/jin-ping-mei/build/BUILD_BRIEF.md +27 -202
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/RUN.md +23 -115
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/chamber.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/court_night.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/court_rain.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/garden.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/hall.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/kitchen.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/street.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/street_dawn.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/temple.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/yue/moon.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/angry.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/blush.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/neutral.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/sad.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/smile.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/surprised.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/angry.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/blush.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/neutral.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/sad.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/smile.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/surprised.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/angry.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/blush.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/neutral.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/sad.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/smile.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/surprised.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/angry.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/blush.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/neutral.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/sad.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/smile.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/surprised.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/angry.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/blush.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/neutral.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/sad.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/smile.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/surprised.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/style.css +326 -2503
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/index.html +71 -4
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/adv.js +221 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/audio.js +113 -257
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/main.js +511 -3026
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/menus.js +277 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/petals.js +67 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/stage.js +180 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/store.js +70 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common.js +198 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common2.js +204 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common3.js +198 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/dates.js +186 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/index.js +122 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/meng.js +190 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/pan.js +212 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/pinger.js +198 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/xuee.js +199 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/yue.js +214 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/package.json +2 -2
- package/lib/novel-to-game/examples/jin-ping-mei/build/art/generated-art.json +182 -45
- package/lib/novel-to-game/examples/jin-ping-mei/qa/README.md +7 -17
- package/lib/novel-to-game/examples/jin-ping-mei/qa/verification.json +9 -9
- package/lib/novel-to-game/manifest.json +303 -78
- package/lib/novel-to-game/skills/game-art-direction/references/art-direction-method.md +7 -2
- package/lib/novel-to-game/skills/game-concept/references/concept-method.md +2 -0
- package/lib/novel-to-game/skills/game-world-design/references/narrative-design-method.md +2 -0
- package/lib/novel-to-game/skills/game-world-design/references/world-design-method.md +9 -0
- package/lib/types/client/drama-production-view.d.ts +11 -0
- package/lib/types/client/drama-production-view.d.ts.map +1 -1
- package/lib/types/client/file-activity.d.ts +1 -1
- package/lib/types/client/file-activity.d.ts.map +1 -1
- package/lib/types/client/production-prompts.d.ts +7 -1
- package/lib/types/client/production-prompts.d.ts.map +1 -1
- package/lib/types/client/production-runtime.d.ts +7 -2
- package/lib/types/client/production-runtime.d.ts.map +1 -1
- package/lib/types/drama-adapters.d.ts.map +1 -1
- package/lib/types/skill-provider.d.ts.map +1 -1
- package/lib/types/video-project.d.ts.map +1 -1
- package/lib/video-recap/CHANGELOG.md +108 -1
- package/lib/video-recap/README.en.md +18 -14
- package/lib/video-recap/README.md +18 -14
- package/lib/video-recap/manifest.json +297 -321
- package/lib/video-recap/skills/video-assemble/SKILL.md +18 -16
- package/lib/video-recap/skills/video-assemble/references/explicit-audio-mix.md +3 -1
- package/lib/video-recap/skills/video-assemble/references/jianying/SOURCE.md +5 -5
- package/lib/video-recap/skills/video-assemble/references/packaging.md +4 -4
- package/lib/video-recap/skills/video-assemble/references/subtitle-track.md +10 -10
- package/lib/video-recap/skills/video-assemble/scripts/adoption/audio_mix_binding.py +13 -2
- package/lib/video-recap/skills/video-assemble/scripts/adoption/av_clock.py +109 -0
- package/lib/video-recap/skills/video-assemble/scripts/adoption/strict_publish.py +4 -1
- package/lib/video-recap/skills/video-assemble/scripts/artifacts.py +2 -9
- package/lib/video-recap/skills/video-assemble/scripts/assemble.py +188 -51
- package/lib/video-recap/skills/video-assemble/scripts/assemble_constants.py +0 -1
- package/lib/video-recap/skills/video-assemble/scripts/assembly_contract.py +32 -75
- package/lib/video-recap/skills/video-assemble/scripts/assembly_settings.py +8 -15
- package/lib/video-recap/skills/video-assemble/scripts/audio_mix.py +202 -161
- package/lib/video-recap/skills/video-assemble/scripts/codec_peak.py +112 -0
- package/lib/video-recap/skills/video-assemble/scripts/export_jianying.py +10 -101
- package/lib/video-recap/skills/video-assemble/scripts/jianying/builders.py +44 -475
- package/lib/video-recap/skills/video-assemble/scripts/jianying/model.py +1 -22
- package/lib/video-recap/skills/video-assemble/scripts/jianying/templates.py +0 -3
- package/lib/video-recap/skills/video-assemble/scripts/jianying/timeline_contract.py +36 -103
- package/lib/video-recap/skills/video-assemble/scripts/jianying/tracks.py +0 -8
- package/lib/video-recap/skills/video-assemble/scripts/jianying/writer.py +28 -203
- package/lib/video-recap/skills/video-assemble/scripts/lib.py +20 -34
- package/lib/video-recap/skills/video-assemble/scripts/loudness.py +332 -0
- package/lib/video-recap/skills/video-assemble/scripts/media.py +158 -38
- package/lib/video-recap/skills/video-assemble/scripts/narration_audio.py +30 -16
- package/lib/video-recap/skills/video-assemble/scripts/render_preflight.py +49 -8
- package/lib/video-recap/skills/video-assemble/scripts/source_subtitles.py +17 -6
- package/lib/video-recap/skills/video-assemble/scripts/subtitles/core.py +24 -2
- package/lib/video-recap/skills/video-assemble/scripts/subtitles/track.py +2 -5
- package/lib/video-recap/skills/video-assemble/scripts/subtitles/track_binding.py +2 -9
- package/lib/video-recap/skills/video-assemble/scripts/timeline.py +12 -34
- package/lib/video-recap/skills/video-assemble/scripts/timeline_emit.py +3 -4
- package/lib/video-recap/skills/video-assemble/scripts/visual_render.py +28 -2
- package/lib/video-recap/skills/video-cut/SKILL.md +37 -11
- package/lib/video-recap/skills/video-cut/references/shot-review.md +1 -1
- package/lib/video-recap/skills/video-cut/scripts/cut.py +2 -34
- package/lib/video-recap/skills/video-cut/scripts/cut_cli.py +96 -124
- package/lib/video-recap/skills/video-cut/scripts/cut_contract.py +97 -182
- package/lib/video-recap/skills/video-cut/scripts/cut_render.py +177 -166
- package/lib/video-recap/skills/video-cut/scripts/frame_grid.py +249 -0
- package/lib/video-recap/skills/video-cut/scripts/lib.py +0 -3
- package/lib/video-recap/skills/video-cut/scripts/media_geometry.py +173 -7
- package/lib/video-recap/skills/video-cut/scripts/sentence_boundaries.py +234 -151
- package/lib/video-recap/skills/video-cut/scripts/sentence_gate.py +189 -0
- package/lib/video-recap/skills/video-cut/scripts/shot_review.py +2 -6
- package/lib/video-recap/skills/video-recap/SKILL.md +24 -27
- package/lib/video-recap/skills/video-recap/references/audio-routing.md +8 -6
- package/lib/video-recap/skills/video-recap/references/config-playbook.md +8 -11
- package/lib/video-recap/skills/video-recap/references/data-schema.md +131 -74
- package/lib/video-recap/skills/video-recap/references/resource-library.md +17 -1
- package/lib/video-recap/skills/video-recap/scripts/doctor.py +51 -268
- package/lib/video-recap/skills/video-recap/scripts/final_qc.py +89 -254
- package/lib/video-recap/skills/video-recap/scripts/lib.py +81 -70
- package/lib/video-recap/skills/video-recap/scripts/library.py +40 -4
- package/lib/video-recap/skills/video-recap/scripts/materials.py +13 -80
- package/lib/video-recap/skills/video-recap/scripts/recap_cli.py +49 -17
- package/lib/video-recap/skills/video-recap/scripts/recap_inspect.py +1 -3
- package/lib/video-recap/skills/video-recap/scripts/recap_runner.py +86 -116
- package/lib/video-recap/skills/video-recap/scripts/recap_runtime.py +87 -23
- package/lib/video-recap/skills/video-recap/scripts/recap_source.py +0 -39
- package/lib/video-recap/skills/video-recap/scripts/recap_stage_qc.py +37 -135
- package/lib/video-recap/skills/video-recap/scripts/recap_timeline.py +118 -127
- package/lib/video-recap/skills/video-recap/scripts/resources/__init__.py +4 -0
- package/lib/video-recap/skills/video-recap/scripts/{resource_lock.py → resources/lock.py} +1 -1
- package/lib/video-recap/skills/video-recap/scripts/{project_binding.py → resources/project_binding.py} +78 -17
- package/lib/video-recap/skills/video-reference/SKILL.md +108 -0
- package/lib/video-recap/skills/video-reference/references/reference-schema.md +139 -0
- package/lib/video-recap/skills/video-reference/scripts/lib.py +85 -0
- package/lib/video-recap/skills/video-reference/scripts/reference.py +96 -0
- package/lib/video-recap/skills/video-reference/scripts/reference_check.py +553 -0
- package/lib/video-recap/skills/video-reference/scripts/reference_frames.py +122 -0
- package/lib/video-recap/skills/video-reference/scripts/reference_measure.py +253 -0
- package/lib/video-recap/skills/video-reference/scripts/reference_profile.py +271 -0
- package/lib/video-recap/skills/video-script/SKILL.md +9 -7
- package/lib/video-recap/skills/video-script/scripts/agent_text.py +18 -267
- package/lib/video-recap/skills/video-script/scripts/deslop_qc.py +12 -30
- package/lib/video-recap/skills/video-script/scripts/entry_suggestion.py +125 -0
- package/lib/video-recap/skills/video-script/scripts/evidence_bundle.py +2 -26
- package/lib/video-recap/skills/video-script/scripts/lib.py +5 -15
- package/lib/video-recap/skills/video-script/scripts/lint_summary.py +184 -0
- package/lib/video-recap/skills/video-script/scripts/narration_lint.py +120 -100
- package/lib/video-recap/skills/video-script/scripts/review.py +3 -34
- package/lib/video-recap/skills/video-script/scripts/review_grounding.py +20 -46
- package/lib/video-recap/skills/video-script/scripts/review_response.py +120 -258
- package/lib/video-recap/skills/video-script/scripts/review_runner.py +2 -10
- package/lib/video-recap/skills/video-script/scripts/speech_ownership.py +89 -5
- package/lib/video-recap/skills/video-script/scripts/validate.py +26 -34
- package/lib/video-recap/skills/video-understanding/SKILL.md +29 -4
- package/lib/video-recap/skills/video-understanding/references/data-schema.md +5 -22
- package/lib/video-recap/skills/video-understanding/scripts/agent_text.py +24 -121
- package/lib/video-recap/skills/video-understanding/scripts/asr.py +11 -5
- package/lib/video-recap/skills/video-understanding/scripts/asr_timing_evidence.py +15 -24
- package/lib/video-recap/skills/video-understanding/scripts/briefing/builder.py +59 -25
- package/lib/video-recap/skills/video-understanding/scripts/briefing/context.py +39 -3
- package/lib/video-recap/skills/video-understanding/scripts/briefing/inputs.py +15 -27
- package/lib/video-recap/skills/video-understanding/scripts/briefing/timeline.py +136 -37
- package/lib/video-recap/skills/video-understanding/scripts/consolidate.py +121 -37
- package/lib/video-recap/skills/video-understanding/scripts/detect.py +118 -16
- package/lib/video-recap/skills/video-understanding/scripts/index_normalize.py +359 -0
- package/lib/video-recap/skills/video-understanding/scripts/lib.py +62 -21
- package/lib/video-recap/skills/video-understanding/scripts/storyboard.py +181 -51
- package/lib/video-recap/skills/video-understanding/scripts/timeline_fusion.py +0 -26
- package/lib/video-recap/skills/video-understanding/scripts/understanding_brief.py +37 -14
- package/lib/video-recap/skills/video-understanding/scripts/understanding_cache.py +40 -32
- package/lib/video-recap/skills/video-understanding/scripts/understanding_runner.py +100 -127
- package/lib/video-recap/skills/video-understanding/scripts/understanding_storyboard.py +182 -30
- package/lib/video-recap/skills/video-understanding/scripts/vlm.py +30 -27
- package/lib/video-recap/skills/video-voiceover/SKILL.md +19 -12
- package/lib/video-recap/skills/video-voiceover/references/index-tts.md +3 -3
- package/lib/video-recap/skills/video-voiceover/scripts/approved_text_policy.py +3 -1
- package/lib/video-recap/skills/video-voiceover/scripts/dub.py +60 -135
- package/lib/video-recap/skills/video-voiceover/scripts/lib.py +9 -34
- package/lib/video-recap/skills/video-voiceover/scripts/providers/fish_audio.py +6 -1
- package/lib/video-recap/skills/video-voiceover/scripts/tts_audio.py +64 -1
- package/lib/video-recap/skills/video-voiceover/scripts/tts_cache.py +99 -0
- package/lib/video-recap/skills/video-voiceover/scripts/voiceover.py +177 -200
- package/package.json +45 -45
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/inner_court_accord_five.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/public_day15.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/household/li_jiaoer.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/new-guofeng.css +0 -1494
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/assets.js +0 -147
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/data.js +0 -7470
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/engine.js +0 -13066
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/text.js +0 -32
- package/lib/novel-to-game/examples/jin-ping-mei/qa/playable-model.json +0 -99
- package/lib/novel-to-game/examples/jin-ping-mei/qa/verify_playable_model.mjs +0 -152
- package/lib/video-recap/skills/video-assemble/references/foreground-compose.md +0 -118
- package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_combination_segment.json +0 -84
- package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_combination_video_material.json +0 -98
- package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_draft.json +0 -204
- package/lib/video-recap/skills/video-assemble/references/pair-media.md +0 -74
- package/lib/video-recap/skills/video-assemble/scripts/compose_foreground.py +0 -293
- package/lib/video-recap/skills/video-assemble/scripts/pair_media.py +0 -216
- package/lib/video-recap/skills/video-recap/references/shift-left-qc-schema.md +0 -23
- package/lib/video-recap/skills/video-recap/scripts/mimo_qc.py +0 -33
- package/lib/video-recap/skills/video-recap/scripts/qc/__init__.py +0 -1
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_client.py +0 -13
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_contract.py +0 -9
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_evidence.py +0 -287
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_observations.py +0 -175
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_payload.py +0 -206
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_report.py +0 -318
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_runner.py +0 -130
- package/lib/video-recap/skills/video-recap/scripts/qc_contract.py +0 -320
- package/lib/video-recap/skills/video-script/scripts/timeline_fusion.py +0 -108
- package/lib/video-recap/skills/video-understanding/scripts/brief.py +0 -9
- package/lib/video-recap/skills/video-understanding/scripts/deslop_qc.py +0 -211
- package/lib/video-recap/skills/video-understanding/scripts/narration_lint.py +0 -707
- package/lib/video-recap/skills/video-understanding/scripts/speech_ownership.py +0 -143
- /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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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":
|
|
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
|
-
|
|
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
|
+
}
|