@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,122 @@
|
|
|
1
|
+
"""Contact sheets for looking at a finished video: suppressed cut candidates, longest shots, or a span.
|
|
2
|
+
|
|
3
|
+
The measurement can miss cuts (dissolves, dark scenes, rapid montage) or keep a few false ones,
|
|
4
|
+
and when no understanding run exists the agent has no pictures at all. Each sheet row is one
|
|
5
|
+
ffmpeg seek tiled to CELLS frames; rows are stacked into pages under U/reference_frames/ and the
|
|
6
|
+
printed legend gives every cell's time, so no drawtext/font support is needed.
|
|
7
|
+
"""
|
|
8
|
+
import math
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from lib import ffprobe, run_cmd
|
|
13
|
+
|
|
14
|
+
FRAMES_DIR = "reference_frames"
|
|
15
|
+
CELLS = 12
|
|
16
|
+
CELL_WIDTH = 160
|
|
17
|
+
ROWS_PER_PAGE = 8
|
|
18
|
+
LONGEST_EDGE_S = 0.1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _row(label, start, end, cells):
|
|
22
|
+
"""Evenly spaced cell times over [start, end]; a single time when the range is a point."""
|
|
23
|
+
cells = max(1, min(CELLS, cells))
|
|
24
|
+
step = (end - start) / (cells - 1) if cells > 1 else 0.0
|
|
25
|
+
return {"label": label, "times": [round(start + i * step, 2) for i in range(cells)], "step": step}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def review_rows(measurements, fps):
|
|
29
|
+
"""Every frame of each suppressed-candidate window, CELLS frames per row."""
|
|
30
|
+
frame_s = 1.0 / fps if fps else 0.04
|
|
31
|
+
rows = []
|
|
32
|
+
for start, end in measurements["shots"].get("review_windows") or []:
|
|
33
|
+
for row in span_rows(start, end, frame_s):
|
|
34
|
+
rows.append({**row, "label": f"待复核 {start}–{end}s"})
|
|
35
|
+
return rows
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def longest_rows(measurements, count):
|
|
39
|
+
"""One row per longest measured shot: a long 'shot' is where a missed cut hides."""
|
|
40
|
+
duration = measurements["source"]["duration_s"]
|
|
41
|
+
bounds = [0.0, *measurements["shots"]["cuts"], duration]
|
|
42
|
+
shots = sorted(zip(bounds, bounds[1:]), key=lambda s: s[1] - s[0], reverse=True)[:count]
|
|
43
|
+
return [_row(f"镜头 {a:.2f}–{b:.2f}s({b - a:.1f}s)", a + LONGEST_EDGE_S, b - LONGEST_EDGE_S, CELLS)
|
|
44
|
+
for a, b in sorted(shots) if b - a > 2 * LONGEST_EDGE_S]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def span_rows(start, end, step):
|
|
48
|
+
"""Cells every `step` seconds over [start, end], CELLS per row."""
|
|
49
|
+
if step <= 0 or end <= start:
|
|
50
|
+
raise ValueError("--span 需要 A<B,--step 必须为正数")
|
|
51
|
+
times = [round(start + i * step, 3) for i in range(int(math.floor((end - start) / step + 1e-9)) + 1)]
|
|
52
|
+
return [{"label": f"{chunk[0]:g}–{chunk[-1]:g}s,每 {step:g}s", "times": chunk, "step": step}
|
|
53
|
+
for chunk in (times[i:i + CELLS] for i in range(0, len(times), CELLS))]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _render_row(video, row, out_path):
|
|
57
|
+
times, step = row["times"], row["step"]
|
|
58
|
+
# Cell i is the first frame at or after times[0] + i * step (the unrounded step). The fps filter
|
|
59
|
+
# would instead pick the frame nearest each slot, which can be a frame of the *next* shot and hide
|
|
60
|
+
# where a cut is. Only len(times) frames are picked: a short row leaves its other cells blank.
|
|
61
|
+
pick = f"select='gte(t\\,selected_n*{step:.6f}-0.001)*lt(selected_n\\,{len(times)})'"
|
|
62
|
+
cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-ss", f"{times[0]:.3f}",
|
|
63
|
+
"-t", f"{step * (len(times) - 1) + 0.2:.3f}", "-i", video,
|
|
64
|
+
"-vf", f"{pick},scale={CELL_WIDTH}:-2,tile={CELLS}x1", "-frames:v", "1", out_path]
|
|
65
|
+
result = run_cmd(cmd)
|
|
66
|
+
if result.returncode != 0 or not Path(out_path).is_file():
|
|
67
|
+
raise RuntimeError(f"ffmpeg 取帧失败({row['label']}): {result.stderr.strip()[-400:]}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def render(video, work_dir, rows, name):
|
|
71
|
+
"""Write pages <name>_<n>.jpg under work_dir/reference_frames; return [(page, rows)]."""
|
|
72
|
+
out_dir = Path(work_dir) / FRAMES_DIR
|
|
73
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
pages = []
|
|
75
|
+
with tempfile.TemporaryDirectory(prefix="reference-frames-") as scratch:
|
|
76
|
+
for page_index in range(0, len(rows), ROWS_PER_PAGE):
|
|
77
|
+
page_rows = rows[page_index:page_index + ROWS_PER_PAGE]
|
|
78
|
+
parts = []
|
|
79
|
+
for i, row in enumerate(page_rows):
|
|
80
|
+
part = Path(scratch) / f"row_{page_index + i}.jpg"
|
|
81
|
+
_render_row(video, row, part)
|
|
82
|
+
parts.append(str(part))
|
|
83
|
+
page = out_dir / f"{name}_{page_index // ROWS_PER_PAGE + 1}.jpg"
|
|
84
|
+
if len(parts) == 1:
|
|
85
|
+
cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", parts[0], str(page)]
|
|
86
|
+
else:
|
|
87
|
+
cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y"]
|
|
88
|
+
for part in parts:
|
|
89
|
+
cmd += ["-i", part]
|
|
90
|
+
cmd += ["-filter_complex", f"vstack=inputs={len(parts)}", str(page)]
|
|
91
|
+
result = run_cmd(cmd)
|
|
92
|
+
if result.returncode != 0:
|
|
93
|
+
raise RuntimeError(f"ffmpeg 拼页失败: {result.stderr.strip()[-400:]}")
|
|
94
|
+
pages.append((page, page_rows))
|
|
95
|
+
return pages
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def legend(pages):
|
|
99
|
+
"""Printable mapping from page/row/cell to seconds."""
|
|
100
|
+
lines = []
|
|
101
|
+
for page, rows in pages:
|
|
102
|
+
lines.append(str(page))
|
|
103
|
+
for i, row in enumerate(rows, 1):
|
|
104
|
+
lines.append(f" 第 {i} 行 {row['label']}:" + " ".join(f"{t:g}" for t in row["times"]))
|
|
105
|
+
return "\n".join(lines)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def frames(video, work_dir, measurements, *, mode, longest=5, span=None, step=2.0):
|
|
109
|
+
"""Build the rows for `mode` (review | longest | span) and render them."""
|
|
110
|
+
if mode == "span":
|
|
111
|
+
start, end = span
|
|
112
|
+
rows = span_rows(start, end, step)
|
|
113
|
+
else:
|
|
114
|
+
if measurements is None:
|
|
115
|
+
raise ValueError("先运行 measure:review / longest 需要 reference_measurements.json")
|
|
116
|
+
if mode == "review":
|
|
117
|
+
rows = review_rows(measurements, measurements["source"].get("fps") or ffprobe(video)["fps"])
|
|
118
|
+
else:
|
|
119
|
+
rows = longest_rows(measurements, longest)
|
|
120
|
+
if not rows:
|
|
121
|
+
return []
|
|
122
|
+
return render(str(video), work_dir, rows, mode)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""One ffmpeg pass over a finished video: hard-cut times (scdet) and loudness (ebur128).
|
|
2
|
+
|
|
3
|
+
scdet reports a per-frame score; a fixed score threshold is wrong in both directions on real
|
|
4
|
+
recaps (threshold 10 missed about a third of the cuts in a dark scene; threshold 6 counted a
|
|
5
|
+
fast-moving single shot as nine cuts). So every frame scoring >= SCORE_FLOOR is kept and a cut is
|
|
6
|
+
a frame that scores >= hard, or >= soft while scoring at least ISOLATION_RATIO times every other
|
|
7
|
+
frame within ISOLATION_WINDOW_S (the adjacent frames excluded: they carry the cut's own
|
|
8
|
+
after-image). Suppressed candidates are grouped into `shots.review_windows` for the agent to look
|
|
9
|
+
at; the agent's corrections live in the breakdown (labels.cut_fixes), never in this file.
|
|
10
|
+
|
|
11
|
+
The score is min(mafd, |mafd - previous mafd|), so a cut out of fast motion scores near zero (a
|
|
12
|
+
dark hard cut after a fight scored 0.9). Every cut is still a one-sided peak of mafd itself: at
|
|
13
|
+
least every other frame within the window and ISOLATION_RATIO times every frame on one side.
|
|
14
|
+
Such peaks (`mafd_peaks`) join the review windows; they never become cuts on their own.
|
|
15
|
+
|
|
16
|
+
The result is cached in `reference_measurements.json` by the video's {size, mtime_ns}; the raw
|
|
17
|
+
scores are cached too, so changing the score settings re-derives the cuts without decoding again.
|
|
18
|
+
Shot statistics are computed from these cuts directly; an understanding run's scene list merges
|
|
19
|
+
short shots and would inflate the median shot length.
|
|
20
|
+
"""
|
|
21
|
+
import bisect
|
|
22
|
+
import math
|
|
23
|
+
import re
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from lib import ffprobe, file_identity, log, read_json, run_cmd, write_json
|
|
27
|
+
|
|
28
|
+
MEASUREMENTS_FILE = "reference_measurements.json"
|
|
29
|
+
MEASUREMENTS_SCHEMA = "video-reference.measurements.v3"
|
|
30
|
+
DETECTOR = "scdet-isolated-v1"
|
|
31
|
+
DEFAULT_HARD_SCORE = 10.0 # always a cut
|
|
32
|
+
DEFAULT_SOFT_SCORE = 4.0 # a cut only when isolated
|
|
33
|
+
ISOLATION_RATIO = 2.0
|
|
34
|
+
ISOLATION_WINDOW_S = 0.3
|
|
35
|
+
SCORE_FLOOR = 2.0 # below soft / ratio a frame can neither be a cut nor suppress one;
|
|
36
|
+
# also the least mafd a review peak needs
|
|
37
|
+
REVIEW_PAD_S = 0.2
|
|
38
|
+
REVIEW_JOIN_S = 0.5
|
|
39
|
+
CURVE_WINDOW_S = 10.0
|
|
40
|
+
EDGE_S = 0.05 # cuts this close to either end are not cuts between two shots
|
|
41
|
+
SILENT_LUFS = -70.0 # ebur128 reports -120.7 for digital silence / warm-up blocks
|
|
42
|
+
|
|
43
|
+
_SCDET = re.compile(
|
|
44
|
+
r"lavfi\.scd\.score\s*[:=]\s*(-?\d+(?:\.\d+)?)\s*,?\s*lavfi\.scd\.time\s*[:=]\s*(-?\d+(?:\.\d+)?)")
|
|
45
|
+
_MAFD = re.compile(r"\[Parsed_metadata[^\]]*\]\s*(?:frame:.*?pts_time:\s*(\S+)|lavfi\.scd\.mafd=(\S+))")
|
|
46
|
+
_EBU_FRAME = re.compile(r"\bt:\s*(\d+(?:\.\d+)?)\s.*?\bS:\s*(-?\d+(?:\.\d+)?|-?inf|nan)")
|
|
47
|
+
_SUMMARY_I = re.compile(r"\bI:\s*(-?\d+(?:\.\d+)?)\s*LUFS")
|
|
48
|
+
_SUMMARY_LRA = re.compile(r"\bLRA:\s*(-?\d+(?:\.\d+)?)\s*LU\b")
|
|
49
|
+
_SUMMARY_PEAK = re.compile(r"True peak:\s*\n\s*Peak:\s*(-?\d+(?:\.\d+)?|-inf)")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_scdet(stderr, duration=None):
|
|
53
|
+
"""Sorted [time, score] pairs from scdet log lines (`score: 4.2, time: 2.6` or `score=4.2 time=2.6`).
|
|
54
|
+
|
|
55
|
+
Frames within EDGE_S of either end are dropped: they are not cuts between two shots.
|
|
56
|
+
"""
|
|
57
|
+
scores = {}
|
|
58
|
+
for match in _SCDET.finditer(stderr or ""):
|
|
59
|
+
score, t = float(match.group(1)), round(float(match.group(2)), 3)
|
|
60
|
+
if t <= EDGE_S or (duration is not None and t >= duration - EDGE_S):
|
|
61
|
+
continue
|
|
62
|
+
scores[t] = max(score, scores.get(t, score))
|
|
63
|
+
return [[t, round(scores[t], 3)] for t in sorted(scores)]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _sides(series, times, i, adjacent):
|
|
67
|
+
"""Highest value before and after series[i] within ISOLATION_WINDOW_S, adjacent frames excluded."""
|
|
68
|
+
t = series[i][0]
|
|
69
|
+
lo = bisect.bisect_left(times, t - ISOLATION_WINDOW_S - 1e-9)
|
|
70
|
+
hi = bisect.bisect_right(times, t + ISOLATION_WINDOW_S + 1e-9)
|
|
71
|
+
return (max((v for u, v in series[lo:i] if t - u > adjacent), default=0.0),
|
|
72
|
+
max((v for u, v in series[i + 1:hi] if u - t > adjacent), default=0.0))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def mafd_peaks(stderr, fps, duration=None):
|
|
76
|
+
"""[time, mafd] of frames whose mafd (metadata print) is a one-sided peak >= SCORE_FLOOR."""
|
|
77
|
+
series, t = [], None
|
|
78
|
+
for time_text, value in _MAFD.findall(stderr or ""):
|
|
79
|
+
if time_text:
|
|
80
|
+
t = _number(time_text)
|
|
81
|
+
elif t is not None:
|
|
82
|
+
series.append((round(t, 3), _number(value) or 0.0))
|
|
83
|
+
t = None
|
|
84
|
+
adjacent = 1.5 / fps if fps else 0.06
|
|
85
|
+
times = [u for u, _ in series]
|
|
86
|
+
peaks = []
|
|
87
|
+
for i, (t, value) in enumerate(series):
|
|
88
|
+
if value < SCORE_FLOOR or t <= EDGE_S or (duration is not None and t >= duration - EDGE_S):
|
|
89
|
+
continue
|
|
90
|
+
before, after = _sides(series, times, i, adjacent)
|
|
91
|
+
if value >= max(before, after) and value >= ISOLATION_RATIO * min(before, after):
|
|
92
|
+
peaks.append([t, round(value, 3)])
|
|
93
|
+
return peaks
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def detect_cuts(scores, fps, *, hard=DEFAULT_HARD_SCORE, soft=DEFAULT_SOFT_SCORE, peaks=()):
|
|
97
|
+
"""(cuts, review_windows) from [time, score] pairs and mafd peaks; see the module docstring."""
|
|
98
|
+
frame_s = 1.0 / fps if fps else 0.04
|
|
99
|
+
adjacent = 1.5 * frame_s
|
|
100
|
+
times = [t for t, _ in scores]
|
|
101
|
+
cuts, suppressed = [], [t for t, _ in peaks]
|
|
102
|
+
for i, (t, score) in enumerate(scores):
|
|
103
|
+
if score < soft:
|
|
104
|
+
continue
|
|
105
|
+
if score < hard and score < ISOLATION_RATIO * max(_sides(scores, times, i, adjacent)):
|
|
106
|
+
suppressed.append(t)
|
|
107
|
+
continue
|
|
108
|
+
if cuts and t - cuts[-1][0] <= adjacent: # one cut spread over two frames
|
|
109
|
+
if score > cuts[-1][1]:
|
|
110
|
+
cuts[-1] = (t, score)
|
|
111
|
+
continue
|
|
112
|
+
cuts.append((t, score))
|
|
113
|
+
cut_times = [t for t, _ in cuts]
|
|
114
|
+
windows = []
|
|
115
|
+
for t in sorted(set(suppressed)):
|
|
116
|
+
if any(abs(t - c) <= adjacent for c in cut_times):
|
|
117
|
+
continue
|
|
118
|
+
if windows and t - windows[-1][1] <= REVIEW_JOIN_S:
|
|
119
|
+
windows[-1][1] = t
|
|
120
|
+
else:
|
|
121
|
+
windows.append([t, t])
|
|
122
|
+
return cut_times, [[round(max(0.0, a - REVIEW_PAD_S), 2), round(b + REVIEW_PAD_S, 2)] for a, b in windows]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _number(text):
|
|
126
|
+
try:
|
|
127
|
+
value = float(text)
|
|
128
|
+
except ValueError:
|
|
129
|
+
return None
|
|
130
|
+
return value if math.isfinite(value) else None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse_ebur128(stderr, duration):
|
|
134
|
+
"""Integrated loudness, LRA, true peak (Summary block) and a 1 s short-term series."""
|
|
135
|
+
stderr = stderr or ""
|
|
136
|
+
head, marker, summary = stderr.rpartition("Summary:")
|
|
137
|
+
if not marker:
|
|
138
|
+
return None
|
|
139
|
+
seconds = max(1, math.ceil(duration))
|
|
140
|
+
series = [None] * seconds
|
|
141
|
+
for match in _EBU_FRAME.finditer(head):
|
|
142
|
+
bucket = int(float(match.group(1)))
|
|
143
|
+
value = _number(match.group(2))
|
|
144
|
+
if 0 <= bucket < seconds:
|
|
145
|
+
series[bucket] = None if value is None or value < SILENT_LUFS else round(value, 1)
|
|
146
|
+
|
|
147
|
+
def pick(pattern):
|
|
148
|
+
match = pattern.search(summary)
|
|
149
|
+
return None if match is None else _number(match.group(1))
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
"integrated_lufs": pick(_SUMMARY_I),
|
|
153
|
+
"lra_lu": pick(_SUMMARY_LRA),
|
|
154
|
+
"true_peak_dbtp": pick(_SUMMARY_PEAK),
|
|
155
|
+
"short_term_1s": series,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _percentile(sorted_values, q):
|
|
160
|
+
if not sorted_values:
|
|
161
|
+
return None
|
|
162
|
+
position = (len(sorted_values) - 1) * q
|
|
163
|
+
low = math.floor(position)
|
|
164
|
+
high = min(low + 1, len(sorted_values) - 1)
|
|
165
|
+
return sorted_values[low] + (sorted_values[high] - sorted_values[low]) * (position - low)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def shot_stats(cuts, duration):
|
|
169
|
+
"""Shot-length distribution and cut density for cuts inside [0, duration]."""
|
|
170
|
+
bounds = [0.0, *cuts, duration]
|
|
171
|
+
lengths = sorted(b - a for a, b in zip(bounds, bounds[1:]) if b > a)
|
|
172
|
+
count = len(lengths)
|
|
173
|
+
minutes = duration / 60.0
|
|
174
|
+
windows = max(1, math.ceil(duration / CURVE_WINDOW_S))
|
|
175
|
+
curve = [0] * windows
|
|
176
|
+
for cut in cuts:
|
|
177
|
+
curve[min(windows - 1, int(cut // CURVE_WINDOW_S))] += 1
|
|
178
|
+
# Scale each window by its own length: the last one is usually partial (6.66 s on a 126.66 s
|
|
179
|
+
# video). Floor at 1 s so a sliver of a window cannot turn one cut into hundreds per minute.
|
|
180
|
+
per_min = [60.0 / max(1.0, min(CURVE_WINDOW_S, duration - i * CURVE_WINDOW_S)) for i in range(windows)]
|
|
181
|
+
return {
|
|
182
|
+
"cuts": list(cuts),
|
|
183
|
+
"count": count,
|
|
184
|
+
"mean_s": round(sum(lengths) / count, 3) if count else None,
|
|
185
|
+
"median_s": round(_percentile(lengths, 0.5), 3) if count else None,
|
|
186
|
+
"p10_s": round(_percentile(lengths, 0.1), 3) if count else None,
|
|
187
|
+
"p90_s": round(_percentile(lengths, 0.9), 3) if count else None,
|
|
188
|
+
"share_under_1s": round(sum(1 for x in lengths if x < 1.0) / count, 3) if count else None,
|
|
189
|
+
"share_over_8s": round(sum(1 for x in lengths if x > 8.0) / count, 3) if count else None,
|
|
190
|
+
"cuts_per_min": round(len(cuts) / minutes, 2) if minutes > 0 else None,
|
|
191
|
+
"curve": {"window_s": CURVE_WINDOW_S, "values": [round(c * k, 1) for c, k in zip(curve, per_min)]},
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _ffmpeg_command(video, scaled, has_audio):
|
|
196
|
+
video_filter = f"scdet=threshold={SCORE_FLOOR:g},metadata=mode=print:key=lavfi.scd.mafd"
|
|
197
|
+
if scaled:
|
|
198
|
+
video_filter = "scale=320:-2," + video_filter
|
|
199
|
+
cmd = ["ffmpeg", "-hide_banner", "-nostats", "-i", video, "-map", "0:v:0", "-vf", video_filter]
|
|
200
|
+
if has_audio:
|
|
201
|
+
cmd += ["-map", "0:a:0", "-af", "ebur128=peak=true:framelog=info"]
|
|
202
|
+
return cmd + ["-f", "null", "-"]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _reusable(cached, identity, scaled):
|
|
206
|
+
"""The cached decode (scores + loudness) is valid for this file and scaling."""
|
|
207
|
+
if not isinstance(cached, dict) or cached.get("schema") != MEASUREMENTS_SCHEMA:
|
|
208
|
+
return False
|
|
209
|
+
source = cached.get("source") or {}
|
|
210
|
+
settings = cached.get("settings") or {}
|
|
211
|
+
same_file = {k: source.get(k) for k in identity} == identity
|
|
212
|
+
return same_file and settings.get("scaled") == scaled and settings.get("score_floor") == SCORE_FLOOR
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def measure(video, work_dir, *, hard=DEFAULT_HARD_SCORE, soft=DEFAULT_SOFT_SCORE, scaled=True):
|
|
216
|
+
"""Measure `video` into work_dir/reference_measurements.json; reuse the decode when the file is unchanged."""
|
|
217
|
+
if not SCORE_FLOOR * ISOLATION_RATIO <= soft <= hard:
|
|
218
|
+
raise ValueError(f"需要 {SCORE_FLOOR * ISOLATION_RATIO:g} ≤ soft ≤ hard,收到 soft={soft} hard={hard}")
|
|
219
|
+
video = Path(video)
|
|
220
|
+
out_path = Path(work_dir) / MEASUREMENTS_FILE
|
|
221
|
+
identity = file_identity(video)
|
|
222
|
+
settings = {"detector": DETECTOR, "hard_score": float(hard), "soft_score": float(soft),
|
|
223
|
+
"isolation_ratio": ISOLATION_RATIO, "isolation_window_s": ISOLATION_WINDOW_S,
|
|
224
|
+
"score_floor": SCORE_FLOOR, "scaled": bool(scaled)}
|
|
225
|
+
cached = read_json(out_path)
|
|
226
|
+
if _reusable(cached, identity, bool(scaled)):
|
|
227
|
+
if cached.get("settings") == settings:
|
|
228
|
+
log(f"复用 {MEASUREMENTS_FILE}(成片与设置未变)")
|
|
229
|
+
return cached
|
|
230
|
+
log("成片未变,只按新的分数设置重算切点(不重新解码)")
|
|
231
|
+
source, scores, loudness = cached["source"], cached["scdet_scores"], cached.get("loudness")
|
|
232
|
+
peaks = cached.get("mafd_peaks") or []
|
|
233
|
+
else:
|
|
234
|
+
source = {**identity, **ffprobe(video)}
|
|
235
|
+
result = run_cmd(_ffmpeg_command(video, scaled, source["audio_streams"] > 0))
|
|
236
|
+
if result.returncode != 0:
|
|
237
|
+
raise RuntimeError(f"ffmpeg 测量失败: {result.stderr.strip()[-600:]}")
|
|
238
|
+
scores = parse_scdet(result.stderr, source["duration_s"])
|
|
239
|
+
peaks = mafd_peaks(result.stderr, source.get("fps"), source["duration_s"])
|
|
240
|
+
loudness = parse_ebur128(result.stderr, source["duration_s"]) if source["audio_streams"] else None
|
|
241
|
+
cuts, review_windows = detect_cuts(scores, source.get("fps"), hard=hard, soft=soft, peaks=peaks)
|
|
242
|
+
payload = {
|
|
243
|
+
"schema": MEASUREMENTS_SCHEMA,
|
|
244
|
+
"source": source,
|
|
245
|
+
"settings": settings,
|
|
246
|
+
"shots": {**shot_stats(cuts, source["duration_s"]), "review_windows": review_windows},
|
|
247
|
+
"loudness": loudness,
|
|
248
|
+
"scdet_scores": scores,
|
|
249
|
+
"mafd_peaks": peaks,
|
|
250
|
+
}
|
|
251
|
+
write_json(out_path, payload)
|
|
252
|
+
log(f"写入 {out_path}:{len(cuts)} 个切点,{len(review_windows)} 个待复核窗口,时长 {source['duration_s']:.1f}s")
|
|
253
|
+
return payload
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Pure functions: derive labeled metrics from labels + measurements, and project the export.
|
|
2
|
+
|
|
3
|
+
`derived` only ever lives in memory: `check` prints it so the agent can write methods against
|
|
4
|
+
it, `export` copies the needed values into production_reference.json. The agent never types a
|
|
5
|
+
number — a method target names a measurement path and the export fills the value in.
|
|
6
|
+
|
|
7
|
+
The agent's cut review (labels.cut_fixes) is applied first: every shot number, derived cut
|
|
8
|
+
density and export value is computed from the reviewed cut list, never from the raw detector.
|
|
9
|
+
"""
|
|
10
|
+
import statistics
|
|
11
|
+
|
|
12
|
+
from reference_measure import shot_stats
|
|
13
|
+
|
|
14
|
+
PRODUCTION_SCHEMA = "video-reference.production.v1"
|
|
15
|
+
CUT_MATCH_S = 0.1 # a removal or addition this close to a detected cut refers to that cut
|
|
16
|
+
OWNERS = ("narration", "original_dialogue", "action_sound", "ambience", "music", "silence")
|
|
17
|
+
SWITCH_TOLERANCE_S = 0.25
|
|
18
|
+
MIN_WINDOW_COVERAGE = 0.8
|
|
19
|
+
NULL_ASR_STATUSES = ("EXPLICITLY_SKIPPED",)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def cut_fix_counts(labels):
|
|
23
|
+
fixes = (labels or {}).get("cut_fixes") or {}
|
|
24
|
+
return len(fixes.get("add") or []), len(fixes.get("remove") or [])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def apply_cut_fixes(measurements, labels):
|
|
28
|
+
"""Measurements whose `shots` come from the reviewed cut list (unchanged without fixes)."""
|
|
29
|
+
fixes = (labels or {}).get("cut_fixes") or {}
|
|
30
|
+
if not any(cut_fix_counts(labels)):
|
|
31
|
+
return measurements
|
|
32
|
+
removed = fixes.get("remove") or []
|
|
33
|
+
cuts = [c for c in measurements["shots"]["cuts"] if not any(abs(c - r) <= CUT_MATCH_S for r in removed)]
|
|
34
|
+
cuts = sorted({*cuts, *(round(float(t), 3) for t in fixes.get("add") or [])})
|
|
35
|
+
shots = shot_stats(cuts, measurements["source"]["duration_s"])
|
|
36
|
+
shots["review_windows"] = measurements["shots"].get("review_windows") or []
|
|
37
|
+
return {**measurements, "shots": shots}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _overlap(a0, a1, b0, b1):
|
|
41
|
+
return max(0.0, min(a1, b1) - max(a0, b0))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _seconds_in(spans, start, end):
|
|
45
|
+
return sum(_overlap(start, end, s["start"], s["end"]) for s in spans)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _cuts_inside(cuts, start, end):
|
|
49
|
+
return sum(1 for c in cuts if start < c < end)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _per_min(count, seconds):
|
|
53
|
+
return round(count * 60.0 / seconds, 2) if seconds > 0 else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _r(value, digits=3):
|
|
57
|
+
return None if value is None else round(value, digits)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _merged_blocks(spans, owner):
|
|
61
|
+
"""Contiguous runs of one owner (adjacent spans with different narration_job join)."""
|
|
62
|
+
blocks = []
|
|
63
|
+
for span in spans:
|
|
64
|
+
if span["owner"] != owner:
|
|
65
|
+
continue
|
|
66
|
+
if blocks and abs(blocks[-1][1] - span["start"]) < 1e-6:
|
|
67
|
+
blocks[-1][1] = span["end"]
|
|
68
|
+
else:
|
|
69
|
+
blocks.append([span["start"], span["end"]])
|
|
70
|
+
return blocks
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _mean_lufs(series, blocks):
|
|
74
|
+
values = [
|
|
75
|
+
series[i] for i in range(len(series or []))
|
|
76
|
+
if series[i] is not None and any(a <= i + 0.5 < b for a, b in blocks)
|
|
77
|
+
]
|
|
78
|
+
return _r(sum(values) / len(values), 1) if values else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _by_owner(spans, cuts, duration, series):
|
|
82
|
+
result = {}
|
|
83
|
+
for owner in OWNERS:
|
|
84
|
+
blocks = _merged_blocks(spans, owner)
|
|
85
|
+
if not blocks:
|
|
86
|
+
continue
|
|
87
|
+
seconds = sum(b - a for a, b in blocks)
|
|
88
|
+
result[owner] = {
|
|
89
|
+
"seconds": _r(seconds, 2),
|
|
90
|
+
"share": _r(seconds / duration),
|
|
91
|
+
"blocks": len(blocks),
|
|
92
|
+
"block_median_s": _r(statistics.median(b - a for a, b in blocks), 2),
|
|
93
|
+
"cuts_per_min": _per_min(sum(_cuts_inside(cuts, a, b) for a, b in blocks), seconds),
|
|
94
|
+
"mean_short_term_lufs": _mean_lufs(series, blocks),
|
|
95
|
+
}
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _visible_chars(text):
|
|
100
|
+
return sum(1 for ch in str(text or "") if ch.isalnum())
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def narration_rate(spans, asr_windows, asr_status):
|
|
104
|
+
"""Chars/s over ASR windows that narration spans cover by >= 80%; null when unusable."""
|
|
105
|
+
narration = [s for s in spans if s["owner"] == "narration"]
|
|
106
|
+
usable = asr_status is None or not (
|
|
107
|
+
asr_status.startswith("FAILED_") or asr_status in NULL_ASR_STATUSES
|
|
108
|
+
)
|
|
109
|
+
used, chars, seconds, lengths = 0, 0, 0.0, []
|
|
110
|
+
for window in asr_windows if usable else []:
|
|
111
|
+
start, end = float(window.get("start", 0)), float(window.get("end", 0))
|
|
112
|
+
if end <= start:
|
|
113
|
+
continue
|
|
114
|
+
lengths.append(end - start)
|
|
115
|
+
if _seconds_in(narration, start, end) / (end - start) >= MIN_WINDOW_COVERAGE:
|
|
116
|
+
used += 1
|
|
117
|
+
chars += _visible_chars(window.get("text"))
|
|
118
|
+
seconds += end - start
|
|
119
|
+
return {
|
|
120
|
+
"value": _r(chars / seconds, 2) if seconds > 0 and chars > 0 else None,
|
|
121
|
+
"windows_used": used,
|
|
122
|
+
"window_s": _r(statistics.median(lengths), 2) if lengths else None,
|
|
123
|
+
"precision": "coarse_asr_windows",
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def switch_on_cut_share(spans, cuts):
|
|
128
|
+
"""Share of audio-owner switch points that land within ±0.25 s of a picture cut."""
|
|
129
|
+
switches = [b["start"] for a, b in zip(spans, spans[1:]) if a["owner"] != b["owner"]]
|
|
130
|
+
if not switches:
|
|
131
|
+
return None
|
|
132
|
+
hits = sum(1 for t in switches if any(abs(t - c) <= SWITCH_TOLERANCE_S for c in cuts))
|
|
133
|
+
return _r(hits / len(switches))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _lead_owner(spans, start, end):
|
|
137
|
+
totals = {}
|
|
138
|
+
for span in spans:
|
|
139
|
+
totals[span["owner"]] = totals.get(span["owner"], 0.0) + _overlap(
|
|
140
|
+
start, end, span["start"], span["end"])
|
|
141
|
+
return max(totals, key=totals.get) if totals and max(totals.values()) > 0 else None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def derive(labels, measurements, asr_windows=(), asr_status=None):
|
|
145
|
+
"""All labeled metrics from well-formed labels; see references/reference-schema.md."""
|
|
146
|
+
duration = measurements["source"]["duration_s"]
|
|
147
|
+
cuts = measurements["shots"]["cuts"]
|
|
148
|
+
series = (measurements.get("loudness") or {}).get("short_term_1s") or []
|
|
149
|
+
spans = sorted(labels.get("audio_spans") or [], key=lambda s: s["start"])
|
|
150
|
+
sections = sorted(labels.get("sections") or [], key=lambda s: s["start"])
|
|
151
|
+
narration = [s for s in spans if s["owner"] == "narration"]
|
|
152
|
+
|
|
153
|
+
by_section = {}
|
|
154
|
+
for section in sections:
|
|
155
|
+
entry = by_section.setdefault(section["function"], {"seconds": 0.0, "cuts": 0, "narr": 0.0})
|
|
156
|
+
entry["seconds"] += section["end"] - section["start"]
|
|
157
|
+
entry["cuts"] += _cuts_inside(cuts, section["start"], section["end"])
|
|
158
|
+
entry["narr"] += _seconds_in(narration, section["start"], section["end"])
|
|
159
|
+
first_original = next((s for s in spans if s["owner"] == "original_dialogue"), None)
|
|
160
|
+
derived = {
|
|
161
|
+
"by_owner": _by_owner(spans, cuts, duration, series),
|
|
162
|
+
"narration_chars_per_s": narration_rate(spans, asr_windows, asr_status),
|
|
163
|
+
"switch_on_cut_share": switch_on_cut_share(spans, cuts),
|
|
164
|
+
"by_section": {
|
|
165
|
+
function: {
|
|
166
|
+
"seconds": _r(e["seconds"], 2),
|
|
167
|
+
"cuts_per_min": _per_min(e["cuts"], e["seconds"]),
|
|
168
|
+
"narration_share": _r(e["narr"] / e["seconds"]) if e["seconds"] > 0 else None,
|
|
169
|
+
}
|
|
170
|
+
for function, e in by_section.items()
|
|
171
|
+
},
|
|
172
|
+
"structure": [
|
|
173
|
+
{
|
|
174
|
+
"function": s["function"],
|
|
175
|
+
"at": [_r(s["start"] / duration), _r(s["end"] / duration)],
|
|
176
|
+
"lead_owner": _lead_owner(spans, s["start"], s["end"]),
|
|
177
|
+
}
|
|
178
|
+
for s in sections
|
|
179
|
+
],
|
|
180
|
+
"first_original_at": None if first_original is None else {
|
|
181
|
+
"s": _r(first_original["start"], 2),
|
|
182
|
+
"fraction": _r(first_original["start"] / duration),
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
jobs = {}
|
|
186
|
+
for span in narration:
|
|
187
|
+
if span.get("narration_job"):
|
|
188
|
+
jobs[span["narration_job"]] = jobs.get(span["narration_job"], 0.0) + span["end"] - span["start"]
|
|
189
|
+
narration_seconds = sum(s["end"] - s["start"] for s in narration)
|
|
190
|
+
if jobs and narration_seconds > 0:
|
|
191
|
+
derived["narration_jobs"] = {job: _r(sec / narration_seconds) for job, sec in jobs.items()}
|
|
192
|
+
return derived
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
_MISSING = object()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def resolve(path, measurements, derived):
|
|
199
|
+
"""Value at a dot path in measurements (or in derived for `derived.*`); _MISSING if absent."""
|
|
200
|
+
parts = str(path).split(".")
|
|
201
|
+
node = {"derived": derived} if parts[0] == "derived" else measurements
|
|
202
|
+
for part in parts:
|
|
203
|
+
if isinstance(node, dict) and part in node:
|
|
204
|
+
node = node[part]
|
|
205
|
+
elif isinstance(node, list) and part.isdigit() and int(part) < len(node):
|
|
206
|
+
node = node[int(part)]
|
|
207
|
+
else:
|
|
208
|
+
return _MISSING
|
|
209
|
+
return node
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def is_resolved(value):
|
|
213
|
+
return value is not _MISSING
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _metric(value, provenance, **extra):
|
|
217
|
+
return {"value": value, "provenance": provenance, **extra}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def build_production(breakdown, measurements, derived):
|
|
221
|
+
"""production_reference.json: methods + numbers only; no facts, labels, evidence or paths."""
|
|
222
|
+
labels = breakdown["labels"]
|
|
223
|
+
shots = measurements["shots"]
|
|
224
|
+
loudness = measurements.get("loudness") or {}
|
|
225
|
+
added, removed = cut_fix_counts(labels)
|
|
226
|
+
shot_provenance = "reviewed" if "cut_fixes" in labels else "measured" # {} = looked, no change
|
|
227
|
+
profile = {
|
|
228
|
+
"shot_median_s": _metric(shots.get("median_s"), shot_provenance),
|
|
229
|
+
"cuts_per_min": _metric(shots.get("cuts_per_min"), shot_provenance),
|
|
230
|
+
"integrated_lufs": _metric(loudness.get("integrated_lufs"), "measured"),
|
|
231
|
+
"narration_share": _metric(
|
|
232
|
+
(derived["by_owner"].get("narration") or {}).get("share", 0.0), "labeled"),
|
|
233
|
+
"narration_chars_per_s": _metric(
|
|
234
|
+
derived["narration_chars_per_s"]["value"], "labeled", precision="coarse_asr_windows"),
|
|
235
|
+
"switch_on_cut_share": _metric(derived["switch_on_cut_share"], "labeled"),
|
|
236
|
+
"first_original_at": _metric(
|
|
237
|
+
None if derived["first_original_at"] is None
|
|
238
|
+
else {"fraction": derived["first_original_at"]["fraction"]},
|
|
239
|
+
"labeled"),
|
|
240
|
+
}
|
|
241
|
+
methods = []
|
|
242
|
+
for method in breakdown.get("methods") or []:
|
|
243
|
+
entry = {key: method[key] for key in (
|
|
244
|
+
"id", "dimension", "rule", "applies_when", "avoid_when", "applies_to") if key in method}
|
|
245
|
+
targets = {}
|
|
246
|
+
for name, target in (method.get("targets") or {}).items():
|
|
247
|
+
source = target["from"]
|
|
248
|
+
provenance = ("labeled" if source.startswith("derived.")
|
|
249
|
+
else shot_provenance if source.startswith("shots.") else "measured")
|
|
250
|
+
value = resolve(source, measurements, derived)
|
|
251
|
+
if source == "derived.first_original_at": # the absolute `s` stays local, as in the profile
|
|
252
|
+
value = {"fraction": value["fraction"]}
|
|
253
|
+
targets[name] = _metric(value, provenance)
|
|
254
|
+
if targets:
|
|
255
|
+
entry["targets"] = targets
|
|
256
|
+
methods.append(entry)
|
|
257
|
+
subtitles = {k: v for k, v in (labels.get("subtitles") or {}).items() if k != "evidence_t"}
|
|
258
|
+
settings = measurements.get("settings") or {}
|
|
259
|
+
cut_detection = {key: settings.get(key) for key in (
|
|
260
|
+
"detector", "hard_score", "soft_score", "isolation_ratio", "isolation_window_s", "scaled")}
|
|
261
|
+
return {
|
|
262
|
+
"schema": PRODUCTION_SCHEMA,
|
|
263
|
+
"duration_s": measurements["source"]["duration_s"],
|
|
264
|
+
"canvas": measurements["source"]["canvas"],
|
|
265
|
+
"cut_detection": {**cut_detection, "agent_added": added, "agent_removed": removed},
|
|
266
|
+
"profile": {name: metric for name, metric in profile.items() if metric["value"] is not None},
|
|
267
|
+
"structure": derived["structure"],
|
|
268
|
+
"subtitles": subtitles,
|
|
269
|
+
"methods": methods,
|
|
270
|
+
"skipped_dimensions": dict(breakdown.get("skipped_dimensions") or {}),
|
|
271
|
+
}
|