@oh-story/dsh 0.1.12 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -11
- package/lib/client.js +85 -35
- package/lib/client.js.map +3 -3
- package/lib/drama/manifest.json +41 -36
- package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +340 -13
- package/lib/drama/skills/short-drama-assets/references/voice-direction.md +4 -2
- package/lib/drama/skills/short-drama-edit/SKILL.md +32 -12
- package/lib/drama/skills/short-drama-edit/references/still-cuts.md +90 -0
- package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +1035 -145
- package/lib/drama/skills/short-drama-edit/scripts/selftest.py +48 -0
- package/lib/drama/skills/short-drama-produce/SKILL.md +6 -1
- package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +12 -0
- package/lib/drama/skills/short-drama-produce/scripts/production_tool.py +138 -9
- package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +6 -0
- package/lib/drama/skills/short-drama-storyboard/SKILL.md +2 -0
- package/lib/drama/skills/short-drama-storyboard/references/production-shot-grammar.md +3 -1
- package/lib/drama/skills/short-drama-video-prompts/SKILL.md +7 -0
- package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +14 -0
- package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +19 -6
- package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +1 -0
- package/lib/drama/skills/short-drama-video-prompts/references/target-model-profile.md +5 -0
- package/lib/drama/skills/short-drama-video-prompts/references/wan-3.0.md +4 -1
- package/lib/index.js +16 -7
- package/lib/index.js.map +2 -2
- package/lib/novel-to-game/README.md +98 -44
- package/lib/novel-to-game/README_ZH.md +100 -46
- package/lib/novel-to-game/VERSION +1 -1
- package/lib/novel-to-game/examples/jin-ping-mei/build/BUILD_BRIEF.md +27 -202
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/RUN.md +23 -115
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/chamber.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/court_night.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/court_rain.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/garden.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/hall.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/kitchen.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/street.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/street_dawn.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/bg/temple.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/yue/moon.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/angry.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/blush.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/neutral.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/sad.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/smile.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/meng/surprised.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/angry.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/blush.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/neutral.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/sad.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/smile.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pan/surprised.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/angry.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/blush.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/neutral.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/sad.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/smile.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/pinger/surprised.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/angry.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/blush.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/neutral.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/sad.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/smile.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/xuee/surprised.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/angry.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/blush.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/neutral.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/sad.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/smile.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/sprite/yue/surprised.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/style.css +326 -2503
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/index.html +71 -4
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/adv.js +221 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/audio.js +113 -257
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/main.js +511 -3026
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/menus.js +277 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/petals.js +67 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/stage.js +180 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/store.js +70 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common.js +198 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common2.js +204 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/common3.js +198 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/dates.js +186 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/index.js +122 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/meng.js +190 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/pan.js +212 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/pinger.js +198 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/xuee.js +199 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/story/yue.js +214 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/package.json +2 -2
- package/lib/novel-to-game/examples/jin-ping-mei/build/art/generated-art.json +182 -45
- package/lib/novel-to-game/examples/jin-ping-mei/qa/README.md +7 -17
- package/lib/novel-to-game/examples/jin-ping-mei/qa/verification.json +9 -9
- package/lib/novel-to-game/manifest.json +303 -78
- package/lib/novel-to-game/skills/game-art-direction/references/art-direction-method.md +7 -2
- package/lib/novel-to-game/skills/game-concept/references/concept-method.md +2 -0
- package/lib/novel-to-game/skills/game-world-design/references/narrative-design-method.md +2 -0
- package/lib/novel-to-game/skills/game-world-design/references/world-design-method.md +9 -0
- package/lib/types/client/drama-production-view.d.ts +11 -0
- package/lib/types/client/drama-production-view.d.ts.map +1 -1
- package/lib/types/client/file-activity.d.ts +1 -1
- package/lib/types/client/file-activity.d.ts.map +1 -1
- package/lib/types/client/production-prompts.d.ts +7 -1
- package/lib/types/client/production-prompts.d.ts.map +1 -1
- package/lib/types/client/production-runtime.d.ts +7 -2
- package/lib/types/client/production-runtime.d.ts.map +1 -1
- package/lib/types/drama-adapters.d.ts.map +1 -1
- package/lib/types/skill-provider.d.ts.map +1 -1
- package/lib/types/video-project.d.ts.map +1 -1
- package/lib/video-recap/CHANGELOG.md +108 -1
- package/lib/video-recap/README.en.md +18 -14
- package/lib/video-recap/README.md +18 -14
- package/lib/video-recap/manifest.json +297 -321
- package/lib/video-recap/skills/video-assemble/SKILL.md +18 -16
- package/lib/video-recap/skills/video-assemble/references/explicit-audio-mix.md +3 -1
- package/lib/video-recap/skills/video-assemble/references/jianying/SOURCE.md +5 -5
- package/lib/video-recap/skills/video-assemble/references/packaging.md +4 -4
- package/lib/video-recap/skills/video-assemble/references/subtitle-track.md +10 -10
- package/lib/video-recap/skills/video-assemble/scripts/adoption/audio_mix_binding.py +13 -2
- package/lib/video-recap/skills/video-assemble/scripts/adoption/av_clock.py +109 -0
- package/lib/video-recap/skills/video-assemble/scripts/adoption/strict_publish.py +4 -1
- package/lib/video-recap/skills/video-assemble/scripts/artifacts.py +2 -9
- package/lib/video-recap/skills/video-assemble/scripts/assemble.py +188 -51
- package/lib/video-recap/skills/video-assemble/scripts/assemble_constants.py +0 -1
- package/lib/video-recap/skills/video-assemble/scripts/assembly_contract.py +32 -75
- package/lib/video-recap/skills/video-assemble/scripts/assembly_settings.py +8 -15
- package/lib/video-recap/skills/video-assemble/scripts/audio_mix.py +202 -161
- package/lib/video-recap/skills/video-assemble/scripts/codec_peak.py +112 -0
- package/lib/video-recap/skills/video-assemble/scripts/export_jianying.py +10 -101
- package/lib/video-recap/skills/video-assemble/scripts/jianying/builders.py +44 -475
- package/lib/video-recap/skills/video-assemble/scripts/jianying/model.py +1 -22
- package/lib/video-recap/skills/video-assemble/scripts/jianying/templates.py +0 -3
- package/lib/video-recap/skills/video-assemble/scripts/jianying/timeline_contract.py +36 -103
- package/lib/video-recap/skills/video-assemble/scripts/jianying/tracks.py +0 -8
- package/lib/video-recap/skills/video-assemble/scripts/jianying/writer.py +28 -203
- package/lib/video-recap/skills/video-assemble/scripts/lib.py +20 -34
- package/lib/video-recap/skills/video-assemble/scripts/loudness.py +332 -0
- package/lib/video-recap/skills/video-assemble/scripts/media.py +158 -38
- package/lib/video-recap/skills/video-assemble/scripts/narration_audio.py +30 -16
- package/lib/video-recap/skills/video-assemble/scripts/render_preflight.py +49 -8
- package/lib/video-recap/skills/video-assemble/scripts/source_subtitles.py +17 -6
- package/lib/video-recap/skills/video-assemble/scripts/subtitles/core.py +24 -2
- package/lib/video-recap/skills/video-assemble/scripts/subtitles/track.py +2 -5
- package/lib/video-recap/skills/video-assemble/scripts/subtitles/track_binding.py +2 -9
- package/lib/video-recap/skills/video-assemble/scripts/timeline.py +12 -34
- package/lib/video-recap/skills/video-assemble/scripts/timeline_emit.py +3 -4
- package/lib/video-recap/skills/video-assemble/scripts/visual_render.py +28 -2
- package/lib/video-recap/skills/video-cut/SKILL.md +37 -11
- package/lib/video-recap/skills/video-cut/references/shot-review.md +1 -1
- package/lib/video-recap/skills/video-cut/scripts/cut.py +2 -34
- package/lib/video-recap/skills/video-cut/scripts/cut_cli.py +96 -124
- package/lib/video-recap/skills/video-cut/scripts/cut_contract.py +97 -182
- package/lib/video-recap/skills/video-cut/scripts/cut_render.py +177 -166
- package/lib/video-recap/skills/video-cut/scripts/frame_grid.py +249 -0
- package/lib/video-recap/skills/video-cut/scripts/lib.py +0 -3
- package/lib/video-recap/skills/video-cut/scripts/media_geometry.py +173 -7
- package/lib/video-recap/skills/video-cut/scripts/sentence_boundaries.py +234 -151
- package/lib/video-recap/skills/video-cut/scripts/sentence_gate.py +189 -0
- package/lib/video-recap/skills/video-cut/scripts/shot_review.py +2 -6
- package/lib/video-recap/skills/video-recap/SKILL.md +24 -27
- package/lib/video-recap/skills/video-recap/references/audio-routing.md +8 -6
- package/lib/video-recap/skills/video-recap/references/config-playbook.md +8 -11
- package/lib/video-recap/skills/video-recap/references/data-schema.md +131 -74
- package/lib/video-recap/skills/video-recap/references/resource-library.md +17 -1
- package/lib/video-recap/skills/video-recap/scripts/doctor.py +51 -268
- package/lib/video-recap/skills/video-recap/scripts/final_qc.py +89 -254
- package/lib/video-recap/skills/video-recap/scripts/lib.py +81 -70
- package/lib/video-recap/skills/video-recap/scripts/library.py +40 -4
- package/lib/video-recap/skills/video-recap/scripts/materials.py +13 -80
- package/lib/video-recap/skills/video-recap/scripts/recap_cli.py +49 -17
- package/lib/video-recap/skills/video-recap/scripts/recap_inspect.py +1 -3
- package/lib/video-recap/skills/video-recap/scripts/recap_runner.py +86 -116
- package/lib/video-recap/skills/video-recap/scripts/recap_runtime.py +87 -23
- package/lib/video-recap/skills/video-recap/scripts/recap_source.py +0 -39
- package/lib/video-recap/skills/video-recap/scripts/recap_stage_qc.py +37 -135
- package/lib/video-recap/skills/video-recap/scripts/recap_timeline.py +118 -127
- package/lib/video-recap/skills/video-recap/scripts/resources/__init__.py +4 -0
- package/lib/video-recap/skills/video-recap/scripts/{resource_lock.py → resources/lock.py} +1 -1
- package/lib/video-recap/skills/video-recap/scripts/{project_binding.py → resources/project_binding.py} +78 -17
- package/lib/video-recap/skills/video-reference/SKILL.md +108 -0
- package/lib/video-recap/skills/video-reference/references/reference-schema.md +139 -0
- package/lib/video-recap/skills/video-reference/scripts/lib.py +85 -0
- package/lib/video-recap/skills/video-reference/scripts/reference.py +96 -0
- package/lib/video-recap/skills/video-reference/scripts/reference_check.py +553 -0
- package/lib/video-recap/skills/video-reference/scripts/reference_frames.py +122 -0
- package/lib/video-recap/skills/video-reference/scripts/reference_measure.py +253 -0
- package/lib/video-recap/skills/video-reference/scripts/reference_profile.py +271 -0
- package/lib/video-recap/skills/video-script/SKILL.md +9 -7
- package/lib/video-recap/skills/video-script/scripts/agent_text.py +18 -267
- package/lib/video-recap/skills/video-script/scripts/deslop_qc.py +12 -30
- package/lib/video-recap/skills/video-script/scripts/entry_suggestion.py +125 -0
- package/lib/video-recap/skills/video-script/scripts/evidence_bundle.py +2 -26
- package/lib/video-recap/skills/video-script/scripts/lib.py +5 -15
- package/lib/video-recap/skills/video-script/scripts/lint_summary.py +184 -0
- package/lib/video-recap/skills/video-script/scripts/narration_lint.py +120 -100
- package/lib/video-recap/skills/video-script/scripts/review.py +3 -34
- package/lib/video-recap/skills/video-script/scripts/review_grounding.py +20 -46
- package/lib/video-recap/skills/video-script/scripts/review_response.py +120 -258
- package/lib/video-recap/skills/video-script/scripts/review_runner.py +2 -10
- package/lib/video-recap/skills/video-script/scripts/speech_ownership.py +89 -5
- package/lib/video-recap/skills/video-script/scripts/validate.py +26 -34
- package/lib/video-recap/skills/video-understanding/SKILL.md +29 -4
- package/lib/video-recap/skills/video-understanding/references/data-schema.md +5 -22
- package/lib/video-recap/skills/video-understanding/scripts/agent_text.py +24 -121
- package/lib/video-recap/skills/video-understanding/scripts/asr.py +11 -5
- package/lib/video-recap/skills/video-understanding/scripts/asr_timing_evidence.py +15 -24
- package/lib/video-recap/skills/video-understanding/scripts/briefing/builder.py +59 -25
- package/lib/video-recap/skills/video-understanding/scripts/briefing/context.py +39 -3
- package/lib/video-recap/skills/video-understanding/scripts/briefing/inputs.py +15 -27
- package/lib/video-recap/skills/video-understanding/scripts/briefing/timeline.py +136 -37
- package/lib/video-recap/skills/video-understanding/scripts/consolidate.py +121 -37
- package/lib/video-recap/skills/video-understanding/scripts/detect.py +118 -16
- package/lib/video-recap/skills/video-understanding/scripts/index_normalize.py +359 -0
- package/lib/video-recap/skills/video-understanding/scripts/lib.py +62 -21
- package/lib/video-recap/skills/video-understanding/scripts/storyboard.py +181 -51
- package/lib/video-recap/skills/video-understanding/scripts/timeline_fusion.py +0 -26
- package/lib/video-recap/skills/video-understanding/scripts/understanding_brief.py +37 -14
- package/lib/video-recap/skills/video-understanding/scripts/understanding_cache.py +40 -32
- package/lib/video-recap/skills/video-understanding/scripts/understanding_runner.py +100 -127
- package/lib/video-recap/skills/video-understanding/scripts/understanding_storyboard.py +182 -30
- package/lib/video-recap/skills/video-understanding/scripts/vlm.py +30 -27
- package/lib/video-recap/skills/video-voiceover/SKILL.md +19 -12
- package/lib/video-recap/skills/video-voiceover/references/index-tts.md +3 -3
- package/lib/video-recap/skills/video-voiceover/scripts/approved_text_policy.py +3 -1
- package/lib/video-recap/skills/video-voiceover/scripts/dub.py +60 -135
- package/lib/video-recap/skills/video-voiceover/scripts/lib.py +9 -34
- package/lib/video-recap/skills/video-voiceover/scripts/providers/fish_audio.py +6 -1
- package/lib/video-recap/skills/video-voiceover/scripts/tts_audio.py +64 -1
- package/lib/video-recap/skills/video-voiceover/scripts/tts_cache.py +99 -0
- package/lib/video-recap/skills/video-voiceover/scripts/voiceover.py +177 -200
- package/package.json +45 -45
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/inner_court_accord_five.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/cg/group/public_day15.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/assets/household/li_jiaoer.webp +0 -0
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/new-guofeng.css +0 -1494
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/assets.js +0 -147
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/data.js +0 -7470
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/engine.js +0 -13066
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/text.js +0 -32
- package/lib/novel-to-game/examples/jin-ping-mei/qa/playable-model.json +0 -99
- package/lib/novel-to-game/examples/jin-ping-mei/qa/verify_playable_model.mjs +0 -152
- package/lib/video-recap/skills/video-assemble/references/foreground-compose.md +0 -118
- package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_combination_segment.json +0 -84
- package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_combination_video_material.json +0 -98
- package/lib/video-recap/skills/video-assemble/references/jianying/empty_jy_draft.json +0 -204
- package/lib/video-recap/skills/video-assemble/references/pair-media.md +0 -74
- package/lib/video-recap/skills/video-assemble/scripts/compose_foreground.py +0 -293
- package/lib/video-recap/skills/video-assemble/scripts/pair_media.py +0 -216
- package/lib/video-recap/skills/video-recap/references/shift-left-qc-schema.md +0 -23
- package/lib/video-recap/skills/video-recap/scripts/mimo_qc.py +0 -33
- package/lib/video-recap/skills/video-recap/scripts/qc/__init__.py +0 -1
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_client.py +0 -13
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_contract.py +0 -9
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_evidence.py +0 -287
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_observations.py +0 -175
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_payload.py +0 -206
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_report.py +0 -318
- package/lib/video-recap/skills/video-recap/scripts/qc/mimo_runner.py +0 -130
- package/lib/video-recap/skills/video-recap/scripts/qc_contract.py +0 -320
- package/lib/video-recap/skills/video-script/scripts/timeline_fusion.py +0 -108
- package/lib/video-recap/skills/video-understanding/scripts/brief.py +0 -9
- package/lib/video-recap/skills/video-understanding/scripts/deslop_qc.py +0 -211
- package/lib/video-recap/skills/video-understanding/scripts/narration_lint.py +0 -707
- package/lib/video-recap/skills/video-understanding/scripts/speech_ownership.py +0 -143
- /package/lib/video-recap/skills/video-cut/scripts/{narration_mapping.py → cut_qc.py} +0 -0
|
@@ -94,8 +94,8 @@ def env_float(name, default, *, minimum=None):
|
|
|
94
94
|
return _env_number(name, default, float, minimum)
|
|
95
95
|
|
|
96
96
|
|
|
97
|
-
# Single MiMo credential powers ASR + VLM
|
|
98
|
-
# (MIMO_VIDEO_API_KEY /
|
|
97
|
+
# Single MiMo credential powers ASR + VLM. Per-capability overrides
|
|
98
|
+
# (MIMO_VIDEO_API_KEY / MIMO_ASR_API_KEY and their *_API_URL forms)
|
|
99
99
|
# are optional and fall back to MIMO_API_KEY / MIMO_API_URL. Token-Plan keys (tp-*) auto-
|
|
100
100
|
# route to the Token-Plan cluster base URL; pay-as-you-go keys use api.xiaomimimo.com.
|
|
101
101
|
_mimo_api_key = os.environ.get("MIMO_API_KEY", "")
|
|
@@ -121,6 +121,7 @@ CONFIG = {
|
|
|
121
121
|
"mimo_api_key": _mimo_api_key,
|
|
122
122
|
"mimo_video_api_url": normalize_api_url(_raw_mimo_video_api_url),
|
|
123
123
|
"mimo_video_api_key": _mimo_video_api_key,
|
|
124
|
+
"mimo_video_env_var": "MIMO_VIDEO_API_KEY" if os.environ.get("MIMO_VIDEO_API_KEY") else "MIMO_API_KEY",
|
|
124
125
|
"mimo_asr_api_url": normalize_api_url(_raw_mimo_asr_api_url),
|
|
125
126
|
"mimo_asr_api_key": _mimo_asr_api_key,
|
|
126
127
|
"mimo_asr_env_var": "MIMO_ASR_API_KEY" if os.environ.get("MIMO_ASR_API_KEY") else "MIMO_API_KEY",
|
|
@@ -164,19 +165,12 @@ CONFIG = {
|
|
|
164
165
|
# 生成解说时使用 speech_rate * safety_margin 作为约束
|
|
165
166
|
"speech_rate": env_float("SPEECH_RATE", 3.9, minimum=0.5), # 旧值 3.5 系统性偏低 ~10-17%
|
|
166
167
|
"speech_safety_margin": env_float("SPEECH_SAFETY_MARGIN", 0.85, minimum=0.1), # 保守系数:TTS 实际语速有 ±20% 波动
|
|
167
|
-
# Block-coverage
|
|
168
|
+
# Block-coverage targets the brief derives its first-draft block count from
|
|
168
169
|
"narration_coverage_target": 0.7, # rough first-draft/diagnostic fallback; content-led audio decisions may differ (not a quota)
|
|
169
|
-
"narration_coverage_max": 0.85, # above this coverage → no_original_blocks (narration is wall-to-wall)
|
|
170
|
-
"narration_coverage_min": 0.5, # below this coverage → under_narrated
|
|
171
170
|
"narration_block_seconds": 9.0, # block cadence used to derive target block count
|
|
172
|
-
"original_block_min_seconds": 2.5, # a deliberate original-audio gap must be at least this long
|
|
173
|
-
"narration_block_min_chars": 16, # below this avg block size → fragmented_beats
|
|
174
171
|
"breath_ms": 250, # 段间呼吸空间(ms);block recap 块内连贯、块间留原声呼吸
|
|
175
172
|
"narration_speed": env_float("NARRATION_SPEED", 1.15, minimum=0.5), # 解说整体提速(atempo),默认回到可懂区间;长片可设 1.0
|
|
176
173
|
"narration_tail_pad_seconds": 0.1, # 解说尾部最少留白;短 slot 会自动压低 delay 避免截断
|
|
177
|
-
"quiet_overlap_min_ratio": 0.8, # 解说段至少多少比例落在安静窗口内才标记为非对白重叠
|
|
178
|
-
"visual_beat_max_seconds": 18.0, # 单段解说超过该时长且跨多个帧锚点时给 lint 提醒
|
|
179
|
-
"visual_beat_max_facts": 3, # 单段解说最多建议覆盖的 frame_facts 锚点数量
|
|
180
174
|
"asr_chunk_min_chars": env_int("ASR_CHUNK_MIN_CHARS", 500, minimum=1), # brief 中 ASR 写作分块最小字数/词数
|
|
181
175
|
"asr_chunk_max_chars": env_int("ASR_CHUNK_MAX_CHARS", 800, minimum=1), # brief 中 ASR 写作分块最大字数/词数
|
|
182
176
|
"silence_noise_threshold": "-25dB", # ffmpeg silencedetect 噪声阈值
|
|
@@ -250,6 +244,57 @@ def load_background_research(work_dir):
|
|
|
250
244
|
raise ValueError("background_research.json 顶层必须是 JSON 对象({...})")
|
|
251
245
|
return data
|
|
252
246
|
|
|
247
|
+
# MiMo refusal detection, defined once for this skill. Two deliberately different tests:
|
|
248
|
+
#
|
|
249
|
+
# - Narrow (`is_moderation_refusal`): ASR windows and per-scene VLM replies. MiMo answers a
|
|
250
|
+
# moderated request with the English sentence "The request was rejected because it was
|
|
251
|
+
# considered high risk" as the reply content. Only the provider's own wording is matched:
|
|
252
|
+
# a false positive here blanks real dialogue or a real scene description.
|
|
253
|
+
# - Broad (`is_usable_overview_chunk`): only the optional MiMo video overview. A false
|
|
254
|
+
# positive there just retries the chunk and falls back to the frame description, so it also
|
|
255
|
+
# rejects generic refusal phrasing ("违规", "content policy", ...) that would be too
|
|
256
|
+
# aggressive for transcripts.
|
|
257
|
+
MODERATION_REFUSAL_MARKERS = ("request was rejected", "considered high risk")
|
|
258
|
+
OVERVIEW_REJECTION_MARKERS = MODERATION_REFUSAL_MARKERS + (
|
|
259
|
+
"high risk", "content policy", "cannot process", "无法处理", "内容审核", "违规",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def is_moderation_refusal(text):
|
|
264
|
+
"""True when a MiMo reply is a moderation refusal, not a transcript or description.
|
|
265
|
+
|
|
266
|
+
Both markers must appear in a short reply: one of them alone ("my request was rejected")
|
|
267
|
+
is plausible English dialogue, and a 15 s transcript window must not be blanked for it.
|
|
268
|
+
"""
|
|
269
|
+
low = " ".join(str(text or "").lower().split())
|
|
270
|
+
return len(low) <= 200 and all(marker in low for marker in MODERATION_REFUSAL_MARKERS)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def is_usable_overview_chunk(content):
|
|
274
|
+
"""A MiMo overview chunk is usable only if it is non-empty and carries no refusal phrasing."""
|
|
275
|
+
low = str(content or "").strip().lower()
|
|
276
|
+
return bool(low) and not any(marker in low for marker in OVERVIEW_REJECTION_MARKERS)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def offline_ignored_settings(api_key, endpoint_key):
|
|
280
|
+
"""Cache-settings keys not compared when no credential is set.
|
|
281
|
+
|
|
282
|
+
The default endpoint URL is derived from the key prefix (tp-* → Token Plan cluster), so
|
|
283
|
+
unsetting the key changes it although the provider output did not change, and nothing can
|
|
284
|
+
be recomputed without a key anyway: an offline rerun must reuse the paid artifact, not
|
|
285
|
+
discard it. With a key every setting is compared."""
|
|
286
|
+
return () if api_key else (endpoint_key,)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def settings_match(cached, current, ignore=()):
|
|
290
|
+
"""True when two cache-settings dicts are equal outside the `ignore` keys."""
|
|
291
|
+
if not isinstance(cached, dict) or not isinstance(current, dict):
|
|
292
|
+
return False
|
|
293
|
+
return {k: v for k, v in cached.items() if k not in ignore} == {
|
|
294
|
+
k: v for k, v in current.items() if k not in ignore
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
253
298
|
def file_identity(path):
|
|
254
299
|
"""{"size", "mtime_ns"} of a file: the cache identity recorded instead of content hashes."""
|
|
255
300
|
st = os.stat(os.fspath(path))
|
|
@@ -286,9 +331,8 @@ def _sanitize_api_error(value, limit=500):
|
|
|
286
331
|
text = _ERROR_KEY_RE.sub("<redacted-key>", text)
|
|
287
332
|
return text[:limit]
|
|
288
333
|
|
|
289
|
-
def _api_headers(
|
|
334
|
+
def _api_headers(api_key=None):
|
|
290
335
|
"""Build MiMo auth headers (OpenAI-compatible chat/completions with an api-key header)."""
|
|
291
|
-
del api_provider, api_url # MiMo is the only provider; signature kept for call sites
|
|
292
336
|
key = CONFIG["api_key"] if api_key is None else api_key
|
|
293
337
|
return {
|
|
294
338
|
"Content-Type": "application/json",
|
|
@@ -296,9 +340,8 @@ def _api_headers(api_provider=None, api_url=None, api_key=None):
|
|
|
296
340
|
"api-key": key,
|
|
297
341
|
}
|
|
298
342
|
|
|
299
|
-
def _prepare_api_payload(payload
|
|
343
|
+
def _prepare_api_payload(payload):
|
|
300
344
|
"""Normalize payload fields for MiMo's OpenAI-compatible chat/completions API."""
|
|
301
|
-
del api_provider, api_url
|
|
302
345
|
normalized = dict(payload)
|
|
303
346
|
if "max_tokens" in normalized and "max_completion_tokens" not in normalized:
|
|
304
347
|
normalized["max_completion_tokens"] = normalized.pop("max_tokens")
|
|
@@ -314,10 +357,9 @@ def _prepare_api_payload(payload, api_provider=None, api_url=None):
|
|
|
314
357
|
return normalized
|
|
315
358
|
|
|
316
359
|
def _mimo_endpoint(kind):
|
|
317
|
-
"""Return per-capability MiMo endpoint settings (video understanding /
|
|
360
|
+
"""Return per-capability MiMo endpoint settings (video understanding / ASR)."""
|
|
318
361
|
by_kind = {
|
|
319
362
|
"video": ("mimo_video_api_url", "mimo_video_api_key", "mimo_video_env_var"),
|
|
320
|
-
"tts": ("mimo_tts_api_url", "mimo_tts_api_key", "mimo_tts_env_var"),
|
|
321
363
|
"asr": ("mimo_asr_api_url", "mimo_asr_api_key", "mimo_asr_env_var"),
|
|
322
364
|
}
|
|
323
365
|
if kind not in by_kind:
|
|
@@ -326,7 +368,7 @@ def _mimo_endpoint(kind):
|
|
|
326
368
|
return {
|
|
327
369
|
"api_url": CONFIG.get(url_key) or CONFIG.get("mimo_api_url"),
|
|
328
370
|
"api_key": CONFIG.get(key_key) or CONFIG.get("mimo_api_key"),
|
|
329
|
-
"api_env_var": CONFIG
|
|
371
|
+
"api_env_var": CONFIG[src_key],
|
|
330
372
|
}
|
|
331
373
|
|
|
332
374
|
def _call_mimo_endpoint(kind, payload, max_retries=10):
|
|
@@ -334,7 +376,6 @@ def _call_mimo_endpoint(kind, payload, max_retries=10):
|
|
|
334
376
|
return api_call(
|
|
335
377
|
payload,
|
|
336
378
|
max_retries=max_retries,
|
|
337
|
-
api_provider="mimo",
|
|
338
379
|
api_url=settings["api_url"],
|
|
339
380
|
api_key=settings["api_key"],
|
|
340
381
|
api_env_var=settings["api_env_var"],
|
|
@@ -348,7 +389,7 @@ def mimo_asr_api_call(payload, max_retries=10):
|
|
|
348
389
|
"""Call the MiMo speech-recognition (ASR) endpoint."""
|
|
349
390
|
return _call_mimo_endpoint("asr", payload, max_retries=max_retries)
|
|
350
391
|
|
|
351
|
-
def api_call(payload, max_retries=8, *,
|
|
392
|
+
def api_call(payload, max_retries=8, *, api_url=None, api_key=None, api_env_var=None):
|
|
352
393
|
"""调用 OpenAI-compatible API,带重试。
|
|
353
394
|
|
|
354
395
|
长视频理解会发出数百次 VLM/ASR 调用,集群的 429 限流是常态而非错误,所以重试更耐心
|
|
@@ -356,8 +397,8 @@ def api_call(payload, max_retries=8, *, api_provider=None, api_url=None, api_key
|
|
|
356
397
|
集群的配额窗口常以分钟计,所以 429 在没有 Retry-After 时也至少等 10s,给窗口时间复位。
|
|
357
398
|
"""
|
|
358
399
|
endpoint = normalize_api_url(api_url if api_url is not None else CONFIG["api_url"])
|
|
359
|
-
headers = _api_headers(
|
|
360
|
-
data = json.dumps(_prepare_api_payload(payload
|
|
400
|
+
headers = _api_headers(api_key=api_key)
|
|
401
|
+
data = json.dumps(_prepare_api_payload(payload)).encode("utf-8")
|
|
361
402
|
|
|
362
403
|
for attempt in range(max_retries):
|
|
363
404
|
try:
|
|
@@ -8,8 +8,9 @@ one image instead of opening dozens of frames:
|
|
|
8
8
|
- edited_storyboard.{jpg,json} — one row per kept clip over the cut OUTPUT timeline,
|
|
9
9
|
each tile dual-labelled (output time / source time).
|
|
10
10
|
|
|
11
|
-
Both reuse the frames already extracted by understand.py (frames/frame_*.jpg at CONFIG["fps"]
|
|
12
|
-
|
|
11
|
+
Both reuse the frames already extracted by understand.py (frames/frame_*.jpg at CONFIG["fps"];
|
|
12
|
+
a multi-source edited sheet reads each source's own work_dir frames at the fps its frames
|
|
13
|
+
manifest records). Nothing here re-extracts video. Every function returns dict|None and degrades to
|
|
13
14
|
None + log(...) on ANY failure (no frames, ffmpeg missing/non-zero, font probe raises),
|
|
14
15
|
so a storyboard quirk can NEVER block the pipeline (Principle 1: advisory, never blocking).
|
|
15
16
|
|
|
@@ -19,6 +20,7 @@ when no font is available (or drawtext errors) the sheet is still produced UNLAB
|
|
|
19
20
|
"""
|
|
20
21
|
import json
|
|
21
22
|
import math
|
|
23
|
+
import re
|
|
22
24
|
import shutil
|
|
23
25
|
import subprocess
|
|
24
26
|
from pathlib import Path
|
|
@@ -156,30 +158,82 @@ def _scene_anchor_timestamps(scenes, max_tiles):
|
|
|
156
158
|
return anchors
|
|
157
159
|
|
|
158
160
|
|
|
159
|
-
def
|
|
160
|
-
"""
|
|
161
|
+
def _frame_size(frame_path):
|
|
162
|
+
"""(width, height) of one extracted frame via ffprobe, or None on any failure."""
|
|
163
|
+
cmd = [
|
|
164
|
+
"ffprobe", "-v", "error", "-select_streams", "v:0",
|
|
165
|
+
"-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", str(frame_path),
|
|
166
|
+
]
|
|
167
|
+
try:
|
|
168
|
+
result = run_cmd(cmd)
|
|
169
|
+
width, height = (int(v) for v in result.stdout.strip().split("x"))
|
|
170
|
+
except Exception: # noqa: BLE001 - an unreadable size only means "normalise this tile"
|
|
171
|
+
return None
|
|
172
|
+
if result.returncode != 0 or width <= 0 or height <= 0:
|
|
173
|
+
return None
|
|
174
|
+
return width, height
|
|
175
|
+
|
|
161
176
|
|
|
162
|
-
|
|
163
|
-
|
|
177
|
+
def _prepared_frame(frame_path, label, font_path, fit, scratch_dir, out_name):
|
|
178
|
+
"""Copy frame_path through ffmpeg, letterboxed into `fit` and/or labelled; path or None.
|
|
179
|
+
|
|
180
|
+
`fit` = (w, h) letterboxes a frame into one tile size and pixel format. A sheet mixing
|
|
181
|
+
sources needs both: the tile filter's input is re-initialised whenever frame size or
|
|
182
|
+
pixel format changes mid-sequence (a 4:4:4 source next to a 4:2:0 one), which silently
|
|
183
|
+
drops the tiles already placed, and mixed sizes would otherwise be squashed to the first.
|
|
184
|
+
`font_path=None` skips the label. None signals the caller to degrade; a failure here is
|
|
185
|
+
never fatal to the pipeline.
|
|
164
186
|
"""
|
|
187
|
+
filters = []
|
|
188
|
+
if fit is not None:
|
|
189
|
+
width, height = fit
|
|
190
|
+
filters.append(
|
|
191
|
+
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
|
192
|
+
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,format=yuvj420p"
|
|
193
|
+
)
|
|
194
|
+
if font_path:
|
|
195
|
+
safe_label = label.replace("\\", "\\\\").replace(":", "\\:").replace("'", "’")
|
|
196
|
+
filters.append(
|
|
197
|
+
f"drawtext=fontfile='{font_path}':text='{safe_label}':"
|
|
198
|
+
"x=8:y=8:fontsize=28:fontcolor=white:"
|
|
199
|
+
"box=1:boxcolor=black@0.55:boxborderw=6"
|
|
200
|
+
)
|
|
165
201
|
out_path = scratch_dir / out_name
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
"box=1:boxcolor=black@0.55:boxborderw=6"
|
|
171
|
-
)
|
|
172
|
-
cmd = ["ffmpeg", "-y", "-i", str(frame_path), "-vf", drawtext, "-frames:v", "1", str(out_path)]
|
|
202
|
+
cmd = [
|
|
203
|
+
"ffmpeg", "-y", "-i", str(frame_path), "-vf", ",".join(filters),
|
|
204
|
+
"-frames:v", "1", str(out_path),
|
|
205
|
+
]
|
|
173
206
|
try:
|
|
174
207
|
result = run_cmd(cmd)
|
|
175
|
-
except Exception as exc: # noqa: BLE001 - a
|
|
176
|
-
log(f"storyboard
|
|
208
|
+
except Exception as exc: # noqa: BLE001 - a tile render must never abort the sheet
|
|
209
|
+
log(f"storyboard 帧处理异常(降级): {exc}")
|
|
177
210
|
return None
|
|
178
211
|
if result.returncode != 0 or not out_path.exists():
|
|
179
212
|
return None
|
|
180
213
|
return out_path
|
|
181
214
|
|
|
182
215
|
|
|
216
|
+
def _prepare_frames(tiles, font_path, scratch_dir, out_stem):
|
|
217
|
+
"""Frames to tile, labelled when font_path is set; None as soon as one render fails.
|
|
218
|
+
|
|
219
|
+
A tile with neither a label nor a `fit` uses its extracted frame as-is (no ffmpeg).
|
|
220
|
+
"""
|
|
221
|
+
frames = []
|
|
222
|
+
for idx, tile in enumerate(tiles):
|
|
223
|
+
fit = tile.get("fit")
|
|
224
|
+
if not font_path and fit is None:
|
|
225
|
+
frames.append(Path(tile["frame_file"]))
|
|
226
|
+
continue
|
|
227
|
+
prepared = _prepared_frame(
|
|
228
|
+
Path(tile["frame_file"]), tile["label"], font_path, fit, scratch_dir,
|
|
229
|
+
f"lbl_{out_stem}_{idx:05d}.jpg",
|
|
230
|
+
)
|
|
231
|
+
if prepared is None:
|
|
232
|
+
return None
|
|
233
|
+
frames.append(prepared)
|
|
234
|
+
return frames
|
|
235
|
+
|
|
236
|
+
|
|
183
237
|
def _tile_pages(frame_paths, columns, out_dir, out_stem, scratch_dir):
|
|
184
238
|
"""Tile frame_paths into one or more contact-sheet pages; return the list of page paths.
|
|
185
239
|
|
|
@@ -225,11 +279,24 @@ def _tile_pages(frame_paths, columns, out_dir, out_stem, scratch_dir):
|
|
|
225
279
|
return pages
|
|
226
280
|
|
|
227
281
|
|
|
282
|
+
_PATH_SEPARATORS = re.compile(r"[\\/]")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def work_dir_relative_pages(pages):
|
|
286
|
+
"""Page images as work_dir-relative POSIX paths ("storyboard/<name>").
|
|
287
|
+
|
|
288
|
+
Pages always live in <work_dir>/storyboard/, so only the file name is kept: an absolute
|
|
289
|
+
path would keep naming the original work_dir after the directory is copied or moved."""
|
|
290
|
+
# Split on both separators: a sidecar written on Windows may be read on POSIX and back.
|
|
291
|
+
return ["storyboard/" + _PATH_SEPARATORS.split(str(page))[-1] for page in pages or []]
|
|
292
|
+
|
|
293
|
+
|
|
228
294
|
def _render_storyboard(work_dir, tiles, out_stem):
|
|
229
295
|
"""Shared render path: optionally burn labels, tile to pages, return (page_paths, labels_burned).
|
|
230
296
|
|
|
231
|
-
`tiles` is a list of dicts that ALREADY carry a resolved `frame_file` (absolute path)
|
|
232
|
-
`label` string. Returns (None, _) on hard failure so
|
|
297
|
+
`tiles` is a list of dicts that ALREADY carry a resolved `frame_file` (absolute path), a
|
|
298
|
+
`label` string and optionally a `fit` tile size. Returns (None, _) on hard failure so
|
|
299
|
+
callers degrade to None.
|
|
233
300
|
"""
|
|
234
301
|
if not _ffmpeg_available():
|
|
235
302
|
log("storyboard 跳过:未找到 ffmpeg")
|
|
@@ -242,27 +309,22 @@ def _render_storyboard(work_dir, tiles, out_stem):
|
|
|
242
309
|
scratch_dir.mkdir(parents=True, exist_ok=True)
|
|
243
310
|
|
|
244
311
|
font_path = _probe_font()
|
|
245
|
-
labels_burned = bool(font_path)
|
|
246
|
-
render_frames = []
|
|
247
|
-
if labels_burned:
|
|
248
|
-
for idx, tile in enumerate(tiles):
|
|
249
|
-
labelled = _labelled_frame(
|
|
250
|
-
Path(tile["frame_file"]), tile["label"], font_path, scratch_dir,
|
|
251
|
-
f"lbl_{out_stem}_{idx:05d}.jpg",
|
|
252
|
-
)
|
|
253
|
-
if labelled is None:
|
|
254
|
-
# First failure → abandon labelling entirely so the WHOLE sheet is consistent
|
|
255
|
-
# (no half-labelled pages). The JSON sidecar still carries every timestamp.
|
|
256
|
-
labels_burned = False
|
|
257
|
-
break
|
|
258
|
-
render_frames.append(labelled)
|
|
259
|
-
if not labels_burned:
|
|
260
|
-
render_frames = [Path(tile["frame_file"]) for tile in tiles]
|
|
261
|
-
|
|
262
312
|
try:
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
313
|
+
render_frames = (
|
|
314
|
+
_prepare_frames(tiles, font_path, scratch_dir, out_stem) if font_path else None
|
|
315
|
+
)
|
|
316
|
+
labels_burned = render_frames is not None
|
|
317
|
+
if render_frames is None:
|
|
318
|
+
# First label failure → abandon labelling for the WHOLE sheet (no half-labelled
|
|
319
|
+
# pages). The JSON sidecar still carries every timestamp.
|
|
320
|
+
render_frames = _prepare_frames(tiles, None, scratch_dir, out_stem)
|
|
321
|
+
pages = (
|
|
322
|
+
_tile_pages(
|
|
323
|
+
render_frames, CONFIG["storyboard_columns"],
|
|
324
|
+
storyboard_dir, out_stem, scratch_dir,
|
|
325
|
+
)
|
|
326
|
+
if render_frames is not None
|
|
327
|
+
else []
|
|
266
328
|
)
|
|
267
329
|
finally:
|
|
268
330
|
shutil.rmtree(scratch_dir, ignore_errors=True)
|
|
@@ -320,7 +382,7 @@ def build_source_storyboard(work_dir, video_path, scenes, fps):
|
|
|
320
382
|
"video_path": str(video_path),
|
|
321
383
|
"fps": float(fps) if fps else None,
|
|
322
384
|
"labels_burned": labels_burned,
|
|
323
|
-
"page_images":
|
|
385
|
+
"page_images": work_dir_relative_pages(pages),
|
|
324
386
|
"sample_policy": {
|
|
325
387
|
"max_tiles": max_tiles,
|
|
326
388
|
"columns": columns,
|
|
@@ -354,7 +416,23 @@ def _source_to_output(source_time, clip):
|
|
|
354
416
|
return round(max(out_start, min(mapped, out_end)), 3)
|
|
355
417
|
|
|
356
418
|
|
|
357
|
-
def
|
|
419
|
+
def _multi_source_tile_size(frame_sets):
|
|
420
|
+
"""One tile size for a multi-source sheet: the first source's frame size rounded down to
|
|
421
|
+
even numbers (640x360 when no frame can be probed). Every multi-source tile is re-encoded
|
|
422
|
+
into it — see _prepared_frame. Odd sizes are real (frames keep storage resolution, e.g.
|
|
423
|
+
853x480) and ffmpeg's scale rounds a 4:2:0 frame up to even, so an odd pad target would
|
|
424
|
+
be smaller than its input and every tile would fail."""
|
|
425
|
+
for entry in frame_sets.values():
|
|
426
|
+
size = _frame_size(entry["paths"][0])
|
|
427
|
+
if size:
|
|
428
|
+
width, height = size
|
|
429
|
+
return max(2, width - width % 2), max(2, height - height % 2)
|
|
430
|
+
return 640, 360
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def build_edited_storyboard(
|
|
434
|
+
work_dir, source_video_path, clip_plan_validated, fps, *, source_frames=None
|
|
435
|
+
):
|
|
358
436
|
"""OUTPUT-timeline contact sheet: one row per kept clip over the cut. Returns dict|None.
|
|
359
437
|
|
|
360
438
|
For each kept clip, samples source start / mid / (end − 0.5s), maps each to the nearest
|
|
@@ -362,13 +440,37 @@ def build_edited_storyboard(work_dir, source_video_path, clip_plan_validated, fp
|
|
|
362
440
|
dedupes so a ≤1s clip yields 1-2 tiles (not 3 identical). Each tile is dual-labelled with
|
|
363
441
|
both `output_timestamp` and `source_timestamp` (+ `source_clip_id`). Writes
|
|
364
442
|
storyboard/edited_storyboard.json. Returns None + log on any failure.
|
|
443
|
+
|
|
444
|
+
Single-source plans read work_dir/frames at `fps`. A multi-source plan passes
|
|
445
|
+
`source_frames` = {source_id: {"paths", "numbers", "fps"[, "source_path"]}} built from each
|
|
446
|
+
source's own work_dir; its tiles also carry `source_id` and a short `S<n>` label listed
|
|
447
|
+
under `sources`, and `frame_file` keeps the full path (frame names repeat across sources).
|
|
365
448
|
"""
|
|
366
449
|
try:
|
|
367
450
|
work_dir = Path(work_dir)
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
451
|
+
if source_frames is None:
|
|
452
|
+
paths, numbers = _frame_index(work_dir)
|
|
453
|
+
if not paths:
|
|
454
|
+
log("storyboard 跳过 edited:frames/ 为空或缺失")
|
|
455
|
+
return None
|
|
456
|
+
frame_sets = {None: {"paths": paths, "numbers": numbers, "fps": fps}}
|
|
457
|
+
tile_size = None
|
|
458
|
+
else:
|
|
459
|
+
# Only sources a clip uses: an unused one must not take an S<n> label, be listed
|
|
460
|
+
# under `sources`, or set the tile size.
|
|
461
|
+
used = {
|
|
462
|
+
clip.get("source_id") for clip in clip_plan_validated["clips"]
|
|
463
|
+
if isinstance(clip, dict)
|
|
464
|
+
}
|
|
465
|
+
frame_sets = {
|
|
466
|
+
sid: entry for sid, entry in source_frames.items()
|
|
467
|
+
if entry["paths"] and sid in used
|
|
468
|
+
}
|
|
469
|
+
if not frame_sets:
|
|
470
|
+
log("storyboard 跳过 edited:剪辑用到的来源都没有 frames/")
|
|
471
|
+
return None
|
|
472
|
+
tile_size = _multi_source_tile_size(frame_sets)
|
|
473
|
+
short_labels = {sid: f"S{n}" for n, sid in enumerate(frame_sets, start=1)}
|
|
372
474
|
clips = clip_plan_validated["clips"]
|
|
373
475
|
if not clips:
|
|
374
476
|
log("storyboard 跳过 edited:clip_plan_validated 无 clips")
|
|
@@ -388,10 +490,16 @@ def build_edited_storyboard(work_dir, source_video_path, clip_plan_validated, fp
|
|
|
388
490
|
continue
|
|
389
491
|
if source_end <= source_start:
|
|
390
492
|
continue
|
|
493
|
+
source_id = clip.get("source_id") if source_frames is not None else None
|
|
494
|
+
frame_set = frame_sets.get(source_id)
|
|
495
|
+
if frame_set is None:
|
|
496
|
+
continue # this source has no extracted frames (e.g. a material restore)
|
|
391
497
|
mid = (source_start + source_end) / 2.0
|
|
392
498
|
end_sample = max(source_start, source_end - 0.5)
|
|
393
499
|
for src_ts in (source_start, mid, end_sample):
|
|
394
|
-
frame = _nearest_existing_frame(
|
|
500
|
+
frame = _nearest_existing_frame(
|
|
501
|
+
src_ts, frame_set["fps"], frame_set["paths"], frame_set["numbers"]
|
|
502
|
+
)
|
|
395
503
|
if frame is None:
|
|
396
504
|
continue
|
|
397
505
|
key = (clip_id, str(frame))
|
|
@@ -399,14 +507,20 @@ def build_edited_storyboard(work_dir, source_video_path, clip_plan_validated, fp
|
|
|
399
507
|
continue # same clip resolving to the same frame → drop the duplicate tile
|
|
400
508
|
seen_frames.add(key)
|
|
401
509
|
out_ts = _source_to_output(src_ts, clip)
|
|
402
|
-
|
|
510
|
+
src_label = "src" if source_id is None else short_labels[source_id]
|
|
511
|
+
tile = {
|
|
403
512
|
"tile_id": tile_id,
|
|
404
513
|
"output_timestamp": out_ts,
|
|
405
514
|
"source_timestamp": round(float(src_ts), 3),
|
|
406
515
|
"source_clip_id": clip_id,
|
|
407
|
-
"label": f"out {_fmt_mmss(out_ts)} /
|
|
516
|
+
"label": f"out {_fmt_mmss(out_ts)} / {src_label} {_fmt_mmss(src_ts)}",
|
|
408
517
|
"frame_file": str(frame),
|
|
409
|
-
}
|
|
518
|
+
}
|
|
519
|
+
if source_id is not None:
|
|
520
|
+
tile["source_id"] = source_id
|
|
521
|
+
if tile_size is not None:
|
|
522
|
+
tile["fit"] = tile_size
|
|
523
|
+
tiles.append(tile)
|
|
410
524
|
tile_id += 1
|
|
411
525
|
if not tiles:
|
|
412
526
|
log("storyboard 跳过 edited:未解析到任何帧")
|
|
@@ -420,15 +534,20 @@ def build_edited_storyboard(work_dir, source_video_path, clip_plan_validated, fp
|
|
|
420
534
|
return None
|
|
421
535
|
|
|
422
536
|
for tile in tiles:
|
|
423
|
-
tile["frame_file"] =
|
|
537
|
+
tile["frame_file"] = (
|
|
538
|
+
Path(tile["frame_file"]).name
|
|
539
|
+
if source_frames is None
|
|
540
|
+
else str(tile["frame_file"])
|
|
541
|
+
)
|
|
542
|
+
tile.pop("fit", None)
|
|
424
543
|
edited_source = Path(work_dir) / "edited_source.mp4"
|
|
425
544
|
payload = {
|
|
426
545
|
"schema_version": 1,
|
|
427
546
|
"timeline": "output",
|
|
428
|
-
"source_video_path": str(source_video_path),
|
|
429
|
-
"edited_video_path":
|
|
547
|
+
"source_video_path": str(source_video_path) if source_frames is None else None,
|
|
548
|
+
"edited_video_path": edited_source.name if edited_source.exists() else None,
|
|
430
549
|
"labels_burned": labels_burned,
|
|
431
|
-
"page_images":
|
|
550
|
+
"page_images": work_dir_relative_pages(pages),
|
|
432
551
|
"sample_policy": {
|
|
433
552
|
"max_tiles": max_tiles,
|
|
434
553
|
"columns": columns,
|
|
@@ -436,6 +555,17 @@ def build_edited_storyboard(work_dir, source_video_path, clip_plan_validated, fp
|
|
|
436
555
|
},
|
|
437
556
|
"tiles": tiles,
|
|
438
557
|
}
|
|
558
|
+
if source_frames is not None:
|
|
559
|
+
on_sheet = {tile.get("source_id") for tile in tiles}
|
|
560
|
+
payload["sources"] = [
|
|
561
|
+
{
|
|
562
|
+
"label": short_labels[sid],
|
|
563
|
+
"source_id": sid,
|
|
564
|
+
"source_path": frame_sets[sid].get("source_path"),
|
|
565
|
+
}
|
|
566
|
+
for sid in frame_sets
|
|
567
|
+
if sid in on_sheet
|
|
568
|
+
]
|
|
439
569
|
json_path = work_dir / "storyboard" / "edited_storyboard.json"
|
|
440
570
|
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
441
571
|
log(f"storyboard edited: {len(tiles)} tiles → {len(pages)} page(s), labels_burned={labels_burned}")
|
|
@@ -1,38 +1,12 @@
|
|
|
1
1
|
"""Fuse scenes, ASR, and quiet windows for narration planning."""
|
|
2
2
|
|
|
3
|
-
from lib import CONFIG
|
|
4
3
|
from agent_text import _overlap_seconds, _recommended_char_budget
|
|
5
|
-
from narration_lint import _validate_narration_budget
|
|
6
4
|
|
|
7
5
|
|
|
8
6
|
def _quiet_windows(silence_periods):
|
|
9
7
|
return [qp for qp in silence_periods if not qp["has_speech"]]
|
|
10
8
|
|
|
11
9
|
|
|
12
|
-
def _align_narration_to_quiet(narration, scenes_analysis, silence_periods):
|
|
13
|
-
"""Recompute overlaps_speech from real quiet windows; keep the agent's timing.
|
|
14
|
-
|
|
15
|
-
The dense continuous-bed design places narration ON the pictured beat over a
|
|
16
|
-
ducked original bed, so segments are never relocated into silence gaps. Only
|
|
17
|
-
the overlaps_speech flag that the ducking stage consumes is corrected, leaving
|
|
18
|
-
the agent's start/end (and text) intact.
|
|
19
|
-
|
|
20
|
-
Budget/dedup runs FIRST so a dedup-merged beat's overlaps_speech reflects its
|
|
21
|
-
extended timing, not its original shorter span.
|
|
22
|
-
"""
|
|
23
|
-
aligned = _validate_narration_budget(narration, scenes_analysis)
|
|
24
|
-
quiet_windows = _quiet_windows(silence_periods)
|
|
25
|
-
quiet_ratio_min = CONFIG["quiet_overlap_min_ratio"]
|
|
26
|
-
for n in aligned:
|
|
27
|
-
seg_dur = n["end"] - n["start"]
|
|
28
|
-
quiet_overlap = sum(
|
|
29
|
-
_overlap_seconds(n["start"], n["end"], qw["start"], qw["end"])
|
|
30
|
-
for qw in quiet_windows
|
|
31
|
-
)
|
|
32
|
-
n["overlaps_speech"] = quiet_overlap < max(0.3, seg_dur * quiet_ratio_min)
|
|
33
|
-
return aligned
|
|
34
|
-
|
|
35
|
-
|
|
36
10
|
def _scene_asr_lines(asr_result, scene):
|
|
37
11
|
lines = []
|
|
38
12
|
for seg in asr_result:
|
|
@@ -8,10 +8,11 @@ from lib import CONFIG, log, load_background_research
|
|
|
8
8
|
from asr_timing_evidence import asr_evidence_summary_for_brief
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
from detect import
|
|
11
|
+
from detect import ensure_speech_boundary_anchors
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
from
|
|
14
|
+
from briefing.builder import build_agent_brief
|
|
15
|
+
from briefing.context import assess_understanding_substrate
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
from understanding_cache import _load_json, _merge_overview_into_scenes
|
|
@@ -136,23 +137,44 @@ def _write_brief_from_existing_artifacts(video, work_dir, args, video_duration):
|
|
|
136
137
|
scenes, asr_result, silence_periods = _load_understanding_artifacts_for_brief(
|
|
137
138
|
work_dir
|
|
138
139
|
)
|
|
139
|
-
|
|
140
|
-
detect_speech_boundary_anchors(work_dir, asr_result)
|
|
140
|
+
ensure_speech_boundary_anchors(work_dir, asr_result)
|
|
141
141
|
overview_path = Path(work_dir) / "mimo_video_overview.json"
|
|
142
142
|
if CONFIG["mimo_video_overview"]:
|
|
143
143
|
scenes = _merge_overview_into_scenes(scenes, overview_path)
|
|
144
144
|
|
|
145
|
+
_finish_brief(
|
|
146
|
+
video, work_dir, args, video_duration,
|
|
147
|
+
storyboard_scenes=scenes, brief_scenes=scenes,
|
|
148
|
+
asr_result=asr_result, silence_periods=silence_periods,
|
|
149
|
+
status="brief_only", done_label="brief-only 完成", force=False,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _finish_brief(
|
|
154
|
+
video, work_dir, args, video_duration, *, storyboard_scenes, brief_scenes,
|
|
155
|
+
asr_result, silence_periods, status, done_label, force,
|
|
156
|
+
):
|
|
157
|
+
"""Storyboards, substrate banner, writing brief and the JSON status line.
|
|
158
|
+
|
|
159
|
+
Shared by the full analysis run and --brief-only. The full run passes the detected
|
|
160
|
+
scenes to the storyboard and the VLM analysis to the brief; --brief-only has only the
|
|
161
|
+
VLM analysis, so it passes that for both.
|
|
162
|
+
"""
|
|
163
|
+
work_dir = Path(work_dir)
|
|
164
|
+
# Storyboard contact sheets (advisory, never blocking). Source uses scene anchors over the
|
|
165
|
+
# source timeline; edited is gated on clip_plan_validated.json file-presence (NOT edit_mode —
|
|
166
|
+
# recap.py forwards --edit-mode cut in BOTH passes, so the validated plan is the only reliable
|
|
167
|
+
# pass2 signal). Both cache via _write_stage_meta/_stage_cache_valid with fps + frame-set in the key.
|
|
145
168
|
source_storyboard = None
|
|
146
|
-
|
|
147
|
-
scenes_json = Path(work_dir) / "scenes.json"
|
|
169
|
+
scenes_json = work_dir / "scenes.json"
|
|
148
170
|
if scenes_json.exists():
|
|
149
171
|
source_storyboard = _generate_source_storyboard(
|
|
150
|
-
work_dir, Path(video),
|
|
172
|
+
work_dir, Path(video), storyboard_scenes, scenes_json, force=force
|
|
151
173
|
)
|
|
152
|
-
edited_storyboard = _generate_edited_storyboard(work_dir, video, force=
|
|
153
|
-
cut_mode = (
|
|
174
|
+
edited_storyboard = _generate_edited_storyboard(work_dir, video, force=force)
|
|
175
|
+
cut_mode = (work_dir / "clip_plan_validated.json").exists()
|
|
154
176
|
|
|
155
|
-
substrate = assess_understanding_substrate(
|
|
177
|
+
substrate = assess_understanding_substrate(brief_scenes, asr_result)
|
|
156
178
|
if substrate["level"] != "rich":
|
|
157
179
|
banner = "理解素材为空" if substrate["level"] == "empty" else "理解素材偏薄"
|
|
158
180
|
log(
|
|
@@ -160,7 +182,7 @@ def _write_brief_from_existing_artifacts(video, work_dir, args, video_duration):
|
|
|
160
182
|
f"带 frame_facts 的场景 {substrate['scenes_with_frame_facts']} | 平均画面描述 {substrate['avg_description_len']} 字"
|
|
161
183
|
)
|
|
162
184
|
brief_path = build_agent_brief(
|
|
163
|
-
|
|
185
|
+
brief_scenes,
|
|
164
186
|
asr_result,
|
|
165
187
|
silence_periods,
|
|
166
188
|
video_duration,
|
|
@@ -170,19 +192,20 @@ def _write_brief_from_existing_artifacts(video, work_dir, args, video_duration):
|
|
|
170
192
|
mimo_overview_video_path=video,
|
|
171
193
|
asr_evidence=asr_evidence_summary_for_brief(work_dir, video),
|
|
172
194
|
)
|
|
195
|
+
# C1: prepend a storyboard header to the returned brief file, pointing the agent at the sheet(s).
|
|
173
196
|
_prepend_storyboard_brief_header(
|
|
174
197
|
brief_path, source_storyboard, edited_storyboard, cut_mode=cut_mode
|
|
175
198
|
)
|
|
176
199
|
log("=" * 50)
|
|
177
|
-
log(f"
|
|
200
|
+
log(f"{done_label}。写作 brief: {brief_path}")
|
|
178
201
|
print(
|
|
179
202
|
json.dumps(
|
|
180
203
|
{
|
|
181
|
-
"status":
|
|
204
|
+
"status": status,
|
|
182
205
|
"work_dir": str(work_dir),
|
|
183
206
|
"brief": str(brief_path),
|
|
184
207
|
"substrate": substrate["level"],
|
|
185
|
-
"scenes": len(
|
|
208
|
+
"scenes": len(storyboard_scenes),
|
|
186
209
|
"asr_segments": len(asr_result),
|
|
187
210
|
},
|
|
188
211
|
ensure_ascii=False,
|