@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
|
@@ -4,49 +4,33 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
-
from
|
|
8
|
-
from lib import log, run_cmd
|
|
9
|
-
|
|
10
|
-
def _load_cut_timeline_plan(work_dir):
|
|
11
|
-
"""The cut plan, preferring clip_plan_validated.json unless the raw plan is newer; None in full mode."""
|
|
12
|
-
raw_plan_path = Path(work_dir) / "clip_plan.json"
|
|
13
|
-
validated_plan_path = Path(work_dir) / "clip_plan_validated.json"
|
|
14
|
-
if not validated_plan_path.exists():
|
|
15
|
-
return json.loads(raw_plan_path.read_text(encoding="utf-8")) if raw_plan_path.exists() else None
|
|
16
|
-
if not raw_plan_path.exists():
|
|
17
|
-
return json.loads(validated_plan_path.read_text(encoding="utf-8"))
|
|
18
|
-
if validated_plan_path.stat().st_mtime_ns >= raw_plan_path.stat().st_mtime_ns:
|
|
19
|
-
return json.loads(validated_plan_path.read_text(encoding="utf-8"))
|
|
20
|
-
return json.loads(raw_plan_path.read_text(encoding="utf-8"))
|
|
21
|
-
|
|
7
|
+
from lib import CONFIG, log, run_cmd
|
|
22
8
|
|
|
23
9
|
def _plan_clip_spans(work_dir):
|
|
24
10
|
"""Cut-mode clip spans [{source_start, source_end, output_start, output_end, entry}], or None.
|
|
25
11
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
12
|
+
Read only clip_plan_validated.json: edited_source.mp4 was rendered from it, and its clips
|
|
13
|
+
carry explicit source/output spans. Without it this is full mode (None = identity mapping).
|
|
14
|
+
A validated plan older than clip_plan.json no longer describes the picture, so it fails.
|
|
29
15
|
"""
|
|
30
|
-
|
|
31
|
-
|
|
16
|
+
work_dir = Path(work_dir)
|
|
17
|
+
validated_path = work_dir / "clip_plan_validated.json"
|
|
18
|
+
if not validated_path.exists():
|
|
32
19
|
return None
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
cursor = out_e
|
|
44
|
-
spans.append({
|
|
45
|
-
"source_start": ss, "source_end": se,
|
|
46
|
-
"output_start": out_s, "output_end": out_e,
|
|
20
|
+
raw_path = work_dir / "clip_plan.json"
|
|
21
|
+
if raw_path.exists() and validated_path.stat().st_mtime_ns < raw_path.stat().st_mtime_ns:
|
|
22
|
+
raise ValueError(
|
|
23
|
+
"clip_plan.json 在剪辑之后被修改,clip_plan_validated.json 已过期;请先重新剪辑再组装"
|
|
24
|
+
)
|
|
25
|
+
plan = json.loads(validated_path.read_text(encoding="utf-8"))
|
|
26
|
+
return [
|
|
27
|
+
{
|
|
28
|
+
"source_start": float(entry["source_start"]), "source_end": float(entry["source_end"]),
|
|
29
|
+
"output_start": float(entry["output_start"]), "output_end": float(entry["output_end"]),
|
|
47
30
|
"entry": entry,
|
|
48
|
-
}
|
|
49
|
-
|
|
31
|
+
}
|
|
32
|
+
for entry in plan["clips"]
|
|
33
|
+
]
|
|
50
34
|
|
|
51
35
|
|
|
52
36
|
def _ratio_to_float(value, default=1.0):
|
|
@@ -152,10 +136,10 @@ def _build_video_clips(input_video, work_dir, duration_s):
|
|
|
152
136
|
|
|
153
137
|
In cut mode each plan entry becomes a clip referencing the ORIGINAL source
|
|
154
138
|
range. Multi-source validated plans carry per-clip source_path and do not
|
|
155
|
-
require
|
|
139
|
+
require --source-video. Without any declared source (full
|
|
156
140
|
mode, or cut mode rendered without --source-video) the rendered input is one clip.
|
|
157
141
|
"""
|
|
158
|
-
explicit_source_video =
|
|
142
|
+
explicit_source_video = CONFIG["source_video"]
|
|
159
143
|
spans = _plan_clip_spans(work_dir)
|
|
160
144
|
multi_source = spans is not None and any(span["entry"].get("source_path") for span in spans)
|
|
161
145
|
if spans is None or not (explicit_source_video or multi_source):
|
|
@@ -188,3 +172,139 @@ def _build_video_clips(input_video, work_dir, duration_s):
|
|
|
188
172
|
"timeline_start": timeline_start,
|
|
189
173
|
"timeline_end": timeline_end})
|
|
190
174
|
return clips
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# Delivered-picture format. `_probe_video_format`, `_output_color_tags`,
|
|
178
|
+
# `_color_tag_filter` and `_color_tag_args` are intentional function-level copies shared
|
|
179
|
+
# with the cut render (tests/orchestrator/test_render_format_parity.py keeps them identical).
|
|
180
|
+
_COLOR_FIELDS = (
|
|
181
|
+
("color_space", "colorspace"),
|
|
182
|
+
("color_primaries", "color_primaries"),
|
|
183
|
+
("color_transfer", "color_trc"),
|
|
184
|
+
)
|
|
185
|
+
_UNTAGGED_COLOR = {None, "", "unknown", "unspecified", "reserved", "N/A"}
|
|
186
|
+
# ffprobe's colour space for an RGB picture (PNG, libx264rgb, FFV1 RGB). ffmpeg rejects
|
|
187
|
+
# `-colorspace gbr`, and the delivered picture is YUV anyway, so it is never written.
|
|
188
|
+
_RGB_COLOR_SPACE = "gbr"
|
|
189
|
+
# Pixel-format name prefixes of ffmpeg's RGB family (packed, planar `gbr*`, paletted, Bayer).
|
|
190
|
+
# Some RGB sources report no colour space at all (QuickTime RLE `argb`, raw `bgr24`, GIF
|
|
191
|
+
# `pal8`), so the pixel format decides too; tests/assemble/test_render_delivery.py checks
|
|
192
|
+
# the prefixes against every format a real ffprobe lists.
|
|
193
|
+
_RGB_PIX_FMT_PREFIXES = (
|
|
194
|
+
"rgb", "bgr", "gbr", "argb", "abgr", "0rgb", "0bgr", "x2rgb", "x2bgr", "pal8", "bayer_",
|
|
195
|
+
)
|
|
196
|
+
# Names ffprobe prints that setparams and the -colorspace/-color_primaries/-color_trc
|
|
197
|
+
# output options accept and libx264 writes back (tests/assemble/test_render_delivery.py
|
|
198
|
+
# runs each through a real ffmpeg); anything else is not written explicitly.
|
|
199
|
+
_KNOWN_COLOR_VALUES = {
|
|
200
|
+
"colorspace": {
|
|
201
|
+
"bt709", "fcc", "bt470bg", "smpte170m", "smpte240m", "ycgco", "bt2020nc", "bt2020c",
|
|
202
|
+
"smpte2085", "chroma-derived-nc", "chroma-derived-c", "ictcp",
|
|
203
|
+
},
|
|
204
|
+
"color_primaries": {
|
|
205
|
+
"bt709", "bt470m", "bt470bg", "smpte170m", "smpte240m", "film", "bt2020",
|
|
206
|
+
"smpte428", "smpte431", "smpte432",
|
|
207
|
+
},
|
|
208
|
+
"color_trc": {
|
|
209
|
+
"bt709", "bt470m", "bt470bg", "smpte170m", "smpte240m", "linear", "log100",
|
|
210
|
+
"log316", "iec61966-2-4", "bt1361e", "iec61966-2-1", "bt2020-10", "bt2020-12",
|
|
211
|
+
"smpte2084", "smpte428", "arib-std-b67",
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
# setparams only knows the BT.470 transfers by their ffprobe names, while ffmpeg 8's
|
|
215
|
+
# -color_trc output option only knows them as gamma22/gamma28 (ffmpeg 9 takes both).
|
|
216
|
+
_COLOR_OPTION_SPELLING = {"color_trc": {"bt470m": "gamma22", "bt470bg": "gamma28"}}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _probe_video_format(video_path):
|
|
220
|
+
"""Codec, pixel format, size and colour tags of the first video stream ({} if unreadable)."""
|
|
221
|
+
result = run_cmd([
|
|
222
|
+
"ffprobe", "-v", "error", "-select_streams", "v:0",
|
|
223
|
+
"-show_entries",
|
|
224
|
+
"stream=codec_name,pix_fmt,width,height,color_space,color_primaries,color_transfer,color_range",
|
|
225
|
+
"-of", "json", str(video_path),
|
|
226
|
+
])
|
|
227
|
+
if result.returncode != 0:
|
|
228
|
+
return {}
|
|
229
|
+
try:
|
|
230
|
+
streams = json.loads(result.stdout).get("streams") or []
|
|
231
|
+
except (ValueError, AttributeError):
|
|
232
|
+
return {}
|
|
233
|
+
return streams[0] if streams and isinstance(streams[0], dict) else {}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _output_color_tags(stream):
|
|
237
|
+
"""Colour tags to write on the delivered picture, as {ffmpeg option: value}.
|
|
238
|
+
|
|
239
|
+
An untagged or BT.709 source is labelled BT.709, which is how players already decode
|
|
240
|
+
untagged web video. Any other declared colour space passes through as declared. The
|
|
241
|
+
range follows the source: full range stays `pc`, everything else is limited `tv`.
|
|
242
|
+
For a YUV source nothing here converts pixels; it only fixes the labels.
|
|
243
|
+
|
|
244
|
+
An RGB source (colour space `gbr`, or an RGB pixel format with or without a colour
|
|
245
|
+
space) has no YUV matrix to keep: it is converted to BT.709 limited range, and
|
|
246
|
+
`from_rgb` tells `_color_tag_filter` to do that conversion explicitly instead of
|
|
247
|
+
letting ffmpeg pick a matrix.
|
|
248
|
+
"""
|
|
249
|
+
declared = {option: stream.get(key) for key, option in _COLOR_FIELDS}
|
|
250
|
+
from_rgb = (
|
|
251
|
+
declared["colorspace"] == _RGB_COLOR_SPACE
|
|
252
|
+
or str(stream.get("pix_fmt") or "").startswith(_RGB_PIX_FMT_PREFIXES)
|
|
253
|
+
)
|
|
254
|
+
if from_rgb:
|
|
255
|
+
declared["colorspace"] = "bt709"
|
|
256
|
+
if all(value in _UNTAGGED_COLOR or value == "bt709" for value in declared.values()):
|
|
257
|
+
tags = {option: "bt709" for _, option in _COLOR_FIELDS}
|
|
258
|
+
else:
|
|
259
|
+
tags = {
|
|
260
|
+
option: value for option, value in declared.items()
|
|
261
|
+
if value in _KNOWN_COLOR_VALUES[option]
|
|
262
|
+
}
|
|
263
|
+
if from_rgb:
|
|
264
|
+
tags["color_range"] = "tv"
|
|
265
|
+
tags["from_rgb"] = True
|
|
266
|
+
else:
|
|
267
|
+
tags["color_range"] = "pc" if stream.get("color_range") == "pc" else "tv"
|
|
268
|
+
return tags
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _color_tag_filter(tags):
|
|
272
|
+
"""setparams filter that stamps the tags on every frame before the encoder.
|
|
273
|
+
|
|
274
|
+
Output options alone are not enough when re-encoding: ffmpeg 8/9 let the (untagged)
|
|
275
|
+
frame properties win for primaries and transfer, so the bitstream would stay untagged.
|
|
276
|
+
An RGB frame is first converted to BT.709 yuv420p explicitly: stamping a YUV colour
|
|
277
|
+
space on an RGB frame makes ffmpeg's own later conversion use the BT.601 matrix
|
|
278
|
+
while the file says BT.709.
|
|
279
|
+
"""
|
|
280
|
+
parts = [f"{option}={tags[option]}" for _, option in _COLOR_FIELDS if option in tags]
|
|
281
|
+
parts.append(f"range={tags['color_range']}")
|
|
282
|
+
setparams = "setparams=" + ":".join(parts)
|
|
283
|
+
if tags.get("from_rgb"):
|
|
284
|
+
return (
|
|
285
|
+
f"scale=out_color_matrix=bt709:out_range={tags['color_range']},"
|
|
286
|
+
f"format=yuv420p,{setparams}"
|
|
287
|
+
)
|
|
288
|
+
return setparams
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _color_tag_args(tags):
|
|
292
|
+
"""Output options that write the tags into the container (and encoder) metadata."""
|
|
293
|
+
args = []
|
|
294
|
+
for _, option in _COLOR_FIELDS:
|
|
295
|
+
if option in tags:
|
|
296
|
+
value = _COLOR_OPTION_SPELLING.get(option, {}).get(tags[option], tags[option])
|
|
297
|
+
args += [f"-{option}", value]
|
|
298
|
+
return args + ["-color_range", tags["color_range"]]
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _video_copy_safe(stream):
|
|
302
|
+
"""True when the source picture can be stream-copied and still match a re-encoded
|
|
303
|
+
delivery: H.264, 8-bit 4:2:0 (yuvj420p is the same layout in full range), even size."""
|
|
304
|
+
width, height = stream.get("width"), stream.get("height")
|
|
305
|
+
return (
|
|
306
|
+
stream.get("codec_name") == "h264"
|
|
307
|
+
and stream.get("pix_fmt") in {"yuv420p", "yuvj420p"}
|
|
308
|
+
and isinstance(width, int) and isinstance(height, int)
|
|
309
|
+
and width % 2 == 0 and height % 2 == 0
|
|
310
|
+
)
|
|
@@ -4,6 +4,7 @@ import os
|
|
|
4
4
|
import wave
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
+
from audio_mix import _dialogue_free_pull_start, _paragraph_pull_evidence
|
|
7
8
|
from lib import CONFIG, get_video_duration, log, narration_tempo_budget, run_cmd
|
|
8
9
|
|
|
9
10
|
def _apply_narration_speed(
|
|
@@ -44,6 +45,7 @@ def _adjust_tts_speed(
|
|
|
44
45
|
tts_rate_offset=0.0,
|
|
45
46
|
*,
|
|
46
47
|
tempo_policy=None,
|
|
48
|
+
segment_index=None,
|
|
47
49
|
):
|
|
48
50
|
"""Fit overlong TTS with bounded atempo; never time-trim speech in assemble.
|
|
49
51
|
|
|
@@ -98,8 +100,9 @@ def _adjust_tts_speed(
|
|
|
98
100
|
"placed_audio_duration": 0.0,
|
|
99
101
|
"needed_tempo_factor": ratio,
|
|
100
102
|
})
|
|
103
|
+
label = "" if segment_index is None else f"段 {segment_index + 1} "
|
|
101
104
|
log(
|
|
102
|
-
f" TTS 无安全放置: {current_dur:.1f}s 需 x{ratio:.2f},"
|
|
105
|
+
f" TTS 无安全放置: {label}{current_dur:.1f}s 需 x{ratio:.2f},"
|
|
103
106
|
f"超过段内预算 x{effective_max:.2f}(assemble 不按时间硬切)"
|
|
104
107
|
)
|
|
105
108
|
return (str(audio_path), current_dur, meta)
|
|
@@ -196,18 +199,16 @@ def _build_timed_narration(
|
|
|
196
199
|
no_safe_fit_count = 0 # 超预算但不能安全截断;交由 QC/manifest 阻断
|
|
197
200
|
prev_authored_end = None # 上一段作者标注的结束时间,用于判断"段落"边界
|
|
198
201
|
run_gap = CONFIG["narration_run_gap_seconds"] # 作者留白 > 此值 = 新段落
|
|
199
|
-
tighten = CONFIG["narration_tighten"]
|
|
200
202
|
tight_pause_samples = int(CONFIG["narration_tight_pause_seconds"] * sample_rate)
|
|
201
203
|
# 漂移上限:收紧时一句最多比作者标注的时间提前 max_pull 秒,避免整段解说被全部压到前面、与画面脱节
|
|
202
204
|
max_pull_samples = int(CONFIG["narration_max_pull_seconds"] * sample_rate)
|
|
203
|
-
|
|
204
|
-
tail_pad = CONFIG["narration_tail_pad_seconds"]
|
|
205
|
+
pull_evidence = None # (dialogue, quiet),第一次真的要提前时才读
|
|
205
206
|
|
|
206
207
|
for seg in tts_segments:
|
|
207
208
|
wav_path = seg["audio_path"]
|
|
208
209
|
pause_samples = int(seg["pause_after_ms"] * sample_rate / 1000)
|
|
209
210
|
# 段落收紧:同一段落内(与上一句作者留白 <= run_gap)把这一句紧贴上一句的实际收尾播放,
|
|
210
|
-
# 句间间隔固定为 tight_pause,不受 slot
|
|
211
|
+
# 句间间隔固定为 tight_pause,不受 slot 余量 / TTS 时长波动影响。段落之间(作者特意留
|
|
211
212
|
# 的大留白,让精彩原声透出)才放回原声。这样句间间隔稳定、不会出现"一句解说一段空白"。
|
|
212
213
|
cur_authored_start = float(seg["start"])
|
|
213
214
|
is_run_start = (placed_count == 0 or prev_authored_end is None
|
|
@@ -234,21 +235,26 @@ def _build_timed_narration(
|
|
|
234
235
|
tts_rate_offset = seg["tts_rate_offset"]
|
|
235
236
|
tts_dur = seg["audio_duration"]
|
|
236
237
|
|
|
237
|
-
|
|
238
|
-
max_delay = max(0.0, slot_duration - tts_dur - tail_pad)
|
|
239
|
-
narration_delay = min(configured_delay, max_delay)
|
|
240
|
-
start_sample = int((seg["start"] + narration_delay) * sample_rate)
|
|
238
|
+
start_sample = int(seg["start"] * sample_rate)
|
|
241
239
|
end_boundary = int(min(seg["end"], video_duration) * sample_rate)
|
|
242
240
|
|
|
243
241
|
# 段间间隔:使用前一段的 pause_after_ms(来自 narration.json)
|
|
244
242
|
min_start_with_pause = last_written_end + prev_pause_samples
|
|
245
|
-
if
|
|
246
|
-
# 段落内:紧贴上一句的实际收尾播放,句间间隔固定为 tight_pause(不被 slot
|
|
243
|
+
if not is_run_start:
|
|
244
|
+
# 段落内:紧贴上一句的实际收尾播放,句间间隔固定为 tight_pause(不被 slot 余量撑大),
|
|
247
245
|
# 但不早于"作者标注起始 - max_pull",防止整段被压到前面与画面脱节。
|
|
248
246
|
drift_floor = int(cur_authored_start * sample_rate) - max_pull_samples
|
|
249
247
|
actual_start = max(last_written_end + tight_pause_samples, drift_floor)
|
|
248
|
+
if actual_start < start_sample:
|
|
249
|
+
# 校验只检查过写的 start;提前的这一段不能进入原声对白,否则停在最后一段对白结束处
|
|
250
|
+
if pull_evidence is None:
|
|
251
|
+
pull_evidence = _paragraph_pull_evidence(work_dir)
|
|
252
|
+
safe_start = _dialogue_free_pull_start(
|
|
253
|
+
actual_start / sample_rate, cur_authored_start, *pull_evidence
|
|
254
|
+
)
|
|
255
|
+
actual_start = min(start_sample, max(actual_start, int(round(safe_start * sample_rate))))
|
|
250
256
|
else:
|
|
251
|
-
#
|
|
257
|
+
# 段落起点:采用作者标注的起始,让画面/原声先立住;上一块超时则顺延到它结尾 + pause_after_ms
|
|
252
258
|
actual_start = max(start_sample, min_start_with_pause)
|
|
253
259
|
actual_start = min(actual_start, end_boundary) # 不超出 slot 边界
|
|
254
260
|
|
|
@@ -259,11 +265,12 @@ def _build_timed_narration(
|
|
|
259
265
|
if tempo_policy:
|
|
260
266
|
wav_path, _actual_dur, fit_meta = _adjust_tts_speed(
|
|
261
267
|
wav_path, available_duration, tts_rate_offset,
|
|
262
|
-
tempo_policy=tempo_policy,
|
|
268
|
+
tempo_policy=tempo_policy, segment_index=seg["index"],
|
|
263
269
|
)
|
|
264
270
|
else:
|
|
265
271
|
wav_path, _actual_dur, fit_meta = _adjust_tts_speed(
|
|
266
|
-
wav_path, available_duration, tts_rate_offset
|
|
272
|
+
wav_path, available_duration, tts_rate_offset,
|
|
273
|
+
segment_index=seg["index"],
|
|
267
274
|
)
|
|
268
275
|
seg.update({
|
|
269
276
|
"fit_status": fit_meta["fit_status"],
|
|
@@ -273,6 +280,8 @@ def _build_timed_narration(
|
|
|
273
280
|
"blocking": fit_meta["blocking"],
|
|
274
281
|
})
|
|
275
282
|
if fit_meta["fit_status"] == "no_safe_fit":
|
|
283
|
+
if "needed_tempo_factor" in fit_meta:
|
|
284
|
+
seg["needed_tempo_factor"] = round(fit_meta["needed_tempo_factor"], 4)
|
|
276
285
|
_unplaced(seg, actual_start / sample_rate, "no_safe_fit",
|
|
277
286
|
fit_meta["truncate_reason"], blocking=True)
|
|
278
287
|
prev_pause_samples = pause_samples
|
|
@@ -331,7 +340,7 @@ def _build_timed_narration(
|
|
|
331
340
|
# consonant/vowel release. _adjust_tts_speed must produce a complete file
|
|
332
341
|
# that fits; otherwise block and ask the Agent to shorten/move the block.
|
|
333
342
|
over = (audio_samples - available) / sample_rate
|
|
334
|
-
log(f" TTS 无安全放置: 段 {seg['index']} 超出可用窗口 {over:.3f}s;禁止裁尾,交由 QC 阻断")
|
|
343
|
+
log(f" TTS 无安全放置: 段 {seg['index'] + 1} 超出可用窗口 {over:.3f}s;禁止裁尾,交由 QC 阻断")
|
|
335
344
|
_unplaced(seg, actual_start / sample_rate, "no_safe_fit", "no_safe_boundary", blocking=True)
|
|
336
345
|
prev_pause_samples = pause_samples
|
|
337
346
|
skipped_count += 1
|
|
@@ -342,7 +351,7 @@ def _build_timed_narration(
|
|
|
342
351
|
if actual_start < last_written_end:
|
|
343
352
|
overlap_ms = (last_written_end - actual_start) * 1000 / sample_rate
|
|
344
353
|
if last_written_end >= actual_start + write_samples:
|
|
345
|
-
log(f" 跳过重叠段: {actual_start/sample_rate:.1f}s "
|
|
354
|
+
log(f" 跳过重叠段: 段 {seg['index'] + 1} {actual_start/sample_rate:.1f}s "
|
|
346
355
|
f"(与前段重叠 {overlap_ms:.0f}ms)")
|
|
347
356
|
_unplaced(seg, seg["start"], "no_safe_fit", "no_room", blocking=True)
|
|
348
357
|
prev_pause_samples = pause_samples
|
|
@@ -391,6 +400,11 @@ def _build_timed_narration(
|
|
|
391
400
|
buffer[actual_start * 2: actual_start * 2 + write_samples * 2] = wf_data
|
|
392
401
|
seg["actual_place_start"] = actual_start / sample_rate
|
|
393
402
|
seg["actual_place_end"] = (actual_start + write_samples) / sample_rate
|
|
403
|
+
if actual_start < start_sample:
|
|
404
|
+
# 段落收紧把这一块提前到写的 start 之前;若它成为新压低段的首块,
|
|
405
|
+
# 原声交接会用自己的入口判定覆盖这个状态
|
|
406
|
+
seg["source_entry_status"] = "paragraph_tightened"
|
|
407
|
+
seg["written_start"] = cur_authored_start
|
|
394
408
|
seg["placed_audio_duration"] = write_samples / sample_rate
|
|
395
409
|
last_written_end = actual_start + write_samples
|
|
396
410
|
prev_pause_samples = pause_samples
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
"""Local ffmpeg capability preflight for subtitle burn-in."""
|
|
1
|
+
"""Local ffmpeg capability preflight for subtitle burn-in and drawtext overlays."""
|
|
2
2
|
|
|
3
3
|
import shutil
|
|
4
4
|
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
5
6
|
|
|
6
|
-
from
|
|
7
|
+
from assemble_constants import VISUAL_OVERLAYS
|
|
8
|
+
from lib import CONFIG, log
|
|
9
|
+
from visual_render import _load_visual_overlays
|
|
7
10
|
|
|
8
11
|
def _ffmpeg_filters():
|
|
9
12
|
"""Return ffmpeg's compiled-in filter names (caller has confirmed ffmpeg exists)."""
|
|
@@ -19,15 +22,53 @@ def _ffmpeg_filters():
|
|
|
19
22
|
return filters
|
|
20
23
|
|
|
21
24
|
|
|
25
|
+
SUBTITLE_BURN_DEGRADED = "subtitle_burn_degraded"
|
|
26
|
+
LIBASS_MISSING = "ffmpeg_missing_libass"
|
|
27
|
+
|
|
28
|
+
|
|
22
29
|
def _preflight_burn_subtitles():
|
|
23
|
-
"""
|
|
24
|
-
|
|
25
|
-
the
|
|
30
|
+
"""Settle subtitle burn-in before the render when ffmpeg lacks the libass `subtitles`
|
|
31
|
+
filter. An explicit request (--burn-subtitles or BURN_SUBTITLES in the environment)
|
|
32
|
+
fails here. The implicit default degrades: burn turns off, the reason is recorded in
|
|
33
|
+
CONFIG["burn_subtitles_degraded"], and the run delivers the .srt sidecar instead (the
|
|
34
|
+
warning lands in visual_qc.json and assembly_manifest.json). Only fires when ffmpeg
|
|
35
|
+
EXISTS but can't burn; an absent ffmpeg fails the render regardless."""
|
|
26
36
|
if not CONFIG["burn_subtitles"]:
|
|
27
37
|
return
|
|
28
38
|
if shutil.which("ffmpeg") is None:
|
|
29
39
|
return
|
|
30
|
-
if "subtitles"
|
|
40
|
+
if "subtitles" in _ffmpeg_filters():
|
|
41
|
+
return
|
|
42
|
+
if CONFIG["burn_subtitles_explicit"]:
|
|
31
43
|
raise SystemExit(
|
|
32
|
-
"
|
|
33
|
-
"
|
|
44
|
+
"已显式要求烧录字幕(--burn-subtitles 或 BURN_SUBTITLES),但当前 ffmpeg 不支持"
|
|
45
|
+
" subtitles/libass 滤镜,渲染会在最后一步失败。\n"
|
|
46
|
+
" 解决:安装带 libass 的 ffmpeg;或去掉显式烧录要求,默认运行会改为输出 .srt 外挂字幕;"
|
|
47
|
+
"或加 --no-burn-subtitles。")
|
|
48
|
+
CONFIG["burn_subtitles"] = False
|
|
49
|
+
CONFIG["burn_subtitles_degraded"] = LIBASS_MISSING
|
|
50
|
+
log("⚠️ 当前 ffmpeg 不支持 subtitles/libass 滤镜:本次不烧录字幕,改为输出 .srt 外挂字幕"
|
|
51
|
+
"(visual_qc.json / assembly_manifest.json 记录 subtitle_burn_degraded)。"
|
|
52
|
+
"要烧录字幕请安装带 libass 的 ffmpeg。")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _preflight_visual_overlays(work_dir):
|
|
56
|
+
"""Fail before the render when visual_overlays.json asks for text but this ffmpeg has no
|
|
57
|
+
drawtext (libfreetype). Overlays are authored content, never a default, so they are not
|
|
58
|
+
silently dropped: remove them or install an ffmpeg with drawtext. An unreadable overlay
|
|
59
|
+
file is left to the render's own validation."""
|
|
60
|
+
if shutil.which("ffmpeg") is None:
|
|
61
|
+
return
|
|
62
|
+
try:
|
|
63
|
+
overlays, _source = _load_visual_overlays(work_dir)
|
|
64
|
+
except ValueError:
|
|
65
|
+
return
|
|
66
|
+
if not any(str(item.get("text", "")).strip() for item in overlays):
|
|
67
|
+
return
|
|
68
|
+
if "drawtext" in _ffmpeg_filters():
|
|
69
|
+
return
|
|
70
|
+
raise SystemExit(
|
|
71
|
+
f"{Path(work_dir) / VISUAL_OVERLAYS} 有 {len(overlays)} 个画面文字叠加,但当前 ffmpeg 不支持"
|
|
72
|
+
" drawtext 滤镜(需要 libfreetype),渲染会在最后一步失败。\n"
|
|
73
|
+
" 解决:安装带 drawtext/libfreetype 的 ffmpeg,或删掉这些叠加"
|
|
74
|
+
"(recap 运行时删掉 narration.json 各段的 visual_overlays)后重跑。")
|
|
@@ -57,11 +57,11 @@ def _source_subtitle_mask_policy(work_dir=None):
|
|
|
57
57
|
if raw_policy == "off":
|
|
58
58
|
active = False
|
|
59
59
|
elif raw_policy in {"opt_in", "forced"}:
|
|
60
|
-
active =
|
|
60
|
+
active = legacy_flag
|
|
61
61
|
trigger = "burn_subtitles_and_legacy_mask_flag"
|
|
62
62
|
reason = "explicit policy permits masking only with burned recap subtitles"
|
|
63
63
|
elif raw_policy == "safe":
|
|
64
|
-
active =
|
|
64
|
+
active = legacy_flag or user_subtitles
|
|
65
65
|
trigger = "safe_policy_with_burned_subtitles"
|
|
66
66
|
reason = "safe policy masks only when recap subtitles are burned and an original-subtitle source is declared"
|
|
67
67
|
else:
|
|
@@ -70,8 +70,15 @@ def _source_subtitle_mask_policy(work_dir=None):
|
|
|
70
70
|
reason = "mask_source_subtitles requires explicit SOURCE_SUBTITLE_MASK_POLICY"
|
|
71
71
|
if not burn and active:
|
|
72
72
|
active = False
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
if CONFIG["burn_subtitles_degraded"]:
|
|
74
|
+
trigger = "burn_subtitles_degraded"
|
|
75
|
+
reason = (
|
|
76
|
+
"subtitle burn-in degraded to the .srt sidecar; a mask-only black band is "
|
|
77
|
+
"forbidden without burned recap subtitles"
|
|
78
|
+
)
|
|
79
|
+
else:
|
|
80
|
+
trigger = "burn_subtitles_disabled"
|
|
81
|
+
reason = "mask-only black band is forbidden without burned recap subtitles"
|
|
75
82
|
return {
|
|
76
83
|
"policy": raw_policy,
|
|
77
84
|
"declared": bool(declared and raw_policy in allowed),
|
|
@@ -269,14 +276,18 @@ def _narration_gap_windows(tts_segments, video_duration, min_gap=_MIN_GAP_TO_SUB
|
|
|
269
276
|
|
|
270
277
|
def _original_gap_subtitle_entries(tts_segments, work_dir, video_duration):
|
|
271
278
|
"""Subtitle entries for the ORIGINAL dialogue during the original-audio blocks (narration
|
|
272
|
-
gaps), so the band is not blank while the original speaks. Off unless
|
|
279
|
+
gaps), so the band is not blank while the original speaks. Off unless subtitles are
|
|
280
|
+
delivered (burned, or burn degraded to the .srt sidecar because ffmpeg lacks libass) and
|
|
273
281
|
subtitle_original_in_gaps is set; no-op when there is no ASR. Cut mode remaps ASR to output."""
|
|
274
282
|
# Fill the gaps when either (a) we are masking the source's own burned-in subs (so the band is
|
|
275
283
|
# blank without us), or (b) the user supplied their own subtitle file — a clear signal they want
|
|
276
284
|
# the original dialogue shown, e.g. a clean/foreign source with mask OFF (no burned subs to
|
|
277
285
|
# double). Without a user file we keep the mask requirement so we don't double the source's own
|
|
278
286
|
# visible subs. subtitle_original_in_gaps is the explicit override either way.
|
|
279
|
-
|
|
287
|
+
# A degraded burn keeps the cues a burned run would show, so the sidecar .srt is the same
|
|
288
|
+
# subtitle deliverable; with the mask off only the user-file trigger can still pass.
|
|
289
|
+
delivering = CONFIG["burn_subtitles"] or bool(CONFIG["burn_subtitles_degraded"])
|
|
290
|
+
if not (delivering
|
|
280
291
|
and CONFIG["subtitle_original_in_gaps"]
|
|
281
292
|
and (_source_subtitle_mask_covers_gaps(work_dir) or _has_user_subtitles(work_dir))):
|
|
282
293
|
return []
|
|
@@ -13,6 +13,13 @@ from assemble_constants import (
|
|
|
13
13
|
)
|
|
14
14
|
from media import _ratio_to_float
|
|
15
15
|
|
|
16
|
+
# Measured subtitle bands accept near-square pixels: Y rows are converted between the display
|
|
17
|
+
# canvas and the decoded frame exactly (_frame_row), so the only SAR effect left is a burned
|
|
18
|
+
# ASS line stretched horizontally by the same factor — invisible within 2%. Kept equal to the
|
|
19
|
+
# orchestrator's preflight copy and the measuring tool's (parity-tested).
|
|
20
|
+
SUBTITLE_BAND_SAR_TOLERANCE = 0.02
|
|
21
|
+
|
|
22
|
+
|
|
16
23
|
def _seconds_to_srt_time(seconds):
|
|
17
24
|
"""Floor times to SRT milliseconds without float remainder artifacts.
|
|
18
25
|
|
|
@@ -102,9 +109,11 @@ def _measured_subtitle_band(canvas):
|
|
|
102
109
|
if y_top < 0 and y_bot < 0:
|
|
103
110
|
return None
|
|
104
111
|
sar_text = canvas["sample_aspect_ratio"]
|
|
105
|
-
|
|
112
|
+
# Unspecified SAR ("0:1"/"N/A") is square, exactly as the canvas probe reads it.
|
|
113
|
+
if abs(_ratio_to_float(sar_text, 1.0) - 1.0) > SUBTITLE_BAND_SAR_TOLERANCE + 1e-9:
|
|
106
114
|
raise ValueError(
|
|
107
|
-
|
|
115
|
+
"字幕带坐标仅支持方形或近方形像素画布 "
|
|
116
|
+
f"(SAR 与 1:1 相差不超过 {SUBTITLE_BAND_SAR_TOLERANCE:.0%});当前 SAR={sar_text}"
|
|
108
117
|
)
|
|
109
118
|
canvas_h = canvas["height"]
|
|
110
119
|
if not 0 <= y_top < y_bot <= canvas_h:
|
|
@@ -115,6 +124,19 @@ def _measured_subtitle_band(canvas):
|
|
|
115
124
|
return y_top, y_bot
|
|
116
125
|
|
|
117
126
|
|
|
127
|
+
def _frame_row(canvas, display_y):
|
|
128
|
+
"""Display-canvas row → row of the decoded, auto-rotated frame that drawbox paints on.
|
|
129
|
+
|
|
130
|
+
The canvas keeps the frame's pixel width and stretches the height by the SAR only for a
|
|
131
|
+
90°/270° stream (its stored width becomes the height); otherwise both share rows exactly.
|
|
132
|
+
"""
|
|
133
|
+
rotated = canvas["rotation"] in {90, 270}
|
|
134
|
+
frame_h = canvas["storage_width"] if rotated else canvas["storage_height"]
|
|
135
|
+
if frame_h == canvas["height"]:
|
|
136
|
+
return display_y
|
|
137
|
+
return round(display_y * frame_h / canvas["height"])
|
|
138
|
+
|
|
139
|
+
|
|
118
140
|
def _style_for_measured_subtitle_band(style, canvas):
|
|
119
141
|
"""Fit the ASS baseline and font into explicit auto-rotated display-frame Y coordinates."""
|
|
120
142
|
style = dict(style)
|
|
@@ -16,8 +16,6 @@ from pathlib import Path
|
|
|
16
16
|
|
|
17
17
|
SCHEMA_VERSION = 1
|
|
18
18
|
|
|
19
|
-
# Digest keys older tracks declared; they are ignored, never a reason to fail.
|
|
20
|
-
_LEGACY_BINDING_KEYS = frozenset({"sha256", "edit_sha256"})
|
|
21
19
|
_EVIDENCE_KINDS = frozenset(
|
|
22
20
|
{
|
|
23
21
|
"human_verified",
|
|
@@ -112,7 +110,7 @@ def _load_document(path_or_mapping):
|
|
|
112
110
|
|
|
113
111
|
def _validate_picture_binding(binding, expected):
|
|
114
112
|
binding = _strict_fields(
|
|
115
|
-
binding, required={"path"}, optional={"edit_plan"}
|
|
113
|
+
binding, required={"path"}, optional={"edit_plan"},
|
|
116
114
|
path="bindings.picture",
|
|
117
115
|
)
|
|
118
116
|
expected = _mapping(expected, "expected_picture_identity")
|
|
@@ -137,8 +135,7 @@ _AUDIO_BINDING_FACTS = ("selected_stream", "sample_rate", "packet_count")
|
|
|
137
135
|
|
|
138
136
|
def _validate_audio_binding(binding, expected):
|
|
139
137
|
binding = _strict_fields(
|
|
140
|
-
binding, required=set(_AUDIO_BINDING_FACTS),
|
|
141
|
-
path="bindings.audio",
|
|
138
|
+
binding, required=set(_AUDIO_BINDING_FACTS), path="bindings.audio",
|
|
142
139
|
)
|
|
143
140
|
expected = _mapping(expected, "expected_audio_identity")
|
|
144
141
|
result = {}
|
|
@@ -17,8 +17,6 @@ from subtitles.track import load_subtitle_track
|
|
|
17
17
|
|
|
18
18
|
TRACK = 'subtitle_track.json'
|
|
19
19
|
VALIDATION = 'subtitle_track_validation.json'
|
|
20
|
-
VALIDATION_SCHEMA = 1
|
|
21
|
-
PROJECTOR_VERSION = 1
|
|
22
20
|
|
|
23
21
|
|
|
24
22
|
def current_bindings(video, selected_audio_stream=0, *, edit_plan_path=None):
|
|
@@ -113,8 +111,6 @@ def prepare_subtitle_track(input_video, work_dir, video_duration, *, audio_mode,
|
|
|
113
111
|
start=float(Fraction(start['pts'])), end=float(Fraction(end['pts'])))
|
|
114
112
|
entry['frame_projection'] = {'requested_ticks': [cue['start_tick'], cue['end_tick']],
|
|
115
113
|
'start': start, 'end': end}
|
|
116
|
-
loaded['validation_schema'] = VALIDATION_SCHEMA
|
|
117
|
-
loaded['projector_version'] = PROJECTOR_VERSION
|
|
118
114
|
loaded['binding'] = {
|
|
119
115
|
'input_video': str(Path(input_video).resolve()), 'identities': bindings,
|
|
120
116
|
'inputs': {'track': file_identity(path), 'video': file_identity(input_video),
|
|
@@ -129,14 +125,11 @@ def prepare_subtitle_track(input_video, work_dir, video_duration, *, audio_mode,
|
|
|
129
125
|
|
|
130
126
|
|
|
131
127
|
def _load_validation(work):
|
|
128
|
+
"""The record prepare_subtitle_track rewrote earlier in this same assemble run."""
|
|
132
129
|
path = Path(work) / VALIDATION
|
|
133
130
|
if not path.exists():
|
|
134
131
|
raise ValueError('Explicit subtitle track must be prepared against current media before use')
|
|
135
|
-
|
|
136
|
-
if (type(loaded.get('validation_schema')) is not int or loaded['validation_schema'] != VALIDATION_SCHEMA
|
|
137
|
-
or type(loaded.get('projector_version')) is not int or loaded['projector_version'] != PROJECTOR_VERSION):
|
|
138
|
-
raise ValueError('Subtitle validation/projector version changed; prepare again')
|
|
139
|
-
return loaded
|
|
132
|
+
return json.loads(path.read_text(encoding='utf-8'))
|
|
140
133
|
|
|
141
134
|
|
|
142
135
|
def bound_subtitle_entries(work_dir, video_duration):
|