@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
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
"""Render-affecting settings payload recorded in
|
|
1
|
+
"""Render-affecting settings payload recorded in assembly_manifest.json for audit."""
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
|
|
5
5
|
from artifacts import _artifact_identity
|
|
6
6
|
from assemble_constants import VISUAL_OVERLAYS
|
|
7
|
-
from
|
|
7
|
+
from loudness import _loudness_mode, final_loudnorm_filter
|
|
8
8
|
from lib import CONFIG
|
|
9
9
|
from source_subtitles import _has_user_subtitles, _source_subtitle_mask_policy
|
|
10
10
|
from subtitles.core import _subtitle_style_config
|
|
@@ -14,10 +14,10 @@ from packaging import packaging_settings
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def assembly_settings_payload(work_dir=None, *, audio_mode="narration", audio_stream_index=0):
|
|
17
|
-
"""Settings that affect the rendered video, as a plain nested dict
|
|
18
|
-
|
|
19
|
-
``{size, mtime_ns}`` identity of the overlay
|
|
20
|
-
|
|
17
|
+
"""Settings that affect the rendered video, as a plain nested dict. assembly_manifest.json
|
|
18
|
+
records it for audit; nothing compares it to decide a rebuild. When work_dir is given, a
|
|
19
|
+
user_subtitles presence flag and the ``{size, mtime_ns}`` identity of the overlay and
|
|
20
|
+
subtitle-track inputs are included."""
|
|
21
21
|
burn_subtitles = CONFIG["burn_subtitles"]
|
|
22
22
|
mask_policy = _source_subtitle_mask_policy(work_dir)
|
|
23
23
|
mask_source_subtitles = mask_policy["active"]
|
|
@@ -31,6 +31,7 @@ def assembly_settings_payload(work_dir=None, *, audio_mode="narration", audio_st
|
|
|
31
31
|
settings = {
|
|
32
32
|
"user_subtitles": _has_user_subtitles(work_dir),
|
|
33
33
|
"burn_subtitles": burn_subtitles,
|
|
34
|
+
"subtitle_burn_degraded": CONFIG["burn_subtitles_degraded"],
|
|
34
35
|
"force_video_reencode": CONFIG["force_video_reencode"],
|
|
35
36
|
"encode": {
|
|
36
37
|
"output_crf": CONFIG["output_crf"],
|
|
@@ -82,8 +83,6 @@ def assembly_settings_payload(work_dir=None, *, audio_mode="narration", audio_st
|
|
|
82
83
|
narration_binding.get("tempo_policy") if narration_binding else None
|
|
83
84
|
)
|
|
84
85
|
settings["narration_timing"] = {
|
|
85
|
-
"delay_seconds": 0.0 if explicit_mix else CONFIG["narration_delay_seconds"],
|
|
86
|
-
"tail_pad_seconds": 0.0 if explicit_mix else CONFIG["narration_tail_pad_seconds"],
|
|
87
86
|
"fade_ms": 0 if explicit_mix else CONFIG["fade_ms"],
|
|
88
87
|
"narration_speed": (
|
|
89
88
|
1.0 if explicit_mix else
|
|
@@ -107,7 +106,6 @@ def assembly_settings_payload(work_dir=None, *, audio_mode="narration", audio_st
|
|
|
107
106
|
if audio_mode in {"narration", "source-mix"} and not explicit_mix:
|
|
108
107
|
# adopted-packet-copy never decodes or mixes, so mix settings cannot change it.
|
|
109
108
|
settings["audio_mix"] = {
|
|
110
|
-
"ducking_mode": CONFIG["ducking_mode"],
|
|
111
109
|
"duck_fade_seconds": CONFIG["duck_fade_seconds"],
|
|
112
110
|
"duck_bridge_seconds": CONFIG["duck_bridge_seconds"],
|
|
113
111
|
"ducking_narr_weight": CONFIG["ducking_narr_weight"],
|
|
@@ -115,14 +113,9 @@ def assembly_settings_payload(work_dir=None, *, audio_mode="narration", audio_st
|
|
|
115
113
|
"idle_orig_volume": CONFIG["idle_orig_volume"],
|
|
116
114
|
"speech_ducking_volume": CONFIG["speech_ducking_volume"],
|
|
117
115
|
"zone_ducking_volume": CONFIG["zone_ducking_volume"],
|
|
118
|
-
"ducking_threshold": CONFIG["ducking_threshold"],
|
|
119
|
-
"ducking_ratio": CONFIG["ducking_ratio"],
|
|
120
|
-
"ducking_attack": CONFIG["ducking_attack"],
|
|
121
|
-
"ducking_release": CONFIG["ducking_release"],
|
|
122
|
-
"ducking_level_sc": CONFIG["ducking_level_sc"],
|
|
123
|
-
"ducking_makeup": CONFIG["ducking_makeup"],
|
|
124
116
|
"final_loudnorm": final_loudnorm_filter(),
|
|
125
117
|
"loudness_mode": _loudness_mode(),
|
|
118
|
+
"loudness_limiter_max_db": CONFIG["loudness_limiter_max_db"],
|
|
126
119
|
"bgm_path": CONFIG["bgm_path"],
|
|
127
120
|
"bgm_volume": CONFIG["bgm_volume"],
|
|
128
121
|
"bgm_ducking_volume": CONFIG["bgm_ducking_volume"],
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Source handoffs, ducking envelopes, and audio mix graphs."""
|
|
2
2
|
|
|
3
|
-
import json
|
|
4
3
|
import re
|
|
5
4
|
from pathlib import Path
|
|
6
5
|
|
|
@@ -10,104 +9,7 @@ from audio_automation import (
|
|
|
10
9
|
ducking_expression,
|
|
11
10
|
release_ducking_expression,
|
|
12
11
|
)
|
|
13
|
-
from lib import CONFIG,
|
|
14
|
-
|
|
15
|
-
def _limiter_filter():
|
|
16
|
-
return f"alimiter=limit={CONFIG['final_limiter_peak']:.2f}:level=false"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def _loudness_mode(measured=None):
|
|
20
|
-
if not CONFIG["final_loudnorm"]:
|
|
21
|
-
return "limiter_only"
|
|
22
|
-
return "two_pass_linear" if measured else "equivalent"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def final_loudnorm_filter(measured=None):
|
|
26
|
-
"""Final-mix loudness normalization/limiter filter from CONFIG.
|
|
27
|
-
|
|
28
|
-
Ducking branches set only relative balance; this single stage owns the
|
|
29
|
-
absolute output loudness so the recap is not left too quiet. When `measured`
|
|
30
|
-
is supplied from a first loudnorm pass, ffmpeg runs the deterministic second
|
|
31
|
-
pass; without it we still force the same target and peak limiter as a
|
|
32
|
-
documented equivalent/fallback path.
|
|
33
|
-
"""
|
|
34
|
-
if not CONFIG["final_loudnorm"]:
|
|
35
|
-
return _limiter_filter()
|
|
36
|
-
filt = (
|
|
37
|
-
f"loudnorm=I={CONFIG['target_lufs']}"
|
|
38
|
-
f":TP={CONFIG['target_true_peak']}"
|
|
39
|
-
f":LRA={CONFIG['target_lra']}"
|
|
40
|
-
f":linear=true"
|
|
41
|
-
)
|
|
42
|
-
if measured:
|
|
43
|
-
for src, dst in (
|
|
44
|
-
("input_i", "measured_I"),
|
|
45
|
-
("input_tp", "measured_TP"),
|
|
46
|
-
("input_lra", "measured_LRA"),
|
|
47
|
-
("input_thresh", "measured_thresh"),
|
|
48
|
-
("target_offset", "offset"),
|
|
49
|
-
):
|
|
50
|
-
if src in measured:
|
|
51
|
-
filt += f":{dst}={measured[src]}"
|
|
52
|
-
filt += ":print_format=summary"
|
|
53
|
-
return f"{filt},{_limiter_filter()}"
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def _parse_loudnorm_json(text):
|
|
57
|
-
"""Extract ffmpeg loudnorm JSON from stderr/stdout."""
|
|
58
|
-
for match in reversed(list(re.finditer(r"\{[\s\S]*?\}", text))):
|
|
59
|
-
try:
|
|
60
|
-
data = json.loads(match.group(0))
|
|
61
|
-
except ValueError:
|
|
62
|
-
continue
|
|
63
|
-
if {"input_i", "input_tp", "input_lra", "input_thresh", "target_offset"} <= set(data):
|
|
64
|
-
return data
|
|
65
|
-
return None
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def _loudnorm_first_pass_filter():
|
|
69
|
-
return (
|
|
70
|
-
f"loudnorm=I={CONFIG['target_lufs']}"
|
|
71
|
-
f":TP={CONFIG['target_true_peak']}"
|
|
72
|
-
f":LRA={CONFIG['target_lra']}"
|
|
73
|
-
f":print_format=json"
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def _run_loudnorm_first_pass(input_video, narration_wav, original_audio_input,
|
|
78
|
-
bgm_input, filter_complex, work_dir):
|
|
79
|
-
"""Measure the exact mixed audio graph before final render.
|
|
80
|
-
|
|
81
|
-
Returns ffmpeg loudnorm JSON, or None when probing fails. The caller then
|
|
82
|
-
falls back to the documented equivalent single-pass target+limiter filter.
|
|
83
|
-
"""
|
|
84
|
-
if not CONFIG["final_loudnorm"]:
|
|
85
|
-
return None
|
|
86
|
-
probe_fc = f"{filter_complex};[aout]{_loudnorm_first_pass_filter()}[lnprobe]"
|
|
87
|
-
probe_script = Path(work_dir) / ".filter_complex_loudnorm_probe.txt"
|
|
88
|
-
probe_script.write_text(probe_fc, encoding="utf-8")
|
|
89
|
-
cmd = [
|
|
90
|
-
"ffmpeg", "-y",
|
|
91
|
-
"-i", str(input_video),
|
|
92
|
-
"-i", str(narration_wav),
|
|
93
|
-
*original_audio_input,
|
|
94
|
-
*bgm_input,
|
|
95
|
-
*filter_file_args("filter_complex", probe_script),
|
|
96
|
-
"-map", "[lnprobe]",
|
|
97
|
-
"-f", "null", "-",
|
|
98
|
-
]
|
|
99
|
-
try:
|
|
100
|
-
result = run_cmd(cmd)
|
|
101
|
-
finally:
|
|
102
|
-
probe_script.unlink(missing_ok=True)
|
|
103
|
-
if result.returncode != 0:
|
|
104
|
-
log(f" ⚠️ loudnorm 首遍测量失败,降级到目标滤镜+limiter: {result.stderr}")
|
|
105
|
-
return None
|
|
106
|
-
measured = _parse_loudnorm_json(result.stdout + "\n" + result.stderr)
|
|
107
|
-
if not measured:
|
|
108
|
-
log(" ⚠️ loudnorm 首遍未返回 JSON,降级到目标滤镜+limiter")
|
|
109
|
-
return None
|
|
110
|
-
return measured
|
|
12
|
+
from lib import CONFIG, log
|
|
111
13
|
|
|
112
14
|
|
|
113
15
|
def _seg_place_window(seg):
|
|
@@ -115,8 +17,14 @@ def _seg_place_window(seg):
|
|
|
115
17
|
return seg["actual_place_start"], seg["actual_place_end"]
|
|
116
18
|
|
|
117
19
|
|
|
20
|
+
# After narration ends, original audio may stay ducked this long waiting for a sentence-end
|
|
21
|
+
# anchor. Beyond it the duck releases at the narration end: coarse-ASR anchors are estimates,
|
|
22
|
+
# and holding a duck for tens of seconds buries dialogue the viewer must hear.
|
|
23
|
+
SOURCE_HANDOFF_MAX_HOLD_SECONDS = 3.0
|
|
24
|
+
|
|
25
|
+
|
|
118
26
|
def _load_sentence_handoff_anchors(work_dir):
|
|
119
|
-
"""Load
|
|
27
|
+
"""Load usable sentence anchors (`boundary_use` verified/unverified) and their pauses."""
|
|
120
28
|
work_dir = Path(work_dir)
|
|
121
29
|
cut_mode = (work_dir / "edited_source.mp4").exists() or (
|
|
122
30
|
work_dir / "clip_plan_validated.json"
|
|
@@ -139,18 +47,33 @@ def _load_sentence_handoff_anchors(work_dir):
|
|
|
139
47
|
payload = {**payload, "require_measured": True}
|
|
140
48
|
anchors = {}
|
|
141
49
|
for item in payload["sentence_anchors"]:
|
|
142
|
-
|
|
50
|
+
# Schema-1 anchors (no `boundary_use`) came from the old coarse estimator: high/medium
|
|
51
|
+
# labels there are usable but unverified.
|
|
52
|
+
use = item.get("boundary_use") or (
|
|
53
|
+
"unverified" if item["confidence"] in {"high", "medium"} else "none"
|
|
54
|
+
)
|
|
55
|
+
if use == "none":
|
|
143
56
|
continue
|
|
144
57
|
when = float(item["time"])
|
|
145
58
|
pause_start = float(item.get("pause_start", when - 0.12))
|
|
146
59
|
row = {
|
|
147
60
|
"time": round(when, 4),
|
|
148
61
|
"pause_start": round(max(0.0, min(pause_start, when)), 4),
|
|
62
|
+
"verified": use == "verified",
|
|
149
63
|
}
|
|
150
|
-
|
|
64
|
+
key = (row["time"], row["pause_start"])
|
|
65
|
+
row["verified"] = row["verified"] or anchors.get(key, {}).get("verified", False)
|
|
66
|
+
anchors[key] = row
|
|
151
67
|
return sorted(anchors.values(), key=lambda row: row["time"]), artifact, payload
|
|
152
68
|
|
|
153
69
|
|
|
70
|
+
def _prefer_verified(matches):
|
|
71
|
+
"""First verified anchor among time-ordered matches, else the first match, else None."""
|
|
72
|
+
return next((anchor for anchor in matches if anchor["verified"]), None) or (
|
|
73
|
+
matches[0] if matches else None
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
154
77
|
def _timed_rows(rows):
|
|
155
78
|
return [{"start": float(row["start"]), "end": float(row["end"])} for row in rows]
|
|
156
79
|
|
|
@@ -163,17 +86,86 @@ def _asr_segments(work_dir):
|
|
|
163
86
|
return _load_work_json(work_dir, "asr_result.json") or []
|
|
164
87
|
|
|
165
88
|
|
|
89
|
+
# Interjections and common ASR artifacts on screams/music. A window whose text is only these
|
|
90
|
+
# is not dialogue at a clip edge or narration entry; real short lines such as "救我!" still are.
|
|
91
|
+
# Same copy in video-cut, video-script and video-assemble (parity-tested by function).
|
|
92
|
+
_NON_DIALOGUE_TOKENS = frozenset(
|
|
93
|
+
"啊 嗯 哼 哦 呃 唉 嘿 呦 哈 呀 hi yeah ok okay oh uh ah hmm".split()
|
|
94
|
+
)
|
|
95
|
+
_NON_DIALOGUE_CJK = frozenset("啊嗯哼哦呃唉嘿呦哈呀")
|
|
96
|
+
# Lines cross 15s ASR window edges (a line may run 13.2–15.4 while its window ends at 15.0),
|
|
97
|
+
# so an interjection-only window next to real dialogue keeps this much of its shared edge.
|
|
98
|
+
_INTERJECTION_GUARD_SECONDS = 1.0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _interjection_only(text):
|
|
102
|
+
tokens = [token for token in re.split(r"[\W_]+", text.lower()) if token]
|
|
103
|
+
# Punctuation-only rows ("……", "?") are often ASR for unintelligible speech: keep them.
|
|
104
|
+
return bool(tokens) and all(
|
|
105
|
+
token in _NON_DIALOGUE_TOKENS or set(token) <= _NON_DIALOGUE_CJK for token in tokens
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _dialogue_speech_spans(rows):
|
|
110
|
+
"""Merged dialogue spans from timed ASR rows.
|
|
111
|
+
|
|
112
|
+
A row holding only interjections ("啊!", "Hi.") is not dialogue, except a
|
|
113
|
+
`_INTERJECTION_GUARD_SECONDS` guard on an edge it shares with a dialogue row. A row
|
|
114
|
+
whose text is empty or whitespace (ASR heard no words) is timing-only evidence: it is
|
|
115
|
+
skipped here and guards nothing. A row with no `text` field is measured timing whose
|
|
116
|
+
words are unknown and counts as dialogue.
|
|
117
|
+
"""
|
|
118
|
+
rows = sorted(
|
|
119
|
+
(
|
|
120
|
+
{
|
|
121
|
+
"start": row["start"],
|
|
122
|
+
"end": row["end"],
|
|
123
|
+
"dialogue": not _interjection_only(row.get("text", "")),
|
|
124
|
+
}
|
|
125
|
+
for row in rows
|
|
126
|
+
if "text" not in row or row["text"].strip()
|
|
127
|
+
),
|
|
128
|
+
key=lambda row: (row["start"], row["end"]),
|
|
129
|
+
)
|
|
130
|
+
spans = []
|
|
131
|
+
for idx, row in enumerate(rows):
|
|
132
|
+
if row["dialogue"]:
|
|
133
|
+
spans.append({"start": row["start"], "end": row["end"]})
|
|
134
|
+
continue
|
|
135
|
+
before = rows[idx - 1] if idx > 0 else None
|
|
136
|
+
after = rows[idx + 1] if idx + 1 < len(rows) else None
|
|
137
|
+
if before and before["dialogue"] and row["start"] - before["end"] <= 0.05:
|
|
138
|
+
end = min(row["end"], row["start"] + _INTERJECTION_GUARD_SECONDS)
|
|
139
|
+
spans.append({"start": row["start"], "end": end})
|
|
140
|
+
if after and after["dialogue"] and after["start"] - row["end"] <= 0.05:
|
|
141
|
+
start = max(row["start"], row["end"] - _INTERJECTION_GUARD_SECONDS)
|
|
142
|
+
spans.append({"start": start, "end": row["end"]})
|
|
143
|
+
spans.sort(key=lambda row: (row["start"], row["end"]))
|
|
144
|
+
merged = []
|
|
145
|
+
for span in spans:
|
|
146
|
+
if merged and span["start"] <= merged[-1]["end"] + 0.05:
|
|
147
|
+
merged[-1]["end"] = max(merged[-1]["end"], span["end"])
|
|
148
|
+
else:
|
|
149
|
+
merged.append(span)
|
|
150
|
+
return merged
|
|
151
|
+
|
|
152
|
+
|
|
166
153
|
def _handoff_speech_evidence(work_dir, payload):
|
|
167
|
-
|
|
154
|
+
"""(speech, quiet, dialogue) rows; `dialogue` drops interjection-only windows and
|
|
155
|
+
decides only whether a narration entry interrupts source speech.
|
|
156
|
+
|
|
157
|
+
ASR fallback rows with empty text are windows where nothing was recognized, not
|
|
158
|
+
speech (video-cut and video-script drop them the same way). Output-clock
|
|
159
|
+
`speech_spans` carry no `text` and stay timing-only dialogue evidence.
|
|
160
|
+
"""
|
|
161
|
+
rows = payload.get("speech_spans", [])
|
|
168
162
|
quiet = _timed_rows(payload.get("quiet_windows", []))
|
|
169
|
-
if payload.get("require_measured"):
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
quiet = _timed_rows(row for row in silence if not row["has_speech"])
|
|
176
|
-
return speech, quiet
|
|
163
|
+
if not payload.get("require_measured"):
|
|
164
|
+
rows = rows or [row for row in _asr_segments(work_dir) if row["text"].strip()]
|
|
165
|
+
if not quiet:
|
|
166
|
+
silence = _load_work_json(work_dir, "silence_periods.json") or []
|
|
167
|
+
quiet = _timed_rows(row for row in silence if not row["has_speech"])
|
|
168
|
+
return _timed_rows(rows), quiet, _timed_rows(_dialogue_speech_spans(rows))
|
|
177
169
|
|
|
178
170
|
|
|
179
171
|
def _merged_handoff_intervals(start, end, rows):
|
|
@@ -221,17 +213,65 @@ def _measured_speech_owned(
|
|
|
221
213
|
|
|
222
214
|
|
|
223
215
|
def _entry_speech_owned(
|
|
224
|
-
start, speech, quiet, anchors, authored, require_measured=False, tolerance=0.05
|
|
216
|
+
start, speech, dialogue, quiet, anchors, authored, require_measured=False, tolerance=0.05
|
|
225
217
|
):
|
|
226
218
|
if any(row["start"] - tolerance <= start <= row["end"] + tolerance for row in quiet):
|
|
227
219
|
return False
|
|
228
|
-
if any(row["start"] - tolerance <= start < row["end"] - tolerance for row in
|
|
220
|
+
if any(row["start"] - tolerance <= start < row["end"] - tolerance for row in dialogue):
|
|
229
221
|
return True
|
|
230
222
|
if speech:
|
|
231
223
|
return False
|
|
232
224
|
return True if anchors or require_measured else bool(authored)
|
|
233
225
|
|
|
234
226
|
|
|
227
|
+
def _unowned_entry_status(start, speech, quiet, tolerance=0.05):
|
|
228
|
+
"""Entry status when a narration entry does not interrupt source dialogue.
|
|
229
|
+
|
|
230
|
+
`non_dialogue_source` when the entry lands inside measured speech that holds only
|
|
231
|
+
interjections (a scream, "Hi."); `quiet_source` for measured quiet or no speech.
|
|
232
|
+
"""
|
|
233
|
+
if any(row["start"] - tolerance <= start <= row["end"] + tolerance for row in quiet):
|
|
234
|
+
return "quiet_source"
|
|
235
|
+
if any(row["start"] <= start < row["end"] for row in speech):
|
|
236
|
+
return "non_dialogue_source"
|
|
237
|
+
return "quiet_source"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _dialogue_free_pull_start(candidate, written_start, dialogue, quiet, tolerance=0.05):
|
|
241
|
+
"""Earliest start in [candidate, written_start] whose pulled stretch holds no dialogue.
|
|
242
|
+
|
|
243
|
+
Paragraph tightening plays a block up to `narration_max_pull_seconds` before its
|
|
244
|
+
written `start`, after narration lint checked only that written entry. Measured quiet
|
|
245
|
+
inside a dialogue span is not dialogue. When dialogue remains in the stretch, the
|
|
246
|
+
block starts no earlier than the end of the last such piece.
|
|
247
|
+
"""
|
|
248
|
+
safe = candidate
|
|
249
|
+
quiet_intervals = _merged_handoff_intervals(candidate, written_start, quiet)
|
|
250
|
+
for left, right in _merged_handoff_intervals(candidate, written_start, dialogue):
|
|
251
|
+
pieces = [(left, right)]
|
|
252
|
+
for quiet_left, quiet_right in quiet_intervals:
|
|
253
|
+
pieces = [
|
|
254
|
+
part
|
|
255
|
+
for piece_left, piece_right in pieces
|
|
256
|
+
for part in (
|
|
257
|
+
(piece_left, min(piece_right, quiet_left)),
|
|
258
|
+
(max(piece_left, quiet_right), piece_right),
|
|
259
|
+
)
|
|
260
|
+
if part[1] - part[0] > 0
|
|
261
|
+
]
|
|
262
|
+
for piece_left, piece_right in pieces:
|
|
263
|
+
if piece_right - piece_left > tolerance:
|
|
264
|
+
safe = max(safe, piece_right)
|
|
265
|
+
return safe
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _paragraph_pull_evidence(work_dir):
|
|
269
|
+
"""(dialogue, quiet) rows on the narration clock for `_dialogue_free_pull_start`."""
|
|
270
|
+
_anchors, _artifact, payload = _load_sentence_handoff_anchors(work_dir)
|
|
271
|
+
_speech, quiet, dialogue = _handoff_speech_evidence(work_dir, payload)
|
|
272
|
+
return dialogue, quiet
|
|
273
|
+
|
|
274
|
+
|
|
235
275
|
def _work_has_source_speech(work_dir, speech_spans, require_measured):
|
|
236
276
|
if speech_spans or require_measured:
|
|
237
277
|
return True
|
|
@@ -239,15 +279,20 @@ def _work_has_source_speech(work_dir, speech_spans, require_measured):
|
|
|
239
279
|
|
|
240
280
|
|
|
241
281
|
def _apply_source_sentence_handoffs(tts_segments, work_dir, video_duration):
|
|
242
|
-
"""Keep source audio ducked until a
|
|
282
|
+
"""Keep source audio ducked until a nearby sentence boundary after narration.
|
|
243
283
|
|
|
244
284
|
This does not move or trim narration. It only extends the ORIGINAL-audio duck
|
|
245
|
-
envelope so returning the source track
|
|
285
|
+
envelope so returning the source track does not reveal the middle of a sentence —
|
|
286
|
+
for at most SOURCE_HANDOFF_MAX_HOLD_SECONDS. With no anchor in that window the duck
|
|
287
|
+
releases at the narration end (`bounded_release`) instead of burying source dialogue
|
|
288
|
+
until a distant anchor.
|
|
246
289
|
"""
|
|
247
290
|
fade = CONFIG["duck_fade_seconds"]
|
|
248
291
|
bridge = CONFIG["duck_bridge_seconds"]
|
|
249
292
|
anchors, artifact, evidence_payload = _load_sentence_handoff_anchors(work_dir)
|
|
250
|
-
speech_spans, quiet_windows = _handoff_speech_evidence(
|
|
293
|
+
speech_spans, quiet_windows, dialogue_spans = _handoff_speech_evidence(
|
|
294
|
+
work_dir, evidence_payload
|
|
295
|
+
)
|
|
251
296
|
require_measured = evidence_payload.get("require_measured", False)
|
|
252
297
|
placed = []
|
|
253
298
|
for seg in tts_segments:
|
|
@@ -287,44 +332,66 @@ def _apply_source_sentence_handoffs(tts_segments, work_dir, video_duration):
|
|
|
287
332
|
entry_owned = _entry_speech_owned(
|
|
288
333
|
run["start"],
|
|
289
334
|
speech_spans,
|
|
335
|
+
dialogue_spans,
|
|
290
336
|
quiet_windows,
|
|
291
337
|
anchors,
|
|
292
338
|
first["overlaps_speech"],
|
|
293
339
|
require_measured=require_measured,
|
|
294
340
|
)
|
|
295
341
|
speech_owned = entry_owned or any(ownership)
|
|
342
|
+
if not entry_owned:
|
|
343
|
+
first["source_entry_status"] = _unowned_entry_status(
|
|
344
|
+
run["start"], speech_spans, quiet_windows
|
|
345
|
+
)
|
|
296
346
|
if not speech_owned:
|
|
297
347
|
report.append({"start": run["start"], "end": run["end"], "status": "quiet_source"})
|
|
298
348
|
continue
|
|
299
349
|
last = run["segments"][-1]
|
|
300
|
-
|
|
301
|
-
anchor
|
|
350
|
+
entry_anchor = _prefer_verified([
|
|
351
|
+
anchor
|
|
302
352
|
for anchor in anchors
|
|
303
|
-
|
|
353
|
+
if anchor["pause_start"] - 0.05 <= run["start"] <= anchor["time"] + 0.08
|
|
354
|
+
])
|
|
355
|
+
start_safe = run["start"] <= 0.25 or entry_anchor is not None
|
|
304
356
|
if entry_owned and anchors and not start_safe:
|
|
305
357
|
first["source_handoff_blocking"] = True
|
|
306
358
|
first["source_entry_status"] = "unsafe_entry"
|
|
307
|
-
elif not
|
|
308
|
-
first["source_entry_status"] = "
|
|
309
|
-
|
|
310
|
-
first["source_entry_status"] = "
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
359
|
+
elif entry_owned and not anchors:
|
|
360
|
+
first["source_entry_status"] = "unverified"
|
|
361
|
+
elif entry_owned and entry_anchor is not None and not entry_anchor["verified"]:
|
|
362
|
+
first["source_entry_status"] = "sentence_boundary_unverified"
|
|
363
|
+
elif entry_owned:
|
|
364
|
+
first["source_entry_status"] = "sentence_boundary"
|
|
365
|
+
|
|
366
|
+
max_hold = SOURCE_HANDOFF_MAX_HOLD_SECONDS
|
|
367
|
+
restore_anchor = _prefer_verified([
|
|
368
|
+
anchor
|
|
369
|
+
for anchor in anchors
|
|
370
|
+
if run["end"] - 0.01 <= anchor["time"] <= run["end"] + max_hold
|
|
371
|
+
])
|
|
316
372
|
if restore_anchor is not None:
|
|
317
373
|
# Hold the source low through its last spoken sample, then fit the release
|
|
318
374
|
# entirely inside the measured pause. Never begin the ramp `fade` seconds
|
|
319
375
|
# before the anchor when that would expose the final source phoneme.
|
|
320
376
|
duck_end = max(run["end"], restore_anchor["pause_start"])
|
|
321
377
|
restore_at = max(duck_end, restore_anchor["time"])
|
|
322
|
-
status =
|
|
323
|
-
|
|
324
|
-
|
|
378
|
+
status = (
|
|
379
|
+
"sentence_boundary"
|
|
380
|
+
if restore_anchor["verified"]
|
|
381
|
+
else "sentence_boundary_unverified"
|
|
382
|
+
)
|
|
383
|
+
elif anchors and float(video_duration) - run["end"] <= max_hold:
|
|
384
|
+
# No complete source sentence before a near tail: never expose a fragment there.
|
|
325
385
|
restore_at = float(video_duration)
|
|
326
386
|
duck_end = float(video_duration)
|
|
327
387
|
status = "held_to_timeline_end"
|
|
388
|
+
elif anchors:
|
|
389
|
+
# No anchor within the bound: release at the narration end, same values as
|
|
390
|
+
# `no_source_speech`. Up to the ramp of a source tail is audible; a long duck
|
|
391
|
+
# would instead bury dialogue that may matter more.
|
|
392
|
+
restore_at = run["end"] + fade
|
|
393
|
+
duck_end = run["end"]
|
|
394
|
+
status = "bounded_release"
|
|
328
395
|
elif source_has_speech:
|
|
329
396
|
first["source_handoff_blocking"] = True
|
|
330
397
|
first["source_entry_status"] = "anchors_unavailable"
|
|
@@ -343,6 +410,7 @@ def _apply_source_sentence_handoffs(tts_segments, work_dir, video_duration):
|
|
|
343
410
|
"start": round(run["start"], 4),
|
|
344
411
|
"end": round(run["end"], 4),
|
|
345
412
|
"restore_at": last["source_restore_at"],
|
|
413
|
+
"hold_seconds": round(max(0.0, last["source_restore_at"] - run["end"]), 4),
|
|
346
414
|
"status": status,
|
|
347
415
|
"anchor_artifact": artifact,
|
|
348
416
|
})
|
|
@@ -404,17 +472,8 @@ def _build_audio_filter_complex(
|
|
|
404
472
|
the gaps so the recap never drops to dead air between sentences.
|
|
405
473
|
- bgm (input [2:a], optional): a looped music bed, gently ducked under narration.
|
|
406
474
|
- narration (input [1:a]): the TTS, boosted and laid on top.
|
|
407
|
-
|
|
408
|
-
fixed = the gap-fill envelope above; sidechaincompress = auto-duck keyed off the
|
|
409
|
-
narration; none = no ducking. Placement comes from actual_place_start/end.
|
|
475
|
+
Placement comes from actual_place_start/end.
|
|
410
476
|
"""
|
|
411
|
-
ducking_mode = CONFIG["ducking_mode"]
|
|
412
|
-
if ducking_mode == "sidechaincompress" and any(
|
|
413
|
-
"source_duck_end" in seg and seg["source_duck_end"] > seg["actual_place_end"] + 1e-6
|
|
414
|
-
for seg in tts_segments
|
|
415
|
-
):
|
|
416
|
-
log("sidechaincompress 无法保持句末交接窗口,已回退 fixed ducking")
|
|
417
|
-
ducking_mode = "fixed"
|
|
418
477
|
narr_vol = CONFIG["ducking_narr_weight"]
|
|
419
478
|
fade = CONFIG["duck_fade_seconds"]
|
|
420
479
|
bridge = CONFIG["duck_bridge_seconds"]
|
|
@@ -431,25 +490,7 @@ def _build_audio_filter_complex(
|
|
|
431
490
|
else:
|
|
432
491
|
bgm_chain = f"{bgm_in}volume={base},aresample=48000[bgm];"
|
|
433
492
|
|
|
434
|
-
|
|
435
|
-
# The narration keys the compressor; split it so it can also be mixed in.
|
|
436
|
-
head = (
|
|
437
|
-
f"{original_in}aresample=48000[o0];"
|
|
438
|
-
"[1:a]aresample=48000,asplit=2[sckey][scnarr];"
|
|
439
|
-
f"[o0][sckey]sidechaincompress="
|
|
440
|
-
f"threshold={CONFIG['ducking_threshold']}:ratio={CONFIG['ducking_ratio']}"
|
|
441
|
-
f":attack={CONFIG['ducking_attack']}:release={CONFIG['ducking_release']}"
|
|
442
|
-
f":knee=2.5:makeup={CONFIG['ducking_makeup']}:level_sc={CONFIG['ducking_level_sc']}[orig];"
|
|
443
|
-
)
|
|
444
|
-
narr = f"[scnarr]volume={narr_vol}[narr];"
|
|
445
|
-
if bgm_chain:
|
|
446
|
-
return head + bgm_chain + narr + "[orig][bgm][narr]amix=inputs=3:duration=first:dropout_transition=0:normalize=0[aout]"
|
|
447
|
-
return head + narr + "[orig][narr]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[aout]"
|
|
448
|
-
|
|
449
|
-
if ducking_mode == "none":
|
|
450
|
-
return f"{original_in}aresample=48000[orig];" + _amix_tail(narr_vol, bgm_chain)
|
|
451
|
-
|
|
452
|
-
# fixed (default): gap-fill ducking envelope on the original track.
|
|
493
|
+
# Gap-fill ducking envelope on the original track.
|
|
453
494
|
idle = CONFIG["idle_orig_volume"]
|
|
454
495
|
speech_vol = CONFIG["speech_ducking_volume"]
|
|
455
496
|
quiet_vol = CONFIG["zone_ducking_volume"]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Delivered true peak: measure the encoded file and correct an AAC overshoot over TP.
|
|
2
|
+
|
|
3
|
+
The loudness stage keeps the PCM mix under its true-peak target, but the lossy AAC encode
|
|
4
|
+
adds overshoot (0.3-1.4 dB at 192 kb/s on real renders, most on heavily limited mixes), so
|
|
5
|
+
the delivered file can sit over TP while the stage reported -1.1 dBTP. The first render
|
|
6
|
+
aims `loudness.CODEC_PEAK_HEADROOM_DB` under TP; `deliver_under_true_peak` then decodes
|
|
7
|
+
the delivered audio, and while its true peak is still over TP lowers the pre-encode target
|
|
8
|
+
by the measured overshoot plus CODEC_PEAK_MARGIN_DB and re-encodes only the audio (the
|
|
9
|
+
video stream is copied from the render), at most CODEC_PEAK_MAX_CORRECTIONS times.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import assemble_constants as constants
|
|
16
|
+
import lib
|
|
17
|
+
from lib import CONFIG, filter_file_args, log
|
|
18
|
+
from loudness import (
|
|
19
|
+
_loudnorm_first_pass_filter, _loudnorm_summary_value, _parse_loudnorm_json,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
CODEC_PEAK_MARGIN_DB = 0.1
|
|
23
|
+
CODEC_PEAK_MAX_CORRECTIONS = 2
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def delivered_loudness(path):
|
|
27
|
+
"""Integrated loudness and true peak of the file's first audio stream, or None.
|
|
28
|
+
|
|
29
|
+
The true peak is the higher of loudnorm's and ebur128's readings of the decoded audio:
|
|
30
|
+
the two oversampling detectors disagree by up to about 1 dB on dense transients.
|
|
31
|
+
"""
|
|
32
|
+
result = lib.run_cmd([
|
|
33
|
+
"ffmpeg", "-hide_banner", "-nostats", "-i", str(path), "-map", "0:a:0",
|
|
34
|
+
"-af", f"ebur128=peak=true:framelog=quiet,{_loudnorm_first_pass_filter()}",
|
|
35
|
+
"-f", "null", "-",
|
|
36
|
+
])
|
|
37
|
+
text = f"{result.stdout or ''}\n{result.stderr or ''}"
|
|
38
|
+
measured = _parse_loudnorm_json(text) if result.returncode == 0 else None
|
|
39
|
+
ebur128_peak = _loudnorm_summary_value(text.split("Summary:")[-1], "Peak")
|
|
40
|
+
try:
|
|
41
|
+
integrated, true_peak = float(measured["input_i"]), float(measured["input_tp"])
|
|
42
|
+
except (KeyError, TypeError, ValueError):
|
|
43
|
+
log(f" ⚠️ 成片真峰值测量失败: {path}")
|
|
44
|
+
return None
|
|
45
|
+
if ebur128_peak is not None:
|
|
46
|
+
true_peak = max(true_peak, ebur128_peak)
|
|
47
|
+
return {"integrated": integrated, "true_peak": true_peak}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def deliver_under_true_peak(output_path, peak_target, reencode_audio):
|
|
51
|
+
"""Re-encode the delivered audio until its true peak is under TP; returns (record, state).
|
|
52
|
+
|
|
53
|
+
`reencode_audio(peak_target)` renders the audio again for the lower pre-encode target
|
|
54
|
+
into `output_path` and returns its state; `state` is the last one (None when no
|
|
55
|
+
correction ran). `record` holds the delivered `integrated` / `true_peak` (None when
|
|
56
|
+
unmeasured), the final `peak_target_dbtp` and the number of `corrections`.
|
|
57
|
+
"""
|
|
58
|
+
true_peak = float(CONFIG["target_true_peak"])
|
|
59
|
+
delivered = delivered_loudness(output_path)
|
|
60
|
+
state = None
|
|
61
|
+
corrections = 0
|
|
62
|
+
while (delivered is not None and delivered["true_peak"] > true_peak
|
|
63
|
+
and corrections < CODEC_PEAK_MAX_CORRECTIONS):
|
|
64
|
+
overshoot = delivered["true_peak"] - true_peak
|
|
65
|
+
peak_target = round(peak_target - overshoot - CODEC_PEAK_MARGIN_DB, 2)
|
|
66
|
+
log(f" 成片 AAC 真峰值 {delivered['true_peak']} dBTP 超过 {true_peak},"
|
|
67
|
+
f"编码前目标降到 {peak_target} dBTP 后重编码音频")
|
|
68
|
+
state = reencode_audio(peak_target)
|
|
69
|
+
corrections += 1
|
|
70
|
+
delivered = delivered_loudness(output_path)
|
|
71
|
+
return {
|
|
72
|
+
"integrated": delivered["integrated"] if delivered else None,
|
|
73
|
+
"true_peak": delivered["true_peak"] if delivered else None,
|
|
74
|
+
"peak_target_dbtp": peak_target,
|
|
75
|
+
"corrections": corrections,
|
|
76
|
+
}, state
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def reencode_audio_track(output_path, inputs, filter_complex, audio_map, video_duration,
|
|
80
|
+
work_dir):
|
|
81
|
+
"""Replace `output_path`'s audio with `audio_map` rendered from `inputs`, copying its video.
|
|
82
|
+
|
|
83
|
+
`inputs` are the render's own input arguments, so `filter_complex` keeps its indices;
|
|
84
|
+
the rendered file is appended as the last input. Returns ffmpeg's stderr.
|
|
85
|
+
"""
|
|
86
|
+
output_path = Path(output_path)
|
|
87
|
+
video_index = inputs.count("-i")
|
|
88
|
+
candidate = output_path.with_name(f".{output_path.stem}.audio-reencode{output_path.suffix}")
|
|
89
|
+
script = None
|
|
90
|
+
if len(filter_complex.encode("utf-8")) > constants.FILTER_SCRIPT_THRESHOLD_BYTES:
|
|
91
|
+
script = Path(work_dir) / ".filter_complex_reencode.txt"
|
|
92
|
+
script.write_text(filter_complex, encoding="utf-8")
|
|
93
|
+
filter_args = filter_file_args("filter_complex", script)
|
|
94
|
+
else:
|
|
95
|
+
filter_args = ["-filter_complex", filter_complex]
|
|
96
|
+
cmd = [
|
|
97
|
+
"ffmpeg", "-y", *inputs, "-i", str(output_path), *filter_args,
|
|
98
|
+
"-map", f"{video_index}:v:0", "-map", audio_map, "-c:v", "copy",
|
|
99
|
+
"-map_metadata", "-1", "-map_chapters", "-1",
|
|
100
|
+
"-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-movflags", "+faststart",
|
|
101
|
+
"-t", str(video_duration), str(candidate),
|
|
102
|
+
]
|
|
103
|
+
try:
|
|
104
|
+
result = lib.run_cmd(cmd)
|
|
105
|
+
if result.returncode != 0:
|
|
106
|
+
raise RuntimeError(f"成片音频重编码失败: {result.stderr}")
|
|
107
|
+
os.replace(candidate, output_path)
|
|
108
|
+
finally:
|
|
109
|
+
candidate.unlink(missing_ok=True)
|
|
110
|
+
if script is not None:
|
|
111
|
+
script.unlink(missing_ok=True)
|
|
112
|
+
return result.stderr
|