@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
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Frame-grid facts and edge snapping so edited_source.mp4 is constant frame rate.
|
|
2
|
+
|
|
3
|
+
A clip edge between two source frames leaves the concat filter a segment whose audio is a
|
|
4
|
+
fraction of a frame longer than its video, so the next segment starts off the frame grid and
|
|
5
|
+
the encoded file loses one frame slot per join (an 80 ms hold at 25 fps). Every clip therefore
|
|
6
|
+
starts on its source's frame grid and lasts a whole number of output frames.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from fractions import Fraction
|
|
11
|
+
|
|
12
|
+
# The NTSC rates ffprobe reports as N/1001; canvas fps buckets store them rounded.
|
|
13
|
+
_NTSC_RATES = {
|
|
14
|
+
23.976: Fraction(24000, 1001),
|
|
15
|
+
29.97: Fraction(30000, 1001),
|
|
16
|
+
47.952: Fraction(48000, 1001),
|
|
17
|
+
59.94: Fraction(60000, 1001),
|
|
18
|
+
}
|
|
19
|
+
# An edge "inside" a pause window is judged without the gate's 50 ms tolerance: a half-frame
|
|
20
|
+
# move must not pull an edge that sits on a pause boundary back over the spoken word.
|
|
21
|
+
_INSIDE_EPSILON = 0.001
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_frame_rate(text):
|
|
25
|
+
"""An ffprobe 'N/D' rate as a Fraction; None when unknown or implausible.
|
|
26
|
+
|
|
27
|
+
Rates above 120 fps are container timebases of variable-rate sources, not a frame grid.
|
|
28
|
+
"""
|
|
29
|
+
num, _, den = str(text or "").partition("/")
|
|
30
|
+
try:
|
|
31
|
+
rate = Fraction(num) / Fraction(den or 1)
|
|
32
|
+
except (ValueError, ZeroDivisionError):
|
|
33
|
+
return None
|
|
34
|
+
return rate if 0 < rate <= 120 else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def canvas_frame_rate(fps):
|
|
38
|
+
"""Exact rate for a canvas fps bucket (29.97 is 30000/1001, not 2997/100)."""
|
|
39
|
+
return _NTSC_RATES.get(round(float(fps), 3)) or Fraction(float(fps)).limit_denominator(1001)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def output_frame_rate(rows, canvas_fps):
|
|
43
|
+
"""The frame rate edited_source.mp4 is rendered at.
|
|
44
|
+
|
|
45
|
+
One source keeps its own exact rate; several sources are resampled to the canvas bucket.
|
|
46
|
+
"""
|
|
47
|
+
if len(rows) == 1:
|
|
48
|
+
rate = parse_frame_rate(rows[0].get("frame_rate"))
|
|
49
|
+
if rate:
|
|
50
|
+
return rate
|
|
51
|
+
return canvas_frame_rate(canvas_fps)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def frame_count(duration, rate):
|
|
55
|
+
"""Whole output frames a clip of `duration` seconds renders to (at least one)."""
|
|
56
|
+
return max(1, int(round(duration * rate)))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def source_frame_grids(geometry_qc):
|
|
60
|
+
"""{source_path: grid} from the selected output geometry, for snap_edges_to_frames."""
|
|
61
|
+
out_rate = Fraction(geometry_qc["frame_rate"])
|
|
62
|
+
return {
|
|
63
|
+
row["path"]: {
|
|
64
|
+
"source_rate": parse_frame_rate(row.get("frame_rate")),
|
|
65
|
+
"origin": float(row.get("video_start_offset", 0.0)),
|
|
66
|
+
"output_rate": out_rate,
|
|
67
|
+
}
|
|
68
|
+
for row in geometry_qc["sources"]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Gate statuses from sentence_boundaries' edge classifier, safest first.
|
|
73
|
+
_GATE_ORDER = {"safe": 0, "unchecked": 1, "blocking": 2}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _rank(edge, ts, windows, classify):
|
|
77
|
+
"""(gate status, 0 strictly inside a sentence/quiet window else 1).
|
|
78
|
+
|
|
79
|
+
`classify(edge, ts)` is the sentence-boundary gate itself, so a sub-frame move never
|
|
80
|
+
turns an edge the gate passes into one it blocks.
|
|
81
|
+
"""
|
|
82
|
+
strict = any(w["start"] - _INSIDE_EPSILON <= ts <= w["end"] + _INSIDE_EPSILON
|
|
83
|
+
for w in windows)
|
|
84
|
+
return _GATE_ORDER[classify(edge, ts)[0]], 0 if strict else 1
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _drops_kept_edge(edge, original, ts, keep_ranges):
|
|
88
|
+
"""Whether moving a clip edge from `original` to `ts` cuts into a required range.
|
|
89
|
+
|
|
90
|
+
Required-evidence coverage is exact, so a start that moves past a node's start (or an
|
|
91
|
+
end that moves before a node's end) the clip used to cover would lose that node.
|
|
92
|
+
"""
|
|
93
|
+
if edge == "start":
|
|
94
|
+
return any(original <= start < ts for start, _ in keep_ranges)
|
|
95
|
+
return any(ts < end <= original for _, end in keep_ranges)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# How far a detected shot change may sit from a frame boundary and still count as on it
|
|
99
|
+
# (ffmpeg pts times and clip edges are both rounded to the millisecond).
|
|
100
|
+
_CUT_EPSILON = 0.002
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _straddles_cut(edge, ts, candidates, scene_cuts):
|
|
104
|
+
"""Whether choosing `ts` keeps a sliver of the other shot across a source hard cut.
|
|
105
|
+
|
|
106
|
+
A start before a cut that lies up to the later candidate opens on the old shot (a flash
|
|
107
|
+
frame before the cut); an end after a cut that lies from the earlier candidate on closes on
|
|
108
|
+
the next shot. The candidate at or past the cut (start) or before it (end) does not.
|
|
109
|
+
"""
|
|
110
|
+
if edge == "start":
|
|
111
|
+
last = max(candidates)
|
|
112
|
+
return any(ts + _CUT_EPSILON < cut <= last + _CUT_EPSILON for cut in scene_cuts)
|
|
113
|
+
first = min(candidates)
|
|
114
|
+
return any(first - _CUT_EPSILON <= cut < ts - _CUT_EPSILON for cut in scene_cuts)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _pick(edge, original, candidates, windows, classify, keep_ranges, scene_cuts=()):
|
|
118
|
+
"""The candidate the gate likes best (then one strictly inside a pause window), then one
|
|
119
|
+
that keeps required ranges, then one that does not straddle a detected source hard cut
|
|
120
|
+
(no flash frame), then the nearest, then the earlier (a deterministic tie-break)."""
|
|
121
|
+
return min(
|
|
122
|
+
candidates,
|
|
123
|
+
key=lambda t: (
|
|
124
|
+
*_rank(edge, t, windows, classify),
|
|
125
|
+
_drops_kept_edge(edge, original, t, keep_ranges),
|
|
126
|
+
_straddles_cut(edge, t, candidates, scene_cuts),
|
|
127
|
+
abs(t - original),
|
|
128
|
+
t,
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _overlaps(clips, idx, start, end):
|
|
134
|
+
return any(
|
|
135
|
+
start < other["source_end"] and end > other["source_start"]
|
|
136
|
+
for j, other in enumerate(clips)
|
|
137
|
+
if j != idx
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def snap_edges_to_frames(clips, grid, windows, classify, source_duration, *,
|
|
142
|
+
allow_overlap, joined_to_previous):
|
|
143
|
+
"""Snap every clip of ONE source onto the frame grid; returns (clips, events).
|
|
144
|
+
|
|
145
|
+
The start moves to the nearest source frame boundary and the end to the nearest whole
|
|
146
|
+
number of output frames after it, each choosing between the two neighbouring candidates
|
|
147
|
+
the one the sentence-boundary gate `classify(edge, ts) -> (status, reason)` rates safest
|
|
148
|
+
(safe, then unchecked, then blocking), preferring one strictly inside a pause window.
|
|
149
|
+
A start that continues the previous clip's source range exactly follows that clip's
|
|
150
|
+
snapped end, so a lossless join stays lossless. Between equally safe candidates the one
|
|
151
|
+
that still covers every `grid["keep_ranges"]` (required-evidence nodes) edge it covered
|
|
152
|
+
wins, then the one that does not straddle a `grid["scene_cuts"]` source hard cut (a start
|
|
153
|
+
at or after the cut, an end at or before it, so no frame of the other shot flashes).
|
|
154
|
+
Without a usable source rate the start is left alone, but the length is still a whole
|
|
155
|
+
number of output frames.
|
|
156
|
+
"""
|
|
157
|
+
clips = [dict(c) for c in clips]
|
|
158
|
+
rate, origin, out_rate = grid["source_rate"], grid["origin"], grid["output_rate"]
|
|
159
|
+
keep_ranges = grid.get("keep_ranges", ())
|
|
160
|
+
scene_cuts = grid.get("scene_cuts", ())
|
|
161
|
+
events = []
|
|
162
|
+
for idx, clip in enumerate(clips):
|
|
163
|
+
start, end = clip["source_start"], clip["source_end"]
|
|
164
|
+
if joined_to_previous[idx] and idx > 0:
|
|
165
|
+
new_start, start_reason = clips[idx - 1]["source_end"], "continuous_source_join"
|
|
166
|
+
elif rate is None:
|
|
167
|
+
new_start, start_reason = start, "source_frame_rate_unknown"
|
|
168
|
+
else:
|
|
169
|
+
pos = (start - origin) * rate
|
|
170
|
+
ks = {max(0, math.floor(pos)), max(0, math.ceil(pos))}
|
|
171
|
+
options = [round(origin + float(k / rate), 3) for k in sorted(ks)]
|
|
172
|
+
safe = [t for t in options if t >= start or allow_overlap
|
|
173
|
+
or not _overlaps(clips, idx, t, end)]
|
|
174
|
+
new_start = _pick("start", start, safe or [max(options)], windows, classify,
|
|
175
|
+
keep_ranges, scene_cuts)
|
|
176
|
+
start_reason = "frame_grid"
|
|
177
|
+
span = (end - new_start) * out_rate
|
|
178
|
+
counts = sorted({max(1, math.floor(span)), max(1, math.ceil(span))})
|
|
179
|
+
options = [
|
|
180
|
+
(n, round(new_start + float(n / out_rate), 3)) for n in counts
|
|
181
|
+
if new_start + float(n / out_rate) <= source_duration + _INSIDE_EPSILON
|
|
182
|
+
] or [(counts[0], round(new_start + float(counts[0] / out_rate), 3))]
|
|
183
|
+
safe = [(n, t) for n, t in options if t <= end or allow_overlap
|
|
184
|
+
or not _overlaps(clips, idx, new_start, t)] or options[:1]
|
|
185
|
+
new_end = _pick("end", end, [t for _, t in safe], windows, classify, keep_ranges,
|
|
186
|
+
scene_cuts)
|
|
187
|
+
frames = next(n for n, t in safe if t == new_end)
|
|
188
|
+
clip["source_start"], clip["source_end"] = new_start, new_end
|
|
189
|
+
events.append({
|
|
190
|
+
"clip_id": clip["clip_id"],
|
|
191
|
+
"source_id": clip.get("source_id"),
|
|
192
|
+
"original_start": round(start, 3),
|
|
193
|
+
"original_end": round(end, 3),
|
|
194
|
+
"new_start": new_start,
|
|
195
|
+
"new_end": new_end,
|
|
196
|
+
"start_reason": start_reason,
|
|
197
|
+
"frame_count": frames,
|
|
198
|
+
})
|
|
199
|
+
return clips, events
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def edge_frame_snapper(grid, windows, classify, source_duration):
|
|
203
|
+
"""frame_snap(edge, clip, ts): where snap_edges_to_frames puts `clip`'s edge moved to ts.
|
|
204
|
+
|
|
205
|
+
The gate uses it to check a suggested edge time after frame alignment, not before.
|
|
206
|
+
"""
|
|
207
|
+
def frame_snap(edge, clip, when):
|
|
208
|
+
key = "source_start" if edge == "start" else "source_end"
|
|
209
|
+
moved = {**clip, key: when}
|
|
210
|
+
if moved["source_end"] <= moved["source_start"]:
|
|
211
|
+
return when
|
|
212
|
+
snapped, _ = snap_edges_to_frames([moved], grid, windows, classify, source_duration,
|
|
213
|
+
allow_overlap=True, joined_to_previous=[False])
|
|
214
|
+
return snapped[0][key]
|
|
215
|
+
|
|
216
|
+
return frame_snap
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def record_frame_grid(plan, geometry_qc):
|
|
220
|
+
"""Stamp each clip's frame_count and qc.frame_grid; the render draws exactly these frames.
|
|
221
|
+
|
|
222
|
+
The output timeline (output_start/output_end, total_duration) is rewritten from the
|
|
223
|
+
cumulative frame count, so it matches the render instead of drifting by the per-clip
|
|
224
|
+
millisecond rounding (a 29.97 fps frame is 33.367 ms).
|
|
225
|
+
"""
|
|
226
|
+
out_rate = Fraction(geometry_qc["frame_rate"])
|
|
227
|
+
total = 0
|
|
228
|
+
for clip in plan["clips"]:
|
|
229
|
+
clip["frame_count"] = frame_count(clip["duration"], out_rate)
|
|
230
|
+
clip["output_start"] = round(float(total / out_rate), 3)
|
|
231
|
+
total += clip["frame_count"]
|
|
232
|
+
clip["output_end"] = round(float(total / out_rate), 3)
|
|
233
|
+
plan["total_duration"] = round(float(total / out_rate), 3)
|
|
234
|
+
plan.setdefault("qc", {})["frame_grid"] = {
|
|
235
|
+
"output_frame_rate": geometry_qc["frame_rate"],
|
|
236
|
+
"frame_count": total,
|
|
237
|
+
"duration": round(float(total / out_rate), 3),
|
|
238
|
+
"sources": [
|
|
239
|
+
{
|
|
240
|
+
"source_id": row["source_id"],
|
|
241
|
+
"path": row["path"],
|
|
242
|
+
"frame_rate": row.get("frame_rate"),
|
|
243
|
+
"video_start_offset": row.get("video_start_offset", 0.0),
|
|
244
|
+
"start_snapped": parse_frame_rate(row.get("frame_rate")) is not None,
|
|
245
|
+
}
|
|
246
|
+
for row in geometry_qc["sources"]
|
|
247
|
+
],
|
|
248
|
+
}
|
|
249
|
+
return plan
|
|
@@ -46,9 +46,6 @@ CONFIG = {
|
|
|
46
46
|
"clip_start_snap_max_prepend": env_float("CLIP_START_SNAP_MAX_PREPEND", 1.8, min_val=0.0),
|
|
47
47
|
"clip_start_snap_max_trim": env_float("CLIP_START_SNAP_MAX_TRIM", 0.35, min_val=0.0),
|
|
48
48
|
"clip_join_audio_fade_ms": env_float("CLIP_JOIN_AUDIO_FADE_MS", 30.0, min_val=0.0),
|
|
49
|
-
# video-cut is the only skill that implements clip padding, so it is the only skill that
|
|
50
|
-
# may declare the knob. cut_cli reads this as the default for --clip-padding.
|
|
51
|
-
"clip_padding": env_float("CLIP_PADDING", 0.0, min_val=0.0),
|
|
52
49
|
# Keep clip boundaries off the ORIGINAL footage's hard cuts: a clip that opens/closes a few
|
|
53
50
|
# tenths of a second from a source shot-change shows a brief sliver of the adjacent shot that
|
|
54
51
|
# then hard-cuts again — a visible 闪烁/flicker at the edit point. Snap source_start forward
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
|
|
5
|
+
from frame_grid import canvas_frame_rate, output_frame_rate, parse_frame_rate
|
|
5
6
|
from lib import run_cmd
|
|
6
7
|
|
|
7
8
|
|
|
@@ -57,7 +58,38 @@ def _fps_from_rate(rate):
|
|
|
57
58
|
return float(num) / float(den) if float(den) > 0 else 0.0
|
|
58
59
|
|
|
59
60
|
|
|
60
|
-
def
|
|
61
|
+
def _frame_rate_text(stream):
|
|
62
|
+
"""The stream's real frame grid as an 'N/D' string.
|
|
63
|
+
|
|
64
|
+
Interlaced streams often report the field rate as r_frame_rate (50/1 for 25 frames a
|
|
65
|
+
second); when r_frame_rate is exactly twice avg_frame_rate the average is the grid.
|
|
66
|
+
A variable-rate phone clip can report r_frame_rate well above its real average (60/1
|
|
67
|
+
for ~29.6 frames a second); rendering that at 60 fps CFR would double every frame, so
|
|
68
|
+
the common rate nearest the average is used instead, as it is when r_frame_rate is
|
|
69
|
+
unusable (`0/0`) but the average is not.
|
|
70
|
+
"""
|
|
71
|
+
r_rate = stream.get("r_frame_rate", "0/0")
|
|
72
|
+
avg = stream.get("avg_frame_rate", "0/0")
|
|
73
|
+
avg_fps = _fps_from_rate(avg)
|
|
74
|
+
if 0 < avg_fps <= 120:
|
|
75
|
+
ratio = _fps_from_rate(r_rate) / avg_fps
|
|
76
|
+
if abs(ratio - 2) < 0.01:
|
|
77
|
+
return avg
|
|
78
|
+
if ratio > 1.5 or parse_frame_rate(r_rate) is None:
|
|
79
|
+
rate = canvas_frame_rate(_fps_bucket(avg_fps))
|
|
80
|
+
return f"{rate.numerator}/{rate.denominator}"
|
|
81
|
+
return r_rate
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _video_start_offset(stream, format_start):
|
|
85
|
+
"""Seconds from the file start (ffmpeg's input zero) to the first video frame."""
|
|
86
|
+
try:
|
|
87
|
+
return round(max(0.0, float(stream.get("start_time")) - float(format_start or 0.0)), 6)
|
|
88
|
+
except (TypeError, ValueError):
|
|
89
|
+
return 0.0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _geometry_from_stream(stream, format_start=0.0):
|
|
61
93
|
coded_width, coded_height = stream["width"], stream["height"]
|
|
62
94
|
parsed_sar = _parse_ratio(stream.get("sample_aspect_ratio"))
|
|
63
95
|
dar = _parse_ratio(stream.get("display_aspect_ratio"))
|
|
@@ -80,9 +112,9 @@ def _geometry_from_stream(stream):
|
|
|
80
112
|
display_width, display_height = display_height, display_width
|
|
81
113
|
|
|
82
114
|
width, height = _clamp_even_geometry(round(display_width), round(display_height))
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
)
|
|
115
|
+
frame_rate = _frame_rate_text(stream)
|
|
116
|
+
# The canvas fps bucket follows the same frame grid (not an interlaced field rate).
|
|
117
|
+
fps = _fps_from_rate(frame_rate) or _fps_from_rate(stream.get("avg_frame_rate", "0/0"))
|
|
86
118
|
if not 0 < fps <= 120:
|
|
87
119
|
fps = 30.0
|
|
88
120
|
facts = {
|
|
@@ -91,6 +123,9 @@ def _geometry_from_stream(stream):
|
|
|
91
123
|
"width": width,
|
|
92
124
|
"height": height,
|
|
93
125
|
"fps": round(fps, 3),
|
|
126
|
+
# Exact rate and first-frame time: the grid video-cut snaps clip edges onto.
|
|
127
|
+
"frame_rate": frame_rate,
|
|
128
|
+
"video_start_offset": _video_start_offset(stream, format_start),
|
|
94
129
|
"sample_aspect_ratio": stream.get("sample_aspect_ratio", "1:1"),
|
|
95
130
|
"sample_aspect_ratio_float": round(sar, 6),
|
|
96
131
|
"display_aspect_ratio": stream.get("display_aspect_ratio"),
|
|
@@ -118,7 +153,8 @@ def _probe_video_geometry(video_path):
|
|
|
118
153
|
"-select_streams",
|
|
119
154
|
"v:0",
|
|
120
155
|
"-show_entries",
|
|
121
|
-
"stream=width,height,r_frame_rate,avg_frame_rate,sample_aspect_ratio,
|
|
156
|
+
"stream=width,height,r_frame_rate,avg_frame_rate,start_time,sample_aspect_ratio,"
|
|
157
|
+
"display_aspect_ratio:stream_tags=rotate:stream_side_data=rotation:format=start_time",
|
|
122
158
|
"-of",
|
|
123
159
|
"json",
|
|
124
160
|
str(video_path),
|
|
@@ -126,10 +162,11 @@ def _probe_video_geometry(video_path):
|
|
|
126
162
|
result = run_cmd(cmd)
|
|
127
163
|
if result.returncode != 0:
|
|
128
164
|
raise RuntimeError(f"ffprobe 无法读取视频几何信息: {video_path}: {result.stderr.strip()}")
|
|
129
|
-
|
|
165
|
+
payload = json.loads(result.stdout)
|
|
166
|
+
streams = payload.get("streams", [])
|
|
130
167
|
if not streams:
|
|
131
168
|
raise RuntimeError(f"没有视频流: {video_path}")
|
|
132
|
-
return _geometry_from_stream(streams[0])
|
|
169
|
+
return _geometry_from_stream(streams[0], payload.get("format", {}).get("start_time"))
|
|
133
170
|
|
|
134
171
|
|
|
135
172
|
def _orientation(width, height):
|
|
@@ -179,6 +216,8 @@ def _select_output_geometry(source_paths, clips):
|
|
|
179
216
|
"display_height": facts["display_height"],
|
|
180
217
|
"area": width * height,
|
|
181
218
|
"fps": fps,
|
|
219
|
+
"frame_rate": facts["frame_rate"],
|
|
220
|
+
"video_start_offset": facts["video_start_offset"],
|
|
182
221
|
"fps_bucket": _fps_bucket(fps),
|
|
183
222
|
"orientation": _orientation(width, height),
|
|
184
223
|
"rotation": facts["rotation"],
|
|
@@ -220,6 +259,8 @@ def _select_output_geometry(source_paths, clips):
|
|
|
220
259
|
"width": width,
|
|
221
260
|
"height": height,
|
|
222
261
|
"fps": round(fps, 3),
|
|
262
|
+
# The exact rate edited_source.mp4 is rendered at (one source keeps its own rate).
|
|
263
|
+
"frame_rate": str(output_frame_rate(rows, fps)),
|
|
223
264
|
"reason": "weighted_orientation_area_fps",
|
|
224
265
|
"source_id": selected["source_id"],
|
|
225
266
|
"source_path": selected["path"],
|
|
@@ -236,3 +277,128 @@ def _select_output_geometry(source_paths, clips):
|
|
|
236
277
|
"sources": rows,
|
|
237
278
|
}
|
|
238
279
|
return width, height, round(fps, 3), reason
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# Delivered-picture format. `_probe_video_format`, `_output_color_tags`,
|
|
283
|
+
# `_color_tag_filter` and `_color_tag_args` are intentional function-level copies shared
|
|
284
|
+
# with the final render (tests/orchestrator/test_render_format_parity.py keeps them identical).
|
|
285
|
+
_COLOR_FIELDS = (
|
|
286
|
+
("color_space", "colorspace"),
|
|
287
|
+
("color_primaries", "color_primaries"),
|
|
288
|
+
("color_transfer", "color_trc"),
|
|
289
|
+
)
|
|
290
|
+
_UNTAGGED_COLOR = {None, "", "unknown", "unspecified", "reserved", "N/A"}
|
|
291
|
+
# ffprobe's colour space for an RGB picture (PNG, libx264rgb, FFV1 RGB). ffmpeg rejects
|
|
292
|
+
# `-colorspace gbr`, and the delivered picture is YUV anyway, so it is never written.
|
|
293
|
+
_RGB_COLOR_SPACE = "gbr"
|
|
294
|
+
# Pixel-format name prefixes of ffmpeg's RGB family (packed, planar `gbr*`, paletted, Bayer).
|
|
295
|
+
# Some RGB sources report no colour space at all (QuickTime RLE `argb`, raw `bgr24`, GIF
|
|
296
|
+
# `pal8`), so the pixel format decides too; tests/assemble/test_render_delivery.py checks
|
|
297
|
+
# the prefixes against every format a real ffprobe lists.
|
|
298
|
+
_RGB_PIX_FMT_PREFIXES = (
|
|
299
|
+
"rgb", "bgr", "gbr", "argb", "abgr", "0rgb", "0bgr", "x2rgb", "x2bgr", "pal8", "bayer_",
|
|
300
|
+
)
|
|
301
|
+
# Names ffprobe prints that setparams and the -colorspace/-color_primaries/-color_trc
|
|
302
|
+
# output options accept and libx264 writes back (tests/assemble/test_render_delivery.py
|
|
303
|
+
# runs each through a real ffmpeg); anything else is not written explicitly.
|
|
304
|
+
_KNOWN_COLOR_VALUES = {
|
|
305
|
+
"colorspace": {
|
|
306
|
+
"bt709", "fcc", "bt470bg", "smpte170m", "smpte240m", "ycgco", "bt2020nc", "bt2020c",
|
|
307
|
+
"smpte2085", "chroma-derived-nc", "chroma-derived-c", "ictcp",
|
|
308
|
+
},
|
|
309
|
+
"color_primaries": {
|
|
310
|
+
"bt709", "bt470m", "bt470bg", "smpte170m", "smpte240m", "film", "bt2020",
|
|
311
|
+
"smpte428", "smpte431", "smpte432",
|
|
312
|
+
},
|
|
313
|
+
"color_trc": {
|
|
314
|
+
"bt709", "bt470m", "bt470bg", "smpte170m", "smpte240m", "linear", "log100",
|
|
315
|
+
"log316", "iec61966-2-4", "bt1361e", "iec61966-2-1", "bt2020-10", "bt2020-12",
|
|
316
|
+
"smpte2084", "smpte428", "arib-std-b67",
|
|
317
|
+
},
|
|
318
|
+
}
|
|
319
|
+
# setparams only knows the BT.470 transfers by their ffprobe names, while ffmpeg 8's
|
|
320
|
+
# -color_trc output option only knows them as gamma22/gamma28 (ffmpeg 9 takes both).
|
|
321
|
+
_COLOR_OPTION_SPELLING = {"color_trc": {"bt470m": "gamma22", "bt470bg": "gamma28"}}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _probe_video_format(video_path):
|
|
325
|
+
"""Codec, pixel format, size and colour tags of the first video stream ({} if unreadable)."""
|
|
326
|
+
result = run_cmd([
|
|
327
|
+
"ffprobe", "-v", "error", "-select_streams", "v:0",
|
|
328
|
+
"-show_entries",
|
|
329
|
+
"stream=codec_name,pix_fmt,width,height,color_space,color_primaries,color_transfer,color_range",
|
|
330
|
+
"-of", "json", str(video_path),
|
|
331
|
+
])
|
|
332
|
+
if result.returncode != 0:
|
|
333
|
+
return {}
|
|
334
|
+
try:
|
|
335
|
+
streams = json.loads(result.stdout).get("streams") or []
|
|
336
|
+
except (ValueError, AttributeError):
|
|
337
|
+
return {}
|
|
338
|
+
return streams[0] if streams and isinstance(streams[0], dict) else {}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _output_color_tags(stream):
|
|
342
|
+
"""Colour tags to write on the delivered picture, as {ffmpeg option: value}.
|
|
343
|
+
|
|
344
|
+
An untagged or BT.709 source is labelled BT.709, which is how players already decode
|
|
345
|
+
untagged web video. Any other declared colour space passes through as declared. The
|
|
346
|
+
range follows the source: full range stays `pc`, everything else is limited `tv`.
|
|
347
|
+
For a YUV source nothing here converts pixels; it only fixes the labels.
|
|
348
|
+
|
|
349
|
+
An RGB source (colour space `gbr`, or an RGB pixel format with or without a colour
|
|
350
|
+
space) has no YUV matrix to keep: it is converted to BT.709 limited range, and
|
|
351
|
+
`from_rgb` tells `_color_tag_filter` to do that conversion explicitly instead of
|
|
352
|
+
letting ffmpeg pick a matrix.
|
|
353
|
+
"""
|
|
354
|
+
declared = {option: stream.get(key) for key, option in _COLOR_FIELDS}
|
|
355
|
+
from_rgb = (
|
|
356
|
+
declared["colorspace"] == _RGB_COLOR_SPACE
|
|
357
|
+
or str(stream.get("pix_fmt") or "").startswith(_RGB_PIX_FMT_PREFIXES)
|
|
358
|
+
)
|
|
359
|
+
if from_rgb:
|
|
360
|
+
declared["colorspace"] = "bt709"
|
|
361
|
+
if all(value in _UNTAGGED_COLOR or value == "bt709" for value in declared.values()):
|
|
362
|
+
tags = {option: "bt709" for _, option in _COLOR_FIELDS}
|
|
363
|
+
else:
|
|
364
|
+
tags = {
|
|
365
|
+
option: value for option, value in declared.items()
|
|
366
|
+
if value in _KNOWN_COLOR_VALUES[option]
|
|
367
|
+
}
|
|
368
|
+
if from_rgb:
|
|
369
|
+
tags["color_range"] = "tv"
|
|
370
|
+
tags["from_rgb"] = True
|
|
371
|
+
else:
|
|
372
|
+
tags["color_range"] = "pc" if stream.get("color_range") == "pc" else "tv"
|
|
373
|
+
return tags
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _color_tag_filter(tags):
|
|
377
|
+
"""setparams filter that stamps the tags on every frame before the encoder.
|
|
378
|
+
|
|
379
|
+
Output options alone are not enough when re-encoding: ffmpeg 8/9 let the (untagged)
|
|
380
|
+
frame properties win for primaries and transfer, so the bitstream would stay untagged.
|
|
381
|
+
An RGB frame is first converted to BT.709 yuv420p explicitly: stamping a YUV colour
|
|
382
|
+
space on an RGB frame makes ffmpeg's own later conversion use the BT.601 matrix
|
|
383
|
+
while the file says BT.709.
|
|
384
|
+
"""
|
|
385
|
+
parts = [f"{option}={tags[option]}" for _, option in _COLOR_FIELDS if option in tags]
|
|
386
|
+
parts.append(f"range={tags['color_range']}")
|
|
387
|
+
setparams = "setparams=" + ":".join(parts)
|
|
388
|
+
if tags.get("from_rgb"):
|
|
389
|
+
return (
|
|
390
|
+
f"scale=out_color_matrix=bt709:out_range={tags['color_range']},"
|
|
391
|
+
f"format=yuv420p,{setparams}"
|
|
392
|
+
)
|
|
393
|
+
return setparams
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _color_tag_args(tags):
|
|
397
|
+
"""Output options that write the tags into the container (and encoder) metadata."""
|
|
398
|
+
args = []
|
|
399
|
+
for _, option in _COLOR_FIELDS:
|
|
400
|
+
if option in tags:
|
|
401
|
+
value = _COLOR_OPTION_SPELLING.get(option, {}).get(tags[option], tags[option])
|
|
402
|
+
args += [f"-{option}", value]
|
|
403
|
+
return args + ["-color_range", tags["color_range"]]
|
|
404
|
+
|