@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
|
@@ -1,707 +0,0 @@
|
|
|
1
|
-
"""Enforce narration timing, evidence, budget, and sentence-integrity rules.
|
|
2
|
-
|
|
3
|
-
This is the single validator for the agent-authored narration.json: shape, numeric
|
|
4
|
-
timing and text checks live here, and every later consumer trusts its result.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import json
|
|
8
|
-
import math
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
|
|
11
|
-
from lib import CONFIG, log
|
|
12
|
-
from agent_text import (
|
|
13
|
-
_clean_narration_punctuation,
|
|
14
|
-
_find_scene_for_midpoint,
|
|
15
|
-
_normalise_narration_segment,
|
|
16
|
-
_post_dedup_narration,
|
|
17
|
-
_recommended_char_budget,
|
|
18
|
-
_scene_available_seconds,
|
|
19
|
-
_text_char_count,
|
|
20
|
-
_truncate_at_sentence,
|
|
21
|
-
)
|
|
22
|
-
from deslop_qc import analyze_deslop_qc
|
|
23
|
-
from speech_ownership import (
|
|
24
|
-
entry_overlaps_source_speech,
|
|
25
|
-
load_source_sentence_evidence,
|
|
26
|
-
)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _lint_issue(level, index, code, message, **extra):
|
|
30
|
-
issue = {"level": level, "index": index, "code": code, "message": message}
|
|
31
|
-
issue.update(extra)
|
|
32
|
-
return issue
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def _is_number(value):
|
|
36
|
-
return (
|
|
37
|
-
isinstance(value, (int, float))
|
|
38
|
-
and not isinstance(value, bool)
|
|
39
|
-
and math.isfinite(value)
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def _visual_overlay_issues(index, seg):
|
|
44
|
-
"""Shape-check the renderer handoff recap reads verbatim from a validated segment.
|
|
45
|
-
|
|
46
|
-
recap owns which overlay `type` values it actually renders and drops the rest; lint owns
|
|
47
|
-
the shape, because nothing between here and that filter re-checks it — and the filter runs
|
|
48
|
-
after TTS, so a malformed overlay caught there would already have cost a synthesis pass.
|
|
49
|
-
"""
|
|
50
|
-
overlays = seg.get("visual_overlays")
|
|
51
|
-
if overlays is None:
|
|
52
|
-
return []
|
|
53
|
-
if not isinstance(overlays, list):
|
|
54
|
-
return [
|
|
55
|
-
_lint_issue(
|
|
56
|
-
"error", index, "invalid_visual_overlays", "visual_overlays must be an array"
|
|
57
|
-
)
|
|
58
|
-
]
|
|
59
|
-
issues = []
|
|
60
|
-
for position, overlay in enumerate(overlays):
|
|
61
|
-
if not isinstance(overlay, dict):
|
|
62
|
-
issues.append(
|
|
63
|
-
_lint_issue(
|
|
64
|
-
"error", index, "invalid_visual_overlay",
|
|
65
|
-
"visual_overlays entries must be objects",
|
|
66
|
-
overlay_index=position,
|
|
67
|
-
)
|
|
68
|
-
)
|
|
69
|
-
continue
|
|
70
|
-
for key in ("type", "text"):
|
|
71
|
-
value = overlay.get(key)
|
|
72
|
-
if not isinstance(value, str) or not value.strip():
|
|
73
|
-
issues.append(
|
|
74
|
-
_lint_issue(
|
|
75
|
-
"error", index, "invalid_visual_overlay",
|
|
76
|
-
f"visual_overlays[].{key} must be a non-empty string",
|
|
77
|
-
overlay_index=position,
|
|
78
|
-
)
|
|
79
|
-
)
|
|
80
|
-
for key in ("start", "end"):
|
|
81
|
-
if key in overlay and not _is_number(overlay[key]):
|
|
82
|
-
issues.append(
|
|
83
|
-
_lint_issue(
|
|
84
|
-
"error", index, "invalid_visual_overlay",
|
|
85
|
-
f"visual_overlays[].{key} must be numeric",
|
|
86
|
-
overlay_index=position,
|
|
87
|
-
)
|
|
88
|
-
)
|
|
89
|
-
return issues
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def _scene_bounds_for_midpoint(scenes_analysis, start, end):
|
|
93
|
-
scene = _find_scene_for_midpoint(scenes_analysis, start, end)
|
|
94
|
-
if not scene:
|
|
95
|
-
return None
|
|
96
|
-
return scene["start"], scene["end"]
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def _frame_fact_times_for_segment(scenes_analysis, start, end):
|
|
100
|
-
"""Return frame-fact timestamps covered by a narration segment.
|
|
101
|
-
|
|
102
|
-
These timestamps are cheap visual anchors. A narration slot that spans too
|
|
103
|
-
many anchors is more likely to drift into "general story summary" instead
|
|
104
|
-
of staying attached to what is on screen.
|
|
105
|
-
"""
|
|
106
|
-
scene = _find_scene_for_midpoint(scenes_analysis, start, end)
|
|
107
|
-
if not scene:
|
|
108
|
-
return []
|
|
109
|
-
return sorted(
|
|
110
|
-
ts
|
|
111
|
-
for ts in map(float, scene.get("frame_facts", {}))
|
|
112
|
-
if start <= ts <= end
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def _clip_span(clip):
|
|
117
|
-
"""A validated plan clip carries source_start/source_end; a raw agent plan start/end."""
|
|
118
|
-
if "source_start" in clip:
|
|
119
|
-
return clip["source_start"], clip["source_end"]
|
|
120
|
-
return clip["start"], clip["end"]
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def _clip_matches_for_segment(seg, clip_plan, requested_clip_id):
|
|
124
|
-
if not clip_plan:
|
|
125
|
-
return []
|
|
126
|
-
clips = clip_plan["clips"]
|
|
127
|
-
midpoint = (seg["start"] + seg["end"]) / 2
|
|
128
|
-
if requested_clip_id is not None:
|
|
129
|
-
clips = [clip for clip in clips if clip.get("clip_id") == requested_clip_id]
|
|
130
|
-
return [
|
|
131
|
-
clip
|
|
132
|
-
for clip in clips
|
|
133
|
-
if _clip_span(clip)[0] <= midpoint <= _clip_span(clip)[1]
|
|
134
|
-
]
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def _source_sentence_entry_issue(index, start, anchors, speech_owned):
|
|
138
|
-
"""Return a blocking issue when narration enters midway through source speech.
|
|
139
|
-
|
|
140
|
-
The validator reports a correction instead of silently moving audio. Source
|
|
141
|
-
sentence integrity is invariant; an editorial policy string cannot bypass it.
|
|
142
|
-
"""
|
|
143
|
-
if not speech_owned:
|
|
144
|
-
return None
|
|
145
|
-
# A cold-open at the first frame is not an interruption: the source sentence
|
|
146
|
-
# has not been allowed to start. All later entries must use a measured anchor.
|
|
147
|
-
if start <= 0.25:
|
|
148
|
-
return None
|
|
149
|
-
if not anchors:
|
|
150
|
-
return _lint_issue(
|
|
151
|
-
"error",
|
|
152
|
-
index,
|
|
153
|
-
"source_sentence_anchors_unavailable",
|
|
154
|
-
"Source speech owns this entry but no verified sentence-end anchor is available. "
|
|
155
|
-
"Move/remove the narration or regenerate output speech evidence before TTS.",
|
|
156
|
-
entry_time=round(start, 3),
|
|
157
|
-
suggested_start=None,
|
|
158
|
-
)
|
|
159
|
-
# Only the measured acoustic pause owns a safe entry. A tiny 80ms post-anchor
|
|
160
|
-
# tolerance covers timestamp/sample rounding; the old +450ms allowance could
|
|
161
|
-
# already be several Chinese syllables into the next sentence.
|
|
162
|
-
for anchor in anchors:
|
|
163
|
-
when = anchor["time"]
|
|
164
|
-
pause_start = anchor.get("pause_start", when - 0.12)
|
|
165
|
-
if pause_start - 0.05 <= start <= when + 0.08:
|
|
166
|
-
return None
|
|
167
|
-
|
|
168
|
-
suggested = next(
|
|
169
|
-
(anchor for anchor in anchors if anchor["time"] > start + 0.08), None
|
|
170
|
-
)
|
|
171
|
-
return _lint_issue(
|
|
172
|
-
"error",
|
|
173
|
-
index,
|
|
174
|
-
"interrupts_source_sentence",
|
|
175
|
-
"Narration enters before the source sentence finishes. Move this block to the suggested "
|
|
176
|
-
"sentence-end anchor, or shorten/move/remove it when there is no later verified anchor, "
|
|
177
|
-
"then rerun lint before TTS. Source sentence interruption has no override.",
|
|
178
|
-
entry_time=round(start, 3),
|
|
179
|
-
suggested_start=round(suggested["time"], 3) if suggested else None,
|
|
180
|
-
source_text_tail=str(suggested.get("text_tail", "")).strip() if suggested else "",
|
|
181
|
-
anchor_confidence=suggested["confidence"] if suggested else None,
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def _has_connected_predecessor(narration, idx, start):
|
|
186
|
-
"""A back-to-back narration handoff (<=150ms gap) does not expose the source track,
|
|
187
|
-
so the next TTS block is not a new source-speech entry."""
|
|
188
|
-
for other_idx, other in enumerate(narration):
|
|
189
|
-
if other_idx == idx or not isinstance(other, dict):
|
|
190
|
-
continue
|
|
191
|
-
other_start, other_end = other.get("start"), other.get("end")
|
|
192
|
-
if not _is_number(other_start) or not _is_number(other_end):
|
|
193
|
-
continue
|
|
194
|
-
if other_start < start and -0.001 <= start - other_end <= 0.15:
|
|
195
|
-
return True
|
|
196
|
-
return False
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
def lint_narration(
|
|
200
|
-
narration, scenes_analysis=None, *, clip_plan=None, mode="full", work_dir=None,
|
|
201
|
-
require_chronological=False,
|
|
202
|
-
):
|
|
203
|
-
"""Preflight-check agent narration before TTS; write narration_lint.json when work_dir is set.
|
|
204
|
-
|
|
205
|
-
Segments are sorted by start for the timing checks; ``require_chronological`` (the
|
|
206
|
-
--preserve-approved-text contract) additionally rejects input that is not already in order."""
|
|
207
|
-
scenes_analysis = scenes_analysis or []
|
|
208
|
-
errors = []
|
|
209
|
-
warnings = []
|
|
210
|
-
normalized = []
|
|
211
|
-
source_evidence = load_source_sentence_evidence(work_dir, mode=mode)
|
|
212
|
-
source_sentence_anchors = source_evidence["anchors"]
|
|
213
|
-
if not isinstance(narration, list):
|
|
214
|
-
errors.append(
|
|
215
|
-
_lint_issue(
|
|
216
|
-
"error",
|
|
217
|
-
None,
|
|
218
|
-
"invalid_json_shape",
|
|
219
|
-
"narration.json must be a JSON array",
|
|
220
|
-
)
|
|
221
|
-
)
|
|
222
|
-
else:
|
|
223
|
-
previous_start = None
|
|
224
|
-
for idx, seg in enumerate(narration):
|
|
225
|
-
if not isinstance(seg, dict):
|
|
226
|
-
errors.append(
|
|
227
|
-
_lint_issue(
|
|
228
|
-
"error",
|
|
229
|
-
idx,
|
|
230
|
-
"invalid_segment",
|
|
231
|
-
"Narration segment must be an object",
|
|
232
|
-
)
|
|
233
|
-
)
|
|
234
|
-
continue
|
|
235
|
-
start, end = seg.get("start"), seg.get("end")
|
|
236
|
-
if not _is_number(start) or not _is_number(end):
|
|
237
|
-
errors.append(
|
|
238
|
-
_lint_issue(
|
|
239
|
-
"error", idx, "invalid_time", "start/end must be finite numbers"
|
|
240
|
-
)
|
|
241
|
-
)
|
|
242
|
-
continue
|
|
243
|
-
if require_chronological and previous_start is not None and start < previous_start:
|
|
244
|
-
errors.append(
|
|
245
|
-
_lint_issue(
|
|
246
|
-
"error",
|
|
247
|
-
idx,
|
|
248
|
-
"out_of_order",
|
|
249
|
-
"Segments must be listed in chronological order",
|
|
250
|
-
start=start,
|
|
251
|
-
previous_start=previous_start,
|
|
252
|
-
)
|
|
253
|
-
)
|
|
254
|
-
previous_start = start
|
|
255
|
-
text = seg.get("narration")
|
|
256
|
-
if not isinstance(text, str):
|
|
257
|
-
errors.append(
|
|
258
|
-
_lint_issue(
|
|
259
|
-
"error",
|
|
260
|
-
idx,
|
|
261
|
-
"invalid_narration",
|
|
262
|
-
"narration must be a string",
|
|
263
|
-
start=start,
|
|
264
|
-
end=end,
|
|
265
|
-
)
|
|
266
|
-
)
|
|
267
|
-
continue
|
|
268
|
-
text = text.strip()
|
|
269
|
-
pause = seg.get("pause_after_ms", CONFIG["breath_ms"])
|
|
270
|
-
if not isinstance(pause, int) or isinstance(pause, bool) or pause < 0:
|
|
271
|
-
errors.append(
|
|
272
|
-
_lint_issue(
|
|
273
|
-
"error",
|
|
274
|
-
idx,
|
|
275
|
-
"invalid_pause",
|
|
276
|
-
"pause_after_ms must be a non-negative integer number of milliseconds",
|
|
277
|
-
)
|
|
278
|
-
)
|
|
279
|
-
continue
|
|
280
|
-
errors.extend(_visual_overlay_issues(idx, seg))
|
|
281
|
-
if end <= start:
|
|
282
|
-
errors.append(
|
|
283
|
-
_lint_issue(
|
|
284
|
-
"error",
|
|
285
|
-
idx,
|
|
286
|
-
"invalid_time_range",
|
|
287
|
-
"end must be greater than start",
|
|
288
|
-
start=start,
|
|
289
|
-
end=end,
|
|
290
|
-
)
|
|
291
|
-
)
|
|
292
|
-
continue
|
|
293
|
-
if not text:
|
|
294
|
-
errors.append(
|
|
295
|
-
_lint_issue(
|
|
296
|
-
"error",
|
|
297
|
-
idx,
|
|
298
|
-
"empty_narration",
|
|
299
|
-
"narration text must not be empty",
|
|
300
|
-
start=start,
|
|
301
|
-
end=end,
|
|
302
|
-
)
|
|
303
|
-
)
|
|
304
|
-
continue
|
|
305
|
-
|
|
306
|
-
char_count = _text_char_count(text)
|
|
307
|
-
budget = _recommended_char_budget(start, end)
|
|
308
|
-
# estimate at the REAL playback rate (after the narration_speed atempo); otherwise a
|
|
309
|
-
# beat sized to its 1.3x-sped slot looks "over budget" when it actually fits.
|
|
310
|
-
play_rate = CONFIG["speech_rate"] * CONFIG["narration_speed"]
|
|
311
|
-
estimated_tts_seconds = char_count / play_rate
|
|
312
|
-
slot_seconds = _scene_available_seconds(start, end)
|
|
313
|
-
if budget < 5:
|
|
314
|
-
warnings.append(
|
|
315
|
-
_lint_issue(
|
|
316
|
-
"warning",
|
|
317
|
-
idx,
|
|
318
|
-
"slot_too_short",
|
|
319
|
-
"Narration slot is very short; TTS may be clipped",
|
|
320
|
-
start=start,
|
|
321
|
-
end=end,
|
|
322
|
-
budget_chars=budget,
|
|
323
|
-
)
|
|
324
|
-
)
|
|
325
|
-
elif estimated_tts_seconds > slot_seconds:
|
|
326
|
-
warnings.append(
|
|
327
|
-
_lint_issue(
|
|
328
|
-
"warning",
|
|
329
|
-
idx,
|
|
330
|
-
"over_budget",
|
|
331
|
-
"Text may exceed the available TTS slot",
|
|
332
|
-
start=start,
|
|
333
|
-
end=end,
|
|
334
|
-
budget_chars=budget,
|
|
335
|
-
actual_chars=char_count,
|
|
336
|
-
estimated_tts_seconds=round(estimated_tts_seconds, 2),
|
|
337
|
-
slot_seconds=round(slot_seconds, 2),
|
|
338
|
-
)
|
|
339
|
-
)
|
|
340
|
-
if text[-1] not in "。!?!?….":
|
|
341
|
-
warnings.append(
|
|
342
|
-
_lint_issue(
|
|
343
|
-
"warning",
|
|
344
|
-
idx,
|
|
345
|
-
"incomplete_sentence",
|
|
346
|
-
"Narration should end with a complete sentence punctuation",
|
|
347
|
-
text_tail=text[-8:],
|
|
348
|
-
)
|
|
349
|
-
)
|
|
350
|
-
|
|
351
|
-
if not _has_connected_predecessor(narration, idx, start):
|
|
352
|
-
entry_issue = _source_sentence_entry_issue(
|
|
353
|
-
idx,
|
|
354
|
-
start,
|
|
355
|
-
source_sentence_anchors,
|
|
356
|
-
entry_overlaps_source_speech(
|
|
357
|
-
start,
|
|
358
|
-
source_evidence,
|
|
359
|
-
authored_overlap=seg.get("overlaps_speech", True),
|
|
360
|
-
),
|
|
361
|
-
)
|
|
362
|
-
if entry_issue:
|
|
363
|
-
errors.append(entry_issue)
|
|
364
|
-
|
|
365
|
-
scene_bounds = _scene_bounds_for_midpoint(scenes_analysis, start, end)
|
|
366
|
-
if scenes_analysis and not scene_bounds:
|
|
367
|
-
warnings.append(
|
|
368
|
-
_lint_issue(
|
|
369
|
-
"warning",
|
|
370
|
-
idx,
|
|
371
|
-
"outside_scene",
|
|
372
|
-
"Narration midpoint does not match any detected scene",
|
|
373
|
-
start=start,
|
|
374
|
-
end=end,
|
|
375
|
-
)
|
|
376
|
-
)
|
|
377
|
-
elif scene_bounds and (start < scene_bounds[0] or end > scene_bounds[1]):
|
|
378
|
-
warnings.append(
|
|
379
|
-
_lint_issue(
|
|
380
|
-
"warning",
|
|
381
|
-
idx,
|
|
382
|
-
"crosses_scene_boundary",
|
|
383
|
-
"Narration extends outside its midpoint scene boundary",
|
|
384
|
-
start=start,
|
|
385
|
-
end=end,
|
|
386
|
-
scene_start=scene_bounds[0],
|
|
387
|
-
scene_end=scene_bounds[1],
|
|
388
|
-
)
|
|
389
|
-
)
|
|
390
|
-
|
|
391
|
-
frame_fact_times = _frame_fact_times_for_segment(
|
|
392
|
-
scenes_analysis, start, end
|
|
393
|
-
)
|
|
394
|
-
if (end - start) > CONFIG["visual_beat_max_seconds"] and len(
|
|
395
|
-
frame_fact_times
|
|
396
|
-
) > CONFIG["visual_beat_max_facts"]:
|
|
397
|
-
warnings.append(
|
|
398
|
-
_lint_issue(
|
|
399
|
-
"warning",
|
|
400
|
-
idx,
|
|
401
|
-
"visual_beat_too_broad",
|
|
402
|
-
"Narration spans many visual anchors; split or tighten timing so the voiceover stays tied to current pictures",
|
|
403
|
-
start=start,
|
|
404
|
-
end=end,
|
|
405
|
-
duration=round(end - start, 2),
|
|
406
|
-
frame_fact_times=[round(ts, 2) for ts in frame_fact_times[:8]],
|
|
407
|
-
)
|
|
408
|
-
)
|
|
409
|
-
|
|
410
|
-
if mode == "cut":
|
|
411
|
-
requested_clip_id = seg.get("source_clip_id")
|
|
412
|
-
if requested_clip_id is not None:
|
|
413
|
-
try:
|
|
414
|
-
requested_clip_id = int(requested_clip_id)
|
|
415
|
-
except (TypeError, ValueError):
|
|
416
|
-
errors.append(
|
|
417
|
-
_lint_issue(
|
|
418
|
-
"error",
|
|
419
|
-
idx,
|
|
420
|
-
"invalid_source_clip_id",
|
|
421
|
-
"source_clip_id must be an integer",
|
|
422
|
-
)
|
|
423
|
-
)
|
|
424
|
-
continue
|
|
425
|
-
matches = _clip_matches_for_segment(seg, clip_plan, requested_clip_id)
|
|
426
|
-
if not matches:
|
|
427
|
-
errors.append(
|
|
428
|
-
_lint_issue(
|
|
429
|
-
"error",
|
|
430
|
-
idx,
|
|
431
|
-
"outside_clip_plan",
|
|
432
|
-
"Cut-mode narration must fall inside a selected clip",
|
|
433
|
-
start=start,
|
|
434
|
-
end=end,
|
|
435
|
-
)
|
|
436
|
-
)
|
|
437
|
-
elif len(matches) > 1 and requested_clip_id is None:
|
|
438
|
-
errors.append(
|
|
439
|
-
_lint_issue(
|
|
440
|
-
"error",
|
|
441
|
-
idx,
|
|
442
|
-
"ambiguous_source_clip",
|
|
443
|
-
"Repeated/overlapping clips require source_clip_id",
|
|
444
|
-
start=start,
|
|
445
|
-
end=end,
|
|
446
|
-
)
|
|
447
|
-
)
|
|
448
|
-
if len(matches) == 1:
|
|
449
|
-
clip_start, clip_end = _clip_span(matches[0])
|
|
450
|
-
if start < clip_start or end > clip_end:
|
|
451
|
-
warnings.append(
|
|
452
|
-
_lint_issue(
|
|
453
|
-
"warning",
|
|
454
|
-
idx,
|
|
455
|
-
"crosses_clip_boundary",
|
|
456
|
-
"Narration extends beyond its clip; it will be trimmed to the clip and may describe footage that was cut",
|
|
457
|
-
start=start,
|
|
458
|
-
end=end,
|
|
459
|
-
clip_start=round(clip_start, 3),
|
|
460
|
-
clip_end=round(clip_end, 3),
|
|
461
|
-
)
|
|
462
|
-
)
|
|
463
|
-
|
|
464
|
-
normalized.append(
|
|
465
|
-
{"index": idx, "start": start, "end": end, "char_count": char_count}
|
|
466
|
-
)
|
|
467
|
-
|
|
468
|
-
sorted_segments = sorted(normalized, key=lambda item: item["start"])
|
|
469
|
-
if isinstance(narration, list) and not sorted_segments:
|
|
470
|
-
errors.append(
|
|
471
|
-
_lint_issue(
|
|
472
|
-
"error",
|
|
473
|
-
None,
|
|
474
|
-
"empty_narration_file",
|
|
475
|
-
"narration.json must contain at least one valid narration segment",
|
|
476
|
-
)
|
|
477
|
-
)
|
|
478
|
-
for prev, curr in zip(sorted_segments, sorted_segments[1:]):
|
|
479
|
-
if curr["start"] < prev["end"]:
|
|
480
|
-
errors.append(
|
|
481
|
-
_lint_issue(
|
|
482
|
-
"error",
|
|
483
|
-
curr["index"],
|
|
484
|
-
"time_overlap",
|
|
485
|
-
"Segment overlaps the previous narration segment",
|
|
486
|
-
previous_index=prev["index"],
|
|
487
|
-
previous_end=prev["end"],
|
|
488
|
-
start=curr["start"],
|
|
489
|
-
end=curr["end"],
|
|
490
|
-
)
|
|
491
|
-
)
|
|
492
|
-
|
|
493
|
-
# Block-coverage check (full mode only; cut-mode density is measured on the mapped output
|
|
494
|
-
# timeline, not the source timestamps used here). Coverage is diagnostic, not a creative quota:
|
|
495
|
-
# flag wall-to-wall or sparse drafts so the Agent consciously checks the visual/audio board,
|
|
496
|
-
# plus missing original-audio gaps and fragmented one-sentence TTS blocks.
|
|
497
|
-
metrics = {}
|
|
498
|
-
if mode == "full" and len(sorted_segments) >= 2:
|
|
499
|
-
timeline_start = 0.0
|
|
500
|
-
timeline_end = sorted_segments[-1]["end"]
|
|
501
|
-
scene_ends = [scene["end"] for scene in scenes_analysis]
|
|
502
|
-
if scene_ends:
|
|
503
|
-
timeline_end = max(timeline_end, max(scene_ends))
|
|
504
|
-
span = max(0.0, timeline_end - timeline_start)
|
|
505
|
-
# Score coverage at the SAME conservative rate the writer is budgeted at
|
|
506
|
-
# (_recommended_char_budget uses speech_rate * speech_safety_margin * narration_speed),
|
|
507
|
-
# else the metric scores ~18% stricter than its own budget and false-flags under_narrated.
|
|
508
|
-
play_rate = (
|
|
509
|
-
CONFIG["speech_rate"] * CONFIG["speech_safety_margin"] * CONFIG["narration_speed"]
|
|
510
|
-
)
|
|
511
|
-
spoken = [s["char_count"] / play_rate for s in sorted_segments]
|
|
512
|
-
spoken_ends = [
|
|
513
|
-
min(sorted_segments[i]["end"], sorted_segments[i]["start"] + spoken[i])
|
|
514
|
-
for i in range(len(sorted_segments))
|
|
515
|
-
]
|
|
516
|
-
# Measure the UNION of estimated playback intervals. Adjacent authored
|
|
517
|
-
# blocks can overlap the conservative text-duration estimate; summing them
|
|
518
|
-
# double-counted time and falsely called a recap wall-to-wall.
|
|
519
|
-
estimated_intervals = sorted(
|
|
520
|
-
(sorted_segments[i]["start"], spoken_ends[i])
|
|
521
|
-
for i in range(len(sorted_segments))
|
|
522
|
-
if spoken_ends[i] > sorted_segments[i]["start"]
|
|
523
|
-
)
|
|
524
|
-
merged_intervals = []
|
|
525
|
-
for start, end in estimated_intervals:
|
|
526
|
-
if merged_intervals and start <= merged_intervals[-1][1]:
|
|
527
|
-
merged_intervals[-1][1] = max(merged_intervals[-1][1], end)
|
|
528
|
-
else:
|
|
529
|
-
merged_intervals.append([start, end])
|
|
530
|
-
narrated_seconds = sum(end - start for start, end in merged_intervals)
|
|
531
|
-
coverage = narrated_seconds / span if span > 0 else 0.0
|
|
532
|
-
orig_min = CONFIG["original_block_min_seconds"]
|
|
533
|
-
orig_gaps = [sorted_segments[0]["start"] - timeline_start]
|
|
534
|
-
orig_gaps.extend(
|
|
535
|
-
sorted_segments[i + 1]["start"] - spoken_ends[i]
|
|
536
|
-
for i in range(len(sorted_segments) - 1)
|
|
537
|
-
)
|
|
538
|
-
orig_gaps.append(timeline_end - spoken_ends[-1])
|
|
539
|
-
original_blocks = sum(1 for g in orig_gaps if g >= orig_min)
|
|
540
|
-
avg_chars = sum(s["char_count"] for s in sorted_segments) / len(sorted_segments)
|
|
541
|
-
cov_target = CONFIG["narration_coverage_target"]
|
|
542
|
-
cov_max = CONFIG["narration_coverage_max"]
|
|
543
|
-
cov_min = CONFIG["narration_coverage_min"]
|
|
544
|
-
block_min_chars = CONFIG["narration_block_min_chars"]
|
|
545
|
-
metrics = {
|
|
546
|
-
"segment_count": len(sorted_segments),
|
|
547
|
-
"timeline_span_seconds": round(span, 2),
|
|
548
|
-
"narrated_seconds": round(narrated_seconds, 2),
|
|
549
|
-
"narration_coverage": round(coverage, 2),
|
|
550
|
-
"coverage_target": cov_target,
|
|
551
|
-
"original_block_count": original_blocks,
|
|
552
|
-
"avg_block_chars": round(avg_chars, 1),
|
|
553
|
-
}
|
|
554
|
-
if coverage > cov_max:
|
|
555
|
-
warnings.append(
|
|
556
|
-
_lint_issue(
|
|
557
|
-
"warning",
|
|
558
|
-
None,
|
|
559
|
-
"no_original_blocks",
|
|
560
|
-
"Narration is nearly wall-to-wall — the original audio never gets to breathe. Pull back at a "
|
|
561
|
-
"few strong moments and write NO narration there so the original plays at full volume. "
|
|
562
|
-
"Choose those moments by audio_owner, not by a fixed ratio.",
|
|
563
|
-
narration_coverage=round(coverage, 2),
|
|
564
|
-
coverage_max=cov_max,
|
|
565
|
-
)
|
|
566
|
-
)
|
|
567
|
-
elif coverage < cov_min:
|
|
568
|
-
warnings.append(
|
|
569
|
-
_lint_issue(
|
|
570
|
-
"warning",
|
|
571
|
-
None,
|
|
572
|
-
"under_narrated",
|
|
573
|
-
"Narration coverage is sparse. This is not automatically wrong: verify that every long gap is "
|
|
574
|
-
"intentionally owned by original dialogue/action/ambience/music/silence in visual_audio_board.json. "
|
|
575
|
-
"Only add a block when it has a specific narration job.",
|
|
576
|
-
narration_coverage=round(coverage, 2),
|
|
577
|
-
coverage_min=cov_min,
|
|
578
|
-
)
|
|
579
|
-
)
|
|
580
|
-
if original_blocks == 0 and span >= 3 * orig_min:
|
|
581
|
-
warnings.append(
|
|
582
|
-
_lint_issue(
|
|
583
|
-
"warning",
|
|
584
|
-
None,
|
|
585
|
-
"no_original_breaks",
|
|
586
|
-
"No deliberate original-audio blocks — narration runs end-to-end with no gap for a strong "
|
|
587
|
-
"original moment (a key line, an action beat, the music). Leave a few multi-second gaps "
|
|
588
|
-
"between blocks where the original plays alone.",
|
|
589
|
-
original_block_min_seconds=orig_min,
|
|
590
|
-
)
|
|
591
|
-
)
|
|
592
|
-
if len(sorted_segments) >= 8 and avg_chars < block_min_chars:
|
|
593
|
-
warnings.append(
|
|
594
|
-
_lint_issue(
|
|
595
|
-
"warning",
|
|
596
|
-
None,
|
|
597
|
-
"fragmented_beats",
|
|
598
|
-
"Beats are fragmented into single short sentences; each is synthesized as a separate TTS "
|
|
599
|
-
"utterance, which sounds choppy. Merge adjacent related sentences into one fluent BLOCK "
|
|
600
|
-
"that completes a continuous thought; sentence count is not the target.",
|
|
601
|
-
avg_block_chars=round(avg_chars, 1),
|
|
602
|
-
block_min_chars=block_min_chars,
|
|
603
|
-
)
|
|
604
|
-
)
|
|
605
|
-
|
|
606
|
-
deslop_qc = analyze_deslop_qc(
|
|
607
|
-
[seg for seg in narration if isinstance(seg, dict)]
|
|
608
|
-
if isinstance(narration, list)
|
|
609
|
-
else [],
|
|
610
|
-
work_dir=work_dir,
|
|
611
|
-
)
|
|
612
|
-
for blocker in deslop_qc["blockers"]:
|
|
613
|
-
errors.append(
|
|
614
|
-
_lint_issue(
|
|
615
|
-
"error",
|
|
616
|
-
blocker["index"],
|
|
617
|
-
blocker["code"],
|
|
618
|
-
blocker["message"],
|
|
619
|
-
source=blocker["source"],
|
|
620
|
-
matches=blocker.get("matches"),
|
|
621
|
-
sentence=blocker.get("sentence"),
|
|
622
|
-
)
|
|
623
|
-
)
|
|
624
|
-
|
|
625
|
-
report = {
|
|
626
|
-
"ok": not errors,
|
|
627
|
-
"error_count": len(errors),
|
|
628
|
-
"warning_count": len(warnings),
|
|
629
|
-
"metrics": metrics,
|
|
630
|
-
"deslop_qc": deslop_qc,
|
|
631
|
-
"errors": errors,
|
|
632
|
-
"warnings": warnings,
|
|
633
|
-
}
|
|
634
|
-
if work_dir is not None:
|
|
635
|
-
Path(work_dir, "deslop_qc.json").write_text(
|
|
636
|
-
json.dumps(deslop_qc, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
637
|
-
)
|
|
638
|
-
Path(work_dir, "narration_lint.json").write_text(
|
|
639
|
-
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
640
|
-
)
|
|
641
|
-
return report
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
def validate_narration_or_raise(
|
|
645
|
-
narration, scenes_analysis=None, *, clip_plan=None, mode="full", work_dir=None,
|
|
646
|
-
require_chronological=False,
|
|
647
|
-
):
|
|
648
|
-
report = lint_narration(
|
|
649
|
-
narration, scenes_analysis, clip_plan=clip_plan, mode=mode, work_dir=work_dir,
|
|
650
|
-
require_chronological=require_chronological,
|
|
651
|
-
)
|
|
652
|
-
if report["errors"]:
|
|
653
|
-
sample = "; ".join(
|
|
654
|
-
f"#{e['index']}: {e['code']}" for e in report["errors"][:3]
|
|
655
|
-
)
|
|
656
|
-
raise ValueError(f"narration.json 预检失败: {sample}; 详见 narration_lint.json")
|
|
657
|
-
if report["warnings"]:
|
|
658
|
-
log(
|
|
659
|
-
f"narration lint: {len(report['warnings'])} warnings (see narration_lint.json)"
|
|
660
|
-
)
|
|
661
|
-
else:
|
|
662
|
-
log("narration lint: ok")
|
|
663
|
-
return report
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
def _validate_narration_budget(narration, scenes_analysis):
|
|
667
|
-
"""Trim lint-validated narration to its timing budgets; drop what cannot be spoken."""
|
|
668
|
-
del scenes_analysis # scene boundaries are advisory (lint warns); authored timing is kept
|
|
669
|
-
cleaned = []
|
|
670
|
-
for raw in narration:
|
|
671
|
-
item = _normalise_narration_segment(raw)
|
|
672
|
-
max_chars = _recommended_char_budget(item["start"], item["end"])
|
|
673
|
-
if max_chars < 5:
|
|
674
|
-
log(f" 丢弃过短解说段 {item['start']:.1f}-{item['end']:.1f}s")
|
|
675
|
-
continue
|
|
676
|
-
if _text_char_count(item["narration"]) > max_chars * 1.25:
|
|
677
|
-
truncated = _truncate_at_sentence(item["narration"], max_chars)
|
|
678
|
-
if truncated and _text_char_count(truncated) >= 5:
|
|
679
|
-
log(f" 解说超预算,已截短: {item['start']:.1f}-{item['end']:.1f}s")
|
|
680
|
-
item["narration"] = truncated
|
|
681
|
-
else:
|
|
682
|
-
log(
|
|
683
|
-
f" 解说超预算且无法安全截断,已丢弃: {item['start']:.1f}-{item['end']:.1f}s"
|
|
684
|
-
)
|
|
685
|
-
continue
|
|
686
|
-
item["narration"] = _clean_narration_punctuation(item["narration"])
|
|
687
|
-
stripped = item["narration"].strip()
|
|
688
|
-
if stripped and stripped[-1] in ",:、;,—":
|
|
689
|
-
item["narration"] = stripped.rstrip(",:、;,—") + "。"
|
|
690
|
-
cleaned.append(item)
|
|
691
|
-
|
|
692
|
-
cleaned.sort(key=lambda n: n["start"])
|
|
693
|
-
deduped = []
|
|
694
|
-
for item in cleaned:
|
|
695
|
-
if deduped and item["start"] < deduped[-1]["end"]:
|
|
696
|
-
prev = deduped[-1]
|
|
697
|
-
log(
|
|
698
|
-
f" 解说时间重叠: {item['start']:.1f}-{item['end']:.1f}s vs "
|
|
699
|
-
f"{prev['start']:.1f}-{prev['end']:.1f}s"
|
|
700
|
-
)
|
|
701
|
-
if _text_char_count(item["narration"]) > _text_char_count(
|
|
702
|
-
prev["narration"]
|
|
703
|
-
):
|
|
704
|
-
deduped[-1] = item
|
|
705
|
-
else:
|
|
706
|
-
deduped.append(item)
|
|
707
|
-
return _post_dedup_narration(deduped)
|