@oh-story/dsh 0.1.11 → 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
|
@@ -5,8 +5,9 @@ Three subcommands, deliberately separated so a report can never borrow one's
|
|
|
5
5
|
evidence for another's claim:
|
|
6
6
|
|
|
7
7
|
``check`` parse and cross-check ``剪辑单.md`` against the project. No rendering.
|
|
8
|
-
``render`` cut, join, mix sound effects
|
|
9
|
-
normalize loudness into
|
|
8
|
+
``render`` cut video and draw stills, join, mix sound effects and voice lines,
|
|
9
|
+
burn subtitles and screen text, and normalize loudness into
|
|
10
|
+
制作成果/成片/.
|
|
10
11
|
``verify`` measure an already-rendered film and print the numbers.
|
|
11
12
|
|
|
12
13
|
``verify`` prints measurements, never verdicts. Whether the film is any good is
|
|
@@ -16,6 +17,7 @@ a question for review or for the creator, and no number here answers it.
|
|
|
16
17
|
from __future__ import annotations
|
|
17
18
|
|
|
18
19
|
import argparse
|
|
20
|
+
from array import array
|
|
19
21
|
import json
|
|
20
22
|
import operator
|
|
21
23
|
import re
|
|
@@ -24,7 +26,7 @@ import subprocess
|
|
|
24
26
|
import sys
|
|
25
27
|
import tempfile
|
|
26
28
|
from pathlib import Path
|
|
27
|
-
from typing import Any, Callable, NamedTuple, Optional, Sequence, Union
|
|
29
|
+
from typing import Any, Callable, Iterable, NamedTuple, Optional, Sequence, Union
|
|
28
30
|
|
|
29
31
|
MINIMUM_PYTHON = (3, 9)
|
|
30
32
|
if sys.version_info < MINIMUM_PYTHON:
|
|
@@ -81,8 +83,57 @@ FIELD = re.compile(r"^-\s*([^:]+):\s*(.*)$")
|
|
|
81
83
|
UNUSED_LINE = re.compile(r"^-\s*未采用镜头:\s*(.*)$")
|
|
82
84
|
# A source line is "MOTION-... · relative/path". The separator is the same
|
|
83
85
|
# middle dot the storyboard uses for reference slots, so the two documents read
|
|
84
|
-
# alike; a plain slash would collide with the path itself.
|
|
85
|
-
|
|
86
|
+
# alike; a plain slash would collide with the path itself. A still cut -- the
|
|
87
|
+
# static 漫剧 route, keyframes and voiceover -- names the storyboard shot or the
|
|
88
|
+
# image prompt its picture answers to instead.
|
|
89
|
+
SOURCE = re.compile(r"^((?:MOTION|SHOT|IMG)-\S+)\s*·\s*(.+?)\s*$")
|
|
90
|
+
IMAGE_DOCUMENT = "图片提示词.md"
|
|
91
|
+
IMAGE_HEADING = re.compile(r"^##\s+(IMG-[^\s·]+)")
|
|
92
|
+
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"}
|
|
93
|
+
# A still holds the screen for as long as it is written to; shorter than this
|
|
94
|
+
# it reads as a flash frame rather than a shot.
|
|
95
|
+
STILL_MINIMUM = 0.50
|
|
96
|
+
# 「运镜:推近 8%」 moves the frame across a still. Past 30% the picture is
|
|
97
|
+
# being re-framed, not moved, and the upscale starts to show.
|
|
98
|
+
CAMERA_MOVES = ("推近", "拉远", "左移", "右移", "上移", "下移")
|
|
99
|
+
CAMERA_MOVE = re.compile(r"^(" + "|".join(CAMERA_MOVES) + r")\s*([0-9]+)\s*[%%]$")
|
|
100
|
+
# 「推近 2.5%/秒」: a speed rather than a distance, so a scene's cuts can share
|
|
101
|
+
# one and the move runs on through the cut instead of settling at every one.
|
|
102
|
+
CAMERA_RATE = re.compile(
|
|
103
|
+
r"^(" + "|".join(CAMERA_MOVES) + r")\s*([0-9]+(?:\.[0-9]+)?)\s*[%%]\s*[//]\s*秒$"
|
|
104
|
+
)
|
|
105
|
+
CAMERA_HOLD = "固定"
|
|
106
|
+
CAMERA_MOVE_LIMITS = (1, 30)
|
|
107
|
+
CAMERA_RATE_LIMITS = (0.1, 10.0)
|
|
108
|
+
# A move that starts or stops from rest ramps its speed over this long.
|
|
109
|
+
CAMERA_EASE = 0.5
|
|
110
|
+
CAMERA_OPPOSITES = {"推近": "拉远", "拉远": "推近", "左移": "右移", "右移": "左移", "上移": "下移", "下移": "上移"}
|
|
111
|
+
# Adjacent moves in one scene whose speeds differ by more than this read as a lurch.
|
|
112
|
+
CAMERA_SPEED_NOTICE = 0.30
|
|
113
|
+
# 「环境声:[<起>] <文件>」 lays a room under the film from this cut on, looping the
|
|
114
|
+
# file, until the next 环境声 line (「无」 stops it).
|
|
115
|
+
BED_FIELD = "环境声"
|
|
116
|
+
BED = re.compile(r"^\s*(?:(-?[0-9]+(?:\.[0-9]+)?)\s+)?(.+?)\s*$")
|
|
117
|
+
BED_FADE = 0.3
|
|
118
|
+
# The silent track under a still when there is no video audio to match.
|
|
119
|
+
STILL_AUDIO = (48000, "stereo")
|
|
120
|
+
# 「配音 N:<起> <文件>」 plays a voice file to its end, from its cut's start plus
|
|
121
|
+
# 起. It may run on over the cuts after it (an L-cut), and 起 may be negative, as
|
|
122
|
+
# far back as the previous cut's start (a J-cut): the line is part of the film's
|
|
123
|
+
# sound, not of one picture.
|
|
124
|
+
VOICE_FIELD = re.compile(r"^配音(?:\s*(\d+))?$")
|
|
125
|
+
VOICE = re.compile(r"^\s*(-?[0-9]+(?:\.[0-9]+)?)\s+(.+?)\s*$")
|
|
126
|
+
VOICE_OVERRUN = 0.05
|
|
127
|
+
# A sound line's trailing 「(起点:<秒>;增益:<dB>)」, either or both, in any order.
|
|
128
|
+
SOUND_OPTIONS = re.compile(r"[((]\s*((?:起点|增益)[::][^()()]*)[))]\s*$")
|
|
129
|
+
SOUND_OPTION = re.compile(r"^(起点|增益)[::]\s*([+-]?[0-9]+(?:\.[0-9]+)?)\s*(dB)?$", re.I)
|
|
130
|
+
# What counts as audible in a voice file: a 20 ms window within this many dB of
|
|
131
|
+
# its loudest window. TTS pads each line with a breath of silence at both ends;
|
|
132
|
+
# measured on the files, the checks would keep two lines 0.4 s apart that are
|
|
133
|
+
# not overlapping at all.
|
|
134
|
+
AUDIBLE_RANGE_DB = 40.0
|
|
135
|
+
AUDIBLE_WINDOW = 0.02
|
|
136
|
+
AUDIBLE_RATE = 8000
|
|
86
137
|
SUBTITLE_WINDOW = re.compile(r"^\s*([0-9.]+)\s*[-–~]\s*([0-9.]+)\s*$")
|
|
87
138
|
# One shot can carry several lines: an exchange of three is one shot, not three.
|
|
88
139
|
# Numbered fields keep them ordered and let each one state its own window.
|
|
@@ -134,7 +185,6 @@ SCREENPLAY_SCREEN_TEXT = re.compile(r"^\s*\[画面文字\]\s*(.+?)\s*$", re.MULT
|
|
|
134
185
|
# screen: a chip that persists across five shots must not re-enter five times.
|
|
135
186
|
SCREEN_TEXT_JOIN_GAP = 0.05
|
|
136
187
|
SOUND_EFFECT_FIELD = re.compile(r"^音效(?:\s*(\d+))?$")
|
|
137
|
-
GAIN = re.compile(r"[((]增益[::]\s*([+-]?[0-9]+(?:\.[0-9]+)?)\s*(?:dB)?\s*[))]\s*$", re.I)
|
|
138
188
|
GAIN_LIMITS = (-40.0, 6.0)
|
|
139
189
|
# A shot-match is three numbers and nothing else. Anything richer belongs in a
|
|
140
190
|
# grading tool, and anything implicit belongs nowhere: a correction the cut list
|
|
@@ -211,11 +261,61 @@ class SoundEffect(NamedTuple):
|
|
|
211
261
|
end: float
|
|
212
262
|
path: str
|
|
213
263
|
gain_db: float = 0.0
|
|
264
|
+
# 「起点」: where in the file playback begins.
|
|
265
|
+
offset: float = 0.0
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class CameraMove(NamedTuple):
|
|
269
|
+
"""「运镜」 on a still: 固定, a push, pull or pan of `amount` percent, or at `rate` %/s."""
|
|
270
|
+
|
|
271
|
+
kind: str = CAMERA_HOLD
|
|
272
|
+
amount: float = 0
|
|
273
|
+
rate: Optional[float] = None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class Bed(NamedTuple):
|
|
277
|
+
"""One 「环境声」 line: from `start` seconds into its cut, `path` looping; None is 「无」."""
|
|
278
|
+
|
|
279
|
+
start: float
|
|
280
|
+
path: Optional[str]
|
|
281
|
+
gain_db: float = 0.0
|
|
282
|
+
offset: float = 0.0
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class Placed(NamedTuple):
|
|
286
|
+
"""A sound laid on the film: output start and length, the file, and how it is played."""
|
|
287
|
+
|
|
288
|
+
start: float
|
|
289
|
+
duration: float
|
|
290
|
+
path: str
|
|
291
|
+
gain_db: float = 0.0
|
|
292
|
+
offset: float = 0.0
|
|
293
|
+
loop: bool = False
|
|
294
|
+
fade_in: float = 0.0
|
|
295
|
+
fade_out: float = 0.05
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class Voice(NamedTuple):
|
|
299
|
+
"""One 「配音」 line: the file from `offset` to its end, `start` seconds into the cut."""
|
|
300
|
+
|
|
301
|
+
start: float
|
|
302
|
+
path: str
|
|
303
|
+
gain_db: float = 0.0
|
|
304
|
+
offset: float = 0.0
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class Audible(NamedTuple):
|
|
308
|
+
"""A sound file's length and the span inside it that is not silence, in seconds."""
|
|
309
|
+
|
|
310
|
+
duration: float
|
|
311
|
+
head: float
|
|
312
|
+
tail: float
|
|
214
313
|
|
|
215
314
|
|
|
216
315
|
class Cut(NamedTuple):
|
|
217
316
|
cut_id: str
|
|
218
317
|
title: str
|
|
318
|
+
# The source ID: MOTION-... for a video cut, SHOT-... or IMG-... for a still.
|
|
219
319
|
motion: str
|
|
220
320
|
media: str
|
|
221
321
|
start: float
|
|
@@ -228,6 +328,14 @@ class Cut(NamedTuple):
|
|
|
228
328
|
sound_effects: tuple[SoundEffect, ...] = ()
|
|
229
329
|
# 「画面:不校」: neither a stated nor an automatic correction.
|
|
230
330
|
untouched: bool = False
|
|
331
|
+
# Stills only; None on a video cut.
|
|
332
|
+
move: Optional[CameraMove] = None
|
|
333
|
+
voices: tuple[Voice, ...] = ()
|
|
334
|
+
bed: Optional[Bed] = None
|
|
335
|
+
|
|
336
|
+
@property
|
|
337
|
+
def still(self) -> bool:
|
|
338
|
+
return not self.motion.startswith("MOTION-")
|
|
231
339
|
|
|
232
340
|
|
|
233
341
|
class Delivery(NamedTuple):
|
|
@@ -369,6 +477,7 @@ def _repeated_field(name: str, cut_id: str, line: int) -> str:
|
|
|
369
477
|
where = f"{CUT_LIST_NAME}:{line}: {cut_id} 的「{name}」写了两遍"
|
|
370
478
|
for pattern, label in (
|
|
371
479
|
(SUBTITLE_FIELD, "字幕"), (SCREEN_TEXT_FIELD, "画面文字"), (SOUND_EFFECT_FIELD, "音效"),
|
|
480
|
+
(VOICE_FIELD, "配音"),
|
|
372
481
|
):
|
|
373
482
|
if pattern.match(name):
|
|
374
483
|
return f"{where};一段有多条{label}时全部编号,且编号不重复:「{label} 1」「{label} 2」…"
|
|
@@ -390,7 +499,15 @@ def _finish_cut(pending: dict[str, Any]) -> Cut:
|
|
|
390
499
|
if not source:
|
|
391
500
|
raise EditError(
|
|
392
501
|
f"{CUT_LIST_NAME}:{source_line}: {cut_id} 的来源要写成 "
|
|
393
|
-
f"「MOTION-... ·
|
|
502
|
+
f"「MOTION-... · 项目相对路径」,静帧写「SHOT-... · 图片路径」或「IMG-... · 图片路径」,"
|
|
503
|
+
f"当前是 {source_raw!r}"
|
|
504
|
+
)
|
|
505
|
+
still = not source.group(1).startswith("MOTION-")
|
|
506
|
+
if still != (Path(source.group(2)).suffix.lower() in IMAGE_SUFFIXES):
|
|
507
|
+
raise EditError(
|
|
508
|
+
f"{CUT_LIST_NAME}:{source_line}: {cut_id} 的来源 {source.group(1)} 与文件类型不符;"
|
|
509
|
+
"视频素材写 MOTION-...,静帧(" + "/".join(sorted(IMAGE_SUFFIXES))
|
|
510
|
+
+ ")写 SHOT-... 或 IMG-..."
|
|
394
511
|
)
|
|
395
512
|
start_raw, start_line = required("入点")
|
|
396
513
|
end_raw, end_line = required("出点")
|
|
@@ -432,9 +549,64 @@ def _finish_cut(pending: dict[str, Any]) -> Cut:
|
|
|
432
549
|
screen_texts=_parse_screen_texts(fields, cut_id=cut_id, line=line),
|
|
433
550
|
sound_effects=_parse_sound_effects(fields, cut_id=cut_id, line=line),
|
|
434
551
|
untouched=untouched,
|
|
552
|
+
move=_parse_move(fields, cut_id=cut_id, still=still),
|
|
553
|
+
voices=_parse_voices(fields, cut_id=cut_id, line=line),
|
|
554
|
+
bed=_parse_bed(fields, cut_id=cut_id),
|
|
435
555
|
)
|
|
436
556
|
|
|
437
557
|
|
|
558
|
+
def _parse_bed(fields: dict[str, tuple[str, int]], *, cut_id: str) -> Optional[Bed]:
|
|
559
|
+
"""Read 「环境声:[<起>] <文件>(起点:<秒>;增益:<dB>)」 or 「环境声:[<起>] 无」."""
|
|
560
|
+
|
|
561
|
+
raw = fields.get(BED_FIELD)
|
|
562
|
+
if raw is None:
|
|
563
|
+
return None
|
|
564
|
+
value, gain, offset = _sound_options(raw[0], BED_FIELD, cut_id=cut_id, where=raw[1])
|
|
565
|
+
found = BED.match(value)
|
|
566
|
+
# A bed has a start, not a window: it runs until the next 环境声 line.
|
|
567
|
+
if not found or SUBTITLE_CUE.match(value):
|
|
568
|
+
raise EditError(
|
|
569
|
+
f"{CUT_LIST_NAME}:{raw[1]}: {cut_id} 的「环境声」要写成「[<起>] <项目相对路径>」或「[<起>] 无」"
|
|
570
|
+
)
|
|
571
|
+
path = found.group(2)
|
|
572
|
+
return Bed(float(found.group(1) or 0.0), None if path == "无" else path, gain, offset)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _parse_move(
|
|
576
|
+
fields: dict[str, tuple[str, int]], *, cut_id: str, still: bool
|
|
577
|
+
) -> Optional[CameraMove]:
|
|
578
|
+
"""Read 「运镜:固定|推近 <n>%|拉远|左移|右移|上移|下移」. Stills only; default 固定."""
|
|
579
|
+
|
|
580
|
+
raw = fields.get("运镜")
|
|
581
|
+
if raw is None:
|
|
582
|
+
return CameraMove() if still else None
|
|
583
|
+
value, where = raw[0].strip(), raw[1]
|
|
584
|
+
if not still:
|
|
585
|
+
raise EditError(
|
|
586
|
+
f"{CUT_LIST_NAME}:{where}: {cut_id} 是视频段,「运镜」只写在静帧段上;"
|
|
587
|
+
"视频的运镜在生成时就定了"
|
|
588
|
+
)
|
|
589
|
+
if value == CAMERA_HOLD:
|
|
590
|
+
return CameraMove()
|
|
591
|
+
paced = CAMERA_RATE.match(value)
|
|
592
|
+
if paced:
|
|
593
|
+
low, high = CAMERA_RATE_LIMITS
|
|
594
|
+
rate = float(paced.group(2))
|
|
595
|
+
if not low <= rate <= high:
|
|
596
|
+
raise EditError(
|
|
597
|
+
f"{CUT_LIST_NAME}:{where}: {cut_id} 的运镜速度 {rate:g}%/秒 超出 {low:g}–{high:g}"
|
|
598
|
+
)
|
|
599
|
+
return CameraMove(paced.group(1), 0, rate)
|
|
600
|
+
found = CAMERA_MOVE.match(value)
|
|
601
|
+
low, high = CAMERA_MOVE_LIMITS
|
|
602
|
+
if not found or not low <= int(found.group(2)) <= high:
|
|
603
|
+
raise EditError(
|
|
604
|
+
f"{CUT_LIST_NAME}:{where}: {cut_id} 的运镜要写成「{CAMERA_HOLD}」、"
|
|
605
|
+
f"「{'/'.join(CAMERA_MOVES)} <{low}–{high}>%」或「… <速度>%/秒」,当前是 {value!r}"
|
|
606
|
+
)
|
|
607
|
+
return CameraMove(found.group(1), int(found.group(2)))
|
|
608
|
+
|
|
609
|
+
|
|
438
610
|
def _field_entries(
|
|
439
611
|
fields: dict[str, tuple[str, int]],
|
|
440
612
|
pattern: re.Pattern[str],
|
|
@@ -544,37 +716,79 @@ def _parse_screen_texts(
|
|
|
544
716
|
return tuple(texts)
|
|
545
717
|
|
|
546
718
|
|
|
719
|
+
def _sound_options(
|
|
720
|
+
value: str, label: str, *, cut_id: str, where: int
|
|
721
|
+
) -> tuple[str, float, float]:
|
|
722
|
+
"""Split 「(起点:<秒>;增益:<dB>)」 off a sound line: (rest, dB, 起点 seconds)."""
|
|
723
|
+
|
|
724
|
+
stated = SOUND_OPTIONS.search(value)
|
|
725
|
+
if not stated:
|
|
726
|
+
return value, 0.0, 0.0
|
|
727
|
+
options: dict[str, float] = {}
|
|
728
|
+
for part in re.split(r"[;;]", stated.group(1)):
|
|
729
|
+
found = SOUND_OPTION.match(part.strip())
|
|
730
|
+
if not found or found.group(1) in options or (found.group(3) and found.group(1) != "增益"):
|
|
731
|
+
raise EditError(
|
|
732
|
+
f"{CUT_LIST_NAME}:{where}: {cut_id} 的「{label}」括号里只写「起点:<秒>」"
|
|
733
|
+
f"和「增益:<dB>」,各至多一次,用「;」分隔,当前是 {part.strip()!r}"
|
|
734
|
+
)
|
|
735
|
+
options[found.group(1)] = float(found.group(2))
|
|
736
|
+
gain, offset = options.get("增益", 0.0), options.get("起点", 0.0)
|
|
737
|
+
low, high = GAIN_LIMITS
|
|
738
|
+
if not low <= gain <= high:
|
|
739
|
+
raise EditError(
|
|
740
|
+
f"{CUT_LIST_NAME}:{where}: {cut_id} 的「{label}」增益 {gain:g} dB "
|
|
741
|
+
f"超出 {low:g} 到 {high:g}"
|
|
742
|
+
)
|
|
743
|
+
if offset < 0:
|
|
744
|
+
raise EditError(f"{CUT_LIST_NAME}:{where}: {cut_id} 的「{label}」起点不能为负")
|
|
745
|
+
return value[: stated.start()], gain, offset
|
|
746
|
+
|
|
747
|
+
|
|
547
748
|
def _parse_sound_effects(
|
|
548
749
|
fields: dict[str, tuple[str, int]], *, cut_id: str, line: int
|
|
549
750
|
) -> tuple[SoundEffect, ...]:
|
|
550
|
-
"""Read 「音效 N:<起>-<止>
|
|
751
|
+
"""Read 「音效 N:<起>-<止> <文件>(起点:<秒>;增益:<dB>)」 lines."""
|
|
551
752
|
|
|
552
753
|
_, entries = _field_entries(fields, SOUND_EFFECT_FIELD, "音效", cut_id=cut_id, line=line)
|
|
553
754
|
effects: list[SoundEffect] = []
|
|
554
755
|
for index, value, where in entries:
|
|
555
756
|
if value.strip() == "无":
|
|
556
757
|
continue
|
|
557
|
-
gain =
|
|
558
|
-
stated = GAIN.search(value)
|
|
559
|
-
if stated:
|
|
560
|
-
value = value[: stated.start()]
|
|
561
|
-
gain = float(stated.group(1))
|
|
562
|
-
low, high = GAIN_LIMITS
|
|
563
|
-
if not low <= gain <= high:
|
|
564
|
-
raise EditError(
|
|
565
|
-
f"{CUT_LIST_NAME}:{where}: {cut_id} 的「音效 {index}」增益 {gain:g} dB "
|
|
566
|
-
f"超出 {low:g} 到 {high:g}"
|
|
567
|
-
)
|
|
758
|
+
value, gain, offset = _sound_options(value, f"音效 {index}", cut_id=cut_id, where=where)
|
|
568
759
|
found = SUBTITLE_CUE.match(value)
|
|
569
760
|
if not found:
|
|
570
761
|
raise EditError(
|
|
571
762
|
f"{CUT_LIST_NAME}:{where}: {cut_id} 的「音效 {index}」要写成"
|
|
572
763
|
"「<起>-<止> <项目相对路径>」"
|
|
573
764
|
)
|
|
574
|
-
effects.append(SoundEffect(
|
|
765
|
+
effects.append(SoundEffect(
|
|
766
|
+
float(found.group(1)), float(found.group(2)), found.group(3), gain, offset
|
|
767
|
+
))
|
|
575
768
|
return tuple(effects)
|
|
576
769
|
|
|
577
770
|
|
|
771
|
+
def _parse_voices(
|
|
772
|
+
fields: dict[str, tuple[str, int]], *, cut_id: str, line: int
|
|
773
|
+
) -> tuple[Voice, ...]:
|
|
774
|
+
"""Read 「配音 N:<起> <文件>(起点:<秒>;增益:<dB>)」 lines. The file plays to its end."""
|
|
775
|
+
|
|
776
|
+
_, entries = _field_entries(fields, VOICE_FIELD, "配音", cut_id=cut_id, line=line)
|
|
777
|
+
voices: list[Voice] = []
|
|
778
|
+
for index, value, where in entries:
|
|
779
|
+
if value.strip() == "无":
|
|
780
|
+
continue
|
|
781
|
+
value, gain, offset = _sound_options(value, f"配音 {index}", cut_id=cut_id, where=where)
|
|
782
|
+
found = VOICE.match(value)
|
|
783
|
+
if not found:
|
|
784
|
+
raise EditError(
|
|
785
|
+
f"{CUT_LIST_NAME}:{where}: {cut_id} 的「配音 {index}」要写成"
|
|
786
|
+
"「<起> <项目相对路径>」,从起点放到文件结束"
|
|
787
|
+
)
|
|
788
|
+
voices.append(Voice(float(found.group(1)), found.group(2), gain, offset))
|
|
789
|
+
return tuple(voices)
|
|
790
|
+
|
|
791
|
+
|
|
578
792
|
def _parse_subtitles(
|
|
579
793
|
fields: dict[str, tuple[str, int]], *, cut_id: str, line: int
|
|
580
794
|
) -> tuple[Subtitle, ...]:
|
|
@@ -688,16 +902,47 @@ def probe_stream(media: Path) -> dict[str, Any]:
|
|
|
688
902
|
}
|
|
689
903
|
|
|
690
904
|
|
|
691
|
-
def
|
|
692
|
-
"""
|
|
905
|
+
def _excused(unused: Sequence[str]) -> set[str]:
|
|
906
|
+
"""IDs listed under 「未采用镜头」 with a non-empty reason."""
|
|
693
907
|
|
|
694
|
-
used = {cut.motion for cut in cuts}
|
|
695
908
|
excused = set()
|
|
696
909
|
for note in unused:
|
|
697
|
-
match = re.fullmatch(
|
|
910
|
+
match = re.fullmatch(
|
|
911
|
+
r"((?:MOTION|SHOT|IMG)-[\w-]+)\s*[((]理由[::]\s*(.+?)[))]", note.strip()
|
|
912
|
+
)
|
|
698
913
|
if match and match.group(2).strip():
|
|
699
914
|
excused.add(match.group(1))
|
|
700
|
-
|
|
915
|
+
return excused
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def probe_audio(media: Path) -> Optional[tuple[int, str]]:
|
|
919
|
+
"""(sample rate, channel layout) of the first audio stream; None when it has none."""
|
|
920
|
+
|
|
921
|
+
probe = _require("ffprobe")
|
|
922
|
+
result = subprocess.run(
|
|
923
|
+
[probe, "-v", "error", "-select_streams", "a:0", "-show_entries",
|
|
924
|
+
"stream=sample_rate,channels,channel_layout", "-of", "json", str(media)],
|
|
925
|
+
capture_output=True, text=True, check=False,
|
|
926
|
+
)
|
|
927
|
+
streams = json.loads(result.stdout or "{}").get("streams") if result.returncode == 0 else None
|
|
928
|
+
if not streams:
|
|
929
|
+
return None
|
|
930
|
+
stream = streams[0]
|
|
931
|
+
layout = stream.get("channel_layout") or ("mono" if stream.get("channels") == 1 else "stereo")
|
|
932
|
+
return int(stream.get("sample_rate") or STILL_AUDIO[0]), layout
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _unaccounted_shots(
|
|
936
|
+
known: set[str],
|
|
937
|
+
cuts: Sequence[Cut],
|
|
938
|
+
unused: Sequence[str],
|
|
939
|
+
*,
|
|
940
|
+
accounted: Iterable[str] = (),
|
|
941
|
+
) -> list[str]:
|
|
942
|
+
"""Require each source to be used, accounted for another way, or omitted with a reason."""
|
|
943
|
+
|
|
944
|
+
used = {cut.motion for cut in cuts} | set(accounted)
|
|
945
|
+
missing = sorted(known - used - _excused(unused))
|
|
701
946
|
if not missing:
|
|
702
947
|
return []
|
|
703
948
|
return [
|
|
@@ -706,6 +951,196 @@ def _unaccounted_shots(known: set[str], cuts: Sequence[Cut], unused: Sequence[st
|
|
|
706
951
|
]
|
|
707
952
|
|
|
708
953
|
|
|
954
|
+
def _headings(path: Path, heading: re.Pattern[str]) -> Optional[set[str]]:
|
|
955
|
+
"""The IDs a document declares as `##` headings; None when it does not exist."""
|
|
956
|
+
|
|
957
|
+
if not path.is_file():
|
|
958
|
+
return None
|
|
959
|
+
return {
|
|
960
|
+
match.group(1)
|
|
961
|
+
for match in map(heading.match, path.read_text(encoding="utf-8").splitlines())
|
|
962
|
+
if match
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _motion_shots(episode: Path) -> dict[str, str]:
|
|
967
|
+
"""`{MOTION-...: SHOT-...}` from each video prompt's 「分镜」 line."""
|
|
968
|
+
|
|
969
|
+
fields = _heading_fields(episode / MOTION_DOCUMENT, MOTION_HEADING, "分镜")
|
|
970
|
+
return {
|
|
971
|
+
motion: found.group(0)
|
|
972
|
+
for motion, value in fields.items()
|
|
973
|
+
for found in [SHOT_REFERENCE.search(value)]
|
|
974
|
+
if found
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def _source_findings(episode: Path, cuts: Sequence[Cut], unused: Sequence[str]) -> list[str]:
|
|
979
|
+
"""Each source ID exists in its document, and each shot is in the film or excused.
|
|
980
|
+
|
|
981
|
+
Video prompts are held to coverage when the list has a video cut, storyboard
|
|
982
|
+
shots when it has a SHOT- still. Across the two, a shot counts once however
|
|
983
|
+
it reached the film: a MOTION whose storyboard shot went in as a still is
|
|
984
|
+
accounted for, and so is a SHOT whose MOTION was used or excused.
|
|
985
|
+
"""
|
|
986
|
+
|
|
987
|
+
findings: list[str] = []
|
|
988
|
+
documents = (
|
|
989
|
+
("MOTION-", MOTION_DOCUMENT, MOTION_HEADING),
|
|
990
|
+
("SHOT-", STORYBOARD_DOCUMENT, SHOT_HEADING),
|
|
991
|
+
("IMG-", IMAGE_DOCUMENT, IMAGE_HEADING),
|
|
992
|
+
)
|
|
993
|
+
known: dict[str, Optional[set[str]]] = {}
|
|
994
|
+
for prefix, name, heading in documents:
|
|
995
|
+
using = [cut for cut in cuts if cut.motion.startswith(prefix)]
|
|
996
|
+
declared = known[prefix] = _headings(episode / name, heading) if using else None
|
|
997
|
+
if not using:
|
|
998
|
+
continue
|
|
999
|
+
if declared is None:
|
|
1000
|
+
findings.append(f"没有 {episode / name},来源 {prefix[:-1]} 无法核对")
|
|
1001
|
+
continue
|
|
1002
|
+
findings.extend(
|
|
1003
|
+
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的来源 "
|
|
1004
|
+
f"{cut.motion} 不在《{name}》中"
|
|
1005
|
+
for cut in using if cut.motion not in declared
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
shot_of = _motion_shots(episode)
|
|
1009
|
+
used = {cut.motion for cut in cuts}
|
|
1010
|
+
motions, shots = known["MOTION-"], known["SHOT-"]
|
|
1011
|
+
if motions is not None:
|
|
1012
|
+
findings.extend(_unaccounted_shots(
|
|
1013
|
+
motions, cuts, unused, accounted={m for m in motions if shot_of.get(m) in used},
|
|
1014
|
+
))
|
|
1015
|
+
if shots is not None:
|
|
1016
|
+
findings.extend(_unaccounted_shots(
|
|
1017
|
+
shots, cuts, unused,
|
|
1018
|
+
accounted={shot_of[m] for m in used | _excused(unused) if m in shot_of},
|
|
1019
|
+
))
|
|
1020
|
+
return findings
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def probe_audible(media: Path) -> Audible:
|
|
1024
|
+
"""A sound file's length and where its sound starts and stops, decoded small and mono."""
|
|
1025
|
+
|
|
1026
|
+
ffmpeg = _require("ffmpeg")
|
|
1027
|
+
result = subprocess.run(
|
|
1028
|
+
[ffmpeg, "-v", "error", "-i", str(media), "-ac", "1", "-ar", str(AUDIBLE_RATE),
|
|
1029
|
+
"-f", "s16le", "-"],
|
|
1030
|
+
capture_output=True, check=False,
|
|
1031
|
+
)
|
|
1032
|
+
if result.returncode != 0:
|
|
1033
|
+
raise EditError(f"ffmpeg 读不出声音: {media} ({result.stderr.decode(errors='replace').strip()})")
|
|
1034
|
+
samples = array("h")
|
|
1035
|
+
samples.frombytes(result.stdout[: len(result.stdout) // 2 * 2])
|
|
1036
|
+
if sys.byteorder == "big":
|
|
1037
|
+
samples.byteswap()
|
|
1038
|
+
return _audible_span(samples, AUDIBLE_RATE)
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _audible_span(samples: Sequence[int], rate: int) -> Audible:
|
|
1042
|
+
"""The first and last 20 ms window within AUDIBLE_RANGE_DB of the loudest one."""
|
|
1043
|
+
|
|
1044
|
+
size = max(1, round(rate * AUDIBLE_WINDOW))
|
|
1045
|
+
energies = [
|
|
1046
|
+
sum(value * value for value in samples[at:at + size]) / len(samples[at:at + size])
|
|
1047
|
+
for at in range(0, len(samples), size)
|
|
1048
|
+
]
|
|
1049
|
+
duration = len(samples) / rate
|
|
1050
|
+
loudest = max(energies, default=0.0)
|
|
1051
|
+
if loudest <= 0:
|
|
1052
|
+
return Audible(duration, 0.0, 0.0)
|
|
1053
|
+
floor = loudest * 10 ** (-AUDIBLE_RANGE_DB / 10)
|
|
1054
|
+
heard = [index for index, energy in enumerate(energies) if energy >= floor]
|
|
1055
|
+
return Audible(duration, heard[0] * size / rate, min(duration, (heard[-1] + 1) * size / rate))
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def _voice_sounds(episode: Path, project_root: Path, cuts: Sequence[Cut]) -> dict[str, Audible]:
|
|
1059
|
+
"""Every 「配音」 file, measured, keyed as written. Raises when one cannot be read."""
|
|
1060
|
+
|
|
1061
|
+
sounds: dict[str, Audible] = {}
|
|
1062
|
+
for cut in cuts:
|
|
1063
|
+
for voice in cut.voices:
|
|
1064
|
+
if voice.path in sounds:
|
|
1065
|
+
continue
|
|
1066
|
+
media = _resolve_media(episode, project_root, voice.path)
|
|
1067
|
+
if media is None:
|
|
1068
|
+
raise EditError(f"配音文件不存在或不在项目目录内: {voice.path}")
|
|
1069
|
+
sounds[voice.path] = probe_audible(media)
|
|
1070
|
+
return sounds
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _voice_spans(
|
|
1074
|
+
cuts: Sequence[Cut], sounds: dict[str, Audible], lengths: Sequence[float]
|
|
1075
|
+
) -> list[tuple[float, float, Cut, Voice]]:
|
|
1076
|
+
"""Each voice line's audible span in film seconds, cuts laid end to end at `lengths`."""
|
|
1077
|
+
|
|
1078
|
+
spans: list[tuple[float, float, Cut, Voice]] = []
|
|
1079
|
+
for cut, cursor, length in _timeline(cuts, lengths):
|
|
1080
|
+
for voice in cut.voices:
|
|
1081
|
+
sound = sounds[voice.path]
|
|
1082
|
+
# Where the file's own second zero lands on the film.
|
|
1083
|
+
zero = _at(cut, cursor, length, voice.start) - voice.offset
|
|
1084
|
+
spans.append((zero + max(sound.head, voice.offset), zero + sound.tail, cut, voice))
|
|
1085
|
+
return sorted(spans, key=lambda span: span[0])
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def _voice_findings(
|
|
1089
|
+
cuts: Sequence[Cut], sounds: dict[str, Audible], lengths: Sequence[float]
|
|
1090
|
+
) -> list[str]:
|
|
1091
|
+
"""A voice line is heard inside the film and never over another line, across cuts too."""
|
|
1092
|
+
|
|
1093
|
+
total = sum(lengths)
|
|
1094
|
+
findings: list[str] = []
|
|
1095
|
+
latest: Optional[tuple[float, float, Cut, Voice]] = None
|
|
1096
|
+
for span in _voice_spans(cuts, sounds, lengths):
|
|
1097
|
+
start, end, cut, voice = span
|
|
1098
|
+
where = f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id}"
|
|
1099
|
+
if end <= start:
|
|
1100
|
+
findings.append(f"{where} 的配音 {voice.path} 起点 {voice.offset:g} 秒之后没有声音")
|
|
1101
|
+
continue
|
|
1102
|
+
if end > total + VOICE_OVERRUN:
|
|
1103
|
+
findings.append(
|
|
1104
|
+
f"{where} 的配音 {voice.path} 在成片 {start:.2f} 秒开口、{end:.2f} 秒才说完,"
|
|
1105
|
+
f"超出成片结尾 {total:.2f};加长末几段或提前起点"
|
|
1106
|
+
)
|
|
1107
|
+
if latest is not None and start < latest[1] - TOLERANCE:
|
|
1108
|
+
findings.append(
|
|
1109
|
+
f"{where} 的配音 {voice.path}(成片 {start:.2f} 秒开口)压在 {latest[2].cut_id} 的"
|
|
1110
|
+
f"配音 {latest[3].path} 上(说到 {latest[1]:.2f} 秒)"
|
|
1111
|
+
)
|
|
1112
|
+
if latest is None or end > latest[1]:
|
|
1113
|
+
latest = span
|
|
1114
|
+
return findings
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def _subtitle_findings(cuts: Sequence[Cut], lengths: Sequence[float]) -> list[str]:
|
|
1118
|
+
"""A subtitle may run on past its cut, but stays in the film and off every other subtitle."""
|
|
1119
|
+
|
|
1120
|
+
total = sum(lengths)
|
|
1121
|
+
placed: list[tuple[float, float, Cut]] = []
|
|
1122
|
+
findings: list[str] = []
|
|
1123
|
+
for cut, cursor, length in _timeline(cuts, lengths):
|
|
1124
|
+
for window_start, window_end, _, _ in cut.subtitles:
|
|
1125
|
+
start = 0.0 if window_start is None else window_start
|
|
1126
|
+
end = cut.declared if window_end is None else window_end
|
|
1127
|
+
shown = (_at(cut, cursor, length, start), _at(cut, cursor, length, end))
|
|
1128
|
+
if not 0 <= start < min(end, cut.declared) or shown[1] > total + TOLERANCE:
|
|
1129
|
+
findings.append(
|
|
1130
|
+
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的字幕时间要从本段内开始、"
|
|
1131
|
+
f"在成片结尾之前结束: {start:g}-{end:g}"
|
|
1132
|
+
)
|
|
1133
|
+
placed.append((*shown, cut))
|
|
1134
|
+
placed.sort(key=lambda item: item[0])
|
|
1135
|
+
for (_, end, earlier), (start, _, later) in zip(placed, placed[1:]):
|
|
1136
|
+
if earlier is not later and start < end - TOLERANCE:
|
|
1137
|
+
findings.append(
|
|
1138
|
+
f"{CUT_LIST_NAME}:{later.line_number}: {later.cut_id} 的字幕与 {earlier.cut_id} "
|
|
1139
|
+
f"跨过来的字幕同时在屏上(成片 {start:.2f} 秒)"
|
|
1140
|
+
)
|
|
1141
|
+
return findings
|
|
1142
|
+
|
|
1143
|
+
|
|
709
1144
|
def check_cuts(
|
|
710
1145
|
episode: Path,
|
|
711
1146
|
cuts: Sequence[Cut],
|
|
@@ -713,8 +1148,17 @@ def check_cuts(
|
|
|
713
1148
|
*,
|
|
714
1149
|
probe: bool,
|
|
715
1150
|
unused: Sequence[str] = (),
|
|
1151
|
+
delivery: Optional[Delivery] = None,
|
|
1152
|
+
listen: Optional[bool] = None,
|
|
716
1153
|
) -> list[str]:
|
|
717
|
-
"""Every mechanical cross-check the cut list can be held to. Returns findings.
|
|
1154
|
+
"""Every mechanical cross-check the cut list can be held to. Returns findings.
|
|
1155
|
+
|
|
1156
|
+
`probe` reads media with ffprobe; `listen` (default: `probe`) decodes voice
|
|
1157
|
+
files with ffmpeg to find where they are audible.
|
|
1158
|
+
"""
|
|
1159
|
+
|
|
1160
|
+
if listen is None:
|
|
1161
|
+
listen = probe
|
|
718
1162
|
|
|
719
1163
|
findings: list[str] = []
|
|
720
1164
|
if not cuts:
|
|
@@ -727,25 +1171,22 @@ def check_cuts(
|
|
|
727
1171
|
findings.append(f"{CUT_LIST_NAME}:{cut.line_number}: CUT ID 重复: {cut.cut_id}")
|
|
728
1172
|
seen.add(cut.cut_id)
|
|
729
1173
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
1174
|
+
findings.extend(_source_findings(episode, cuts, unused))
|
|
1175
|
+
# Stills are drawn at the delivery frame; video in the same film must match it.
|
|
1176
|
+
spec: Optional[tuple[Any, Any, float]] = None
|
|
1177
|
+
if any(cut.still for cut in cuts):
|
|
1178
|
+
if delivery is None or delivery.frame_size is None or delivery.fps is None:
|
|
1179
|
+
findings.append(
|
|
1180
|
+
f"{CUT_LIST_NAME}: 有静帧段时,交付规格必须写「画幅与帧率」"
|
|
1181
|
+
"(如 1080×1920 · 24fps);静帧按它出画"
|
|
737
1182
|
)
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
if cut.motion not in known:
|
|
1183
|
+
else:
|
|
1184
|
+
spec = (*delivery.frame_size, round(delivery.fps, 3))
|
|
1185
|
+
if any(side % 2 for side in delivery.frame_size):
|
|
742
1186
|
findings.append(
|
|
743
|
-
f"{CUT_LIST_NAME}:{
|
|
744
|
-
|
|
1187
|
+
f"{CUT_LIST_NAME}: 交付画幅 {delivery.frame_size[0]}×{delivery.frame_size[1]} "
|
|
1188
|
+
"有奇数边;静帧按它编码成 4:2:0,宽高都要是偶数"
|
|
745
1189
|
)
|
|
746
|
-
findings.extend(_unaccounted_shots(known, cuts, unused))
|
|
747
|
-
else:
|
|
748
|
-
findings.append(f"没有 {motion_path},来源 MOTION 无法核对")
|
|
749
1190
|
|
|
750
1191
|
screenplay = ""
|
|
751
1192
|
screenplay_path = episode / SCREENPLAY_DOCUMENT
|
|
@@ -753,6 +1194,7 @@ def check_cuts(
|
|
|
753
1194
|
screenplay = screenplay_path.read_text(encoding="utf-8")
|
|
754
1195
|
|
|
755
1196
|
media_format: Optional[tuple[Any, Any, float]] = None
|
|
1197
|
+
voiced = True
|
|
756
1198
|
for cut in cuts:
|
|
757
1199
|
span = cut.end - cut.start
|
|
758
1200
|
if cut.start < 0:
|
|
@@ -766,11 +1208,28 @@ def check_cuts(
|
|
|
766
1208
|
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 出点 - 入点 = "
|
|
767
1209
|
f"{span:.2f},与「时长:{cut.declared:.2f}」不符"
|
|
768
1210
|
)
|
|
1211
|
+
if cut.still and abs(cut.start) > TOLERANCE:
|
|
1212
|
+
findings.append(
|
|
1213
|
+
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 是静帧,入点必须是 0.00,"
|
|
1214
|
+
"出点等于时长"
|
|
1215
|
+
)
|
|
1216
|
+
if cut.still and cut.declared < STILL_MINIMUM - TOLERANCE:
|
|
1217
|
+
findings.append(
|
|
1218
|
+
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 是静帧,时长 "
|
|
1219
|
+
f"{cut.declared:.2f} 短于 {STILL_MINIMUM:.2f} 秒"
|
|
1220
|
+
)
|
|
769
1221
|
media = _resolve_media(episode, project_root, cut.media)
|
|
770
1222
|
if media is None:
|
|
771
1223
|
findings.append(
|
|
772
|
-
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id}
|
|
1224
|
+
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的素材不存在或不在项目目录内: {cut.media}"
|
|
773
1225
|
)
|
|
1226
|
+
elif probe and cut.still:
|
|
1227
|
+
try:
|
|
1228
|
+
probe_stream(media)
|
|
1229
|
+
except EditError as error:
|
|
1230
|
+
findings.append(
|
|
1231
|
+
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的图片读不出: {error}"
|
|
1232
|
+
)
|
|
774
1233
|
elif probe:
|
|
775
1234
|
stream = probe_stream(media)
|
|
776
1235
|
available = stream["duration"]
|
|
@@ -783,35 +1242,87 @@ def check_cuts(
|
|
|
783
1242
|
f"{current_format} 与首段 {media_format} 不一致;"
|
|
784
1243
|
"先在外部统一素材规格,再更新来源路径与入出点"
|
|
785
1244
|
)
|
|
1245
|
+
if spec is not None and current_format != spec:
|
|
1246
|
+
findings.append(
|
|
1247
|
+
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的画幅或帧率 "
|
|
1248
|
+
f"{current_format} 与交付规格 {spec} 不一致;静帧按交付规格出画,"
|
|
1249
|
+
"同一条片里的视频段须与之相同"
|
|
1250
|
+
)
|
|
786
1251
|
if cut.end > available + TOLERANCE:
|
|
787
1252
|
findings.append(
|
|
788
1253
|
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 出点 {cut.end:.2f} "
|
|
789
1254
|
f"超过素材实际时长 {available:.2f}"
|
|
790
1255
|
)
|
|
791
|
-
for
|
|
1256
|
+
for _, _, text, _ in cut.subtitles:
|
|
792
1257
|
if screenplay and _normalize(text) not in _normalize(screenplay):
|
|
793
1258
|
findings.append(
|
|
794
1259
|
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的字幕在《"
|
|
795
1260
|
f"{SCREENPLAY_DOCUMENT}》里找不到原文: {text}"
|
|
796
1261
|
)
|
|
797
|
-
if window_start is None or window_end is None:
|
|
798
|
-
continue
|
|
799
|
-
if not 0 <= window_start < window_end <= span + TOLERANCE:
|
|
800
|
-
findings.append(
|
|
801
|
-
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的字幕时间超出本段区间"
|
|
802
|
-
f": {window_start}-{window_end}"
|
|
803
|
-
)
|
|
804
1262
|
for effect in cut.sound_effects:
|
|
805
1263
|
if not 0 <= effect.start < effect.end <= span + TOLERANCE:
|
|
806
1264
|
findings.append(
|
|
807
1265
|
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的音效时间超出本段区间"
|
|
808
1266
|
f": {effect.start:g}-{effect.end:g}"
|
|
809
1267
|
)
|
|
810
|
-
|
|
1268
|
+
effect_media = _resolve_media(episode, project_root, effect.path)
|
|
1269
|
+
if effect_media is None:
|
|
811
1270
|
findings.append(
|
|
812
|
-
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id}
|
|
1271
|
+
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的音效文件不存在或不在项目目录内: "
|
|
813
1272
|
f"{effect.path}"
|
|
814
1273
|
)
|
|
1274
|
+
elif probe and effect.offset > 0:
|
|
1275
|
+
try:
|
|
1276
|
+
available_sound = probe_duration(effect_media)
|
|
1277
|
+
if effect.offset >= available_sound:
|
|
1278
|
+
findings.append(
|
|
1279
|
+
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的音效起点 "
|
|
1280
|
+
f"{effect.offset:g} 必须小于 {effect.path} 的实际时长 {available_sound:g}"
|
|
1281
|
+
)
|
|
1282
|
+
except EditError as error:
|
|
1283
|
+
findings.append(f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的音效读不出: {error}")
|
|
1284
|
+
for voice in cut.voices:
|
|
1285
|
+
if _resolve_media(episode, project_root, voice.path) is None:
|
|
1286
|
+
voiced = False
|
|
1287
|
+
findings.append(
|
|
1288
|
+
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的配音文件不存在或不在项目目录内: "
|
|
1289
|
+
f"{voice.path}"
|
|
1290
|
+
)
|
|
1291
|
+
|
|
1292
|
+
# The film's rate: the spec's, else the one the probe loop read off the video.
|
|
1293
|
+
fps = delivery.fps if delivery is not None and delivery.fps else (media_format or (0, 0, 0.0))[2]
|
|
1294
|
+
lengths = _film_spans(cuts, fps)
|
|
1295
|
+
for index, cut in enumerate(cuts):
|
|
1296
|
+
where = f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id}"
|
|
1297
|
+
if cut.move is not None and cut.move.rate is not None:
|
|
1298
|
+
reach = cut.move.rate * lengths[index]
|
|
1299
|
+
if reach > CAMERA_MOVE_LIMITS[1]:
|
|
1300
|
+
findings.append(
|
|
1301
|
+
f"{where} 的运镜 {cut.move.rate:g}%/秒 走完这一段是 {reach:.0f}%,"
|
|
1302
|
+
f"超过 {CAMERA_MOVE_LIMITS[1]}%;放慢或缩短这一段"
|
|
1303
|
+
)
|
|
1304
|
+
if cut.bed is not None:
|
|
1305
|
+
earliest = -lengths[index - 1] if index else 0.0
|
|
1306
|
+
if not earliest - TOLERANCE <= cut.bed.start < cut.end - cut.start:
|
|
1307
|
+
findings.append(
|
|
1308
|
+
f"{where} 的环境声起点 {cut.bed.start:g} 要在 {earliest:g}(上一段开头)到本段结尾之间"
|
|
1309
|
+
)
|
|
1310
|
+
if cut.bed.path is not None and _resolve_media(episode, project_root, cut.bed.path) is None:
|
|
1311
|
+
findings.append(f"{where} 的环境声文件不存在或不在项目目录内: {cut.bed.path}")
|
|
1312
|
+
for voice in cut.voices:
|
|
1313
|
+
# A J-cut reaches back at most to the previous cut's start.
|
|
1314
|
+
earliest = -lengths[index - 1] if index else 0.0
|
|
1315
|
+
if not earliest - TOLERANCE <= voice.start < cut.end - cut.start:
|
|
1316
|
+
findings.append(
|
|
1317
|
+
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的配音起点 {voice.start:g} "
|
|
1318
|
+
f"要在 {earliest:g}(上一段开头)到本段结尾之间"
|
|
1319
|
+
)
|
|
1320
|
+
findings.extend(_subtitle_findings(cuts, lengths))
|
|
1321
|
+
if listen and voiced and any(cut.voices for cut in cuts):
|
|
1322
|
+
try:
|
|
1323
|
+
findings.extend(_voice_findings(cuts, _voice_sounds(episode, project_root, cuts), lengths))
|
|
1324
|
+
except EditError as error:
|
|
1325
|
+
findings.append(f"{CUT_LIST_NAME}: {error}")
|
|
815
1326
|
|
|
816
1327
|
findings.extend(_screen_text_findings(cuts, screenplay))
|
|
817
1328
|
findings.extend(_overlap_findings(cuts))
|
|
@@ -876,22 +1387,26 @@ def _stale_window_findings(
|
|
|
876
1387
|
any(subtitle[0] is not None for subtitle in cut.subtitles)
|
|
877
1388
|
or cut.screen_texts
|
|
878
1389
|
or cut.sound_effects
|
|
1390
|
+
or cut.voices
|
|
879
1391
|
)
|
|
880
1392
|
if not timed:
|
|
881
1393
|
continue
|
|
882
|
-
|
|
883
|
-
if
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1394
|
+
# A still's timings were measured on its voice files, not on the picture.
|
|
1395
|
+
written = [voice.path for voice in cut.voices] + ([] if cut.still else [cut.media])
|
|
1396
|
+
for relative in written:
|
|
1397
|
+
media = _resolve_media(episode, project_root, relative)
|
|
1398
|
+
if media is None:
|
|
1399
|
+
continue
|
|
1400
|
+
try:
|
|
1401
|
+
changed = media.stat().st_mtime
|
|
1402
|
+
except OSError:
|
|
1403
|
+
continue
|
|
1404
|
+
if changed > authored + 1.0:
|
|
1405
|
+
findings.append(
|
|
1406
|
+
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的素材比剪辑单新"
|
|
1407
|
+
f"({relative});这一段的字幕、画面文字与音效时间是按旧素材反推的,"
|
|
1408
|
+
"重出之后必须重测再改,不能沿用"
|
|
1409
|
+
)
|
|
895
1410
|
return findings
|
|
896
1411
|
|
|
897
1412
|
|
|
@@ -901,7 +1416,9 @@ def _overlap_findings(cuts: Sequence[Cut]) -> list[str]:
|
|
|
901
1416
|
findings: list[str] = []
|
|
902
1417
|
by_media: dict[str, list[Cut]] = {}
|
|
903
1418
|
for cut in cuts:
|
|
904
|
-
|
|
1419
|
+
# A keyframe reused in a shot/reverse-shot is a choice, not repeated footage.
|
|
1420
|
+
if not cut.still:
|
|
1421
|
+
by_media.setdefault(cut.media, []).append(cut)
|
|
905
1422
|
for media, group in by_media.items():
|
|
906
1423
|
ordered = sorted(group, key=lambda item: item.start)
|
|
907
1424
|
for earlier, later in zip(ordered, ordered[1:]):
|
|
@@ -920,9 +1437,16 @@ def _normalize(text: str) -> str:
|
|
|
920
1437
|
|
|
921
1438
|
|
|
922
1439
|
def _resolve_media(episode: Path, project_root: Path, relative: str) -> Optional[Path]:
|
|
1440
|
+
"""The file a cut list names, against the episode then the project; None if absent.
|
|
1441
|
+
|
|
1442
|
+
A path that leaves the project (`../`, an absolute path, a link out) is
|
|
1443
|
+
treated as absent: the cut list describes this project's material only.
|
|
1444
|
+
"""
|
|
1445
|
+
|
|
1446
|
+
root = project_root.resolve()
|
|
923
1447
|
for base in (episode, project_root):
|
|
924
1448
|
candidate = (base / relative).resolve()
|
|
925
|
-
if candidate.is_file():
|
|
1449
|
+
if candidate.is_file() and (candidate == root or root in candidate.parents):
|
|
926
1450
|
return candidate
|
|
927
1451
|
return None
|
|
928
1452
|
|
|
@@ -946,31 +1470,62 @@ def render(
|
|
|
946
1470
|
|
|
947
1471
|
def measure(cut: Cut) -> Optional[ChannelStats]:
|
|
948
1472
|
media = _resolve_media(episode, project_root, cut.media)
|
|
949
|
-
|
|
1473
|
+
if media is None:
|
|
1474
|
+
return None
|
|
1475
|
+
if cut.still:
|
|
1476
|
+
return None if delivery.frame_size is None else _still_stats(
|
|
1477
|
+
ffmpeg, media, delivery.frame_size
|
|
1478
|
+
)
|
|
1479
|
+
return _channel_stats(ffmpeg, media, cut.start, cut.end - cut.start)
|
|
950
1480
|
|
|
951
1481
|
scenes = _scene_keys(episode, cuts)
|
|
952
1482
|
pictures, auto = _picture_plan(cuts, scenes, measure, enabled=delivery.shot_match)
|
|
1483
|
+
silence = _silent_track(episode, project_root, cuts, delivery)
|
|
1484
|
+
sounds = _voice_sounds(episode, project_root, cuts)
|
|
1485
|
+
fps = _film_fps(episode, project_root, cuts, delivery, probe=True)
|
|
1486
|
+
spans = _film_spans(cuts, fps)
|
|
953
1487
|
|
|
1488
|
+
eases = _move_ease(cuts, scenes)
|
|
954
1489
|
segments: list[Path] = []
|
|
955
|
-
|
|
956
|
-
for cut, match in zip(cuts, pictures):
|
|
1490
|
+
for cut, match, length, ease in zip(cuts, pictures, spans, eases):
|
|
957
1491
|
media = _resolve_media(episode, project_root, cut.media)
|
|
958
1492
|
if media is None:
|
|
959
|
-
raise EditError(f"{cut.cut_id}
|
|
1493
|
+
raise EditError(f"{cut.cut_id} 的素材不存在或不在项目目录内: {cut.media}")
|
|
960
1494
|
segment = segments_root / f"{cut.cut_id}.mp4"
|
|
961
|
-
|
|
962
|
-
ffmpeg,
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
command
|
|
1495
|
+
if cut.still:
|
|
1496
|
+
command = _still_command(ffmpeg, media, cut, delivery, silence, match, length, ease)
|
|
1497
|
+
else:
|
|
1498
|
+
# Read enough source for the whole frames the film gives this cut.
|
|
1499
|
+
span = max(cut.end - cut.start, length)
|
|
1500
|
+
command = [
|
|
1501
|
+
ffmpeg, "-hide_banner", "-loglevel", "error", "-y",
|
|
1502
|
+
"-ss", f"{cut.start:.3f}", "-t", f"{span:.3f}", "-i", str(media),
|
|
1503
|
+
]
|
|
1504
|
+
if probe_audio(media) is None:
|
|
1505
|
+
rate, layout = silence
|
|
1506
|
+
command += [
|
|
1507
|
+
"-f", "lavfi", "-t", f"{span:.3f}", "-i", f"anullsrc=r={rate}:cl={layout}",
|
|
1508
|
+
"-map", "0:v", "-map", "1:a",
|
|
1509
|
+
]
|
|
1510
|
+
if match:
|
|
1511
|
+
command += ["-vf", match]
|
|
1512
|
+
if fps:
|
|
1513
|
+
command += ["-frames:v", str(round(length * fps)), "-t", f"{length:.6f}"]
|
|
967
1514
|
command += [
|
|
968
1515
|
"-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p",
|
|
1516
|
+
"-ar", str(silence[0]), "-channel_layout", silence[1],
|
|
969
1517
|
"-c:a", "aac", "-b:a", "192k", str(segment),
|
|
970
1518
|
]
|
|
971
1519
|
_run(command)
|
|
972
1520
|
segments.append(segment)
|
|
973
|
-
spans
|
|
1521
|
+
# Everything after this cut is placed by `spans`; a segment that came
|
|
1522
|
+
# out short (the source ran out of frames) would shift all of it.
|
|
1523
|
+
rendered = probe_duration(segment)
|
|
1524
|
+
if fps and abs(rendered - length) > 1.5 / fps:
|
|
1525
|
+
raise EditError(
|
|
1526
|
+
f"{cut.cut_id} 渲染出 {rendered:.3f} 秒,按 {fps:g} fps 应为 {length:.3f} 秒;"
|
|
1527
|
+
"素材在出点附近可能不够帧,先把出点提前一帧"
|
|
1528
|
+
)
|
|
974
1529
|
|
|
975
1530
|
with tempfile.TemporaryDirectory() as scratch:
|
|
976
1531
|
listing = Path(scratch) / "segments.txt"
|
|
@@ -978,15 +1533,22 @@ def render(
|
|
|
978
1533
|
"".join(f"file '{segment.as_posix()}'\n" for segment in segments), encoding="utf-8"
|
|
979
1534
|
)
|
|
980
1535
|
joined = output_root / "成片-未混音.mp4"
|
|
981
|
-
|
|
1536
|
+
# AAC encoder priming starts before zero. Keep that timestamp rather
|
|
1537
|
+
# than shifting the video by one audio packet (and duplicating a frame
|
|
1538
|
+
# when downstream tools decode at the delivery frame rate).
|
|
1539
|
+
_run([ffmpeg, "-hide_banner", "-loglevel", "error", "-y", "-copyts",
|
|
982
1540
|
"-f", "concat", "-safe", "0", "-i", str(listing), "-c", "copy", str(joined)])
|
|
983
1541
|
|
|
984
|
-
effects: list[
|
|
985
|
-
|
|
986
|
-
|
|
1542
|
+
effects: list[Placed] = []
|
|
1543
|
+
placed = (
|
|
1544
|
+
_placed_sound_effects(cuts, spans) + _placed_voices(cuts, sounds, spans)
|
|
1545
|
+
+ _placed_beds(cuts, spans)
|
|
1546
|
+
)
|
|
1547
|
+
for sound in placed:
|
|
1548
|
+
resolved = _resolve_media(episode, project_root, sound.path)
|
|
987
1549
|
if resolved is None:
|
|
988
|
-
raise EditError(f"
|
|
989
|
-
effects.append((
|
|
1550
|
+
raise EditError(f"音效、配音或环境声文件不存在或不在项目目录内: {sound.path}")
|
|
1551
|
+
effects.append(sound._replace(path=str(resolved)))
|
|
990
1552
|
if effects:
|
|
991
1553
|
# Mixed into its own file first, so the loudness pass below measures
|
|
992
1554
|
# the film the audience hears rather than the one before the chimes.
|
|
@@ -1077,13 +1639,182 @@ def render(
|
|
|
1077
1639
|
"叠层": str(overlay_path) if overlay_path else None,
|
|
1078
1640
|
"字幕渲染": renderer if subtitle_path else None,
|
|
1079
1641
|
"画面文字": len(layers),
|
|
1080
|
-
"音效": len(
|
|
1642
|
+
"音效": sum(len(cut.sound_effects) for cut in cuts),
|
|
1643
|
+
"配音": sum(len(cut.voices) for cut in cuts),
|
|
1644
|
+
"环境声": len(_placed_beds(cuts, spans)),
|
|
1645
|
+
"静帧段": sum(cut.still for cut in cuts),
|
|
1081
1646
|
"自动接镜": _shot_match_report(auto, scenes, enabled=delivery.shot_match),
|
|
1082
1647
|
"段数": len(segments),
|
|
1083
1648
|
"各段时长之和": round(sum(cut.end - cut.start for cut in cuts), 2),
|
|
1084
1649
|
}
|
|
1085
1650
|
|
|
1086
1651
|
|
|
1652
|
+
def _silent_track(
|
|
1653
|
+
episode: Path, project_root: Path, cuts: Sequence[Cut], delivery: Delivery
|
|
1654
|
+
) -> tuple[int, str]:
|
|
1655
|
+
"""Shared (sample rate, layout) for every rendered segment.
|
|
1656
|
+
|
|
1657
|
+
Segments are joined by stream copy, so every segment needs an audio track
|
|
1658
|
+
encoded like the first video audio beside it, including audible clips: a
|
|
1659
|
+
segment without one shifts every sound after it, and a mismatched one
|
|
1660
|
+
decodes wrong. 48 kHz stereo when no clip has sound.
|
|
1661
|
+
"""
|
|
1662
|
+
|
|
1663
|
+
if any(cut.still for cut in cuts) and (delivery.frame_size is None or delivery.fps is None):
|
|
1664
|
+
raise EditError("有静帧段时,交付规格必须写「画幅与帧率」;静帧按它出画")
|
|
1665
|
+
for cut in cuts:
|
|
1666
|
+
media = None if cut.still else _resolve_media(episode, project_root, cut.media)
|
|
1667
|
+
heard = probe_audio(media) if media is not None else None
|
|
1668
|
+
if heard is not None:
|
|
1669
|
+
return heard
|
|
1670
|
+
return STILL_AUDIO
|
|
1671
|
+
|
|
1672
|
+
|
|
1673
|
+
def _move_ease(
|
|
1674
|
+
cuts: Sequence[Cut], scenes: Sequence[Optional[tuple[str, ...]]]
|
|
1675
|
+
) -> list[tuple[bool, bool]]:
|
|
1676
|
+
"""(ease in, ease out) per cut: a move ramps only where it starts or stops from rest.
|
|
1677
|
+
|
|
1678
|
+
Two adjacent rate moves (「%/秒」) in the same direction and the same scene
|
|
1679
|
+
run straight through the cut between them: the camera does not stop at the
|
|
1680
|
+
join. A move eases in where the cut before it is in another scene, is 固定,
|
|
1681
|
+
or moves some other way, and eases out likewise. The percentage form always
|
|
1682
|
+
eases at both ends: it states where the move ends, not how fast it goes.
|
|
1683
|
+
"""
|
|
1684
|
+
|
|
1685
|
+
def continues(earlier: int, later: int) -> bool:
|
|
1686
|
+
first, second = cuts[earlier].move, cuts[later].move
|
|
1687
|
+
return (
|
|
1688
|
+
first is not None and second is not None
|
|
1689
|
+
and first.rate is not None and second.rate is not None
|
|
1690
|
+
and first.kind == second.kind != CAMERA_HOLD
|
|
1691
|
+
and scenes[earlier] is not None and scenes[earlier] == scenes[later]
|
|
1692
|
+
)
|
|
1693
|
+
|
|
1694
|
+
last = len(cuts) - 1
|
|
1695
|
+
return [
|
|
1696
|
+
(not (index and continues(index - 1, index)), not (index < last and continues(index, index + 1)))
|
|
1697
|
+
for index in range(len(cuts))
|
|
1698
|
+
]
|
|
1699
|
+
|
|
1700
|
+
|
|
1701
|
+
def _move_progress(
|
|
1702
|
+
move: CameraMove, frames: int, fps: float, ease: tuple[bool, bool]
|
|
1703
|
+
) -> tuple[str, float]:
|
|
1704
|
+
"""How far the move has gone at output frame `on`, in percent, and how far it goes in all.
|
|
1705
|
+
|
|
1706
|
+
A rate move cruises at its speed and ramps (a half cosine, CAMERA_EASE long)
|
|
1707
|
+
only at the ends `ease` names, so where it does not ease its speed at the
|
|
1708
|
+
cut equals the next cut's. The percentage form eases over the whole cut.
|
|
1709
|
+
"""
|
|
1710
|
+
|
|
1711
|
+
last = max(frames - 1, 1)
|
|
1712
|
+
if move.rate is None:
|
|
1713
|
+
return f"({move.amount}*(1-cos(PI*on/{last}))/2)", float(move.amount)
|
|
1714
|
+
span = last / fps
|
|
1715
|
+
ramp_in = min(CAMERA_EASE, span / 2) if ease[0] else 0.0
|
|
1716
|
+
ramp_out = min(CAMERA_EASE, span / 2) if ease[1] else 0.0
|
|
1717
|
+
t = f"(on/{fps:g})"
|
|
1718
|
+
terms = [t]
|
|
1719
|
+
# Distance lost to each ramp so far: the integral of (1 - speed / cruise).
|
|
1720
|
+
if ramp_in:
|
|
1721
|
+
a = f"min({t},{ramp_in:.6f})"
|
|
1722
|
+
terms.append(f"-({a}+{ramp_in / 3.141592653589793:.6f}*sin(PI*{a}/{ramp_in:.6f}))/2")
|
|
1723
|
+
if ramp_out:
|
|
1724
|
+
b = f"max(0,{t}-{span - ramp_out:.6f})"
|
|
1725
|
+
terms.append(f"-({b}-{ramp_out / 3.141592653589793:.6f}*sin(PI*{b}/{ramp_out:.6f}))/2")
|
|
1726
|
+
total = move.rate * (span - ramp_in / 2 - ramp_out / 2)
|
|
1727
|
+
return f"({move.rate:g}*({''.join(terms)}))", total
|
|
1728
|
+
|
|
1729
|
+
|
|
1730
|
+
def _still_filter(
|
|
1731
|
+
move: CameraMove, width: int, height: int, frames: int,
|
|
1732
|
+
fps: float = 24.0, ease: tuple[bool, bool] = (True, True),
|
|
1733
|
+
) -> str:
|
|
1734
|
+
"""Cover-fit a still to the frame and move across it on an eased curve.
|
|
1735
|
+
|
|
1736
|
+
The move is a window over the fitted picture, `1/zoom` of it on each side,
|
|
1737
|
+
whose corners `perspective` samples per frame at sub-pixel positions.
|
|
1738
|
+
zoompan and crop place the window on whole pixels, which is what makes a
|
|
1739
|
+
slow push judder. Measured on 1080×1920 renders, the frame-to-frame motion
|
|
1740
|
+
of a push wavered 0.34 px rms with zoompan, 0.14 px with zoompan at 4×
|
|
1741
|
+
internal scale and 0.10 px here; of a pan, 0.84, 0.23 and 0.11 px. This
|
|
1742
|
+
route also renders no slower than the 4× one.
|
|
1743
|
+
"""
|
|
1744
|
+
|
|
1745
|
+
fitted = (
|
|
1746
|
+
f"scale={width}:{height}:force_original_aspect_ratio=increase:flags=lanczos,"
|
|
1747
|
+
f"crop={width}:{height},setsar=1"
|
|
1748
|
+
)
|
|
1749
|
+
if move.kind == CAMERA_HOLD:
|
|
1750
|
+
return fitted
|
|
1751
|
+
gone, total = _move_progress(move, frames, fps, ease)
|
|
1752
|
+
reach = total / 100
|
|
1753
|
+
# Share of the move done, 0 to 1.
|
|
1754
|
+
eased = f"({gone}/{total:.6f})" if total > 0 else "0"
|
|
1755
|
+
zoom = {
|
|
1756
|
+
"推近": f"(1+{reach:.6f}*{eased})",
|
|
1757
|
+
"拉远": f"(1+{reach:.6f}*(1-{eased}))",
|
|
1758
|
+
}.get(move.kind, f"{1 + reach:.6f}")
|
|
1759
|
+
across, down = f"(W/{zoom})", f"(H/{zoom})"
|
|
1760
|
+
room_x, room_y = f"(W-{across})", f"(H-{down})"
|
|
1761
|
+
# A camera move: 左移 slides the window left, so the picture drifts right.
|
|
1762
|
+
x = {"左移": f"{room_x}*(1-{eased})", "右移": f"{room_x}*{eased}"}.get(move.kind, f"{room_x}/2")
|
|
1763
|
+
y = {"上移": f"{room_y}*(1-{eased})", "下移": f"{room_y}*{eased}"}.get(move.kind, f"{room_y}/2")
|
|
1764
|
+
corners = ((x, y), (f"{x}+{across}", y), (x, f"{y}+{down}"), (f"{x}+{across}", f"{y}+{down}"))
|
|
1765
|
+
points = ":".join(f"x{i}='{cx}':y{i}='{cy}'" for i, (cx, cy) in enumerate(corners))
|
|
1766
|
+
return f"{fitted},perspective={points}:interpolation=cubic:eval=frame"
|
|
1767
|
+
|
|
1768
|
+
|
|
1769
|
+
def _still_command(
|
|
1770
|
+
ffmpeg: str,
|
|
1771
|
+
image: Path,
|
|
1772
|
+
cut: Cut,
|
|
1773
|
+
delivery: Delivery,
|
|
1774
|
+
silence: tuple[int, str],
|
|
1775
|
+
match: str,
|
|
1776
|
+
seconds: float,
|
|
1777
|
+
ease: tuple[bool, bool] = (True, True),
|
|
1778
|
+
) -> list[str]:
|
|
1779
|
+
"""The input half of a still segment's command: picture, move, correction and silence.
|
|
1780
|
+
|
|
1781
|
+
`seconds` is the cut's span from `_film_spans`, a whole number of frames.
|
|
1782
|
+
"""
|
|
1783
|
+
|
|
1784
|
+
assert delivery.frame_size is not None and delivery.fps is not None
|
|
1785
|
+
width, height = delivery.frame_size
|
|
1786
|
+
frames = max(1, round(seconds * delivery.fps))
|
|
1787
|
+
chain = _still_filter(cut.move or CameraMove(), width, height, frames, delivery.fps, ease)
|
|
1788
|
+
rate, layout = silence
|
|
1789
|
+
return [
|
|
1790
|
+
ffmpeg, "-hide_banner", "-loglevel", "error", "-y",
|
|
1791
|
+
"-loop", "1", "-framerate", f"{delivery.fps:g}", "-i", str(image),
|
|
1792
|
+
"-f", "lavfi", "-t", f"{seconds:.3f}", "-i", f"anullsrc=r={rate}:cl={layout}",
|
|
1793
|
+
"-vf", ",".join(filter(None, [chain, match])),
|
|
1794
|
+
"-map", "0:v", "-map", "1:a", "-frames:v", str(frames), "-t", f"{seconds:.3f}",
|
|
1795
|
+
]
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
def _placed_voices(
|
|
1799
|
+
cuts: Sequence[Cut], sounds: dict[str, Audible], spans: Sequence[float]
|
|
1800
|
+
) -> list[Placed]:
|
|
1801
|
+
"""Each voice line in the form `_placed_sound_effects` gives, for the same mix.
|
|
1802
|
+
|
|
1803
|
+
Its start follows the rendered cut positions, like an effect's. Its length
|
|
1804
|
+
does not: an effect's window is a stretch of its cut and scales with the
|
|
1805
|
+
frame rounding, but a voice plays its file from 起点 to the end, and
|
|
1806
|
+
scaling that would clip the line. It is laid at its cut's start plus 起
|
|
1807
|
+
however far past the cut it runs, which is what makes an L-cut.
|
|
1808
|
+
"""
|
|
1809
|
+
|
|
1810
|
+
return [
|
|
1811
|
+
Placed(max(0.0, _at(cut, cursor, length, voice.start)), sounds[voice.path].duration - voice.offset,
|
|
1812
|
+
voice.path, voice.gain_db, voice.offset)
|
|
1813
|
+
for cut, cursor, length in _timeline(cuts, spans)
|
|
1814
|
+
for voice in cut.voices
|
|
1815
|
+
]
|
|
1816
|
+
|
|
1817
|
+
|
|
1087
1818
|
def _shot_match_filter(cut: Cut) -> str:
|
|
1088
1819
|
"""The correction a cut states in 「画面:」. It replaces the automatic match."""
|
|
1089
1820
|
|
|
@@ -1127,20 +1858,54 @@ def _heading_fields(path: Path, heading: re.Pattern[str], field: str) -> dict[st
|
|
|
1127
1858
|
def _scene_keys(episode: Path, cuts: Sequence[Cut]) -> list[Optional[tuple[str, ...]]]:
|
|
1128
1859
|
"""Each cut's scene: MOTION → its 分镜 SHOT → the scene IDs that SHOT's 来源 names.
|
|
1129
1860
|
|
|
1130
|
-
A shot drawing on two scenes keys on both,
|
|
1131
|
-
None where the chain breaks
|
|
1861
|
+
A SHOT- still starts at the SHOT. A shot drawing on two scenes keys on both,
|
|
1862
|
+
so it matches neither neighbour. None where the chain breaks (an IMG- still
|
|
1863
|
+
names no shot); such a cut is never matched.
|
|
1132
1864
|
"""
|
|
1133
1865
|
|
|
1134
|
-
shots =
|
|
1866
|
+
shots = _motion_shots(episode)
|
|
1135
1867
|
sources = _heading_fields(episode / STORYBOARD_DOCUMENT, SHOT_HEADING, "来源")
|
|
1136
1868
|
keys: list[Optional[tuple[str, ...]]] = []
|
|
1137
1869
|
for cut in cuts:
|
|
1138
|
-
shot =
|
|
1139
|
-
scenes = tuple(SCENE_ID.findall(sources.get(shot
|
|
1870
|
+
shot = cut.motion if cut.motion.startswith("SHOT-") else shots.get(cut.motion)
|
|
1871
|
+
scenes = tuple(SCENE_ID.findall(sources.get(shot, ""))) if shot else ()
|
|
1140
1872
|
keys.append(scenes or None)
|
|
1141
1873
|
return keys
|
|
1142
1874
|
|
|
1143
1875
|
|
|
1876
|
+
def move_notices(
|
|
1877
|
+
cuts: Sequence[Cut], scenes: Sequence[Optional[tuple[str, ...]]], lengths: Sequence[float]
|
|
1878
|
+
) -> list[str]:
|
|
1879
|
+
"""Joins inside one scene that will read as a jolt: a speed jump or a reversed move.
|
|
1880
|
+
|
|
1881
|
+
Not errors: a deliberate change of pace is the creator's to make.
|
|
1882
|
+
"""
|
|
1883
|
+
|
|
1884
|
+
def speed(cut: Cut, length: float) -> Optional[float]:
|
|
1885
|
+
move = cut.move
|
|
1886
|
+
if move is None or move.kind == CAMERA_HOLD:
|
|
1887
|
+
return None
|
|
1888
|
+
return move.rate if move.rate is not None else move.amount / length if length else None
|
|
1889
|
+
|
|
1890
|
+
notices: list[str] = []
|
|
1891
|
+
for index in range(1, len(cuts)):
|
|
1892
|
+
before, after = cuts[index - 1], cuts[index]
|
|
1893
|
+
if scenes[index - 1] is None or scenes[index - 1] != scenes[index]:
|
|
1894
|
+
continue
|
|
1895
|
+
first, second = speed(before, lengths[index - 1]), speed(after, lengths[index])
|
|
1896
|
+
if first is None or second is None or before.move is None or after.move is None:
|
|
1897
|
+
continue
|
|
1898
|
+
pair = f"{before.cut_id}({before.move.kind} 约 {first:.1f}%/秒)→ {after.cut_id}({after.move.kind} 约 {second:.1f}%/秒)"
|
|
1899
|
+
if CAMERA_OPPOSITES[before.move.kind] == after.move.kind:
|
|
1900
|
+
notices.append(f"{pair}:同一场里镜头来回反向,切过去像晃了一下;改成同向,或让其中一段固定")
|
|
1901
|
+
elif max(first, second) > (1 + CAMERA_SPEED_NOTICE) * min(first, second):
|
|
1902
|
+
notices.append(
|
|
1903
|
+
f"{pair}:同一场里运镜速度差了 {max(first, second) / min(first, second) - 1:.0%},"
|
|
1904
|
+
"切过去像换了一台机器;同一场用一个速度,写成「%/秒」最省事"
|
|
1905
|
+
)
|
|
1906
|
+
return notices
|
|
1907
|
+
|
|
1908
|
+
|
|
1144
1909
|
def _scene_runs(scenes: Sequence[Optional[tuple[str, ...]]]) -> list[list[int]]:
|
|
1145
1910
|
"""Indexes of consecutive cuts from one scene. Intercut scenes form separate runs."""
|
|
1146
1911
|
|
|
@@ -1265,11 +2030,30 @@ def _channel_stats(
|
|
|
1265
2030
|
) -> Optional[ChannelStats]:
|
|
1266
2031
|
"""Per-channel mean and spread of one cut's source range, sampled small."""
|
|
1267
2032
|
|
|
2033
|
+
return _sampled_stats([
|
|
2034
|
+
ffmpeg, "-v", "error", "-ss", f"{start:.3f}", "-t", f"{duration:.3f}",
|
|
2035
|
+
"-i", str(media), "-vf", SHOT_MATCH_SAMPLE,
|
|
2036
|
+
])
|
|
2037
|
+
|
|
2038
|
+
|
|
2039
|
+
def _still_stats(ffmpeg: str, image: Path, frame_size: tuple[int, int]) -> Optional[ChannelStats]:
|
|
2040
|
+
"""Per-channel mean and spread of a still as the frame shows it, cover-fitted.
|
|
2041
|
+
|
|
2042
|
+
An image has no timeline: `fps` drops its only frame, and a seek on a JPEG
|
|
2043
|
+
returns none, so it is read once, whole.
|
|
2044
|
+
"""
|
|
2045
|
+
|
|
2046
|
+
width, height = frame_size
|
|
2047
|
+
return _sampled_stats([
|
|
2048
|
+
ffmpeg, "-v", "error", "-i", str(image), "-vf",
|
|
2049
|
+
f"scale={width}:{height}:force_original_aspect_ratio=increase,"
|
|
2050
|
+
f"crop={width}:{height},scale=64:-2",
|
|
2051
|
+
])
|
|
2052
|
+
|
|
2053
|
+
|
|
2054
|
+
def _sampled_stats(command: list[str]) -> Optional[ChannelStats]:
|
|
1268
2055
|
result = subprocess.run(
|
|
1269
|
-
[
|
|
1270
|
-
"-i", str(media), "-vf", SHOT_MATCH_SAMPLE,
|
|
1271
|
-
"-pix_fmt", "rgb24", "-f", "rawvideo", "-"],
|
|
1272
|
-
capture_output=True, check=False,
|
|
2056
|
+
command + ["-pix_fmt", "rgb24", "-f", "rawvideo", "-"], capture_output=True, check=False
|
|
1273
2057
|
)
|
|
1274
2058
|
if result.returncode != 0 or len(result.stdout) < 3:
|
|
1275
2059
|
return None
|
|
@@ -1458,42 +2242,81 @@ def _frame_geometry(media: Path) -> str:
|
|
|
1458
2242
|
def _subtitle_cues(
|
|
1459
2243
|
cuts: Sequence[Cut], spans: Sequence[float]
|
|
1460
2244
|
) -> list[tuple[float, float, str, tuple[str, ...]]]:
|
|
1461
|
-
"""Place each line in
|
|
2245
|
+
"""Place each line in film seconds, on the frame-rounded spans the segments are rendered at.
|
|
1462
2246
|
|
|
1463
|
-
A segment lands on a frame boundary, so it
|
|
1464
|
-
the cut list declares. Accumulating the declared numbers instead
|
|
1465
|
-
a third of a second by the end of eight cuts here
|
|
1466
|
-
before the actor stops speaking.
|
|
2247
|
+
A segment lands on a frame boundary, so it runs a few milliseconds off the
|
|
2248
|
+
length the cut list declares. Accumulating the declared numbers instead
|
|
2249
|
+
drifts -- a third of a second by the end of eight cuts here -- and the
|
|
2250
|
+
subtitle leaves before the actor stops speaking.
|
|
1467
2251
|
"""
|
|
1468
2252
|
|
|
1469
2253
|
cues: list[tuple[float, float, str, tuple[str, ...]]] = []
|
|
1470
|
-
for cut, cursor,
|
|
1471
|
-
declared = cut.end - cut.start
|
|
2254
|
+
for cut, cursor, length in _timeline(cuts, spans):
|
|
1472
2255
|
for window_start, window_end, text, words in cut.subtitles:
|
|
1473
2256
|
start = 0.0 if window_start is None else window_start
|
|
1474
|
-
end = declared if window_end is None else window_end
|
|
1475
|
-
cues.append((cursor
|
|
2257
|
+
end = cut.declared if window_end is None else window_end
|
|
2258
|
+
cues.append((_at(cut, cursor, length, start), _at(cut, cursor, length, end), text, words))
|
|
1476
2259
|
return cues
|
|
1477
2260
|
|
|
1478
2261
|
|
|
2262
|
+
def _film_spans(cuts: Sequence[Cut], fps: Optional[float]) -> list[float]:
|
|
2263
|
+
"""How long each cut runs in the film: its declared length rounded to whole frames.
|
|
2264
|
+
|
|
2265
|
+
render writes every segment as exactly this many frames, and everything
|
|
2266
|
+
that places or checks something on the film -- subtitles, screen text,
|
|
2267
|
+
effects, voices, the film's end -- lays the cuts end to end at these spans.
|
|
2268
|
+
Nothing re-derives timing from the declared lengths, so no path can drift
|
|
2269
|
+
from the picture by the frame rounding. Without a known rate, the declared
|
|
2270
|
+
lengths are all there is.
|
|
2271
|
+
"""
|
|
2272
|
+
|
|
2273
|
+
if not fps:
|
|
2274
|
+
return [cut.declared for cut in cuts]
|
|
2275
|
+
return [max(1, round(cut.declared * fps)) / fps for cut in cuts]
|
|
2276
|
+
|
|
2277
|
+
|
|
2278
|
+
def _film_fps(
|
|
2279
|
+
episode: Path, project_root: Path, cuts: Sequence[Cut], delivery: Optional[Delivery], *, probe: bool
|
|
2280
|
+
) -> Optional[float]:
|
|
2281
|
+
"""The film's frame rate: the delivery spec's, else the first video cut's."""
|
|
2282
|
+
|
|
2283
|
+
if delivery is not None and delivery.fps:
|
|
2284
|
+
return delivery.fps
|
|
2285
|
+
if probe:
|
|
2286
|
+
for cut in cuts:
|
|
2287
|
+
media = None if cut.still else _resolve_media(episode, project_root, cut.media)
|
|
2288
|
+
if media is not None:
|
|
2289
|
+
return probe_stream(media)["fps"] or None
|
|
2290
|
+
return None
|
|
2291
|
+
|
|
2292
|
+
|
|
1479
2293
|
def _timeline(
|
|
1480
2294
|
cuts: Sequence[Cut], spans: Sequence[float]
|
|
1481
2295
|
) -> list[tuple[Cut, float, float]]:
|
|
1482
|
-
"""Each cut with its
|
|
1483
|
-
|
|
1484
|
-
Windows were authored against the declared span; scaling holds them in
|
|
1485
|
-
place proportionally rather than letting the tail slip out.
|
|
1486
|
-
"""
|
|
2296
|
+
"""Each cut with its start in the film and its span there."""
|
|
1487
2297
|
|
|
1488
2298
|
placed: list[tuple[Cut, float, float]] = []
|
|
1489
2299
|
cursor = 0.0
|
|
1490
2300
|
for cut, span in zip(cuts, spans):
|
|
1491
|
-
|
|
1492
|
-
placed.append((cut, cursor, span / declared if declared > 0 else 1.0))
|
|
2301
|
+
placed.append((cut, cursor, span))
|
|
1493
2302
|
cursor += span
|
|
1494
2303
|
return placed
|
|
1495
2304
|
|
|
1496
2305
|
|
|
2306
|
+
def _at(cut: Cut, cursor: float, span: float, seconds: float) -> float:
|
|
2307
|
+
"""A time written against a cut, in film seconds.
|
|
2308
|
+
|
|
2309
|
+
Inside the cut it keeps its place relative to the cut's rendered length, so
|
|
2310
|
+
a window written to end with the cut still does. Before or past the cut --
|
|
2311
|
+
a J-cut's start, an L-cut subtitle's end -- it is plain seconds from the
|
|
2312
|
+
cut's start, the same clock a voice file plays on.
|
|
2313
|
+
"""
|
|
2314
|
+
|
|
2315
|
+
if 0 <= seconds <= cut.declared and cut.declared > 0:
|
|
2316
|
+
return cursor + seconds * span / cut.declared
|
|
2317
|
+
return cursor + seconds
|
|
2318
|
+
|
|
2319
|
+
|
|
1497
2320
|
class DisplayCue(NamedTuple):
|
|
1498
2321
|
"""One subtitle as burned: output seconds, display text, speaker kind, highlights."""
|
|
1499
2322
|
|
|
@@ -1639,10 +2462,10 @@ def _screen_text_layers(
|
|
|
1639
2462
|
|
|
1640
2463
|
layers: list[dict[str, Any]] = []
|
|
1641
2464
|
latest: Optional[dict[str, Any]] = None
|
|
1642
|
-
for cut, cursor,
|
|
2465
|
+
for cut, cursor, length in _timeline(cuts, spans):
|
|
1643
2466
|
for text in sorted(cut.screen_texts, key=lambda item: item.start):
|
|
1644
|
-
start = cursor
|
|
1645
|
-
end = cursor
|
|
2467
|
+
start = _at(cut, cursor, length, text.start)
|
|
2468
|
+
end = _at(cut, cursor, length, text.end)
|
|
1646
2469
|
countdown = text.countdown
|
|
1647
2470
|
if text.resume:
|
|
1648
2471
|
if latest is None:
|
|
@@ -1680,30 +2503,56 @@ def _screen_text_layers(
|
|
|
1680
2503
|
return layers
|
|
1681
2504
|
|
|
1682
2505
|
|
|
1683
|
-
def _placed_sound_effects(
|
|
1684
|
-
|
|
1685
|
-
) -> list[tuple[float, float, str, float]]:
|
|
1686
|
-
"""Each effect as (output start, duration, path as written, gain dB)."""
|
|
2506
|
+
def _placed_sound_effects(cuts: Sequence[Cut], spans: Sequence[float]) -> list[Placed]:
|
|
2507
|
+
"""Each effect in output seconds, with its path as written."""
|
|
1687
2508
|
|
|
1688
2509
|
return [
|
|
1689
|
-
(
|
|
1690
|
-
|
|
1691
|
-
|
|
2510
|
+
Placed(max(0.0, _at(cut, cursor, length, effect.start)),
|
|
2511
|
+
_at(cut, cursor, length, effect.end) - _at(cut, cursor, length, effect.start),
|
|
2512
|
+
effect.path, effect.gain_db, effect.offset)
|
|
2513
|
+
for cut, cursor, length in _timeline(cuts, spans)
|
|
1692
2514
|
for effect in cut.sound_effects
|
|
1693
2515
|
]
|
|
1694
2516
|
|
|
1695
2517
|
|
|
2518
|
+
def _placed_beds(cuts: Sequence[Cut], spans: Sequence[float]) -> list[Placed]:
|
|
2519
|
+
"""Each 「环境声」 run, looping its file with no break at the cuts it spans.
|
|
2520
|
+
|
|
2521
|
+
A run starts at its line's cut plus 起 and lasts until the next 环境声 line:
|
|
2522
|
+
to that line's cut start, or to its 起 when that is later. A negative 起
|
|
2523
|
+
therefore overlaps the two rooms across the cut, a sound bridge. It fades
|
|
2524
|
+
only at its own two ends.
|
|
2525
|
+
"""
|
|
2526
|
+
|
|
2527
|
+
lines = [
|
|
2528
|
+
(_at(cut, cursor, length, cut.bed.start), cursor, cut.bed)
|
|
2529
|
+
for cut, cursor, length in _timeline(cuts, spans) if cut.bed is not None
|
|
2530
|
+
]
|
|
2531
|
+
total = sum(spans)
|
|
2532
|
+
placed: list[Placed] = []
|
|
2533
|
+
for index, (start, _, bed) in enumerate(lines):
|
|
2534
|
+
if bed.path is None:
|
|
2535
|
+
continue
|
|
2536
|
+
end = total if index + 1 == len(lines) else max(lines[index + 1][0], lines[index + 1][1])
|
|
2537
|
+
start = max(0.0, start)
|
|
2538
|
+
if end - start > TOLERANCE:
|
|
2539
|
+
placed.append(Placed(start, end - start, bed.path, bed.gain_db, bed.offset,
|
|
2540
|
+
loop=True, fade_in=BED_FADE, fade_out=BED_FADE))
|
|
2541
|
+
return placed
|
|
2542
|
+
|
|
2543
|
+
|
|
1696
2544
|
def _sound_effect_command(
|
|
1697
2545
|
ffmpeg: str,
|
|
1698
2546
|
film: Path,
|
|
1699
|
-
effects: Sequence[
|
|
2547
|
+
effects: Sequence[Union[Placed, tuple[Any, ...]]],
|
|
1700
2548
|
output: Path,
|
|
1701
2549
|
) -> list[str]:
|
|
1702
|
-
"""Lay each effect into the film's own audio at its output time.
|
|
2550
|
+
"""Lay each effect, voice and bed into the film's own audio at its output time.
|
|
1703
2551
|
|
|
1704
2552
|
`normalize=0` keeps the dialogue at its level: amix's default divides every
|
|
1705
2553
|
input by the input count, and the film would get quieter each time a chime
|
|
1706
|
-
is added. A short fade closes each
|
|
2554
|
+
is added. A short fade closes each sound so a trimmed tail does not click;
|
|
2555
|
+
a bed loops its file and fades in and out only at its own ends.
|
|
1707
2556
|
"""
|
|
1708
2557
|
|
|
1709
2558
|
command = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y", "-i", str(film)]
|
|
@@ -1711,14 +2560,17 @@ def _sound_effect_command(
|
|
|
1711
2560
|
# stereo costs it 3 dB, and the effects are converted to match instead.
|
|
1712
2561
|
stages = ["[0:a]aformat=sample_rates=48000[a0]"]
|
|
1713
2562
|
labels = ["[a0]"]
|
|
1714
|
-
for index,
|
|
1715
|
-
|
|
1716
|
-
|
|
2563
|
+
for index, raw in enumerate(effects, start=1):
|
|
2564
|
+
sound = Placed(*raw)
|
|
2565
|
+
command += (["-stream_loop", "-1"] if sound.loop else []) + ["-i", str(sound.path)]
|
|
2566
|
+
fade_in = min(sound.fade_in, sound.duration / 2)
|
|
2567
|
+
fade_out = min(sound.fade_out, sound.duration / 2)
|
|
1717
2568
|
stages.append(
|
|
1718
|
-
f"[{index}:a]atrim=
|
|
1719
|
-
f"afade=t=
|
|
2569
|
+
f"[{index}:a]atrim=start={sound.offset:.3f}:duration={sound.duration:.3f},asetpts=PTS-STARTPTS,"
|
|
2570
|
+
+ (f"afade=t=in:st=0:d={fade_in:.3f}," if fade_in else "")
|
|
2571
|
+
+ f"afade=t=out:st={sound.duration - fade_out:.3f}:d={fade_out:.3f},volume={sound.gain_db:g}dB,"
|
|
1720
2572
|
"aformat=sample_rates=48000,"
|
|
1721
|
-
f"adelay={round(start * 1000)}:all=1[s{index}]"
|
|
2573
|
+
f"adelay={round(sound.start * 1000)}:all=1[s{index}]"
|
|
1722
2574
|
)
|
|
1723
2575
|
labels.append(f"[s{index}]")
|
|
1724
2576
|
stages.append(
|
|
@@ -1872,7 +2724,9 @@ def _segment_colours(ffmpeg: str, output_root: Path, cuts: Sequence[Cut]) -> lis
|
|
|
1872
2724
|
return rows
|
|
1873
2725
|
|
|
1874
2726
|
|
|
1875
|
-
def verify(
|
|
2727
|
+
def verify(
|
|
2728
|
+
episode: Path, cuts: Sequence[Cut], delivery: Delivery, project_root: Optional[Path] = None
|
|
2729
|
+
) -> dict[str, Any]:
|
|
1876
2730
|
"""Measure the rendered film. Every entry is a number or an honest 未测."""
|
|
1877
2731
|
|
|
1878
2732
|
ffmpeg = _require("ffmpeg")
|
|
@@ -1913,14 +2767,20 @@ def verify(episode: Path, cuts: Sequence[Cut], delivery: Delivery) -> dict[str,
|
|
|
1913
2767
|
measurements["画内可读文字"] = (
|
|
1914
2768
|
"未测(抽有画内文字的帧,逐字对《剧本.md》的「画面文字」与提示词声明的内容)"
|
|
1915
2769
|
)
|
|
1916
|
-
|
|
1917
|
-
spans = (
|
|
1918
|
-
[probe_duration(segment) for segment in segments]
|
|
1919
|
-
if all(segment.is_file() for segment in segments)
|
|
1920
|
-
else None
|
|
1921
|
-
)
|
|
2770
|
+
# The same spans render placed everything on; the film's own rate when no spec states one.
|
|
2771
|
+
spans = _film_spans(cuts, delivery.fps or stream["fps"])
|
|
1922
2772
|
if any(cut.screen_texts or cut.sound_effects for cut in cuts):
|
|
1923
2773
|
measurements.update(_placements_for_sampling(cuts, spans))
|
|
2774
|
+
beds = _placed_beds(cuts, spans)
|
|
2775
|
+
if beds:
|
|
2776
|
+
measurements["环境声落点"] = [
|
|
2777
|
+
{"起": round(bed.start, 2), "止": round(bed.start + bed.duration, 2), "文件": bed.path}
|
|
2778
|
+
for bed in beds
|
|
2779
|
+
]
|
|
2780
|
+
if any(cut.voices for cut in cuts):
|
|
2781
|
+
measurements["配音落点"] = _voice_placements(
|
|
2782
|
+
episode, project_root or episode, cuts, spans
|
|
2783
|
+
)
|
|
1924
2784
|
measurements["台词完整性"] = "未测(本工具不做转写;在成片上转写后逐句对《剧本.md》原文)"
|
|
1925
2785
|
measurements.update(_frame_report(
|
|
1926
2786
|
_grey_frames(ffmpeg, final), stream["fps"], cuts, spans, _scene_keys(episode, cuts)
|
|
@@ -2073,13 +2933,34 @@ def _placements_for_sampling(cuts: Sequence[Cut], spans: Optional[Sequence[float
|
|
|
2073
2933
|
}
|
|
2074
2934
|
for layer in placed
|
|
2075
2935
|
],
|
|
2076
|
-
"音效落点":
|
|
2077
|
-
{"起": round(start, 2), "止": round(start + duration, 2), "文件": written}
|
|
2078
|
-
for start, duration, written, _ in _placed_sound_effects(cuts, spans)
|
|
2079
|
-
],
|
|
2936
|
+
"音效落点": _sound_placements(cuts, spans),
|
|
2080
2937
|
}
|
|
2081
2938
|
|
|
2082
2939
|
|
|
2940
|
+
def _sound_placements(cuts: Sequence[Cut], spans: Sequence[float]) -> list[dict[str, Any]]:
|
|
2941
|
+
return [
|
|
2942
|
+
{"起": round(start, 2), "止": round(start + duration, 2), "文件": written}
|
|
2943
|
+
for start, duration, written, *_ in _placed_sound_effects(cuts, spans)
|
|
2944
|
+
]
|
|
2945
|
+
|
|
2946
|
+
|
|
2947
|
+
def _voice_placements(
|
|
2948
|
+
episode: Path, project_root: Path, cuts: Sequence[Cut], spans: Optional[Sequence[float]]
|
|
2949
|
+
) -> Union[list[dict[str, Any]], str]:
|
|
2950
|
+
"""Where each 「配音」 line is heard in the film, for listening back."""
|
|
2951
|
+
|
|
2952
|
+
if spans is None:
|
|
2953
|
+
return "未测(分段缺失,无法换算成片时间)"
|
|
2954
|
+
try:
|
|
2955
|
+
sounds = _voice_sounds(episode, project_root, cuts)
|
|
2956
|
+
except EditError as error:
|
|
2957
|
+
return f"未测({error})"
|
|
2958
|
+
return [
|
|
2959
|
+
{"起": round(start, 2), "止": round(end, 2), "段": cut.cut_id, "文件": voice.path}
|
|
2960
|
+
for start, end, cut, voice in _voice_spans(cuts, sounds, spans)
|
|
2961
|
+
]
|
|
2962
|
+
|
|
2963
|
+
|
|
2083
2964
|
def _matches_frame_size(delivery: Delivery, stream: dict[str, Any]) -> Any:
|
|
2084
2965
|
if delivery.frame_size is None:
|
|
2085
2966
|
return "未测(剪辑单没有声明画幅)"
|
|
@@ -2149,7 +3030,8 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
2149
3030
|
if arguments.command == "check":
|
|
2150
3031
|
findings = check_cuts(
|
|
2151
3032
|
episode, cuts, project_root,
|
|
2152
|
-
probe=_which("ffprobe") is not None, unused=unused,
|
|
3033
|
+
probe=_which("ffprobe") is not None, unused=unused, delivery=delivery,
|
|
3034
|
+
listen=_which("ffmpeg") is not None,
|
|
2153
3035
|
)
|
|
2154
3036
|
payload: dict[str, Any] = {
|
|
2155
3037
|
"段数": len(cuts),
|
|
@@ -2158,13 +3040,21 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
2158
3040
|
"未采用镜头": unused,
|
|
2159
3041
|
"findings": findings,
|
|
2160
3042
|
}
|
|
3043
|
+
notices = move_notices(cuts, _scene_keys(episode, cuts), _film_spans(cuts, delivery.fps))
|
|
3044
|
+
if notices:
|
|
3045
|
+
payload["提醒"] = notices
|
|
3046
|
+
unmeasured = []
|
|
2161
3047
|
if _which("ffprobe") is None:
|
|
2162
|
-
|
|
3048
|
+
unmeasured.append("区间是否超过素材实际时长(PATH 上没有 ffprobe)")
|
|
3049
|
+
if _which("ffmpeg") is None and any(cut.voices for cut in cuts):
|
|
3050
|
+
unmeasured.append("配音是否越过成片结尾、是否互相重叠(PATH 上没有 ffmpeg)")
|
|
3051
|
+
if unmeasured:
|
|
3052
|
+
payload["未测"] = unmeasured
|
|
2163
3053
|
_emit(payload)
|
|
2164
3054
|
return 1 if findings else 0
|
|
2165
3055
|
if arguments.command == "render":
|
|
2166
3056
|
findings = check_cuts(
|
|
2167
|
-
episode, cuts, project_root, probe=True, unused=unused
|
|
3057
|
+
episode, cuts, project_root, probe=True, unused=unused, delivery=delivery
|
|
2168
3058
|
)
|
|
2169
3059
|
if findings:
|
|
2170
3060
|
_emit({"findings": findings, "已渲染": False})
|
|
@@ -2177,7 +3067,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
2177
3067
|
remotion_concurrency=arguments.remotion_concurrency,
|
|
2178
3068
|
))
|
|
2179
3069
|
return 0
|
|
2180
|
-
_emit(verify(episode, cuts, delivery))
|
|
3070
|
+
_emit(verify(episode, cuts, delivery, project_root))
|
|
2181
3071
|
return 0
|
|
2182
3072
|
except EditError as error:
|
|
2183
3073
|
print(str(error), file=sys.stderr)
|