@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
|
@@ -7,7 +7,6 @@ import tempfile
|
|
|
7
7
|
import time
|
|
8
8
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
9
9
|
from pathlib import Path
|
|
10
|
-
from threading import Lock
|
|
11
10
|
|
|
12
11
|
from approved_text_policy import (
|
|
13
12
|
PRESERVE_APPROVED_TEXT_POLICY,
|
|
@@ -18,12 +17,10 @@ from approved_text_policy import (
|
|
|
18
17
|
validate_required_texts,
|
|
19
18
|
write_json_atomically as _write_tts_meta_atomically,
|
|
20
19
|
)
|
|
21
|
-
from providers.fish_audio import synthesize_fish_audio
|
|
20
|
+
from providers.fish_audio import fish_speed, synthesize_fish_audio
|
|
22
21
|
import providers.index_tts as index_provider
|
|
23
22
|
from lib import (
|
|
24
23
|
CONFIG,
|
|
25
|
-
_text_char_count,
|
|
26
|
-
_truncate_at_sentence,
|
|
27
24
|
file_identity,
|
|
28
25
|
get_video_duration,
|
|
29
26
|
log,
|
|
@@ -31,21 +28,17 @@ from lib import (
|
|
|
31
28
|
narration_tempo_budget,
|
|
32
29
|
run_cmd,
|
|
33
30
|
)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
import tts_cache
|
|
32
|
+
from tts_audio import (
|
|
33
|
+
_maybe_normalize_tts_wav,
|
|
34
|
+
implausible_tts_duration,
|
|
35
|
+
rejected_take_hint,
|
|
36
|
+
rejected_take_path,
|
|
37
|
+
)
|
|
37
38
|
|
|
38
39
|
SUPPORTED_TTS_ENGINES = {"mimo-tts", "fish-audio", "index-tts"}
|
|
39
40
|
SEGMENT_AUDIO_SCHEMA_VERSION = 1
|
|
40
41
|
VOICE_REFERENCE_PREP_VERSION = 1
|
|
41
|
-
_VOICE_REFERENCE_LOCK = Lock()
|
|
42
|
-
# Process-wide voice-reference state; prepared bytes are invocation-scoped.
|
|
43
|
-
_VOICE_REFERENCE_STATE_KEYS = (
|
|
44
|
-
"voice_ref_b64",
|
|
45
|
-
"voice_ref_snapshot_path",
|
|
46
|
-
"voice_ref_snapshot_signature",
|
|
47
|
-
"voice_ref_snapshot_locked",
|
|
48
|
-
)
|
|
49
42
|
|
|
50
43
|
|
|
51
44
|
def authored_text_policy():
|
|
@@ -122,68 +115,70 @@ def _clean_narration_text(text):
|
|
|
122
115
|
return re.sub(r'\s+', ' ', text).strip()
|
|
123
116
|
|
|
124
117
|
|
|
125
|
-
def _synthesize_segment(i, seg, narration, tts_dir, engine, prepared=None):
|
|
118
|
+
def _synthesize_segment(i, seg, narration, tts_dir, engine, prepared=None, voice_ref_b64=None):
|
|
126
119
|
"""合成单个 TTS 段(线程安全),支持 resume 跳过已有文件。
|
|
127
120
|
|
|
128
121
|
`prepared` is the `_prepare_tts_segment` tuple of a segment synthesize_tts already probed
|
|
129
|
-
as a cache miss; passing it skips a second sidecar read + settings resolution.
|
|
122
|
+
as a cache miss; passing it skips a second sidecar read + settings resolution.
|
|
123
|
+
`voice_ref_b64` is the reference audio synthesize_tts transcoded once for this run."""
|
|
130
124
|
if prepared is None:
|
|
131
125
|
prepared = _prepare_tts_segment(i, seg, narration, tts_dir, engine)
|
|
132
126
|
if prepared is None:
|
|
133
127
|
return None
|
|
134
|
-
cached = _reuse_tts_segment_cache(
|
|
128
|
+
cached = _reuse_tts_segment_cache(
|
|
129
|
+
i, seg, prepared[1], prepared[4], engine, _parse_rate_offset(prepared[2]))
|
|
135
130
|
if cached:
|
|
136
131
|
return cached
|
|
137
132
|
text, output_wav, rate, pitch, cache_inputs = prepared
|
|
138
133
|
|
|
134
|
+
# output_wav may be a hard link into the cache (another block's audio until now): unlink it
|
|
135
|
+
# so the provider writes a new file instead of overwriting that shared audio in place.
|
|
136
|
+
_cleanup_partial_tts_outputs(output_wav)
|
|
139
137
|
provider_receipt = _run_tts_engine(
|
|
140
|
-
engine, text, output_wav, rate=rate, pitch=pitch, emotion=seg.get("emotion")
|
|
138
|
+
engine, text, output_wav, rate=rate, pitch=pitch, emotion=seg.get("emotion"),
|
|
139
|
+
voice_ref_b64=voice_ref_b64, segment_number=i + 1,
|
|
141
140
|
)
|
|
142
141
|
|
|
143
142
|
dur = get_video_duration(output_wav)
|
|
144
|
-
seg_slot = seg["end"] - seg["start"]
|
|
145
|
-
seg_pause = seg.get("pause_after_ms", CONFIG["breath_ms"]) / 1000
|
|
146
|
-
available = max(0.5, seg_slot - seg_pause)
|
|
147
143
|
rate_offset = _parse_rate_offset(rate)
|
|
148
|
-
budget = narration_tempo_budget(rate_offset)
|
|
149
|
-
raw_budget = available * budget["max_raw_duration_factor"]
|
|
150
|
-
truncated = False
|
|
151
|
-
truncate_reason = "none"
|
|
152
144
|
try:
|
|
153
|
-
|
|
145
|
+
_check_segment_window(i, seg, text, dur, rate_offset)
|
|
154
146
|
except ApprovedTextDurationError:
|
|
155
147
|
_cleanup_partial_tts_outputs(output_wav)
|
|
156
148
|
raise
|
|
157
|
-
if dur > raw_budget and len(text) > 5:
|
|
158
|
-
chars_per_sec = _text_char_count(text) / dur
|
|
159
|
-
target_chars = max(5, int(raw_budget * chars_per_sec) - 1)
|
|
160
|
-
shortened = _truncate_at_sentence(text, target_chars)
|
|
161
|
-
if shortened and len(shortened) >= 5 and shortened != text:
|
|
162
|
-
log(f" 段 {i+1}: 解说超出片段时长,按累计语速预算句界缩短 {len(text)}→{len(shortened)} 字以适配(建议在解说里改写得更短)")
|
|
163
|
-
text = shortened
|
|
164
|
-
truncated = True
|
|
165
|
-
truncate_reason = "sentence_boundary"
|
|
166
|
-
provider_receipt = _run_tts_engine(
|
|
167
|
-
engine, text, output_wav, rate=rate, pitch=pitch, emotion=seg.get("emotion")
|
|
168
|
-
)
|
|
169
|
-
dur = get_video_duration(output_wav)
|
|
170
149
|
|
|
171
150
|
norm_meta = _maybe_normalize_tts_wav(output_wav)
|
|
172
151
|
if norm_meta:
|
|
173
152
|
dur = get_video_duration(output_wav)
|
|
174
|
-
_write_tts_segment_cache(output_wav, cache_inputs, text, dur,
|
|
175
|
-
truncated, truncate_reason, norm_meta, provider_receipt)
|
|
153
|
+
_write_tts_segment_cache(output_wav, cache_inputs, text, dur, norm_meta, provider_receipt)
|
|
176
154
|
return _build_tts_segment_result(
|
|
177
|
-
i, seg, text, output_wav, dur, rate_offset,
|
|
178
|
-
|
|
155
|
+
i, seg, text, output_wav, dur, rate_offset, norm_meta, provider_receipt)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _check_segment_window(index, seg, spoken_text, duration, rate_offset):
|
|
159
|
+
"""Fail an approved-text block that overflows its window; log any other overflow."""
|
|
160
|
+
seg_slot = seg["end"] - seg["start"]
|
|
161
|
+
seg_pause = seg.get("pause_after_ms", CONFIG["breath_ms"]) / 1000
|
|
162
|
+
available = max(0.5, seg_slot - seg_pause)
|
|
163
|
+
raw_budget = available * narration_tempo_budget(rate_offset)["max_raw_duration_factor"]
|
|
164
|
+
enforce_approved_text_policy(index, seg, seg["narration"], spoken_text, duration,
|
|
165
|
+
available, raw_budget)
|
|
166
|
+
if duration > raw_budget:
|
|
167
|
+
# Never shorten authored text here: a truncated segment always blocked later as
|
|
168
|
+
# `truncated_speech`. assemble either fits it with bounded tempo or blocks it as
|
|
169
|
+
# `no_safe_fit` before the video encode.
|
|
170
|
+
log(
|
|
171
|
+
f" 段 {index+1}: 超出预算 {duration:.1f}s > {raw_budget:.1f}s,保留原稿,"
|
|
172
|
+
"交由 assemble 有界提速或在渲染前阻断"
|
|
173
|
+
)
|
|
179
174
|
|
|
180
175
|
|
|
181
176
|
def _build_tts_segment_result(index, seg, text, output_wav, duration, rate_offset,
|
|
182
|
-
|
|
183
|
-
provider_receipt=None):
|
|
177
|
+
norm_meta=None, provider_receipt=None):
|
|
184
178
|
budget = narration_tempo_budget(rate_offset)
|
|
185
179
|
authored_text = _clean_narration_text(seg["narration"])
|
|
186
|
-
|
|
180
|
+
# voiceover never shortens text; still flag any spoken/authored mismatch (e.g. a reused sidecar).
|
|
181
|
+
resolved_truncated = text != authored_text
|
|
187
182
|
result = {
|
|
188
183
|
"segment_audio_schema_version": SEGMENT_AUDIO_SCHEMA_VERSION,
|
|
189
184
|
"index": index,
|
|
@@ -193,7 +188,7 @@ def _build_tts_segment_result(index, seg, text, output_wav, duration, rate_offse
|
|
|
193
188
|
"authored_text": seg["narration"],
|
|
194
189
|
"spoken_text": text,
|
|
195
190
|
"truncated": resolved_truncated,
|
|
196
|
-
"truncate_reason":
|
|
191
|
+
"truncate_reason": "sentence_boundary" if resolved_truncated else "none",
|
|
197
192
|
"fit_status": "pending_assembly",
|
|
198
193
|
"audio_path": str(output_wav),
|
|
199
194
|
"audio_duration": duration,
|
|
@@ -261,15 +256,8 @@ def _build_tts_meta(segments, engine, narration_name, failures):
|
|
|
261
256
|
}
|
|
262
257
|
|
|
263
258
|
|
|
264
|
-
def _reset_voice_reference_state():
|
|
265
|
-
"""Drop prepared reference bytes so a reused process re-hashes the live source."""
|
|
266
|
-
for key in _VOICE_REFERENCE_STATE_KEYS:
|
|
267
|
-
CONFIG.pop(key, None)
|
|
268
|
-
|
|
269
|
-
|
|
270
259
|
def synthesize_tts(narration, work_dir):
|
|
271
260
|
"""合成解说音频(并行)。Returns (segments, engine, failures)."""
|
|
272
|
-
_reset_voice_reference_state()
|
|
273
261
|
voice_ref = CONFIG["voice_ref"]
|
|
274
262
|
tts_dir = work_dir / "tts_segments"
|
|
275
263
|
tts_dir.mkdir(exist_ok=True)
|
|
@@ -285,58 +273,80 @@ def synthesize_tts(narration, work_dir):
|
|
|
285
273
|
if cache_engine == "fish-audio":
|
|
286
274
|
raise RuntimeError("Fish Audio 不接受本地 VOICE_REF/--voice-ref;请改用 FISH_TTS_REFERENCE_ID")
|
|
287
275
|
raise RuntimeError("index-tts 不支持本地 VOICE_REF/--voice-ref 克隆")
|
|
288
|
-
# A fully cached narration needs no credential: probe every segment's
|
|
276
|
+
# A fully cached narration needs no credential: probe every segment's cache once here;
|
|
289
277
|
# hits are final and only misses reach the workers (with their prepared inputs).
|
|
290
278
|
segments = []
|
|
291
279
|
misses = []
|
|
280
|
+
failures = []
|
|
292
281
|
for i, seg in enumerate(narration):
|
|
293
282
|
prepared = _prepare_tts_segment(i, seg, narration, tts_dir, cache_engine)
|
|
294
283
|
if prepared is None:
|
|
295
284
|
continue
|
|
296
|
-
|
|
285
|
+
try:
|
|
286
|
+
cached = _reuse_tts_segment_cache(
|
|
287
|
+
i, seg, prepared[1], prepared[4], cache_engine, _parse_rate_offset(prepared[2]))
|
|
288
|
+
except ApprovedTextDurationError as e:
|
|
289
|
+
failures.append(_tts_failure_record(i, seg, e))
|
|
290
|
+
continue
|
|
297
291
|
if cached:
|
|
298
292
|
segments.append(cached)
|
|
299
293
|
else:
|
|
300
294
|
misses.append((i, seg, prepared))
|
|
301
|
-
if not segments and not misses:
|
|
295
|
+
if not segments and not misses and not failures:
|
|
302
296
|
raise RuntimeError("narration.json 没有可配音的有效文本,已中止以避免生成无解说视频")
|
|
303
|
-
if
|
|
304
|
-
|
|
297
|
+
if misses:
|
|
298
|
+
engine = _synthesize_misses(misses, narration, tts_dir, segments, failures)
|
|
299
|
+
else:
|
|
300
|
+
engine = cache_engine
|
|
305
301
|
log(f"TTS 引擎: {cache_engine} (cache)")
|
|
306
|
-
|
|
302
|
+
return _finish_tts(narration, segments, engine, failures)
|
|
303
|
+
|
|
307
304
|
|
|
305
|
+
def _synthesize_misses(misses, narration, tts_dir, segments, failures):
|
|
306
|
+
"""Synthesize the cache misses in parallel into `segments` / `failures`; returns the engine."""
|
|
308
307
|
engine = resolve_tts_engine()
|
|
308
|
+
voice_ref = CONFIG["voice_ref"]
|
|
309
309
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
310
|
+
# Transcode the reference once per run, and only now: a fully cached rerun never runs
|
|
311
|
+
# ffmpeg. The misses' cache keys hold the identity probed above, so a reference edited
|
|
312
|
+
# meanwhile would label new audio with the old identity; fail instead.
|
|
313
|
+
voice_ref_b64 = None
|
|
314
|
+
if voice_ref and engine == "mimo-tts":
|
|
315
|
+
probed_identity = misses[0][2][4]["settings"]["voice_ref_identity"] # [4] = cache inputs
|
|
316
|
+
voice_ref_b64 = _prepare_voice_reference(voice_ref)
|
|
317
|
+
if _voice_reference_signature(voice_ref) != probed_identity:
|
|
318
|
+
raise RuntimeError(f"参考音频在配音期间被修改: {voice_ref};请重新运行")
|
|
313
319
|
|
|
314
320
|
log(f"TTS 引擎: {engine}")
|
|
315
321
|
|
|
316
|
-
failures = []
|
|
317
322
|
max_workers = min(len(misses), CONFIG["tts_workers"])
|
|
318
323
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
for
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
324
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
325
|
+
futures = {
|
|
326
|
+
executor.submit(
|
|
327
|
+
_synthesize_segment, i, seg, narration, tts_dir, engine, prepared,
|
|
328
|
+
voice_ref_b64=voice_ref_b64,
|
|
329
|
+
): i
|
|
330
|
+
for i, seg, prepared in misses
|
|
331
|
+
}
|
|
332
|
+
for future in as_completed(futures):
|
|
333
|
+
try:
|
|
334
|
+
result = future.result()
|
|
335
|
+
except Exception as e:
|
|
336
|
+
i = futures[future]
|
|
337
|
+
failures.append(_tts_failure_record(i, narration[i], e))
|
|
338
|
+
log(f" TTS 段 {i+1} 失败: {e}")
|
|
339
|
+
continue
|
|
340
|
+
if result:
|
|
341
|
+
segments.append(result)
|
|
342
|
+
log(f" 段 {result['index']+1}: {result['audio_duration']:.1f}s - {result['narration'][:25]}...")
|
|
343
|
+
return engine
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _finish_tts(narration, segments, engine, failures):
|
|
347
|
+
"""Apply the failure policy to one run's results; returns (segments, engine, failures)."""
|
|
339
348
|
segments.sort(key=lambda x: x["index"])
|
|
349
|
+
failures.sort(key=lambda x: x["index"])
|
|
340
350
|
approved_text_failures = [
|
|
341
351
|
failure for failure in failures
|
|
342
352
|
if failure.get("failure_kind") == "approved_text_duration_conflict"
|
|
@@ -363,10 +373,10 @@ def synthesize_tts(narration, work_dir):
|
|
|
363
373
|
f"示例: {sample}。如确需继续,可设置 ALLOW_PARTIAL_TTS=1 或 --allow-partial-tts。"
|
|
364
374
|
)
|
|
365
375
|
if failures:
|
|
366
|
-
missing = "
|
|
376
|
+
missing = "、".join(f"段 {f['index'] + 1}" for f in failures[:8])
|
|
367
377
|
more = "…" if len(failures) > 8 else ""
|
|
368
378
|
log(
|
|
369
|
-
f"警告: TTS 部分失败 {len(failures)}/{len(narration)}
|
|
379
|
+
f"警告: TTS 部分失败 {len(failures)}/{len(narration)} 段({missing}{more}),"
|
|
370
380
|
"成片可预览但不建议直接发布;详见 tts_meta.json failures"
|
|
371
381
|
)
|
|
372
382
|
if not segments:
|
|
@@ -374,36 +384,51 @@ def synthesize_tts(narration, work_dir):
|
|
|
374
384
|
return segments, engine, failures
|
|
375
385
|
|
|
376
386
|
|
|
377
|
-
def _run_tts_engine(engine, text, output_wav, rate="+0%", pitch="+0Hz", emotion=None
|
|
387
|
+
def _run_tts_engine(engine, text, output_wav, rate="+0%", pitch="+0Hz", emotion=None,
|
|
388
|
+
voice_ref_b64=None, segment_number=None):
|
|
378
389
|
"""Run one TTS engine with retry and remove partial files after failures.
|
|
379
390
|
|
|
380
391
|
`engine` comes from resolve_tts_engine; index-tts controls were already forced to the
|
|
381
|
-
provider defaults by _prepare_tts_segment.
|
|
392
|
+
provider defaults by _prepare_tts_segment. A take rejected as implausibly long is kept as
|
|
393
|
+
`<name>.rejected.wav` (the latest one) so the user can listen before relaxing the bound."""
|
|
382
394
|
retries = CONFIG["tts_retries"]
|
|
383
395
|
last_error = None
|
|
396
|
+
rejected = rejected_take_path(output_wav)
|
|
397
|
+
rejected.unlink(missing_ok=True)
|
|
398
|
+
label = f"段 {segment_number} " if segment_number is not None else ""
|
|
384
399
|
|
|
385
400
|
for attempt in range(1, retries + 1):
|
|
386
401
|
try:
|
|
387
402
|
_cleanup_partial_tts_outputs(output_wav)
|
|
388
403
|
receipt = None
|
|
389
404
|
if engine == "mimo-tts":
|
|
390
|
-
_tts_mimo(text, output_wav, rate=rate, pitch=pitch, emotion=emotion
|
|
405
|
+
_tts_mimo(text, output_wav, rate=rate, pitch=pitch, emotion=emotion,
|
|
406
|
+
voice_ref_b64=voice_ref_b64)
|
|
391
407
|
elif engine == "fish-audio":
|
|
392
408
|
synthesize_fish_audio(text, output_wav, rate=rate)
|
|
393
409
|
else:
|
|
394
410
|
receipt = index_provider.synthesize_configured(text, output_wav, CONFIG)
|
|
395
|
-
|
|
411
|
+
duration = get_video_duration(output_wav)
|
|
412
|
+
if duration <= 0:
|
|
396
413
|
raise RuntimeError(f"{engine} 输出音频时长无效")
|
|
414
|
+
# A hallucinated reading (the text plus invented speech) is a failed attempt: it is
|
|
415
|
+
# retried, never cached, and never reaches assemble as an over-budget block.
|
|
416
|
+
implausible = implausible_tts_duration(text, duration)
|
|
417
|
+
if implausible:
|
|
418
|
+
os.replace(output_wav, rejected)
|
|
419
|
+
raise RuntimeError(implausible)
|
|
420
|
+
rejected.unlink(missing_ok=True)
|
|
397
421
|
return receipt
|
|
398
422
|
except Exception as exc:
|
|
399
423
|
last_error = exc
|
|
400
424
|
_cleanup_partial_tts_outputs(output_wav)
|
|
401
425
|
if attempt < retries:
|
|
402
426
|
wait = min(2 ** (attempt - 1), 8)
|
|
403
|
-
log(f" TTS 重试 {attempt+1}/{retries}: {exc},等待 {wait}s")
|
|
427
|
+
log(f" {label}TTS 重试 {attempt+1}/{retries}: {exc},等待 {wait}s")
|
|
404
428
|
time.sleep(wait)
|
|
405
429
|
|
|
406
|
-
|
|
430
|
+
hint = rejected_take_hint(rejected)
|
|
431
|
+
raise RuntimeError(f"{engine} 合成失败: {last_error}{hint}") from last_error
|
|
407
432
|
|
|
408
433
|
|
|
409
434
|
def _cleanup_partial_tts_outputs(output_wav):
|
|
@@ -413,15 +438,11 @@ def _cleanup_partial_tts_outputs(output_wav):
|
|
|
413
438
|
wav_path,
|
|
414
439
|
wav_path.with_suffix(".mp3"),
|
|
415
440
|
Path(str(wav_path) + ".part"),
|
|
416
|
-
|
|
441
|
+
tts_cache.legacy_sidecar_path(output_wav),
|
|
417
442
|
):
|
|
418
443
|
path.unlink(missing_ok=True)
|
|
419
444
|
|
|
420
445
|
|
|
421
|
-
def _tts_segment_cache_path(output_wav):
|
|
422
|
-
return Path(str(output_wav) + ".cache.json")
|
|
423
|
-
|
|
424
|
-
|
|
425
446
|
def _prepare_tts_segment(index, seg, narration, tts_dir, engine):
|
|
426
447
|
text = _clean_narration_text(seg["narration"])
|
|
427
448
|
if not text:
|
|
@@ -429,20 +450,33 @@ def _prepare_tts_segment(index, seg, narration, tts_dir, engine):
|
|
|
429
450
|
output_wav = tts_dir / f"narr_{index:03d}.wav"
|
|
430
451
|
if engine == "index-tts":
|
|
431
452
|
rate, pitch = index_provider.default_controls(seg)
|
|
432
|
-
elif CONFIG["tts_dynamic_params"]:
|
|
433
|
-
rate, pitch = _compute_tts_params(text, narration, index)
|
|
434
453
|
else:
|
|
435
|
-
rate, pitch =
|
|
436
|
-
cache_inputs = _tts_segment_cache_inputs(engine,
|
|
454
|
+
rate, pitch = _compute_tts_params(text, narration, index)
|
|
455
|
+
cache_inputs = _tts_segment_cache_inputs(engine, seg, text, rate, pitch)
|
|
437
456
|
return text, output_wav, rate, pitch, cache_inputs
|
|
438
457
|
|
|
439
458
|
|
|
440
|
-
def _reuse_tts_segment_cache(index, seg, output_wav, cache_inputs, engine):
|
|
441
|
-
|
|
459
|
+
def _reuse_tts_segment_cache(index, seg, output_wav, cache_inputs, engine, rate_offset):
|
|
460
|
+
"""A result from the content-addressed cache, with `output_wav` pointing at its audio.
|
|
461
|
+
|
|
462
|
+
`rate_offset` is this block's own nominal rate. The key holds only what the provider
|
|
463
|
+
receives, so a take synthesized at another nominal rate that produced the same request
|
|
464
|
+
is reused, and reports this block's rate exactly as a fresh synthesis would."""
|
|
465
|
+
cached = tts_cache.load(output_wav.parent, cache_inputs)
|
|
442
466
|
if cached is None:
|
|
443
467
|
return None
|
|
444
468
|
if engine == "index-tts" and not index_provider.valid_cached_receipt(cached, CONFIG):
|
|
445
469
|
return None
|
|
470
|
+
implausible = implausible_tts_duration(cached["spoken_text"], cached["audio_duration"])
|
|
471
|
+
if implausible:
|
|
472
|
+
# Written before this bound existed: re-synthesize instead of blocking every rerun.
|
|
473
|
+
log(f" 段 {index+1}: 不复用缓存,重新合成:{implausible}")
|
|
474
|
+
return None
|
|
475
|
+
# The window is not part of the key (it never changes the audio), so an approved-text
|
|
476
|
+
# block moved into a shorter window is re-checked here instead of re-synthesized.
|
|
477
|
+
_check_segment_window(index, seg, cached["spoken_text"], cached["audio_duration"],
|
|
478
|
+
rate_offset)
|
|
479
|
+
tts_cache.materialize(output_wav.parent, cache_inputs, output_wav)
|
|
446
480
|
# The sidecar's audio identity (size, mtime_ns) still matches the WAV that produced
|
|
447
481
|
# `audio_duration`; re-probing would be one ffprobe process per segment on every rerun.
|
|
448
482
|
log(f" 段 {index+1}: 复用已有 ({cached['audio_duration']:.1f}s)")
|
|
@@ -452,26 +486,36 @@ def _reuse_tts_segment_cache(index, seg, output_wav, cache_inputs, engine):
|
|
|
452
486
|
cached["spoken_text"],
|
|
453
487
|
output_wav,
|
|
454
488
|
cached["audio_duration"],
|
|
455
|
-
|
|
456
|
-
cached["truncated"],
|
|
457
|
-
cached["truncate_reason"],
|
|
489
|
+
rate_offset,
|
|
458
490
|
cached["normalization"],
|
|
459
491
|
cached.get("provider_receipt"),
|
|
460
492
|
)
|
|
461
493
|
|
|
462
494
|
|
|
463
|
-
def
|
|
464
|
-
"""The
|
|
495
|
+
def _provider_prosody_request(engine, rate, pitch, emotion):
|
|
496
|
+
"""The prosody part of the request the provider actually receives for one block.
|
|
497
|
+
|
|
498
|
+
MiMo gets a natural-language instruction in which rate offsets only change the wording
|
|
499
|
+
at >= +6% or <= -3% (pitch only as zero/non-zero); Fish Audio gets a numeric speed and
|
|
500
|
+
ignores pitch and emotion; index-tts takes no per-block controls."""
|
|
501
|
+
if engine == "mimo-tts":
|
|
502
|
+
return {"instruction": _mimo_tts_style_instruction(rate, pitch, emotion)}
|
|
503
|
+
if engine == "fish-audio":
|
|
504
|
+
return {"speed": fish_speed(rate)}
|
|
505
|
+
return {}
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _tts_segment_cache_inputs(engine, seg, source_text, rate, pitch):
|
|
509
|
+
"""The exact inputs that make cached audio safe to reuse (compared by equality).
|
|
510
|
+
|
|
511
|
+
Only what changes the audio: text, the prosody request the provider receives, and
|
|
512
|
+
provider/voice settings. The block's position and window are left out, so deleting or
|
|
513
|
+
inserting a block reuses every other block's audio; a block whose position changes its
|
|
514
|
+
nominal rate is re-synthesized only if the provider would receive a different request."""
|
|
465
515
|
payload = {
|
|
466
516
|
"engine": engine,
|
|
467
517
|
"source_text": source_text,
|
|
468
|
-
"
|
|
469
|
-
"start": round(seg["start"], 3),
|
|
470
|
-
"end": round(seg["end"], 3),
|
|
471
|
-
"pause_after_ms": seg.get("pause_after_ms", CONFIG["breath_ms"]),
|
|
472
|
-
"rate": rate,
|
|
473
|
-
"pitch": pitch,
|
|
474
|
-
"emotion": seg.get("emotion", ""),
|
|
518
|
+
"provider_request": _provider_prosody_request(engine, rate, pitch, seg.get("emotion")),
|
|
475
519
|
"settings": tts_settings_payload(engine),
|
|
476
520
|
}
|
|
477
521
|
if authored_text_policy() == PRESERVE_APPROVED_TEXT_POLICY:
|
|
@@ -483,42 +527,15 @@ def _tts_segment_cache_inputs(engine, index, seg, source_text, rate, pitch):
|
|
|
483
527
|
return payload
|
|
484
528
|
|
|
485
529
|
|
|
486
|
-
def
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
data = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
496
|
-
except ValueError as exc:
|
|
497
|
-
raise RuntimeError(f"TTS 缓存 sidecar 损坏: {cache_path}") from exc
|
|
498
|
-
if not isinstance(data, dict) or "settings" not in data or "audio" not in data:
|
|
499
|
-
return None
|
|
500
|
-
stale = data["settings"] != cache_inputs or data["audio"] != file_identity(output_wav)
|
|
501
|
-
return None if stale else data
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
def _write_tts_segment_cache(output_wav, cache_inputs, spoken_text, duration, rate_offset,
|
|
505
|
-
truncated=False, truncate_reason="none", norm_meta=None,
|
|
506
|
-
provider_receipt=None):
|
|
507
|
-
"""Persist non-secret inputs and the WAV identity for safe per-segment TTS reuse."""
|
|
508
|
-
_tts_segment_cache_path(output_wav).write_text(
|
|
509
|
-
json.dumps({
|
|
510
|
-
"settings": cache_inputs,
|
|
511
|
-
"audio": file_identity(output_wav),
|
|
512
|
-
"spoken_text": spoken_text,
|
|
513
|
-
"audio_duration": duration,
|
|
514
|
-
"tts_rate_offset": rate_offset,
|
|
515
|
-
"truncated": truncated,
|
|
516
|
-
"truncate_reason": truncate_reason if truncated else "none",
|
|
517
|
-
"normalization": norm_meta or None,
|
|
518
|
-
"provider_receipt": provider_receipt,
|
|
519
|
-
}, ensure_ascii=False, indent=2),
|
|
520
|
-
encoding="utf-8",
|
|
521
|
-
)
|
|
530
|
+
def _write_tts_segment_cache(output_wav, cache_inputs, spoken_text, duration,
|
|
531
|
+
norm_meta=None, provider_receipt=None):
|
|
532
|
+
"""Store the finished block WAV under its non-secret synthesis inputs for reuse."""
|
|
533
|
+
tts_cache.store(output_wav, cache_inputs, {
|
|
534
|
+
"spoken_text": spoken_text,
|
|
535
|
+
"audio_duration": duration,
|
|
536
|
+
"normalization": norm_meta or None,
|
|
537
|
+
"provider_receipt": provider_receipt,
|
|
538
|
+
})
|
|
522
539
|
|
|
523
540
|
|
|
524
541
|
def _configured_tts_engine_for_cache():
|
|
@@ -555,7 +572,6 @@ def tts_settings_payload(engine):
|
|
|
555
572
|
"""Return non-secret TTS settings that materially affect generated audio."""
|
|
556
573
|
settings = {
|
|
557
574
|
"engine": engine,
|
|
558
|
-
"tts_dynamic_params": CONFIG["tts_dynamic_params"],
|
|
559
575
|
"narration_speed": CONFIG["narration_speed"],
|
|
560
576
|
"narration_cumulative_tempo_max": CONFIG["narration_cumulative_tempo_max"],
|
|
561
577
|
"narration_cumulative_tempo_hard_max": CONFIG["narration_cumulative_tempo_hard_max"],
|
|
@@ -587,7 +603,7 @@ def tts_settings_payload(engine):
|
|
|
587
603
|
if voice_ref and engine == "mimo-tts":
|
|
588
604
|
ref_path = Path(voice_ref).expanduser()
|
|
589
605
|
settings.pop("mimo_tts_voice", None) # ignored by the voiceclone API
|
|
590
|
-
settings["voice_ref_identity"] =
|
|
606
|
+
settings["voice_ref_identity"] = _voice_reference_signature(ref_path)
|
|
591
607
|
settings["voice_ref_preparation"] = (
|
|
592
608
|
f"pcm_s16le:24000hz:mono:30s:v{VOICE_REFERENCE_PREP_VERSION}"
|
|
593
609
|
)
|
|
@@ -634,52 +650,15 @@ def _voice_reference_signature(ref_path):
|
|
|
634
650
|
return {"path": str(ref), **file_identity(ref)}
|
|
635
651
|
|
|
636
652
|
|
|
637
|
-
def
|
|
638
|
-
"""Identity for cache settings: the locked snapshot's while a run is in flight, else live."""
|
|
639
|
-
resolved = str(Path(ref_path).expanduser().resolve())
|
|
640
|
-
if (
|
|
641
|
-
CONFIG.get("voice_ref_snapshot_locked")
|
|
642
|
-
and CONFIG.get("voice_ref_b64")
|
|
643
|
-
and CONFIG.get("voice_ref_snapshot_path") == resolved
|
|
644
|
-
and CONFIG.get("voice_ref_snapshot_signature")
|
|
645
|
-
):
|
|
646
|
-
return CONFIG["voice_ref_snapshot_signature"]
|
|
647
|
-
return _voice_reference_signature(ref_path)
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
def _cache_prepared_voice_reference(ref_path):
|
|
651
|
-
with _VOICE_REFERENCE_LOCK:
|
|
652
|
-
ref = Path(ref_path).expanduser().resolve()
|
|
653
|
-
resolved = str(ref)
|
|
654
|
-
if (
|
|
655
|
-
CONFIG.get("voice_ref_snapshot_locked")
|
|
656
|
-
and CONFIG.get("voice_ref_b64")
|
|
657
|
-
and CONFIG.get("voice_ref_snapshot_path") == resolved
|
|
658
|
-
):
|
|
659
|
-
return CONFIG["voice_ref_b64"]
|
|
660
|
-
if not ref.is_file():
|
|
661
|
-
raise FileNotFoundError(f"参考音频不存在或不是文件: {ref}")
|
|
662
|
-
signature = _voice_reference_signature(ref)
|
|
663
|
-
if (
|
|
664
|
-
CONFIG.get("voice_ref_b64")
|
|
665
|
-
and CONFIG.get("voice_ref_snapshot_path") == resolved
|
|
666
|
-
and CONFIG.get("voice_ref_snapshot_signature") == signature
|
|
667
|
-
):
|
|
668
|
-
return CONFIG["voice_ref_b64"]
|
|
669
|
-
encoded = _prepare_voice_reference(ref)
|
|
670
|
-
CONFIG["voice_ref_b64"] = encoded
|
|
671
|
-
CONFIG["voice_ref_snapshot_path"] = resolved
|
|
672
|
-
CONFIG["voice_ref_snapshot_signature"] = signature
|
|
673
|
-
return encoded
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
def _tts_mimo(text, output_path, rate="+0%", pitch="+0Hz", emotion=None):
|
|
653
|
+
def _tts_mimo(text, output_path, rate="+0%", pitch="+0Hz", emotion=None, voice_ref_b64=None):
|
|
677
654
|
"""使用 Xiaomi MiMo-V2.5-TTS 合成,按需用参考音频克隆音色。
|
|
678
655
|
|
|
679
656
|
MiMo-v2.5-tts 是 instruct-TTS:user 消息里的自然语言指令控制整句的情绪/语气/语速。
|
|
680
|
-
每段 narration 的 `emotion` 标签即写进该指令,让解说有起伏、不机械。
|
|
657
|
+
每段 narration 的 `emotion` 标签即写进该指令,让解说有起伏、不机械。
|
|
658
|
+
`voice_ref_b64` is the run's prepared reference; a direct call transcodes the live source."""
|
|
681
659
|
voice_ref = CONFIG["voice_ref"]
|
|
682
|
-
|
|
660
|
+
if voice_ref and voice_ref_b64 is None:
|
|
661
|
+
voice_ref_b64 = _prepare_voice_reference(voice_ref)
|
|
683
662
|
payload = {
|
|
684
663
|
"model": "mimo-v2.5-tts-voiceclone" if voice_ref else CONFIG["mimo_tts_model"],
|
|
685
664
|
"messages": [
|
|
@@ -741,10 +720,8 @@ def main():
|
|
|
741
720
|
ap.error("--mimo-voice and --voice-ref are mutually exclusive")
|
|
742
721
|
if args.mimo_voice and args.tts_provider in {"fish-audio", "index-tts"}:
|
|
743
722
|
ap.error("--mimo-voice is only supported by the MiMo TTS provider")
|
|
744
|
-
# A local --voice-ref with fish-audio/index-tts is rejected by synthesize_tts
|
|
745
|
-
#
|
|
746
|
-
# invoke ffmpeg. _tts_mimo uses a process-wide lock so a fresh parallel run still converts
|
|
747
|
-
# the reference exactly once.
|
|
723
|
+
# A local --voice-ref with fish-audio/index-tts is rejected by synthesize_tts, which also
|
|
724
|
+
# transcodes the reference lazily: once per run, and never for a fully cached rerun.
|
|
748
725
|
if args.allow_partial_tts:
|
|
749
726
|
CONFIG["allow_partial_tts"] = True
|
|
750
727
|
if args.narration:
|