@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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
|
+
import shutil
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
|
|
7
8
|
import artifacts
|
|
@@ -9,12 +10,14 @@ import assemble_constants as constants
|
|
|
9
10
|
import assembly_contract
|
|
10
11
|
import assembly_settings
|
|
11
12
|
import audio_mix
|
|
13
|
+
import codec_peak
|
|
12
14
|
import adoption.audio_mix_binding as audio_mix_binding
|
|
15
|
+
import adoption.av_clock as av_clock
|
|
13
16
|
import adoption.frozen_audio as frozen_audio
|
|
17
|
+
import loudness
|
|
14
18
|
import media
|
|
15
19
|
import narration_audio
|
|
16
20
|
import adoption.narration_binding as narration_binding
|
|
17
|
-
import pair_media
|
|
18
21
|
import render_preflight
|
|
19
22
|
import adoption.strict_publish as strict_publish
|
|
20
23
|
import subtitles.render as subtitle_render
|
|
@@ -75,6 +78,7 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
|
|
|
75
78
|
audio_mode != "narration" or narration_adoption_path is None or tts_meta_path is None
|
|
76
79
|
):
|
|
77
80
|
raise RuntimeError("audio mix adoption 要求 narration 模式及显式 narration adoption/tts_meta")
|
|
81
|
+
media._plan_clip_spans(work_dir) # a stale cut plan fails here, not after the render
|
|
78
82
|
|
|
79
83
|
published_output = Path(output_path)
|
|
80
84
|
if audio_mix_adoption_path is not None and published_output.exists():
|
|
@@ -129,6 +133,7 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
|
|
|
129
133
|
explicit_mix, binding, tts_segments, work_dir
|
|
130
134
|
)
|
|
131
135
|
narration_wav = Path(explicit_runtime["voice_bus"]["path"])
|
|
136
|
+
_block_before_render(tts_segments, video_duration, work_dir, output_path, audio_mode)
|
|
132
137
|
elif audio_mode == "narration":
|
|
133
138
|
if binding["tempo_policy"]:
|
|
134
139
|
narration_audio._apply_narration_speed(
|
|
@@ -142,13 +147,6 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
|
|
|
142
147
|
tts_segments, narration_wav, video_duration, work_dir,
|
|
143
148
|
tempo_policy=binding["tempo_policy"],
|
|
144
149
|
)
|
|
145
|
-
if any(
|
|
146
|
-
segment.get("blocking") or segment.get("fit_status") == "no_safe_fit"
|
|
147
|
-
for segment in tts_segments
|
|
148
|
-
):
|
|
149
|
-
raise RuntimeError(
|
|
150
|
-
"严格 narration adoption 存在 no_safe_fit,禁止提速或裁尾渲染"
|
|
151
|
-
)
|
|
152
150
|
else:
|
|
153
151
|
narration_audio._build_timed_narration(
|
|
154
152
|
tts_segments, narration_wav, video_duration, work_dir
|
|
@@ -162,6 +160,7 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
|
|
|
162
160
|
for item in handoffs
|
|
163
161
|
)
|
|
164
162
|
)
|
|
163
|
+
_block_before_render(tts_segments, video_duration, work_dir, output_path, audio_mode)
|
|
165
164
|
narration_binding.seal_render_inputs(binding, tts_segments, narration_wav)
|
|
166
165
|
|
|
167
166
|
# 始终生成 SRT 字幕文件(原声留白处补烧原声字幕,传入成片时长以计算留白区间)
|
|
@@ -212,6 +211,9 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
|
|
|
212
211
|
source_has_audio = media._has_audio_stream(input_video)
|
|
213
212
|
adopted_audio = None
|
|
214
213
|
loudnorm_measurement = None
|
|
214
|
+
peak_limiter = None
|
|
215
|
+
peak_target = loudness.first_render_peak_target()
|
|
216
|
+
mix_graph = None # the mix before the final loudness stage ([aout]); None: no stage
|
|
215
217
|
original_audio_input = []
|
|
216
218
|
bgm_input = []
|
|
217
219
|
filter_complex = None
|
|
@@ -232,9 +234,10 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
|
|
|
232
234
|
)
|
|
233
235
|
else:
|
|
234
236
|
filter_complex += ";[source]anull[aout]"
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
237
|
+
mix_graph = filter_complex
|
|
238
|
+
|
|
239
|
+
def plan_loudness(_target, _measured=None):
|
|
240
|
+
return None, None
|
|
238
241
|
elif explicit_mix is not None:
|
|
239
242
|
audio_map = "1:a:0"
|
|
240
243
|
audio_input_args = ["-i", explicit_mix["runtime"]["master"]["path"]]
|
|
@@ -251,7 +254,7 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
|
|
|
251
254
|
]
|
|
252
255
|
original_audio_label = "2:a"
|
|
253
256
|
bgm_audio_label = "3:a"
|
|
254
|
-
|
|
257
|
+
mix_graph = audio_mix._build_audio_filter_complex(
|
|
255
258
|
tts_segments,
|
|
256
259
|
has_bgm,
|
|
257
260
|
original_audio_label=original_audio_label,
|
|
@@ -260,20 +263,22 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
|
|
|
260
263
|
# BGM is input [2:a]; -stream_loop -1 loops it to cover the whole timeline (amix
|
|
261
264
|
# duration=first + -t trim it back to the video length).
|
|
262
265
|
bgm_input = ["-stream_loop", "-1", "-i", str(bgm_path)] if has_bgm else []
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
work_dir,
|
|
271
|
-
)
|
|
272
|
-
final_ln = audio_mix.final_loudnorm_filter(loudnorm_measurement)
|
|
273
|
-
filter_complex += f";[aout]{final_ln}[aoutln]"
|
|
274
|
-
lib.log(f"成片响度归一: {final_ln}")
|
|
266
|
+
|
|
267
|
+
def plan_loudness(target, measured=None):
|
|
268
|
+
return loudness.plan_final_loudness(
|
|
269
|
+
input_video, narration_wav, original_audio_input, bgm_input, mix_graph,
|
|
270
|
+
work_dir, peak_target=target, measured=measured,
|
|
271
|
+
)
|
|
272
|
+
|
|
275
273
|
audio_map = "[aoutln]"
|
|
276
274
|
audio_input_args = ["-i", str(narration_wav), *original_audio_input]
|
|
275
|
+
if mix_graph is not None:
|
|
276
|
+
# 末端整体响度归一:ducking 只管相对平衡,这一步统一成片绝对响度
|
|
277
|
+
loudnorm_measurement, peak_limiter = plan_loudness(peak_target)
|
|
278
|
+
final_ln = loudness.final_loudnorm_filter(loudnorm_measurement, peak_limiter,
|
|
279
|
+
peak_target)
|
|
280
|
+
filter_complex = f"{mix_graph};[aout]{final_ln}[aoutln]"
|
|
281
|
+
lib.log(f"成片响度归一 ({audio_mode}): {final_ln}")
|
|
277
282
|
|
|
278
283
|
# 对于超长 volume 表达式(多段解说),从脚本文件读取 filter_complex 避免命令行溢出
|
|
279
284
|
if filter_complex is not None:
|
|
@@ -318,12 +323,21 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
|
|
|
318
323
|
# needs EVEN width AND height, so normalize odd dims (4:2:2/4:4:4 permit them) before the
|
|
319
324
|
# encode — otherwise libx264 aborts to a 0-byte file. The downscale helper already evens out.
|
|
320
325
|
even = "scale=trunc(iw/2)*2:trunc(ih/2)*2"
|
|
321
|
-
|
|
326
|
+
# A stream copy only stays when the source already is what a re-encode would deliver
|
|
327
|
+
# (H.264 8-bit 4:2:0, even size); otherwise a filter-free render re-encodes too, so the
|
|
328
|
+
# yuv420p guarantee holds on every path, including no-burn and degraded-burn runs.
|
|
329
|
+
source_format = media._probe_video_format(input_video)
|
|
330
|
+
color_tags = media._output_color_tags(source_format)
|
|
331
|
+
copy_unsafe = not media._video_copy_safe(source_format)
|
|
332
|
+
reencode = (
|
|
333
|
+
bool(vf_chain or packaging_layers) or lib.CONFIG["force_video_reencode"] or copy_unsafe
|
|
334
|
+
)
|
|
322
335
|
notes = []
|
|
323
336
|
video_filter_script = None
|
|
324
337
|
if vf_chain or packaging_layers:
|
|
325
338
|
if max_h <= 0: # no downscale in the chain to force even dims
|
|
326
339
|
vf_chain.append(even)
|
|
340
|
+
vf_chain.append(media._color_tag_filter(color_tags))
|
|
327
341
|
video_filter = packaging.compose_video_filter(
|
|
328
342
|
vf_chain, packaging_layers, mask_first=bool(mask_filter)
|
|
329
343
|
)
|
|
@@ -345,10 +359,24 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
|
|
|
345
359
|
+ ([f"缩放≤{max_h}p"] if max_h > 0 else []))
|
|
346
360
|
lib.log(f"视频重编码: {' + '.join(notes)} (crf={crf}, preset={preset})")
|
|
347
361
|
elif reencode:
|
|
348
|
-
notes =
|
|
349
|
-
|
|
362
|
+
notes = (
|
|
363
|
+
["force_video_reencode"] if lib.CONFIG["force_video_reencode"]
|
|
364
|
+
else ["normalize_source_format"]
|
|
365
|
+
)
|
|
366
|
+
cmd += ["-vf", f"{even},{media._color_tag_filter(color_tags)}",
|
|
367
|
+
"-c:v", "libx264", "-preset", preset, "-crf", crf, "-pix_fmt", "yuv420p"]
|
|
368
|
+
if not lib.CONFIG["force_video_reencode"]:
|
|
369
|
+
lib.log(
|
|
370
|
+
"视频重编码: 源画面不是 H.264 yuv420p "
|
|
371
|
+
f"(codec={source_format.get('codec_name')}, pix_fmt={source_format.get('pix_fmt')})"
|
|
372
|
+
)
|
|
350
373
|
else:
|
|
351
374
|
cmd += ["-c:v", "copy"]
|
|
375
|
+
# Container (and, on a re-encode, bitstream) colour tags; see media._output_color_tags.
|
|
376
|
+
cmd += media._color_tag_args(color_tags)
|
|
377
|
+
# Never inherit the source's container/stream tags (a scraper's title, comment or URL)
|
|
378
|
+
# or its chapters, whose times no longer match an edit; every audio path muxes here.
|
|
379
|
+
cmd += ["-map_metadata", "-1", "-map_chapters", "-1"]
|
|
352
380
|
|
|
353
381
|
# +faststart relocates the moov atom to the front so web/social players can start
|
|
354
382
|
# before the full file downloads; valid (and beneficial) on the copy path too.
|
|
@@ -375,13 +403,32 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
|
|
|
375
403
|
if video_filter_script is not None:
|
|
376
404
|
video_filter_script.unlink(missing_ok=True)
|
|
377
405
|
|
|
406
|
+
loudness_stderr = result.stderr
|
|
407
|
+
delivered_peak = None
|
|
408
|
+
if mix_graph is not None and lib.CONFIG["final_loudnorm"]:
|
|
409
|
+
def reencode_audio(target):
|
|
410
|
+
measured, limiter = plan_loudness(target, loudnorm_measurement)
|
|
411
|
+
graph = (f"{mix_graph};[aout]"
|
|
412
|
+
f"{loudness.final_loudnorm_filter(measured, limiter, target)}[aoutln]")
|
|
413
|
+
stderr = codec_peak.reencode_audio_track(
|
|
414
|
+
output_path, ["-i", str(input_video), *audio_input_args, *bgm_input],
|
|
415
|
+
graph, audio_map, video_duration, work_dir,
|
|
416
|
+
)
|
|
417
|
+
return stderr, measured, limiter
|
|
418
|
+
|
|
419
|
+
delivered_peak, corrected = codec_peak.deliver_under_true_peak(
|
|
420
|
+
output_path, peak_target, reencode_audio
|
|
421
|
+
)
|
|
422
|
+
if corrected is not None:
|
|
423
|
+
loudness_stderr, loudnorm_measurement, peak_limiter = corrected
|
|
424
|
+
peak_target = delivered_peak["peak_target_dbtp"]
|
|
378
425
|
if audio_mode == "adopted-packet-copy":
|
|
379
426
|
# Either check failing means the file at the final path is unverified: never leave it.
|
|
380
427
|
try:
|
|
381
428
|
adopted_audio = frozen_audio.verify_adopted_audio(
|
|
382
429
|
input_video, output_path, audio_stream_index
|
|
383
430
|
)
|
|
384
|
-
|
|
431
|
+
av_clock.validate_aac_packet_interval(adopted_audio["output"])
|
|
385
432
|
except (RuntimeError, ValueError):
|
|
386
433
|
output_path.unlink(missing_ok=True)
|
|
387
434
|
raise
|
|
@@ -408,14 +455,27 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
|
|
|
408
455
|
"audio_sample_rate": (
|
|
409
456
|
adopted_audio["output"]["sample_rate"] if adopted_audio else 48000
|
|
410
457
|
),
|
|
458
|
+
"color_tags": color_tags,
|
|
411
459
|
"final_compat_notes": (
|
|
412
460
|
(["yuv420p"] if reencode else ["video_copy"])
|
|
413
461
|
+ (["aac_packet_copy", "faststart"] if adopted_audio else ["aac_48000", "faststart"])
|
|
414
462
|
),
|
|
415
463
|
}
|
|
464
|
+
loudnorm_final = None
|
|
465
|
+
if audio_mode != "adopted-packet-copy" and explicit_mix is None and lib.CONFIG["final_loudnorm"]:
|
|
466
|
+
loudnorm_final = loudness.loudnorm_final_pass(
|
|
467
|
+
loudness_stderr, loudnorm_measurement, peak_limiter, peak_target
|
|
468
|
+
)
|
|
469
|
+
loudnorm_final["delivered"] = delivered_peak
|
|
470
|
+
if loudnorm_measurement and loudnorm_final["normalization_type"] == "dynamic":
|
|
471
|
+
lib.log(" ⚠️ loudnorm 第二遍退回动态模式(测得的响度范围或峰值超出线性条件)")
|
|
416
472
|
loudness_mode = (
|
|
417
473
|
"not_run" if audio_mode == "adopted-packet-copy" else
|
|
418
|
-
"fixed_master_gain_no_loudnorm" if explicit_mix is not None else
|
|
474
|
+
"fixed_master_gain_no_loudnorm" if explicit_mix is not None else
|
|
475
|
+
loudness._loudness_mode(
|
|
476
|
+
loudnorm_measurement, (loudnorm_final or {}).get("normalization_type"),
|
|
477
|
+
peak_limiter,
|
|
478
|
+
)
|
|
419
479
|
)
|
|
420
480
|
source_audio_status = "prepared_bed_adopted" if explicit_mix is not None else None
|
|
421
481
|
render_output = strict_publish.publish_render(
|
|
@@ -424,7 +484,8 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
|
|
|
424
484
|
render_output=render_output, published_output=published_output,
|
|
425
485
|
audio_mode=audio_mode, audio_operations=audio_operations,
|
|
426
486
|
adopted_audio=adopted_audio, loudness_mode=loudness_mode,
|
|
427
|
-
loudnorm_measurement=loudnorm_measurement,
|
|
487
|
+
loudnorm_measurement=loudnorm_measurement, loudnorm_final_pass=loudnorm_final,
|
|
488
|
+
visual_qc=visual_qc,
|
|
428
489
|
source_has_audio=source_has_audio, video_duration=video_duration,
|
|
429
490
|
render_delivery=render_delivery, source_audio_status=source_audio_status,
|
|
430
491
|
)
|
|
@@ -432,9 +493,55 @@ def assemble_video(input_video, tts_segments, work_dir, output_path, *,
|
|
|
432
493
|
return render_output
|
|
433
494
|
|
|
434
495
|
|
|
496
|
+
class AssemblyBlockedBeforeRender(RuntimeError):
|
|
497
|
+
"""Narration can never pass assembly QC; raised before the video encode."""
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _block_before_render(tts_segments, video_duration, work_dir, output_path, audio_mode):
|
|
501
|
+
"""Fail before the video encode when the narration track can already never pass QC.
|
|
502
|
+
|
|
503
|
+
The segment-level codes (no_safe_fit, skipped_segments, unsafe_source_handoff, ...) are
|
|
504
|
+
final once narration is placed; `main` would block the same codes after a full render.
|
|
505
|
+
"""
|
|
506
|
+
qc = assembly_contract._build_assembly_qc(
|
|
507
|
+
tts_segments, video_duration, audio_operations={},
|
|
508
|
+
render_delivery={
|
|
509
|
+
"video_encode_passes": 0, "reencode_reason": ["blocked_before_render"],
|
|
510
|
+
"audio_sample_rate": None, "final_compat_notes": [],
|
|
511
|
+
},
|
|
512
|
+
audio_mode=audio_mode,
|
|
513
|
+
)
|
|
514
|
+
if not qc["blocking"]:
|
|
515
|
+
return
|
|
516
|
+
# Never leave an earlier render or its timeline beside a FAIL for these placements.
|
|
517
|
+
Path(output_path).unlink(missing_ok=True)
|
|
518
|
+
(Path(work_dir) / "timeline.json").unlink(missing_ok=True)
|
|
519
|
+
assembly_contract._write_assembly_qc(work_dir, qc)
|
|
520
|
+
summary = qc["summary"]
|
|
521
|
+
blocked = sorted(set(
|
|
522
|
+
summary["no_safe_fit_segments"] + summary["skipped_segments"]
|
|
523
|
+
+ summary["tempo_exceeded_segments"] + summary["truncated_segments"]
|
|
524
|
+
+ summary["unsafe_source_handoff_segments"]
|
|
525
|
+
+ summary["timeline_audio_mismatch_segments"]
|
|
526
|
+
))
|
|
527
|
+
needed = {
|
|
528
|
+
seg["index"]: seg["needed_tempo_factor"]
|
|
529
|
+
for seg in tts_segments if seg.get("needed_tempo_factor") is not None
|
|
530
|
+
}
|
|
531
|
+
detail = ", ".join(
|
|
532
|
+
f"段 {index + 1}"
|
|
533
|
+
+ (f" needed_tempo_factor={needed[index]:.2f}" if index in needed else "")
|
|
534
|
+
for index in blocked
|
|
535
|
+
)
|
|
536
|
+
raise AssemblyBlockedBeforeRender(
|
|
537
|
+
f"组装 QC 在渲染前阻断: {', '.join(qc['blocking_codes'])}"
|
|
538
|
+
+ (f"({detail})" if detail else "")
|
|
539
|
+
+ f";详见 {Path(work_dir) / constants.ASSEMBLY_QC}"
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
435
543
|
def main():
|
|
436
544
|
import argparse
|
|
437
|
-
import shutil
|
|
438
545
|
ap = argparse.ArgumentParser(
|
|
439
546
|
description="video-assemble: mux narration audio over the video, duck the original, render subtitles.")
|
|
440
547
|
ap.add_argument("video", help="source video (edited_source.mp4 in cut mode, else the original)")
|
|
@@ -455,7 +562,9 @@ def main():
|
|
|
455
562
|
ap.add_argument("--recap-stem", default=None, help="final recap filename stem (default: video stem)")
|
|
456
563
|
ap.add_argument("--output-dir", default=None)
|
|
457
564
|
ap.add_argument("--burn-subtitles", action=argparse.BooleanOptionalAction, default=None,
|
|
458
|
-
help="burn narration subtitles into the video (default on;
|
|
565
|
+
help="burn narration subtitles into the video (default on; without libass the default "
|
|
566
|
+
"delivers a .srt sidecar instead, while an explicit --burn-subtitles fails; "
|
|
567
|
+
"--no-burn-subtitles to disable)")
|
|
459
568
|
ap.add_argument("--subtitle-y-top", type=int, default=None,
|
|
460
569
|
help="inclusive top of a measured subtitle band in display-frame pixels")
|
|
461
570
|
ap.add_argument("--subtitle-y-bot", type=int, default=None,
|
|
@@ -475,6 +584,7 @@ def main():
|
|
|
475
584
|
work_dir = Path(args.work_dir)
|
|
476
585
|
if args.burn_subtitles is not None:
|
|
477
586
|
lib.CONFIG["burn_subtitles"] = args.burn_subtitles
|
|
587
|
+
lib.CONFIG["burn_subtitles_explicit"] = True
|
|
478
588
|
if (args.subtitle_y_top is None) != (args.subtitle_y_bot is None):
|
|
479
589
|
ap.error("--subtitle-y-top and --subtitle-y-bot must be provided together")
|
|
480
590
|
if args.subtitle_y_top is not None:
|
|
@@ -491,22 +601,17 @@ def main():
|
|
|
491
601
|
# chose a different opacity through the existing environment override.
|
|
492
602
|
if "SUBTITLE_MASK_OPACITY" not in os.environ:
|
|
493
603
|
lib.CONFIG["subtitle_mask_opacity"] = 1.0
|
|
494
|
-
if args.source_video:
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
lib.CONFIG["source_video"] = args.source_video
|
|
498
|
-
lib.CONFIG["source_video_explicit"] = True
|
|
499
|
-
else:
|
|
500
|
-
# SOURCE_VIDEO is an ambient env var in lib.CONFIG. Do not let a stale
|
|
501
|
-
# shell value silently bind full-mode/direct timeline.json or JianYing
|
|
502
|
-
# exports to an unrelated original; cut mode must pass --source-video.
|
|
503
|
-
lib.CONFIG["source_video"] = ""
|
|
504
|
-
lib.CONFIG["source_video_explicit"] = False
|
|
604
|
+
if args.source_video and not os.path.exists(args.source_video):
|
|
605
|
+
ap.error(f"--source-video does not exist: {args.source_video}")
|
|
606
|
+
lib.CONFIG["source_video"] = args.source_video or ""
|
|
505
607
|
if args.export_jianying:
|
|
506
608
|
lib.CONFIG["export_jianying"] = True
|
|
507
609
|
if args.jianying_bundle_media is not None:
|
|
508
610
|
lib.CONFIG["jianying_bundle_media"] = args.jianying_bundle_media
|
|
509
|
-
|
|
611
|
+
# Before the render: an explicit burn without libass fails, the default degrades to the
|
|
612
|
+
# .srt sidecar; drawtext overlays without drawtext fail.
|
|
613
|
+
render_preflight._preflight_burn_subtitles()
|
|
614
|
+
render_preflight._preflight_visual_overlays(work_dir)
|
|
510
615
|
# Argument combinations are validated once, by assemble_video.
|
|
511
616
|
tts_meta = Path(args.tts_meta) if args.tts_meta else None
|
|
512
617
|
tts_segments = []
|
|
@@ -520,14 +625,18 @@ def main():
|
|
|
520
625
|
ap.error("explicit audio mix requires a new final delivery path")
|
|
521
626
|
delivery_stage = None
|
|
522
627
|
owned_alias = None
|
|
628
|
+
sidecar = None
|
|
523
629
|
output_path = work_dir / "output.mp4"
|
|
524
630
|
try:
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
631
|
+
try:
|
|
632
|
+
assemble_video(
|
|
633
|
+
args.video, tts_segments, work_dir, output_path,
|
|
634
|
+
audio_mode=args.audio_mode, audio_stream_index=args.audio_stream_index,
|
|
635
|
+
narration_adoption_path=args.narration_adoption, tts_meta_path=tts_meta,
|
|
636
|
+
audio_mix_adoption_path=args.audio_mix_adoption,
|
|
637
|
+
)
|
|
638
|
+
except AssemblyBlockedBeforeRender as exc:
|
|
639
|
+
raise SystemExit(str(exc)) from None
|
|
531
640
|
assembly_qc = artifacts._load_work_json(work_dir, constants.ASSEMBLY_QC)
|
|
532
641
|
if assembly_qc["blocking"]:
|
|
533
642
|
codes = ", ".join(assembly_qc["blocking_codes"])
|
|
@@ -556,6 +665,7 @@ def main():
|
|
|
556
665
|
delivery_stage = None
|
|
557
666
|
else:
|
|
558
667
|
shutil.copy2(str(output_path), str(final_output))
|
|
668
|
+
sidecar = _publish_subtitle_sidecar(work_dir, final_output)
|
|
559
669
|
manifest = assembly_contract._assembly_manifest_payload(
|
|
560
670
|
args.video, tts_segments, work_dir, output_path,
|
|
561
671
|
tts_meta_path=tts_meta,
|
|
@@ -566,8 +676,14 @@ def main():
|
|
|
566
676
|
audio_mode=args.audio_mode,
|
|
567
677
|
audio_stream_index=args.audio_stream_index,
|
|
568
678
|
)
|
|
679
|
+
visual_qc = artifacts._load_work_json(work_dir, constants.VISUAL_QC) or {}
|
|
680
|
+
warnings = visual_qc.get("warnings", [])
|
|
681
|
+
manifest["subtitle_sidecar"] = str(sidecar) if sidecar else None
|
|
682
|
+
manifest["warnings"] = warnings
|
|
569
683
|
assembly_contract._write_assembly_manifest(work_dir, manifest)
|
|
570
684
|
except BaseException:
|
|
685
|
+
if sidecar is not None:
|
|
686
|
+
sidecar.unlink(missing_ok=True)
|
|
571
687
|
if delivery_stage is not None:
|
|
572
688
|
delivery_stage.unlink(missing_ok=True)
|
|
573
689
|
if owned_alias is not None and final_output.exists():
|
|
@@ -583,9 +699,30 @@ def main():
|
|
|
583
699
|
from jianying.optional import maybe_export_jianying
|
|
584
700
|
maybe_export_jianying(work_dir, args.jianying_out, stem)
|
|
585
701
|
|
|
586
|
-
|
|
702
|
+
for warning in warnings:
|
|
703
|
+
lib.log(f"⚠️ {warning['code']}: {warning['message']}(外挂字幕: {sidecar})")
|
|
704
|
+
print(json.dumps({"status": "assembled", "output": str(final_output), "work_dir": str(work_dir),
|
|
705
|
+
"subtitle_sidecar": str(sidecar) if sidecar else None,
|
|
706
|
+
"warnings": [warning["code"] for warning in warnings]},
|
|
587
707
|
ensure_ascii=False))
|
|
588
708
|
|
|
589
709
|
|
|
710
|
+
def _publish_subtitle_sidecar(work_dir, final_output):
|
|
711
|
+
"""Ship subtitles.srt next to the recap when the subtitles are not burned in.
|
|
712
|
+
|
|
713
|
+
The pair shares the stable recap_<stem> alias, so a burned run removes a sidecar left by
|
|
714
|
+
an earlier unburned run instead of letting players stack it over the burned text. A run
|
|
715
|
+
with no subtitle cues (e.g. source-mix without user subtitles) ships no empty sidecar.
|
|
716
|
+
"""
|
|
717
|
+
sidecar = final_output.with_suffix(".srt")
|
|
718
|
+
srt = work_dir / "subtitles.srt"
|
|
719
|
+
has_cues = srt.is_file() and bool(srt.read_text(encoding="utf-8").strip())
|
|
720
|
+
if lib.CONFIG["burn_subtitles"] or not has_cues:
|
|
721
|
+
sidecar.unlink(missing_ok=True)
|
|
722
|
+
return None
|
|
723
|
+
shutil.copy2(srt, sidecar)
|
|
724
|
+
return sidecar
|
|
725
|
+
|
|
726
|
+
|
|
590
727
|
if __name__ == "__main__":
|
|
591
728
|
main()
|
|
@@ -7,7 +7,6 @@ ASSEMBLY_MANIFEST = "assembly_manifest.json"
|
|
|
7
7
|
ASSEMBLY_QC = "assembly_qc.json"
|
|
8
8
|
VISUAL_QC = "visual_qc.json"
|
|
9
9
|
VISUAL_OVERLAYS = "visual_overlays.json"
|
|
10
|
-
SEGMENT_AUDIO_SCHEMA_VERSION = 1
|
|
11
10
|
|
|
12
11
|
# The picture codecs every packet/frame clock proof in this skill accepts.
|
|
13
12
|
SUPPORTED_PICTURE_CODECS = frozenset({"h264", "hevc"})
|
|
@@ -8,23 +8,10 @@ import wave
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
10
|
from lib import CONFIG
|
|
11
|
-
from assemble_constants import
|
|
12
|
-
|
|
13
|
-
ASSEMBLY_QC,
|
|
14
|
-
SEGMENT_AUDIO_SCHEMA_VERSION,
|
|
15
|
-
)
|
|
16
|
-
from audio_mix import _loudness_mode
|
|
11
|
+
from assemble_constants import ASSEMBLY_MANIFEST, ASSEMBLY_QC
|
|
12
|
+
from loudness import _loudness_mode
|
|
17
13
|
from subtitles.track_binding import manifest_subtitle_evidence
|
|
18
|
-
from artifacts import
|
|
19
|
-
_load_work_json,
|
|
20
|
-
_source_video_identity,
|
|
21
|
-
_timeline_provenance_status,
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
_AUDIO_QC_CODES = frozenset({
|
|
25
|
-
"missing_narration", "skipped_segments", "no_safe_fit", "effective_tempo_exceeded",
|
|
26
|
-
"empty_narration", "truncated_speech", "unsafe_source_handoff", "timeline_audio_mismatch",
|
|
27
|
-
})
|
|
14
|
+
from artifacts import _source_video_identity, _timeline_provenance_status
|
|
28
15
|
|
|
29
16
|
|
|
30
17
|
def _assembly_manifest_payload(input_video, tts_segments, work_dir, output_path,
|
|
@@ -32,13 +19,12 @@ def _assembly_manifest_payload(input_video, tts_segments, work_dir, output_path,
|
|
|
32
19
|
audio_mode="narration", audio_stream_index=0,
|
|
33
20
|
narration_input_binding=None, audio_mix_binding=None):
|
|
34
21
|
"""Slim render record. The orchestrator reads `final_output` to report the result;
|
|
35
|
-
`source_video`
|
|
36
|
-
|
|
22
|
+
`source_video` is set only when cut mode passed --source-video. QC results live in
|
|
23
|
+
assembly_qc.json (`qc_path`), not here."""
|
|
37
24
|
input_video = Path(input_video)
|
|
38
25
|
output_path = Path(output_path)
|
|
39
26
|
source_video_identity = _source_video_identity()
|
|
40
27
|
qc_path = Path(work_dir) / ASSEMBLY_QC
|
|
41
|
-
qc = _load_work_json(work_dir, ASSEMBLY_QC) # always written by publish_render first
|
|
42
28
|
settings = settings_payload(
|
|
43
29
|
work_dir, audio_mode=audio_mode, audio_stream_index=audio_stream_index
|
|
44
30
|
)
|
|
@@ -53,27 +39,14 @@ def _assembly_manifest_payload(input_video, tts_segments, work_dir, output_path,
|
|
|
53
39
|
"selected_audio_stream_index": audio_stream_index,
|
|
54
40
|
"assembly_settings": settings,
|
|
55
41
|
"output_path": str(output_path.resolve()),
|
|
56
|
-
"segment_audio_schema_version": SEGMENT_AUDIO_SCHEMA_VERSION,
|
|
57
42
|
"qc_path": str(qc_path.resolve()),
|
|
58
|
-
"qc_verdict": qc["verdict"],
|
|
59
|
-
"qc_blocking_codes": qc["blocking_codes"],
|
|
60
|
-
# The settings payload records the configured/fallback loudness policy; these QC
|
|
61
|
-
# fields record what the just-finished render actually used after the loudnorm probe.
|
|
62
|
-
"qc_loudness_mode": qc["loudness_mode"],
|
|
63
|
-
"qc_loudnorm_measurement": qc["loudnorm_measurement"],
|
|
64
|
-
"audio_operations": qc["audio_operations"],
|
|
65
|
-
"adopted_audio": qc["adopted_audio"],
|
|
66
43
|
"narration_input_binding": narration_input_binding,
|
|
67
44
|
"audio_mix_binding": audio_mix_binding,
|
|
68
45
|
"audio_segments": [
|
|
69
46
|
{
|
|
70
47
|
"index": seg["index"],
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
# normalization (strict adoption fixtures in tests/orchestrator omit both).
|
|
74
|
-
"segment_audio_schema_version": seg.get(
|
|
75
|
-
"segment_audio_schema_version", SEGMENT_AUDIO_SCHEMA_VERSION
|
|
76
|
-
),
|
|
48
|
+
# The loudness measurements are None whenever voiceover skipped
|
|
49
|
+
# normalization (strict adoption fixtures in tests/orchestrator omit them).
|
|
77
50
|
"narration": seg["narration"],
|
|
78
51
|
"spoken_text": seg["spoken_text"],
|
|
79
52
|
"truncated": seg["truncated"],
|
|
@@ -89,6 +62,7 @@ def _assembly_manifest_payload(input_video, tts_segments, work_dir, output_path,
|
|
|
89
62
|
"source_restore_at": seg.get("source_restore_at"),
|
|
90
63
|
"source_handoff_status": seg.get("source_handoff_status"),
|
|
91
64
|
"source_entry_status": seg.get("source_entry_status"),
|
|
65
|
+
"written_start": seg.get("written_start"),
|
|
92
66
|
"global_narration_speed": seg["global_narration_speed"],
|
|
93
67
|
"segment_tempo_factor": seg["segment_tempo_factor"],
|
|
94
68
|
"effective_tempo": seg["effective_tempo"],
|
|
@@ -119,33 +93,6 @@ def _write_assembly_manifest(work_dir, manifest):
|
|
|
119
93
|
return path
|
|
120
94
|
|
|
121
95
|
|
|
122
|
-
def _visual_qc_rollup(visual_qc):
|
|
123
|
-
subtitles = visual_qc["subtitles"]
|
|
124
|
-
overlays = visual_qc["overlays"]
|
|
125
|
-
return {
|
|
126
|
-
"present": True,
|
|
127
|
-
"artifact": visual_qc["artifact"],
|
|
128
|
-
"verdict": visual_qc["verdict"],
|
|
129
|
-
"blocking": visual_qc["blocking"],
|
|
130
|
-
"blocking_codes": list(visual_qc["blocking_codes"]),
|
|
131
|
-
"summary": visual_qc["summary"],
|
|
132
|
-
"geometry": visual_qc["geometry"],
|
|
133
|
-
"subtitles": {
|
|
134
|
-
"entries": subtitles["entries"],
|
|
135
|
-
"multi_line": subtitles["multi_line"],
|
|
136
|
-
"overflow": subtitles["overflow"],
|
|
137
|
-
"safe_area": subtitles["safe_area"],
|
|
138
|
-
},
|
|
139
|
-
"mask": visual_qc["mask"],
|
|
140
|
-
"overlays": {
|
|
141
|
-
"present": overlays["present"],
|
|
142
|
-
"rendered": overlays["rendered"],
|
|
143
|
-
"unsupported": overlays["unsupported"],
|
|
144
|
-
"overflow": overlays["overflow"],
|
|
145
|
-
},
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
|
|
149
96
|
def _placed_audio_matches_timeline(seg):
|
|
150
97
|
"""True when the persisted per-beat WAV is exactly what the serialized timeline window plays."""
|
|
151
98
|
placed_path = Path(seg["placed_audio_path"])
|
|
@@ -182,14 +129,15 @@ def _placed_audio_matches_timeline(seg):
|
|
|
182
129
|
|
|
183
130
|
def _build_assembly_qc(tts_segments, video_duration, *, audio_operations, render_delivery,
|
|
184
131
|
output_path=None, source_has_audio=None, loudness_mode=None,
|
|
185
|
-
loudnorm_measurement=None,
|
|
132
|
+
loudnorm_measurement=None, loudnorm_final_pass=None,
|
|
133
|
+
visual_qc=None, audio_mode="narration",
|
|
186
134
|
adopted_audio=None,
|
|
187
135
|
narration_input_binding=None, audio_mix_binding=None,
|
|
188
136
|
source_audio_status=None):
|
|
189
137
|
"""Machine-readable assembly release gate.
|
|
190
138
|
|
|
191
|
-
|
|
192
|
-
|
|
139
|
+
Only visual_qc.json's verdict and blocking codes are carried here; its facts stay in
|
|
140
|
+
visual_qc.json. Delivery/render facts live here, never in visual_qc.json.
|
|
193
141
|
"""
|
|
194
142
|
hard_max = CONFIG["narration_cumulative_tempo_hard_max"]
|
|
195
143
|
segments = tts_segments
|
|
@@ -218,6 +166,12 @@ def _build_assembly_qc(tts_segments, video_duration, *, audio_operations, render
|
|
|
218
166
|
timeline_audio_failed.append(s["index"])
|
|
219
167
|
|
|
220
168
|
placed = [s["placed_audio_duration"] for s in segments]
|
|
169
|
+
# Information only: how long original audio stays ducked after each narration run ends.
|
|
170
|
+
duck_holds = [
|
|
171
|
+
float(s["source_restore_at"]) - float(s["actual_place_end"])
|
|
172
|
+
for s in segments
|
|
173
|
+
if s.get("source_restore_at") is not None and s.get("actual_place_end") is not None
|
|
174
|
+
]
|
|
221
175
|
blocking_codes = []
|
|
222
176
|
if audio_mode == "narration" and not segments:
|
|
223
177
|
blocking_codes.append("missing_narration")
|
|
@@ -235,12 +189,16 @@ def _build_assembly_qc(tts_segments, video_duration, *, audio_operations, render
|
|
|
235
189
|
blocking_codes.append("timeline_audio_mismatch")
|
|
236
190
|
if placed and max(placed) <= 0.0 and not no_safe:
|
|
237
191
|
blocking_codes.append("empty_narration")
|
|
238
|
-
|
|
239
|
-
|
|
192
|
+
delivered_peak = ((loudnorm_final_pass or {}).get("delivered") or {}).get("true_peak")
|
|
193
|
+
if delivered_peak is not None and delivered_peak > float(CONFIG["target_true_peak"]):
|
|
194
|
+
# The encoded file, not the PCM mix, is what must stay under TP (codec_peak.py).
|
|
195
|
+
blocking_codes.append("delivered_true_peak_over_target")
|
|
196
|
+
visual_verdict = (
|
|
197
|
+
{"verdict": visual_qc["verdict"], "blocking_codes": list(visual_qc["blocking_codes"])}
|
|
240
198
|
if visual_qc is not None
|
|
241
|
-
else {"
|
|
199
|
+
else {"verdict": "NOT_RUN", "blocking_codes": []}
|
|
242
200
|
)
|
|
243
|
-
if
|
|
201
|
+
if visual_qc is not None and visual_qc["blocking"]:
|
|
244
202
|
blocking_codes.append("visual_qc_failed")
|
|
245
203
|
if source_audio_status is not None:
|
|
246
204
|
source_audio = source_audio_status
|
|
@@ -278,18 +236,14 @@ def _build_assembly_qc(tts_segments, video_duration, *, audio_operations, render
|
|
|
278
236
|
"source_audio": source_audio,
|
|
279
237
|
"loudness_mode": loudness_mode or _loudness_mode(loudnorm_measurement),
|
|
280
238
|
"loudnorm_measurement": loudnorm_measurement,
|
|
281
|
-
"
|
|
282
|
-
|
|
283
|
-
"visual_qc": visual_rollup["verdict"],
|
|
284
|
-
"delivery_qc": "PASS",
|
|
285
|
-
"audio_qc": "FAIL" if _AUDIO_QC_CODES.intersection(blocking_codes) else "PASS",
|
|
286
|
-
},
|
|
287
|
-
"visual_qc": visual_rollup,
|
|
239
|
+
"loudnorm_final_pass": loudnorm_final_pass,
|
|
240
|
+
"visual_qc": visual_verdict,
|
|
288
241
|
"delivery_qc": {
|
|
289
242
|
"video_encode_passes": render_delivery["video_encode_passes"],
|
|
290
243
|
"reencode_reason": render_delivery["reencode_reason"],
|
|
291
244
|
"audio_sample_rate": render_delivery["audio_sample_rate"],
|
|
292
245
|
"final_compat_notes": render_delivery["final_compat_notes"],
|
|
246
|
+
"color_tags": render_delivery.get("color_tags"),
|
|
293
247
|
},
|
|
294
248
|
"summary": {
|
|
295
249
|
"segments": len(segments),
|
|
@@ -301,6 +255,9 @@ def _build_assembly_qc(tts_segments, video_duration, *, audio_operations, render
|
|
|
301
255
|
"truncated_segments": truncated,
|
|
302
256
|
"unsafe_source_handoff_segments": handoff_failed,
|
|
303
257
|
"timeline_audio_mismatch_segments": timeline_audio_failed,
|
|
258
|
+
"max_source_duck_hold_seconds": (
|
|
259
|
+
round(max(0.0, max(duck_holds)), 4) if duck_holds else None
|
|
260
|
+
),
|
|
304
261
|
},
|
|
305
262
|
"output": output,
|
|
306
263
|
}
|