@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
|
@@ -21,6 +21,7 @@ brittle and case-by-case. Two stages around an agent-authored pause:
|
|
|
21
21
|
import argparse
|
|
22
22
|
import base64
|
|
23
23
|
import json
|
|
24
|
+
import os
|
|
24
25
|
import re
|
|
25
26
|
import unicodedata
|
|
26
27
|
import wave
|
|
@@ -35,13 +36,15 @@ from lib import (
|
|
|
35
36
|
mimo_tts_api_call,
|
|
36
37
|
run_cmd,
|
|
37
38
|
)
|
|
39
|
+
from tts_audio import implausible_tts_duration, rejected_take_hint, rejected_take_path
|
|
38
40
|
|
|
39
41
|
CLONE_MODEL = "mimo-v2.5-tts-voiceclone"
|
|
40
42
|
CLONE_SR = 24000 # mimo voiceclone returns 24kHz mono PCM16 wav
|
|
41
43
|
DUB_DELIVERY_SR = 48000 # explicit delivery rate; loudnorm otherwise exposes its 96kHz internal rate
|
|
42
44
|
ASR_MIME = "audio/wav"
|
|
43
45
|
ATEMPO_CAP = 2.0 # max compression before we trim instead (atempo>2 also sounds rushed)
|
|
44
|
-
|
|
46
|
+
DUB_TARGET_CPS = 5.0 # what the brief asks the translator for
|
|
47
|
+
DUB_FAST_SPEECH_CPS = 7.0 # lint warns at or above this density
|
|
45
48
|
DUB_TRIM_RISK_CPS = 7.0
|
|
46
49
|
DUB_MIN_ASR_WINDOW_SECONDS = 0.5
|
|
47
50
|
DUB_TTS_STYLE_PROMPT = "自然、清晰,保持原说话人的音色与节奏,语气平稳。"
|
|
@@ -50,41 +53,6 @@ DUB_SCHEMA_VERSION = 1
|
|
|
50
53
|
# Tolerate ~1-frame rounding in agent-estimated timings so the gate blocks only GENUINE
|
|
51
54
|
# errors, not rounding noise (the old render clamped such cases via min(slot_end, nxt)).
|
|
52
55
|
DUB_TIMING_EPS = 0.05
|
|
53
|
-
DUB_LINT_SCHEMA = {
|
|
54
|
-
"schema_version": DUB_SCHEMA_VERSION,
|
|
55
|
-
"artifact": "dub_lint.json",
|
|
56
|
-
"fields": {
|
|
57
|
-
"verdict": "PASS|FAIL",
|
|
58
|
-
"issues": "[{severity, code, line, message, start, end}]",
|
|
59
|
-
"summary": "{lines, errors, warnings, max_chars_per_second, trim_risk_lines}",
|
|
60
|
-
},
|
|
61
|
-
}
|
|
62
|
-
DUB_REVIEW_SCHEMA = {
|
|
63
|
-
"schema_version": DUB_SCHEMA_VERSION,
|
|
64
|
-
"artifact": "dub_review.json",
|
|
65
|
-
"fields": {
|
|
66
|
-
"verdict": "PASS|REVISE|FAIL",
|
|
67
|
-
"checks": "{faithful_to_source, spoken_chinese, speaker_tone, timing_fit, platform_fit}",
|
|
68
|
-
"highest_return_edits": "[{line, start, issue, suggestion}]",
|
|
69
|
-
"notes": "human/agent review notes; script-level output is deterministic pre-review guidance",
|
|
70
|
-
},
|
|
71
|
-
}
|
|
72
|
-
DUB_ARTIFACT_SCHEMAS = {
|
|
73
|
-
"dub_transcript.json": {
|
|
74
|
-
"schema_version": DUB_SCHEMA_VERSION,
|
|
75
|
-
"shape": {"video": "path", "duration": "seconds", "windows": [{"start": 0.0, "end": 6.0, "text": "English ASR"}]},
|
|
76
|
-
},
|
|
77
|
-
"dub_script.json": {
|
|
78
|
-
"schema_version": DUB_SCHEMA_VERSION,
|
|
79
|
-
"shape": [{"start": 0.0, "end": 2.4, "zh": "中文台词"}],
|
|
80
|
-
},
|
|
81
|
-
"dub_manifest.json": {
|
|
82
|
-
"schema_version": DUB_SCHEMA_VERSION,
|
|
83
|
-
"shape": {"video": "path", "duration": "seconds", "lines": [{"start": 0.0, "end": 2.4, "zh": "中文台词", "tts_cache": "hit|miss", "fitted_wav": "path", "fitted_dur": 1.8, "room": 2.4}]},
|
|
84
|
-
},
|
|
85
|
-
"dub_lint.json": DUB_LINT_SCHEMA,
|
|
86
|
-
"dub_review.json": DUB_REVIEW_SCHEMA,
|
|
87
|
-
}
|
|
88
56
|
|
|
89
57
|
|
|
90
58
|
def _chars_per_second(text, room):
|
|
@@ -205,37 +173,6 @@ def lint_dub_script(script, duration, work_dir=None):
|
|
|
205
173
|
return report
|
|
206
174
|
|
|
207
175
|
|
|
208
|
-
def build_dub_review(script, transcript, lint=None):
|
|
209
|
-
"""Script-level review scaffold: deterministic timing/naturalness signals for agent review."""
|
|
210
|
-
duration = float(transcript["duration"])
|
|
211
|
-
lint = lint or lint_dub_script(script, duration)
|
|
212
|
-
lines = _normalize_dub_script(script) if isinstance(script, list) else []
|
|
213
|
-
edits = []
|
|
214
|
-
for issue in lint["issues"]:
|
|
215
|
-
if issue["severity"] in {"error", "warning"}:
|
|
216
|
-
edits.append({
|
|
217
|
-
"line": issue["line"],
|
|
218
|
-
"start": issue.get("start"), # _issue only records start when the line had one
|
|
219
|
-
"issue": f"{issue['code']}: {issue['message']}",
|
|
220
|
-
"suggestion": "缩短译文、调整 start/end,或回到 transcript 核对原句边界。",
|
|
221
|
-
})
|
|
222
|
-
verdict = "FAIL" if lint["verdict"] == "FAIL" else ("REVISE" if edits else "PASS")
|
|
223
|
-
return {
|
|
224
|
-
"schema_version": DUB_SCHEMA_VERSION,
|
|
225
|
-
"verdict": verdict,
|
|
226
|
-
"checks": {
|
|
227
|
-
"faithful_to_source": "needs_agent_review",
|
|
228
|
-
"spoken_chinese": "REVISE" if any(i["code"] == "fast_speech" for i in lint["issues"]) else "PASS",
|
|
229
|
-
"speaker_tone": "needs_agent_review",
|
|
230
|
-
"timing_fit": verdict,
|
|
231
|
-
"platform_fit": "needs_agent_review",
|
|
232
|
-
},
|
|
233
|
-
"highest_return_edits": edits[:8],
|
|
234
|
-
"notes": "Deterministic script-level review; an agent/human should fill semantic fidelity and tone judgments against dub_transcript.json.",
|
|
235
|
-
"coverage": {"transcript_windows": len(transcript["windows"]), "script_lines": len(lines)},
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
|
|
239
176
|
def _write_json(path, payload):
|
|
240
177
|
Path(path).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
241
178
|
return path
|
|
@@ -362,6 +299,11 @@ def _clone_cache_meta_path(raw_wav):
|
|
|
362
299
|
return raw_wav.with_name(f"{raw_wav.name}.meta.json")
|
|
363
300
|
|
|
364
301
|
|
|
302
|
+
def _wav_seconds(path):
|
|
303
|
+
with wave.open(str(path), "rb") as w:
|
|
304
|
+
return w.getnframes() / w.getframerate()
|
|
305
|
+
|
|
306
|
+
|
|
365
307
|
def _usable_clone_wav(path):
|
|
366
308
|
try:
|
|
367
309
|
sample_rate, channels, frames = _wav_frames(path)
|
|
@@ -376,6 +318,11 @@ def _write_clone_cache_meta(raw_wav, text, ref_identity):
|
|
|
376
318
|
|
|
377
319
|
|
|
378
320
|
def _ensure_clone_tts(text, ref_b64, ref_identity, raw_wav):
|
|
321
|
+
"""Reuse or synthesize one line's raw clone WAV; returns True on a cache hit.
|
|
322
|
+
|
|
323
|
+
A take far longer than reading `text` (the line plus invented speech) is the same failure
|
|
324
|
+
voiceover rejects: retried up to TTS_RETRIES times and never cached or time-fitted; the
|
|
325
|
+
latest such take is kept as `<name>.rejected.wav` for listening."""
|
|
379
326
|
raw_wav = Path(raw_wav)
|
|
380
327
|
expected = _clone_cache_inputs(text, ref_identity)
|
|
381
328
|
meta_path = _clone_cache_meta_path(raw_wav)
|
|
@@ -385,13 +332,32 @@ def _ensure_clone_tts(text, ref_b64, ref_identity, raw_wav):
|
|
|
385
332
|
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
386
333
|
# A meta from the content-hash schema has no "inputs" and is simply a miss.
|
|
387
334
|
if meta.get("inputs") == expected and _usable_clone_wav(raw_wav):
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
335
|
+
implausible = implausible_tts_duration(text, _wav_seconds(raw_wav))
|
|
336
|
+
if not implausible:
|
|
337
|
+
return True
|
|
338
|
+
# Cached before this bound existed: re-synthesize instead of fitting it every rerun.
|
|
339
|
+
log(f" {raw_wav.name}: 不复用缓存,重新合成:{implausible}")
|
|
340
|
+
|
|
341
|
+
rejected = rejected_take_path(raw_wav)
|
|
342
|
+
rejected.unlink(missing_ok=True)
|
|
343
|
+
retries = CONFIG["tts_retries"]
|
|
344
|
+
implausible = None
|
|
345
|
+
for attempt in range(1, retries + 1):
|
|
346
|
+
meta_path.unlink(missing_ok=True) # never leave a sidecar vouching for an unchecked take
|
|
347
|
+
_clone_tts(text, ref_b64, raw_wav)
|
|
348
|
+
if not _usable_clone_wav(raw_wav):
|
|
349
|
+
raise RuntimeError(f"MiMo voiceclone returned an invalid WAV: {raw_wav}")
|
|
350
|
+
implausible = implausible_tts_duration(text, _wav_seconds(raw_wav))
|
|
351
|
+
if not implausible:
|
|
352
|
+
rejected.unlink(missing_ok=True)
|
|
353
|
+
_write_clone_cache_meta(raw_wav, text, ref_identity)
|
|
354
|
+
return False
|
|
355
|
+
os.replace(raw_wav, rejected)
|
|
356
|
+
if attempt < retries:
|
|
357
|
+
log(f" {raw_wav.name}: voiceclone 重试 {attempt + 1}/{retries}: {implausible}")
|
|
358
|
+
raise RuntimeError(
|
|
359
|
+
f"MiMo voiceclone 合成失败(«{text[:18]}»): {implausible}{rejected_take_hint(rejected)}"
|
|
360
|
+
)
|
|
395
361
|
|
|
396
362
|
|
|
397
363
|
def _time_fit(raw_wav, fitted_wav, room_seconds):
|
|
@@ -432,11 +398,15 @@ def _build_dub_track(lines, duration, out_wav):
|
|
|
432
398
|
|
|
433
399
|
|
|
434
400
|
def _mux(video, dub_wav, out_video):
|
|
401
|
+
# The picture is stream-copied as the source has it (no yuv420p / colour-tag
|
|
402
|
+
# normalisation in dub mode); +faststart is copy-safe and lets web players start early.
|
|
403
|
+
# The source's container/stream tags (a scraper's title, comment or URL, the original
|
|
404
|
+
# audio language) and chapters are not carried into the dubbed deliverable.
|
|
435
405
|
_ffmpeg(["-i", str(video), "-i", str(dub_wav),
|
|
436
406
|
"-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy",
|
|
437
407
|
"-af", "loudnorm=I=-16:TP=-1.5:LRA=11", "-c:a", "aac", "-b:a", "192k",
|
|
438
|
-
"-ar", str(DUB_DELIVERY_SR),
|
|
439
|
-
"-shortest", str(out_video)])
|
|
408
|
+
"-ar", str(DUB_DELIVERY_SR), "-map_metadata", "-1", "-map_chapters", "-1",
|
|
409
|
+
"-shortest", "-movflags", "+faststart", str(out_video)])
|
|
440
410
|
|
|
441
411
|
|
|
442
412
|
# ── stages ───────────────────────────────────────────────────────────
|
|
@@ -446,7 +416,7 @@ def _ref_window(duration, ref_start, ref_dur):
|
|
|
446
416
|
return start, max(2.0, min(ref_dur, duration - start))
|
|
447
417
|
|
|
448
418
|
|
|
449
|
-
def stage_prepare(video, work, asr_window, ref_start, ref_dur):
|
|
419
|
+
def stage_prepare(video, work, asr_window=6.0, ref_start=2.0, ref_dur=10.0):
|
|
450
420
|
work.mkdir(parents=True, exist_ok=True)
|
|
451
421
|
segs_dir = work / "dub_asr"
|
|
452
422
|
segs_dir.mkdir(exist_ok=True)
|
|
@@ -484,7 +454,8 @@ def _brief_md(windows, duration):
|
|
|
484
454
|
"原声重复,配音也跟着重复。",
|
|
485
455
|
"2. 每句放在它在原声里被说出的时间,`start`/`end` 取那句话的起止——这样配音才跟着原声节奏走"
|
|
486
456
|
"(该停顿处自然留白)。",
|
|
487
|
-
"3. 译文忠实精简,能在自己的 `start`→`end`
|
|
457
|
+
f"3. 译文忠实精简,能在自己的 `start`→`end` 区间内用正常语速说完(目标约 {DUB_TARGET_CPS:g} 字/秒;"
|
|
458
|
+
f"达到 {DUB_FAST_SPEECH_CPS:g} 字/秒 lint 会警告,渲染时可能被大幅加速或截断)。",
|
|
488
459
|
"4. 只有完全被截断、没法说完整的残句,才取其中说得完整的部分。",
|
|
489
460
|
"",
|
|
490
461
|
"输出 `dub_script.json` = `[{\"start\": 起秒, \"end\": 止秒, \"zh\": \"中文台词\"}, ...]`,按 start "
|
|
@@ -500,16 +471,13 @@ def _brief_md(windows, duration):
|
|
|
500
471
|
return "\n".join(lines) + "\n"
|
|
501
472
|
|
|
502
473
|
|
|
503
|
-
def stage_render(video, work, ref_start, ref_dur):
|
|
474
|
+
def stage_render(video, work, ref_start=2.0, ref_dur=10.0):
|
|
504
475
|
transcript = json.loads((work / "dub_transcript.json").read_text(encoding="utf-8"))
|
|
505
476
|
duration = transcript["duration"]
|
|
506
477
|
script = json.loads((work / "dub_script.json").read_text(encoding="utf-8"))
|
|
507
478
|
# Mechanical hard gate BEFORE any voiceclone spend: empty/overlapping/out-of-range lines
|
|
508
479
|
# cannot produce a publishable dub, so fail fast instead of paying for a broken render.
|
|
509
|
-
lint = lint_dub_script(script, duration)
|
|
510
|
-
_write_json(work / "dub_lint.json", lint)
|
|
511
|
-
review = build_dub_review(script, transcript, lint)
|
|
512
|
-
_write_json(work / "dub_review.json", review)
|
|
480
|
+
lint = lint_dub_script(script, duration, work_dir=work)
|
|
513
481
|
if lint["verdict"] != "PASS":
|
|
514
482
|
raise SystemExit(f"[dub] dub_script.json failed lint; see {work / 'dub_lint.json'}")
|
|
515
483
|
lines = [{"start": ln["start"], "end": ln["end"], "zh": ln["zh"]}
|
|
@@ -556,67 +524,24 @@ def stage_render(video, work, ref_start, ref_dur):
|
|
|
556
524
|
ensure_ascii=False))
|
|
557
525
|
|
|
558
526
|
|
|
559
|
-
def stage_lint(work):
|
|
560
|
-
transcript = json.loads((work / "dub_transcript.json").read_text(encoding="utf-8"))
|
|
561
|
-
script = json.loads((work / "dub_script.json").read_text(encoding="utf-8"))
|
|
562
|
-
lint = lint_dub_script(script, transcript["duration"])
|
|
563
|
-
path = _write_json(work / "dub_lint.json", lint)
|
|
564
|
-
print(json.dumps({"status": "dub_linted", "verdict": lint["verdict"], "issues": len(lint["issues"]), "dub_lint": str(path)}, ensure_ascii=False))
|
|
565
|
-
return lint
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
def stage_review(work):
|
|
569
|
-
transcript = json.loads((work / "dub_transcript.json").read_text(encoding="utf-8"))
|
|
570
|
-
script = json.loads((work / "dub_script.json").read_text(encoding="utf-8"))
|
|
571
|
-
lint = lint_dub_script(script, transcript["duration"])
|
|
572
|
-
_write_json(work / "dub_lint.json", lint)
|
|
573
|
-
review = build_dub_review(script, transcript, lint)
|
|
574
|
-
path = _write_json(work / "dub_review.json", review)
|
|
575
|
-
print(json.dumps({"status": "dub_reviewed", "verdict": review["verdict"], "edits": len(review["highest_return_edits"]), "dub_review": str(path)}, ensure_ascii=False))
|
|
576
|
-
return review
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
def print_schemas():
|
|
580
|
-
print(json.dumps(DUB_ARTIFACT_SCHEMAS, ensure_ascii=False, indent=2))
|
|
581
|
-
|
|
582
|
-
|
|
583
527
|
def main():
|
|
584
528
|
ap = argparse.ArgumentParser(
|
|
585
529
|
description=(
|
|
586
|
-
"English→Chinese dub workflow. Artifacts:
|
|
587
|
-
"agent-authored dub_script.json, dub_lint.json,
|
|
530
|
+
"English→Chinese dub workflow, run by recap.py --edit-mode dub. Artifacts: "
|
|
531
|
+
"dub_transcript.json, agent-authored dub_script.json, dub_lint.json, dub_manifest.json. "
|
|
588
532
|
"Uses MiMo ASR plus mimo-v2.5-tts-voiceclone; ordinary narration TTS config is separate."
|
|
589
533
|
)
|
|
590
534
|
)
|
|
591
|
-
ap.add_argument("--stage", choices=["prepare", "
|
|
592
|
-
help="prepare ASR brief;
|
|
593
|
-
ap.add_argument("--video", required=
|
|
594
|
-
ap.add_argument("--work-dir", required=
|
|
595
|
-
ap.add_argument("--asr-window", type=float, default=6.0)
|
|
596
|
-
ap.add_argument("--ref-start", type=float, default=2.0)
|
|
597
|
-
ap.add_argument("--ref-dur", type=float, default=10.0)
|
|
598
|
-
ap.add_argument("--print-schema", action="store_true",
|
|
599
|
-
help="print JSON schemas for dub_transcript/script/lint/review/manifest and exit")
|
|
535
|
+
ap.add_argument("--stage", choices=["prepare", "render"], required=True,
|
|
536
|
+
help="prepare writes the ASR brief; render lints dub_script.json before voiceclone")
|
|
537
|
+
ap.add_argument("--video", required=True, help="source video")
|
|
538
|
+
ap.add_argument("--work-dir", required=True, help="work directory containing dub artifacts")
|
|
600
539
|
args = ap.parse_args()
|
|
601
|
-
|
|
602
|
-
print_schemas()
|
|
603
|
-
return
|
|
604
|
-
if not args.stage:
|
|
605
|
-
ap.error("--stage is required unless --print-schema is used")
|
|
606
|
-
if not args.work_dir:
|
|
607
|
-
ap.error("--work-dir is required")
|
|
608
|
-
work = Path(args.work_dir)
|
|
609
|
-
if args.stage in {"prepare", "render"} and not args.video:
|
|
610
|
-
ap.error("--video is required for prepare/render")
|
|
611
|
-
video = Path(args.video) if args.video else None
|
|
540
|
+
video, work = Path(args.video), Path(args.work_dir)
|
|
612
541
|
if args.stage == "prepare":
|
|
613
|
-
stage_prepare(video, work
|
|
614
|
-
elif args.stage == "lint":
|
|
615
|
-
stage_lint(work)
|
|
616
|
-
elif args.stage == "review":
|
|
617
|
-
stage_review(work)
|
|
542
|
+
stage_prepare(video, work)
|
|
618
543
|
else:
|
|
619
|
-
stage_render(video, work
|
|
544
|
+
stage_render(video, work)
|
|
620
545
|
|
|
621
546
|
|
|
622
547
|
if __name__ == "__main__":
|
|
@@ -120,10 +120,7 @@ CONFIG = {
|
|
|
120
120
|
"mimo_tts_model": os.environ.get("MIMO_TTS_MODEL", DEFAULT_MIMO_TTS_MODEL),
|
|
121
121
|
"mimo_tts_voice": os.environ.get("MIMO_TTS_VOICE", "冰糖"),
|
|
122
122
|
"voice_ref": os.environ.get("VOICE_REF", "").strip(), # optional arbitrary reference audio for narration voice clone
|
|
123
|
-
"mimo_tts_style":
|
|
124
|
-
"MIMO_TTS_STYLE",
|
|
125
|
-
"自然、清晰、有感染力,像在给观众讲故事;随剧情起伏,该紧张时紧张、该动情时动情,不平铺直叙。",
|
|
126
|
-
),
|
|
123
|
+
"mimo_tts_style": "自然、清晰、有感染力,像在给观众讲故事;随剧情起伏,该紧张时紧张、该动情时动情,不平铺直叙。",
|
|
127
124
|
"tts_provider": os.environ.get("TTS_PROVIDER", "auto").strip().lower(),
|
|
128
125
|
"tts_timeout": env_int("TTS_TIMEOUT", 300, minimum=1),
|
|
129
126
|
"fish_api_key": os.environ.get("FISH_API_KEY", ""),
|
|
@@ -135,16 +132,17 @@ CONFIG = {
|
|
|
135
132
|
"mimo_disable_thinking": env_bool("MIMO_DISABLE_THINKING", True),
|
|
136
133
|
"breath_ms": 250, # 段间呼吸空间(ms);block recap 块内连贯、块间留原声呼吸
|
|
137
134
|
"narration_speed": env_float("NARRATION_SPEED", 1.15, minimum=0.5), # 解说整体提速(atempo),默认回到可懂区间;长片可设 1.0
|
|
138
|
-
"narration_cumulative_tempo_max":
|
|
139
|
-
"narration_cumulative_tempo_hard_max":
|
|
140
|
-
"tts_segment_tempo_max":
|
|
141
|
-
"tts_dynamic_params": True, # 启用动态语速调节
|
|
135
|
+
"narration_cumulative_tempo_max": 1.35, # TTS rate × 全局 atempo × 段内 atempo 的累计上限
|
|
136
|
+
"narration_cumulative_tempo_hard_max": 1.40, # QC/阻断硬上限
|
|
137
|
+
"tts_segment_tempo_max": 1.20, # 兼容旧段内 atempo 上限;实际会被累计预算收紧
|
|
142
138
|
"tts_workers": env_int("TTS_WORKERS", 4, minimum=1), # TTS 并行合成线程数
|
|
143
139
|
"tts_retries": env_int("TTS_RETRIES", 3, minimum=1), # 单段 TTS 失败重试次数
|
|
140
|
+
# 段音频时长合理性下限语速(字/秒,拉丁词按 1.5):比它还慢说明 TTS 多读了内容,按失败重试;0 关闭
|
|
141
|
+
"tts_min_speech_rate": env_float("TTS_MIN_SPEECH_RATE", 2.5, minimum=0.0),
|
|
144
142
|
"allow_partial_tts": env_bool("ALLOW_PARTIAL_TTS", False),
|
|
145
|
-
"tts_segment_normalize":
|
|
146
|
-
"tts_segment_target_rms_dbfs":
|
|
147
|
-
"tts_segment_peak_limit":
|
|
143
|
+
"tts_segment_normalize": True, # 单段 TTS RMS 归一,降低段间忽大忽小
|
|
144
|
+
"tts_segment_target_rms_dbfs": -20.0,
|
|
145
|
+
"tts_segment_peak_limit": 0.98,
|
|
148
146
|
}
|
|
149
147
|
|
|
150
148
|
|
|
@@ -327,26 +325,3 @@ def api_call(payload, *, api_url, api_key, api_env_var, max_retries=8):
|
|
|
327
325
|
raise RuntimeError(f"API 调用失败 {max_retries} 次: {safe_error}")
|
|
328
326
|
|
|
329
327
|
|
|
330
|
-
def _text_char_count(text):
|
|
331
|
-
"""计算文本的有效字数(去除标点和空白,这些不占 TTS 朗读时间)。"""
|
|
332
|
-
return len(re.sub(r'[,。!?、;:…“”‘’《》〈〉\s"\'「」『』()()【】\[\]—~·,.!?;:\\-]', '', text))
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
def _truncate_at_sentence(text, max_chars):
|
|
336
|
-
"""在句子边界截断,不产生残句。max_chars 按有效字符计(不含标点空白)。"""
|
|
337
|
-
if _text_char_count(text) <= max_chars:
|
|
338
|
-
return text
|
|
339
|
-
eff = 0
|
|
340
|
-
cutoff = len(text)
|
|
341
|
-
for i, ch in enumerate(text):
|
|
342
|
-
eff += 1 if _text_char_count(ch) else 0
|
|
343
|
-
if eff > max_chars:
|
|
344
|
-
cutoff = i + 1
|
|
345
|
-
break
|
|
346
|
-
idx = max(text[:cutoff].rfind(sep) for sep in ['。', '!', '?', '!', '?'])
|
|
347
|
-
if idx > 0:
|
|
348
|
-
return text[:idx + 1]
|
|
349
|
-
idx = max(text[:cutoff].rfind(sep) for sep in [',', '、', ';', ','])
|
|
350
|
-
if idx > 3:
|
|
351
|
-
return text[:idx] + '。'
|
|
352
|
-
return ""
|
|
@@ -13,13 +13,18 @@ from pathlib import Path
|
|
|
13
13
|
from lib import CONFIG, _sanitize_api_error
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
def fish_speed(rate):
|
|
17
|
+
"""The numeric prosody speed Fish Audio receives for a '+5%'-style rate."""
|
|
18
|
+
return 1.0 + float(rate.rstrip("%")) / 100.0
|
|
19
|
+
|
|
20
|
+
|
|
16
21
|
def _fish_payload(text, rate):
|
|
17
22
|
payload = {
|
|
18
23
|
"text": text,
|
|
19
24
|
"format": "wav",
|
|
20
25
|
"normalize": True,
|
|
21
26
|
"prosody": {
|
|
22
|
-
"speed":
|
|
27
|
+
"speed": fish_speed(rate),
|
|
23
28
|
"volume": 0,
|
|
24
29
|
"normalize_loudness": True,
|
|
25
30
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Loudness normalization for synthesized TTS blocks.
|
|
1
|
+
"""Loudness normalization and a duration sanity bound for synthesized TTS blocks.
|
|
2
2
|
|
|
3
3
|
Split out of voiceover.py to keep that module within the bundle's per-module line
|
|
4
4
|
budget. Dependency-free on purpose (stdlib `wave` + `array` only) so QC and assembly
|
|
@@ -7,6 +7,7 @@ can reuse the returned metadata without pulling in the synthesis path.
|
|
|
7
7
|
import array
|
|
8
8
|
import math
|
|
9
9
|
import os
|
|
10
|
+
import re
|
|
10
11
|
import sys
|
|
11
12
|
import wave
|
|
12
13
|
from pathlib import Path
|
|
@@ -100,3 +101,65 @@ def _maybe_normalize_tts_wav(output_wav):
|
|
|
100
101
|
)
|
|
101
102
|
os.replace(tmp, output_wav)
|
|
102
103
|
return meta
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Speech units for the duration bound: a CJK character or a digit (half or full width, read
|
|
107
|
+
# one by one as in 二〇二六) is one syllable, "%" is three (百分之), a Latin word about one and
|
|
108
|
+
# a half. Pause marks get a fixed allowance each; "……" is a long pause.
|
|
109
|
+
_CJK_OR_DIGIT = re.compile(r"[\u3007\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff0-9\uff10-\uff19]")
|
|
110
|
+
_PERCENT = re.compile(r"[%\uff05]")
|
|
111
|
+
_PERCENT_UNITS = 3
|
|
112
|
+
_LATIN_WORD = re.compile(r"[A-Za-z]+(?:'[A-Za-z]+)?")
|
|
113
|
+
_LONG_PAUSE = re.compile(r"……|\.\.\.|——")
|
|
114
|
+
_PAUSE_MARK = re.compile(r"[,。!?;:、,.!?;:]")
|
|
115
|
+
TTS_EDGE_SILENCE_SECONDS = 1.5
|
|
116
|
+
TTS_PAUSE_SECONDS = 0.4
|
|
117
|
+
TTS_LONG_PAUSE_SECONDS = 0.8
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def speech_units(text):
|
|
121
|
+
"""Syllable-like units a faithful reading of `text` speaks (pauses not included)."""
|
|
122
|
+
rest = _LONG_PAUSE.sub("", text)
|
|
123
|
+
return (len(_CJK_OR_DIGIT.findall(rest)) + _PERCENT_UNITS * len(_PERCENT.findall(rest))
|
|
124
|
+
+ 1.5 * len(_LATIN_WORD.findall(rest)))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def max_plausible_tts_seconds(text):
|
|
128
|
+
"""Longest a faithful reading of `text` can last, or None when the bound is disabled.
|
|
129
|
+
|
|
130
|
+
Reading every unit at TTS_MIN_SPEECH_RATE (default 2.5 units/s; MiMo's median is ~3.5
|
|
131
|
+
CJK chars/s) plus generous pause and edge-silence allowances. Audio past this is the
|
|
132
|
+
provider reading more than the text: MiMo TTS has returned a sentence followed by
|
|
133
|
+
invented speech at 1.17x this bound, while the slowest of ~100 faithful MiMo segments
|
|
134
|
+
from real runs reached 0.86x."""
|
|
135
|
+
min_rate = CONFIG["tts_min_speech_rate"]
|
|
136
|
+
if min_rate <= 0:
|
|
137
|
+
return None
|
|
138
|
+
long_pauses = len(_LONG_PAUSE.findall(text))
|
|
139
|
+
rest = _LONG_PAUSE.sub("", text)
|
|
140
|
+
return (TTS_EDGE_SILENCE_SECONDS + speech_units(text) / min_rate
|
|
141
|
+
+ TTS_PAUSE_SECONDS * len(_PAUSE_MARK.findall(rest))
|
|
142
|
+
+ TTS_LONG_PAUSE_SECONDS * long_pauses)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def implausible_tts_duration(text, duration):
|
|
146
|
+
"""A user-facing reason when `duration` is too long to be a reading of `text`, else None."""
|
|
147
|
+
limit = max_plausible_tts_seconds(text)
|
|
148
|
+
if limit is None or duration <= limit:
|
|
149
|
+
return None
|
|
150
|
+
return (f"音频 {duration:.1f}s 超过这段文字({len(text)} 字)的合理上限 {limit:.1f}s,"
|
|
151
|
+
"疑似 TTS 多读了原稿以外的内容(幻读),这段音频不会被缓存或交付")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def rejected_take_path(output_wav):
|
|
155
|
+
"""Where the latest take rejected by the bound is kept for listening."""
|
|
156
|
+
output_wav = Path(output_wav)
|
|
157
|
+
return output_wav.with_name(f"{output_wav.stem}.rejected{output_wav.suffix}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def rejected_take_hint(rejected):
|
|
161
|
+
"""Where the last rejected take is, and how to accept such takes; empty when none was kept."""
|
|
162
|
+
if not Path(rejected).is_file():
|
|
163
|
+
return ""
|
|
164
|
+
return (f";最后一次被拒的音频保留在 {rejected},试听确认确实只读了原稿时,"
|
|
165
|
+
"可调低 TTS_MIN_SPEECH_RATE(0 关闭检查)后重跑")
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Content-addressed store for synthesized narration blocks.
|
|
2
|
+
|
|
3
|
+
A block's audio depends on its text and synthesis settings, never on where the block sits in
|
|
4
|
+
narration.json. Entries live in `tts_segments/cache/<digest>.wav` next to a `<digest>.json`
|
|
5
|
+
sidecar recording the exact inputs, the stored WAV's `{size, mtime_ns}` and the facts the
|
|
6
|
+
rerun needs (spoken text, duration, normalization, provider receipt). The index-named
|
|
7
|
+
`narr_NNN.wav` files that tts_meta.json and assemble read are views of an entry: a hard link,
|
|
8
|
+
or a copy where the filesystem has no links. Deleting or inserting a block therefore only
|
|
9
|
+
re-synthesizes the blocks whose own inputs changed.
|
|
10
|
+
"""
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import threading
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from approved_text_policy import write_json_atomically
|
|
19
|
+
from lib import file_identity
|
|
20
|
+
|
|
21
|
+
CACHE_DIR_NAME = "cache"
|
|
22
|
+
_RECORD_KEYS = ("spoken_text", "audio_duration", "normalization", "provider_receipt")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cache_entry(tts_dir, cache_inputs):
|
|
26
|
+
"""(stored WAV, sidecar) paths for one set of synthesis inputs."""
|
|
27
|
+
blob = json.dumps(cache_inputs, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
|
28
|
+
digest = hashlib.sha256(blob.encode("utf-8")).hexdigest()[:32]
|
|
29
|
+
base = Path(tts_dir) / CACHE_DIR_NAME
|
|
30
|
+
return base / f"{digest}.wav", base / f"{digest}.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def legacy_sidecar_path(output_wav):
|
|
34
|
+
"""The per-index sidecar earlier versions wrote; never read (its keys differ), only removed."""
|
|
35
|
+
return Path(str(output_wav) + ".cache.json")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load(tts_dir, cache_inputs):
|
|
39
|
+
"""The stored record for these inputs, or None when absent or its WAV changed since."""
|
|
40
|
+
stored_wav, sidecar = cache_entry(tts_dir, cache_inputs)
|
|
41
|
+
if not sidecar.exists():
|
|
42
|
+
return None
|
|
43
|
+
# This skill wrote the sidecar: one it cannot parse is a bug to surface, not a silent miss
|
|
44
|
+
# that re-bills the provider.
|
|
45
|
+
try:
|
|
46
|
+
data = json.loads(sidecar.read_text(encoding="utf-8"))
|
|
47
|
+
except ValueError as exc:
|
|
48
|
+
raise RuntimeError(f"TTS 缓存 sidecar 损坏: {sidecar}") from exc
|
|
49
|
+
if not isinstance(data, dict) or data.get("settings") != cache_inputs:
|
|
50
|
+
return None
|
|
51
|
+
if not stored_wav.is_file() or stored_wav.stat().st_size == 0:
|
|
52
|
+
return None
|
|
53
|
+
return data if data.get("audio") == file_identity(stored_wav) else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def store(output_wav, cache_inputs, record):
|
|
57
|
+
"""Publish a finished block WAV under its inputs; returns the sidecar payload."""
|
|
58
|
+
stored_wav, sidecar = cache_entry(Path(output_wav).parent, cache_inputs)
|
|
59
|
+
stored_wav.parent.mkdir(exist_ok=True)
|
|
60
|
+
tmp = _tmp_path(stored_wav)
|
|
61
|
+
_link_or_copy(output_wav, tmp)
|
|
62
|
+
# Identity of exactly the bytes being published: if a concurrent worker replaces the entry
|
|
63
|
+
# with another take of the same inputs, the sidecars disagree with the WAV and miss safely.
|
|
64
|
+
identity = file_identity(tmp)
|
|
65
|
+
os.replace(tmp, stored_wav)
|
|
66
|
+
data = {"settings": cache_inputs, "audio": identity,
|
|
67
|
+
**{key: record.get(key) for key in _RECORD_KEYS}}
|
|
68
|
+
write_json_atomically(sidecar, data)
|
|
69
|
+
return data
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def materialize(tts_dir, cache_inputs, output_wav):
|
|
73
|
+
"""Point the index-named `output_wav` at the stored WAV for these inputs."""
|
|
74
|
+
stored_wav, _sidecar = cache_entry(tts_dir, cache_inputs)
|
|
75
|
+
output_wav = Path(output_wav)
|
|
76
|
+
if output_wav.exists():
|
|
77
|
+
try:
|
|
78
|
+
if os.path.samefile(stored_wav, output_wav):
|
|
79
|
+
return
|
|
80
|
+
except OSError:
|
|
81
|
+
pass
|
|
82
|
+
# Anything else is re-linked (or re-copied): {size, mtime_ns} cannot tell two takes of the
|
|
83
|
+
# same length apart when they were written within one timestamp tick, and a stale slot
|
|
84
|
+
# would silently play the wrong block.
|
|
85
|
+
tmp = _tmp_path(output_wav)
|
|
86
|
+
_link_or_copy(stored_wav, tmp)
|
|
87
|
+
os.replace(tmp, output_wav)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _tmp_path(path):
|
|
91
|
+
return path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _link_or_copy(source, destination):
|
|
95
|
+
destination.unlink(missing_ok=True)
|
|
96
|
+
try:
|
|
97
|
+
os.link(source, destination)
|
|
98
|
+
except OSError:
|
|
99
|
+
shutil.copy2(source, destination)
|