@oh-story/dsh 0.1.9 → 0.1.11
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 +14 -14
- package/lib/client.js +209 -73
- package/lib/client.js.map +3 -3
- package/lib/drama/manifest.json +248 -168
- package/lib/drama/skills/short-drama/SKILL.md +16 -4
- package/lib/drama/skills/short-drama/assets/project-template/short-drama.json +3 -0
- package/lib/drama/skills/short-drama/references/creator-documents.md +3 -0
- package/lib/drama/skills/short-drama/references/knowhow-index.md +15 -0
- package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +14 -0
- package/lib/drama/skills/short-drama/scripts/project_tool.py +131 -0
- package/lib/drama/skills/short-drama-assets/SKILL.md +2 -1
- package/lib/drama/skills/short-drama-assets/references/asset-review-checklist.md +2 -0
- package/lib/drama/skills/short-drama-assets/references/character-and-look.md +10 -0
- package/lib/drama/skills/short-drama-assets/references/continuity-lock.md +6 -9
- package/lib/drama/skills/short-drama-assets/references/era-anchors.md +37 -0
- package/lib/drama/skills/short-drama-assets/references/stage-contract.md +2 -0
- package/lib/drama/skills/short-drama-assets/references/voice-direction.md +28 -0
- package/lib/drama/skills/short-drama-develop/SKILL.md +10 -4
- package/lib/drama/skills/short-drama-develop/assets/adaptation-map.example.jsonl +1 -1
- package/lib/drama/skills/short-drama-develop/assets/creative-brief.md +8 -0
- package/lib/drama/skills/short-drama-develop/references/adaptation-craft.md +80 -13
- package/lib/drama/skills/short-drama-develop/references/episode-design.md +95 -12
- package/lib/drama/skills/short-drama-develop/references/genre-and-hook-playbook.md +9 -5
- package/lib/drama/skills/short-drama-develop/references/genre-cards//345/244/215/344/273/207/346/211/223/350/204/270.md +1 -1
- package/lib/drama/skills/short-drama-develop/references/genre-cards//347/263/273/347/273/237/346/265/201.md +51 -0
- package/lib/drama/skills/short-drama-develop/references/genre-cards//351/200/206/350/242/255.md +51 -0
- package/lib/drama/skills/short-drama-develop/references/genre-cards//351/207/215/347/224/237/347/251/277/350/266/212.md +51 -0
- package/lib/drama/skills/short-drama-develop/references/genre-cards.md +10 -7
- package/lib/drama/skills/short-drama-develop/references/premise-devices.md +15 -9
- package/lib/drama/skills/short-drama-develop/references/serial-character-and-memory.md +3 -2
- package/lib/drama/skills/short-drama-develop/references/stage-contract.md +14 -7
- package/lib/drama/skills/short-drama-develop/references/story-craft.md +7 -3
- package/lib/drama/skills/short-drama-edit/SKILL.md +65 -44
- package/lib/drama/skills/short-drama-edit/assets/remotion/README.md +49 -13
- package/lib/drama/skills/short-drama-edit/assets/remotion/package.json +4 -2
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/Overlay.tsx +31 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/Root.tsx +5 -5
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/Subtitles.tsx +68 -69
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/font.ts +39 -29
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/rules.mjs +188 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/schema.ts +26 -13
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/screen/Card.tsx +72 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/screen/CornerChip.tsx +39 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/screen/ScreenText.tsx +36 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/screen/SystemPanel.tsx +44 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/screen/TaskPanel.tsx +100 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/screen/chrome.tsx +197 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/src/screen/tokens.ts +62 -0
- package/lib/drama/skills/short-drama-edit/assets/remotion/tsconfig.json +1 -0
- package/lib/drama/skills/short-drama-edit/assets//345/211/252/350/276/221/345/215/225.md +6 -4
- package/lib/drama/skills/short-drama-edit/references/cut-craft.md +26 -67
- package/lib/drama/skills/short-drama-edit/references/delivery-verify.md +31 -56
- package/lib/drama/skills/short-drama-edit/references/generated-footage.md +42 -78
- package/lib/drama/skills/short-drama-edit/references/sound-and-subtitles.md +44 -92
- package/lib/drama/skills/short-drama-edit/references/stage-contract.md +14 -16
- package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +1272 -125
- package/lib/drama/skills/short-drama-edit/scripts/selftest.py +113 -9
- package/lib/drama/skills/short-drama-image-prompts/SKILL.md +2 -0
- package/lib/drama/skills/short-drama-image-prompts/references/common-recipe.md +11 -0
- package/lib/drama/skills/short-drama-novel-analyze/SKILL.md +3 -3
- package/lib/drama/skills/short-drama-novel-analyze/assets/episode-candidate.example.jsonl +2 -2
- package/lib/drama/skills/short-drama-novel-analyze/references/adaptation-triage.md +8 -4
- package/lib/drama/skills/short-drama-novel-analyze/references/adaptation-value.md +10 -3
- package/lib/drama/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +25 -1
- package/lib/drama/skills/short-drama-novel-analyze/references/stage-contract.md +1 -0
- package/lib/drama/skills/short-drama-produce/SKILL.md +5 -1
- package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +14 -20
- package/lib/drama/skills/short-drama-produce/references/providers/minimax-h3-video.md +8 -26
- package/lib/drama/skills/short-drama-produce/references/providers/minimax-speech.md +41 -0
- package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +142 -1
- package/lib/drama/skills/short-drama-review/SKILL.md +4 -5
- package/lib/drama/skills/short-drama-review/references/production-quality-gates.md +6 -19
- package/lib/drama/skills/short-drama-review/references/review-method.md +6 -1
- package/lib/drama/skills/short-drama-review/references/rubric-assets-prompts.md +11 -0
- package/lib/drama/skills/short-drama-review/references/rubric-story-script.md +77 -3
- package/lib/drama/skills/short-drama-review/references/rubric-visual-motion.md +24 -35
- package/lib/drama/skills/short-drama-review/references/stage-contract.md +3 -0
- package/lib/drama/skills/short-drama-storyboard/SKILL.md +23 -11
- package/lib/drama/skills/short-drama-storyboard/references/blocking-playbooks.md +1 -1
- package/lib/drama/skills/short-drama-storyboard/references/lighting-craft.md +39 -0
- package/lib/drama/skills/short-drama-storyboard/references/production-shot-grammar.md +2 -2
- package/lib/drama/skills/short-drama-storyboard/references/shot-craft.md +139 -34
- package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +5 -3
- package/lib/drama/skills/short-drama-video-prompts/SKILL.md +15 -12
- package/lib/drama/skills/short-drama-video-prompts/references/camera-audio-continuity.md +29 -0
- package/lib/drama/skills/short-drama-video-prompts/references/delivery-profile.md +4 -5
- package/lib/drama/skills/short-drama-video-prompts/references/generability.md +4 -2
- package/lib/drama/skills/short-drama-video-prompts/references/minimax-h3.md +52 -39
- package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +24 -21
- package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +9 -23
- package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +14 -26
- package/lib/drama/skills/short-drama-video-prompts/references/review-and-fixtures.md +1 -1
- package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.0.md +2 -3
- package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +34 -9
- package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +3 -2
- package/lib/drama/skills/short-drama-video-prompts/references/target-model-profile.md +8 -1
- package/lib/drama/skills/short-drama-video-prompts/references/wan-3.0.md +103 -0
- package/lib/drama/skills/short-drama-video-prompts/scripts/motion_timing_check.py +8 -7
- package/lib/drama/skills/short-drama-write/SKILL.md +10 -7
- package/lib/drama/skills/short-drama-write/references/dialogue-craft.md +1 -1
- package/lib/drama/skills/short-drama-write/references/script-craft.md +56 -3
- package/lib/drama/skills/short-drama-write/references/stage-contract.md +4 -0
- package/lib/drama/skills/short-drama-write/scripts/duration_estimate.py +48 -2
- package/lib/index.js +346 -98
- package/lib/index.js.map +4 -4
- package/lib/novel-to-game/README.md +299 -59
- package/lib/novel-to-game/README_ZH.md +299 -56
- package/lib/novel-to-game/VERSION +1 -1
- package/lib/novel-to-game/examples/jin-ping-mei/build/BUILD_BRIEF.md +3 -1
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/RUN.md +11 -3
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/new-guofeng.css +397 -8
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/css/style.css +0 -31
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/data.js +71 -73
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/engine.js +78 -89
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/main.js +37 -14
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/text.js +5 -5
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/package.json +2 -1
- package/lib/novel-to-game/examples/jin-ping-mei/qa/README.md +11 -16
- package/lib/novel-to-game/examples/jin-ping-mei/qa/verification.json +11 -11
- package/lib/novel-to-game/examples/jin-ping-mei/qa/verify_playable_model.mjs +23 -0
- package/lib/novel-to-game/manifest.json +77 -87
- package/lib/novel-to-game/skills/game-art-direction/SKILL.md +7 -12
- package/lib/novel-to-game/skills/game-art-direction/references/art-direction-method.md +27 -106
- package/lib/novel-to-game/skills/game-build/SKILL.md +13 -14
- package/lib/novel-to-game/skills/game-build/references/blender-asset-workflow.md +24 -0
- package/lib/novel-to-game/skills/game-build/references/build-brief-contract.md +45 -82
- package/lib/novel-to-game/skills/game-build/references/generative-media-pipeline.md +14 -45
- package/lib/novel-to-game/skills/game-build/references/playable-model-contract.md +30 -65
- package/lib/novel-to-game/skills/game-build/references/tts-production-contract.md +13 -27
- package/lib/novel-to-game/skills/game-concept/SKILL.md +13 -15
- package/lib/novel-to-game/skills/game-concept/references/concept-method.md +28 -75
- package/lib/novel-to-game/skills/game-qa/SKILL.md +6 -7
- package/lib/novel-to-game/skills/game-qa/references/test-design-method.md +9 -5
- package/lib/novel-to-game/skills/game-world-design/SKILL.md +12 -16
- package/lib/novel-to-game/skills/game-world-design/references/narrative-design-method.md +23 -72
- package/lib/novel-to-game/skills/game-world-design/references/numeric-design-method.md +14 -48
- package/lib/novel-to-game/skills/game-world-design/references/world-design-method.md +36 -103
- package/lib/novel-to-game/skills/novel-game-analyze/SKILL.md +4 -10
- package/lib/novel-to-game/skills/novel-game-analyze/references/gameability-protocol.md +4 -4
- package/lib/novel-to-game/skills/novel-to-game/SKILL.md +9 -21
- package/lib/novel-to-game/skills/novel-to-game/references/intake-method.md +5 -4
- package/lib/novel-to-game/skills/novel-to-game/references/pipeline-contract.md +5 -8
- package/lib/oh-story/manifest.json +607 -327
- package/lib/oh-story/roles/chapter-extractor.md +177 -237
- package/lib/oh-story/roles/character-designer.md +1 -2
- package/lib/oh-story/roles/consistency-checker.md +15 -13
- package/lib/oh-story/roles/narrative-writer.md +36 -38
- package/lib/oh-story/roles/story-architect.md +9 -61
- package/lib/oh-story/roles/story-explorer.md +20 -125
- package/lib/oh-story/roles/story-researcher.md +0 -1
- package/lib/oh-story/skills/story/SKILL.md +15 -9
- package/lib/oh-story/skills/story/VERSION +1 -1
- package/lib/oh-story/skills/story/references/author-memory-maintenance.md +75 -0
- package/lib/oh-story/skills/story/references/author-memory.md +46 -94
- package/lib/oh-story/skills/story/scripts/author_memory_commit.py +934 -170
- package/lib/oh-story/skills/story-cover/SKILL.md +10 -1
- package/lib/oh-story/skills/story-deslop/SKILL.md +59 -73
- package/lib/oh-story/skills/story-deslop/references/agent-calls.md +5 -0
- package/lib/oh-story/skills/story-deslop/references/anti-ai-writing.md +12 -11
- package/lib/oh-story/skills/story-deslop/references/author-memory-maintenance.md +75 -0
- package/lib/oh-story/skills/story-deslop/references/author-memory.md +46 -94
- package/lib/oh-story/skills/story-deslop/references/banned-words.md +2 -2
- package/lib/oh-story/skills/story-deslop/references/deslop-gates.md +22 -5
- package/lib/oh-story/skills/story-deslop/references/style-resolution.md +4 -4
- package/lib/oh-story/skills/story-deslop/scripts/author_memory_commit.py +934 -170
- package/lib/oh-story/skills/story-deslop/scripts/check-ai-patterns.js +155 -8
- package/lib/oh-story/skills/story-import/SKILL.md +64 -584
- package/lib/oh-story/skills/story-import/references/character-state-reverse.md +1 -1
- package/lib/oh-story/skills/story-import/references/deep-analysis.md +47 -0
- package/lib/oh-story/skills/story-import/references/format-and-structure.md +1 -1
- package/lib/oh-story/skills/story-import/references/import-report.md +64 -0
- package/lib/oh-story/skills/story-import/references/import-special-cases.md +34 -0
- package/lib/oh-story/skills/story-import/references/import-tracking.md +95 -0
- package/lib/oh-story/skills/story-import/references/length-routing.md +1 -1
- package/lib/oh-story/skills/story-import/references/outline-reverse-rules.md +79 -0
- package/lib/oh-story/skills/story-import/references/outline-reverse.md +24 -0
- package/lib/oh-story/skills/story-import/references/state-tracking.md +4 -69
- package/lib/oh-story/skills/story-import/references/structure-mapping-long.md +98 -92
- package/lib/oh-story/skills/story-import/references/structure-mapping-short.md +24 -12
- package/lib/oh-story/skills/story-import/references/tracking-initialization.md +1 -1
- package/lib/oh-story/skills/story-import/references/tracking-transaction.md +15 -11
- package/lib/oh-story/skills/story-import/scripts/build_outline_brief.py +332 -0
- package/lib/oh-story/skills/story-import/scripts/check-outline-contract.js +132 -27
- package/lib/oh-story/skills/story-import/scripts/tracking_commit.py +478 -15
- package/lib/oh-story/skills/story-import/scripts/wordcount_core.py +182 -23
- package/lib/oh-story/skills/story-long-analyze/SKILL.md +81 -270
- package/lib/oh-story/skills/story-long-analyze/references/author-facing.md +252 -0
- package/lib/oh-story/skills/story-long-analyze/references/deconstruction-notes.md +3 -3
- package/lib/oh-story/skills/story-long-analyze/references/final-checks.md +19 -0
- package/lib/oh-story/skills/story-long-analyze/references/index-rebuild.md +11 -0
- package/lib/oh-story/skills/story-long-analyze/references/inspiration-library.md +105 -0
- package/lib/oh-story/skills/story-long-analyze/references/pipeline-ops.md +138 -64
- package/lib/oh-story/skills/story-long-analyze/references/semantic-acceptance-fixtures.md +84 -0
- package/lib/oh-story/skills/story-long-analyze/references/stage1-golden-chapters.md +128 -0
- package/lib/oh-story/skills/story-long-analyze/references/stage2-extraction.md +229 -0
- package/lib/oh-story/skills/story-long-analyze/references/stage3-plot-rhythm.md +480 -0
- package/lib/oh-story/skills/story-long-analyze/references/stage4-characters-settings.md +195 -0
- package/lib/oh-story/skills/story-long-analyze/references/stage5-report.md +33 -0
- package/lib/oh-story/skills/story-long-analyze/references/style-profile-generator.md +55 -128
- package/lib/oh-story/skills/story-long-analyze/references/style-profile-protocol.md +6 -6
- package/lib/oh-story/skills/story-long-analyze/references/synthesis-inputs.md +39 -0
- package/lib/oh-story/skills/story-long-analyze/scripts/build_chapter_index.py +466 -0
- package/lib/oh-story/skills/story-long-analyze/scripts/inspect_existing_assets.py +702 -0
- package/lib/oh-story/skills/story-long-analyze/scripts/inspiration_index.py +864 -0
- package/lib/oh-story/skills/story-long-analyze/scripts/manage_analysis_run.py +1145 -0
- package/lib/oh-story/skills/story-long-analyze/scripts/render_relation_chart.py +425 -0
- package/lib/oh-story/skills/story-long-scan/SKILL.md +45 -194
- package/lib/oh-story/skills/story-long-scan/references/platform-ciweimao.md +19 -0
- package/lib/oh-story/skills/story-long-scan/references/platform-fanqie.md +45 -0
- package/lib/oh-story/skills/story-long-scan/references/platform-jjwxc.md +28 -0
- package/lib/oh-story/skills/story-long-scan/references/platform-qidian.md +35 -0
- package/lib/oh-story/skills/story-long-scan/references/platform-qimao.md +27 -0
- package/lib/oh-story/skills/story-long-scan/references/topic-decision.md +6 -2
- package/lib/oh-story/skills/story-long-write/SKILL.md +49 -81
- package/lib/oh-story/skills/story-long-write/references/agent-calls.md +47 -0
- package/lib/oh-story/skills/story-long-write/references/anti-ai-writing.md +12 -11
- package/lib/oh-story/skills/story-long-write/references/artifact-protocols.md +24 -202
- package/lib/oh-story/skills/story-long-write/references/author-memory-maintenance.md +75 -0
- package/lib/oh-story/skills/story-long-write/references/author-memory.md +46 -94
- package/lib/oh-story/skills/story-long-write/references/banned-words.md +2 -2
- package/lib/oh-story/skills/story-long-write/references/benchmark-recall.md +7 -11
- package/lib/oh-story/skills/story-long-write/references/character-relations.md +49 -1
- package/lib/oh-story/skills/story-long-write/references/cross-book-recall.md +26 -0
- package/lib/oh-story/skills/story-long-write/references/deslop-gates.md +171 -0
- package/lib/oh-story/skills/story-long-write/references/dialogue-mastery.md +4 -0
- package/lib/oh-story/skills/story-long-write/references/emotion-on-page.md +116 -0
- package/lib/oh-story/skills/story-long-write/references/emotional-arc-design.md +1 -114
- package/lib/oh-story/skills/story-long-write/references/emotional-methods.md +2 -2
- package/lib/oh-story/skills/story-long-write/references/genre-prose-cards.md +1 -1
- package/lib/oh-story/skills/story-long-write/references/long-chapter-quality.md +7 -15
- package/lib/oh-story/skills/story-long-write/references/long-format.md +0 -8
- package/lib/oh-story/skills/story-long-write/references/long-genre-catalog.md +1 -1
- package/lib/oh-story/skills/story-long-write/references/long-genre-mechanics.md +3 -3
- package/lib/oh-story/skills/story-long-write/references/opening-design.md +2 -2
- package/lib/oh-story/skills/story-long-write/references/outline-conflict.md +4 -4
- package/lib/oh-story/skills/story-long-write/references/outline-methods.md +7 -7
- package/lib/oh-story/skills/story-long-write/references/outline-rhythm.md +3 -3
- package/lib/oh-story/skills/story-long-write/references/outline-structure-theory.md +14 -69
- package/lib/oh-story/skills/story-long-write/references/plot-frameworks.md +1 -1
- package/lib/oh-story/skills/story-long-write/references/plot-special-topics.md +2 -2
- package/lib/oh-story/skills/story-long-write/references/project-files.md +19 -13
- package/lib/oh-story/skills/story-long-write/references/reader-contract-and-progression.md +13 -5
- package/lib/oh-story/skills/story-long-write/references/reference-index.md +5 -6
- package/lib/oh-story/skills/story-long-write/references/stop-and-fix.md +9 -0
- package/lib/oh-story/skills/story-long-write/references/style-combat-face.md +1 -58
- package/lib/oh-story/skills/story-long-write/references/style-genre-modules.md +1 -1
- package/lib/oh-story/skills/story-long-write/references/style-resolution.md +4 -4
- package/lib/oh-story/skills/story-long-write/references/tracking-initialization.md +1 -1
- package/lib/oh-story/skills/story-long-write/references/tracking-transaction.md +15 -11
- package/lib/oh-story/skills/story-long-write/references/workflow-chapter.md +81 -121
- package/lib/oh-story/skills/story-long-write/references/workflow-daily.md +41 -139
- package/lib/oh-story/skills/story-long-write/references/workflow-outline.md +157 -0
- package/lib/oh-story/skills/story-long-write/references/workflow-revision.md +57 -67
- package/lib/oh-story/skills/story-long-write/references/workflow-setup.md +112 -253
- package/lib/oh-story/skills/story-long-write/references/workflow-volume.md +142 -0
- package/lib/oh-story/skills/story-long-write/references/writer-prompt-fallback.md +6 -4
- package/lib/oh-story/skills/story-long-write/references/writing-craft.md +19 -65
- package/lib/oh-story/skills/story-long-write/scripts/author_memory_commit.py +934 -170
- package/lib/oh-story/skills/story-long-write/scripts/build_architect_brief.py +260 -0
- package/lib/oh-story/skills/story-long-write/scripts/build_writer_prompt.py +226 -25
- package/lib/oh-story/skills/story-long-write/scripts/check-ai-patterns.js +155 -8
- package/lib/oh-story/skills/story-long-write/scripts/check-outline-contract.js +132 -27
- package/lib/oh-story/skills/story-long-write/scripts/check-outline-copy.js +7 -2
- package/lib/oh-story/skills/story-long-write/scripts/outline_view.py +7 -4
- package/lib/oh-story/skills/story-long-write/scripts/storyctl.py +304 -46
- package/lib/oh-story/skills/story-long-write/scripts/tracking_commit.py +478 -15
- package/lib/oh-story/skills/story-long-write/scripts/wordcount_core.py +182 -23
- package/lib/oh-story/skills/story-review/SKILL.md +31 -302
- package/lib/oh-story/skills/story-review/references/agent-prompts.md +184 -0
- package/lib/oh-story/skills/story-review/references/anti-ai-writing.md +12 -11
- package/lib/oh-story/skills/story-review/references/author-memory-maintenance.md +75 -0
- package/lib/oh-story/skills/story-review/references/author-memory.md +46 -94
- package/lib/oh-story/skills/story-review/references/banned-words.md +2 -2
- package/lib/oh-story/skills/story-review/references/batch-review.md +19 -0
- package/lib/oh-story/skills/story-review/references/character-relations.md +49 -1
- package/lib/oh-story/skills/story-review/references/dialogue-mastery.md +4 -0
- package/lib/oh-story/skills/story-review/references/review-quality.md +6 -11
- package/lib/oh-story/skills/story-review/references/review-tracking.md +10 -0
- package/lib/oh-story/skills/story-review/references/solo.md +52 -0
- package/lib/oh-story/skills/story-review/references/style-resolution.md +4 -4
- package/lib/oh-story/skills/story-review/references/tracking-initialization.md +1 -1
- package/lib/oh-story/skills/story-review/references/tracking-transaction.md +15 -11
- package/lib/oh-story/skills/story-review/scripts/author_memory_commit.py +934 -170
- package/lib/oh-story/skills/story-review/scripts/check-ai-patterns.js +155 -8
- package/lib/oh-story/skills/story-review/scripts/tracking_commit.py +478 -15
- package/lib/oh-story/skills/story-review/scripts/wordcount_core.py +182 -23
- package/lib/oh-story/skills/story-setup/SKILL.md +71 -452
- package/lib/oh-story/skills/story-setup/references/agent-references/agent-quality.md +4 -4
- package/lib/oh-story/skills/story-setup/references/agent-references/anti-ai-writing.md +12 -11
- package/lib/oh-story/skills/story-setup/references/agent-references/banned-words.md +2 -2
- package/lib/oh-story/skills/story-setup/references/agent-references/benchmark-style-load.md +107 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/character-relations.md +49 -1
- package/lib/oh-story/skills/story-setup/references/agent-references/deslop-gates.md +22 -5
- package/lib/oh-story/skills/story-setup/references/agent-references/dialogue-mastery.md +4 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/emotion-on-page.md +116 -0
- package/lib/oh-story/skills/story-setup/references/agent-references/emotional-arc-design.md +1 -114
- package/lib/oh-story/skills/story-setup/references/agent-references/format-and-structure.md +1 -1
- package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards.md +1 -1
- package/lib/oh-story/skills/story-setup/references/agent-references/long-emotional-methods.md +2 -2
- package/lib/oh-story/skills/story-setup/references/agent-references/long-genre-catalog.md +1 -1
- package/lib/oh-story/skills/story-setup/references/agent-references/long-genre-mechanics.md +3 -3
- package/lib/oh-story/skills/story-setup/references/agent-references/long-quality.md +7 -5
- package/lib/oh-story/skills/story-setup/references/agent-references/opening-design.md +2 -2
- package/lib/oh-story/skills/story-setup/references/agent-references/outline-conflict.md +4 -4
- package/lib/oh-story/skills/story-setup/references/agent-references/outline-methods.md +7 -7
- package/lib/oh-story/skills/story-setup/references/agent-references/outline-rhythm.md +3 -3
- package/lib/oh-story/skills/story-setup/references/agent-references/short-emotional-methods.md +1 -1
- package/lib/oh-story/skills/story-setup/references/agent-references/short-genre-formulas.md +0 -15
- package/lib/oh-story/skills/story-setup/references/agent-references/short-reversal.md +1 -1
- package/lib/oh-story/skills/story-setup/references/agent-references/style-combat-face.md +1 -58
- package/lib/oh-story/skills/story-setup/references/agent-references/style-genre-modules.md +1 -1
- package/lib/oh-story/skills/story-setup/references/agent-references/style-resolution.md +4 -4
- package/lib/oh-story/skills/story-setup/references/agent-references/writing-craft.md +19 -65
- package/lib/oh-story/skills/story-setup/references/diagnostics.md +4 -4
- package/lib/oh-story/skills/story-short-analyze/SKILL.md +56 -110
- package/lib/oh-story/skills/story-short-analyze/references/analysis-character-relations.md +1 -1
- package/lib/oh-story/skills/story-short-analyze/references/analysis-method.md +42 -0
- package/lib/oh-story/skills/story-short-analyze/references/optional-modules.md +97 -0
- package/lib/oh-story/skills/story-short-analyze/references/output-contract.md +9 -47
- package/lib/oh-story/skills/story-short-analyze/references/quality-checklist.md +80 -0
- package/lib/oh-story/skills/story-short-analyze/references/source-story-quality.md +4 -4
- package/lib/oh-story/skills/story-short-analyze/references/stage2-3-structure-emotion.md +308 -0
- package/lib/oh-story/skills/story-short-analyze/references/stage4-6-reversal-summary.md +611 -0
- package/lib/oh-story/skills/story-short-scan/SKILL.md +35 -65
- package/lib/oh-story/skills/story-short-scan/references/manual-rank-input.md +36 -0
- package/lib/oh-story/skills/story-short-scan/references/platform-dianzhong.md +24 -0
- package/lib/oh-story/skills/story-short-scan/references/platform-heiyan.md +21 -0
- package/lib/oh-story/skills/story-short-scan/references/topic-match.md +38 -0
- package/lib/oh-story/skills/story-short-write/SKILL.md +23 -175
- package/lib/oh-story/skills/story-short-write/references/agent-calls.md +13 -0
- package/lib/oh-story/skills/story-short-write/references/author-memory-maintenance.md +75 -0
- package/lib/oh-story/skills/story-short-write/references/author-memory.md +46 -94
- package/lib/oh-story/skills/story-short-write/references/banned-words.md +2 -2
- package/lib/oh-story/skills/story-short-write/references/benchmark-recall.md +1 -21
- package/lib/oh-story/skills/story-short-write/references/dialogue-mastery.md +4 -0
- package/lib/oh-story/skills/story-short-write/references/emotional-methods.md +1 -1
- package/lib/oh-story/skills/story-short-write/references/genre-writing-formulas.md +0 -15
- package/lib/oh-story/skills/story-short-write/references/output-contract.md +9 -47
- package/lib/oh-story/skills/story-short-write/references/reference-index.md +17 -47
- package/lib/oh-story/skills/story-short-write/references/scene-craft.md +40 -0
- package/lib/oh-story/skills/story-short-write/references/short-craft.md +7 -43
- package/lib/oh-story/skills/story-short-write/references/short-format.md +3 -3
- package/lib/oh-story/skills/story-short-write/references/short-prose-quality.md +2 -1
- package/lib/oh-story/skills/story-short-write/references/short-reversal.md +1 -1
- package/lib/oh-story/skills/story-short-write/references/style-resolution.md +4 -4
- package/lib/oh-story/skills/story-short-write/references/submission-craft.md +1 -1
- package/lib/oh-story/skills/story-short-write/references/villain-and-reveal.md +5 -10
- package/lib/oh-story/skills/story-short-write/references/workflow-design.md +21 -13
- package/lib/oh-story/skills/story-short-write/references/workflow-draft.md +116 -0
- package/lib/oh-story/skills/story-short-write/references/workflow-revision.md +99 -0
- package/lib/oh-story/skills/story-short-write/references/writing-workflow.md +7 -119
- package/lib/oh-story/skills/story-short-write/scripts/author_memory_commit.py +934 -170
- package/lib/oh-story/skills/story-short-write/scripts/check-ai-patterns.js +155 -8
- package/lib/oh-story/skills/story-short-write/scripts/check-outline-copy.js +7 -2
- package/lib/types/client/drama-production-view.d.ts.map +1 -1
- package/lib/types/client/file-activity.d.ts +19 -1
- package/lib/types/client/file-activity.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/production-prompts.d.ts +7 -0
- package/lib/types/client/production-prompts.d.ts.map +1 -1
- package/lib/types/client/production-runtime.d.ts +16 -0
- package/lib/types/client/production-runtime.d.ts.map +1 -1
- package/lib/types/client/video-studio.d.ts.map +1 -1
- package/lib/types/drama-adapters.d.ts +5 -3
- package/lib/types/drama-adapters.d.ts.map +1 -1
- package/lib/types/native-hooks.d.ts +53 -1
- package/lib/types/native-hooks.d.ts.map +1 -1
- package/lib/types/role-identity.d.ts +17 -0
- package/lib/types/role-identity.d.ts.map +1 -0
- package/lib/types/role-tool.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/types/workspace-route.d.ts.map +1 -1
- package/lib/video-recap/CHANGELOG.md +60 -1
- package/lib/video-recap/README.en.md +348 -137
- package/lib/video-recap/README.md +340 -136
- package/lib/video-recap/manifest.json +406 -251
- package/lib/video-recap/skills/video-assemble/SKILL.md +30 -15
- package/lib/video-recap/skills/video-assemble/references/audio-modes.md +63 -0
- package/lib/video-recap/skills/video-assemble/references/explicit-audio-mix.md +75 -0
- package/lib/video-recap/skills/video-assemble/references/foreground-compose.md +118 -0
- package/lib/video-recap/skills/video-assemble/references/narration-adoption.md +103 -0
- package/lib/video-recap/skills/video-assemble/references/packaging.md +45 -0
- package/lib/video-recap/skills/video-assemble/references/pair-media.md +74 -0
- package/lib/video-recap/skills/video-assemble/references/source-score.md +147 -0
- package/lib/video-recap/skills/video-assemble/references/subtitle-track.md +202 -0
- package/lib/video-recap/skills/video-assemble/scripts/adoption/__init__.py +1 -0
- package/lib/video-recap/skills/video-assemble/scripts/adoption/audio_mix_binding.py +279 -0
- package/lib/video-recap/skills/video-assemble/scripts/adoption/frozen_audio.py +187 -0
- package/lib/video-recap/skills/video-assemble/scripts/adoption/narration_binding.py +323 -0
- package/lib/video-recap/skills/video-assemble/scripts/adoption/strict_inputs.py +109 -0
- package/lib/video-recap/skills/video-assemble/scripts/adoption/strict_publish.py +130 -0
- package/lib/video-recap/skills/video-assemble/scripts/artifacts.py +10 -46
- package/lib/video-recap/skills/video-assemble/scripts/assemble.py +378 -119
- package/lib/video-recap/skills/video-assemble/scripts/assemble_constants.py +29 -12
- package/lib/video-recap/skills/video-assemble/scripts/assembly_contract.py +95 -44
- package/lib/video-recap/skills/video-assemble/scripts/assembly_settings.py +75 -32
- package/lib/video-recap/skills/video-assemble/scripts/audio_mix.py +11 -11
- package/lib/video-recap/skills/video-assemble/scripts/compose_foreground.py +293 -0
- package/lib/video-recap/skills/video-assemble/scripts/export_jianying.py +71 -78
- package/lib/video-recap/skills/video-assemble/scripts/jianying/__init__.py +1 -0
- package/lib/video-recap/skills/video-assemble/scripts/{jianying_builders.py → jianying/builders.py} +39 -67
- package/lib/video-recap/skills/video-assemble/scripts/{jianying_model.py → jianying/model.py} +3 -11
- package/lib/video-recap/skills/video-assemble/scripts/{jianying_optional.py → jianying/optional.py} +1 -1
- package/lib/video-recap/skills/video-assemble/scripts/jianying/schema.py +53 -0
- package/lib/video-recap/skills/video-assemble/scripts/{jianying_templates.py → jianying/templates.py} +1 -1
- package/lib/video-recap/skills/video-assemble/scripts/{jianying_timeline_contract.py → jianying/timeline_contract.py} +34 -13
- package/lib/video-recap/skills/video-assemble/scripts/{jianying_writer.py → jianying/writer.py} +7 -17
- package/lib/video-recap/skills/video-assemble/scripts/lib.py +37 -4
- package/lib/video-recap/skills/video-assemble/scripts/media.py +12 -21
- package/lib/video-recap/skills/video-assemble/scripts/narration_audio.py +64 -34
- package/lib/video-recap/skills/video-assemble/scripts/packaging.py +122 -0
- package/lib/video-recap/skills/video-assemble/scripts/pair_media.py +216 -0
- package/lib/video-recap/skills/video-assemble/scripts/render_preflight.py +1 -3
- package/lib/video-recap/skills/video-assemble/scripts/source_score.py +469 -0
- package/lib/video-recap/skills/video-assemble/scripts/source_subtitles.py +25 -9
- package/lib/video-recap/skills/video-assemble/scripts/subtitles/__init__.py +1 -0
- package/lib/video-recap/skills/video-assemble/scripts/{subtitle_core.py → subtitles/core.py} +13 -6
- package/lib/video-recap/skills/video-assemble/scripts/{subtitle_render.py → subtitles/render.py} +5 -4
- package/lib/video-recap/skills/video-assemble/scripts/subtitles/track.py +324 -0
- package/lib/video-recap/skills/video-assemble/scripts/subtitles/track_binding.py +200 -0
- package/lib/video-recap/skills/video-assemble/scripts/timeline.py +30 -46
- package/lib/video-recap/skills/video-assemble/scripts/timeline_emit.py +91 -7
- package/lib/video-recap/skills/video-assemble/scripts/visual_render.py +22 -8
- package/lib/video-recap/skills/video-cut/SKILL.md +34 -16
- package/lib/video-recap/skills/video-cut/references/shot-review.md +50 -0
- package/lib/video-recap/skills/video-cut/scripts/cut.py +2 -28
- package/lib/video-recap/skills/video-cut/scripts/cut_cli.py +58 -62
- package/lib/video-recap/skills/video-cut/scripts/cut_contract.py +21 -106
- package/lib/video-recap/skills/video-cut/scripts/cut_render.py +14 -9
- package/lib/video-recap/skills/video-cut/scripts/lib.py +39 -0
- package/lib/video-recap/skills/video-cut/scripts/narration_mapping.py +2 -155
- package/lib/video-recap/skills/video-cut/scripts/narrative_selection.py +244 -0
- package/lib/video-recap/skills/video-cut/scripts/shot_review.py +357 -0
- package/lib/video-recap/skills/video-recap/SKILL.md +86 -42
- package/lib/video-recap/skills/video-recap/references/audio-routing.md +126 -0
- package/lib/video-recap/skills/video-recap/references/config-playbook.md +12 -8
- package/lib/video-recap/skills/video-recap/references/data-schema.md +44 -33
- package/lib/video-recap/skills/video-recap/references/resource-library.md +151 -0
- package/lib/video-recap/skills/video-recap/references/shift-left-qc-schema.md +1 -1
- package/lib/video-recap/skills/video-recap/scripts/doctor.py +123 -30
- package/lib/video-recap/skills/video-recap/scripts/final_qc.py +22 -75
- package/lib/video-recap/skills/video-recap/scripts/lib.py +3 -3
- package/lib/video-recap/skills/video-recap/scripts/library.py +509 -0
- package/lib/video-recap/skills/video-recap/scripts/materials.py +63 -91
- package/lib/video-recap/skills/video-recap/scripts/mimo_qc.py +8 -70
- package/lib/video-recap/skills/video-recap/scripts/project_binding.py +286 -0
- package/lib/video-recap/skills/video-recap/scripts/qc/__init__.py +1 -0
- package/lib/video-recap/skills/video-recap/scripts/{mimo_qc_client.py → qc/mimo_client.py} +1 -1
- package/lib/video-recap/skills/video-recap/scripts/{mimo_qc_evidence.py → qc/mimo_evidence.py} +33 -66
- package/lib/video-recap/skills/video-recap/scripts/{mimo_qc_observations.py → qc/mimo_observations.py} +3 -11
- package/lib/video-recap/skills/video-recap/scripts/{mimo_qc_payload.py → qc/mimo_payload.py} +4 -7
- package/lib/video-recap/skills/video-recap/scripts/{mimo_qc_report.py → qc/mimo_report.py} +17 -28
- package/lib/video-recap/skills/video-recap/scripts/{mimo_qc_runner.py → qc/mimo_runner.py} +2 -2
- package/lib/video-recap/skills/video-recap/scripts/qc_contract.py +0 -19
- package/lib/video-recap/skills/video-recap/scripts/recap_cli.py +119 -50
- package/lib/video-recap/skills/video-recap/scripts/recap_inspect.py +32 -114
- package/lib/video-recap/skills/video-recap/scripts/recap_review.py +9 -23
- package/lib/video-recap/skills/video-recap/scripts/recap_runner.py +387 -334
- package/lib/video-recap/skills/video-recap/scripts/recap_runtime.py +23 -23
- package/lib/video-recap/skills/video-recap/scripts/recap_source.py +278 -0
- package/lib/video-recap/skills/video-recap/scripts/recap_stage_qc.py +25 -12
- package/lib/video-recap/skills/video-recap/scripts/recap_timeline.py +59 -52
- package/lib/video-recap/skills/video-recap/scripts/resource_lock.py +162 -0
- package/lib/video-recap/skills/video-script/SKILL.md +16 -13
- package/lib/video-recap/skills/video-script/references/creative-editing-playbook.md +9 -5
- package/lib/video-recap/skills/video-script/references/promotional-copy.md +68 -0
- package/lib/video-recap/skills/video-script/references/research-guide.md +1 -1
- package/lib/video-recap/skills/video-script/scripts/evidence_bundle.py +24 -76
- package/lib/video-recap/skills/video-script/scripts/lib.py +10 -87
- package/lib/video-recap/skills/video-script/scripts/narration_lint.py +63 -39
- package/lib/video-recap/skills/video-script/scripts/review.py +0 -2
- package/lib/video-recap/skills/video-script/scripts/review_grounding.py +35 -59
- package/lib/video-recap/skills/video-script/scripts/review_response.py +66 -128
- package/lib/video-recap/skills/video-script/scripts/review_runner.py +12 -20
- package/lib/video-recap/skills/video-script/scripts/speech_ownership.py +4 -4
- package/lib/video-recap/skills/video-script/scripts/validate.py +53 -42
- package/lib/video-recap/skills/video-understanding/SKILL.md +6 -4
- package/lib/video-recap/skills/video-understanding/references/data-schema.md +50 -156
- package/lib/video-recap/skills/video-understanding/references/research-guide.md +4 -4
- package/lib/video-recap/skills/video-understanding/scripts/asr.py +85 -65
- package/lib/video-recap/skills/video-understanding/scripts/asr_timing_evidence.py +270 -0
- package/lib/video-recap/skills/video-understanding/scripts/brief.py +2 -5
- package/lib/video-recap/skills/video-understanding/scripts/briefing/__init__.py +1 -0
- package/lib/video-recap/skills/{video-script/scripts/agent_brief.py → video-understanding/scripts/briefing/builder.py} +38 -35
- package/lib/video-recap/skills/video-understanding/scripts/{brief_context.py → briefing/context.py} +4 -35
- package/lib/video-recap/skills/{video-script/scripts/brief_inputs.py → video-understanding/scripts/briefing/inputs.py} +9 -31
- package/lib/video-recap/skills/{video-script/scripts/brief_timeline.py → video-understanding/scripts/briefing/timeline.py} +5 -6
- package/lib/video-recap/skills/video-understanding/scripts/consolidate.py +65 -116
- package/lib/video-recap/skills/video-understanding/scripts/detect.py +31 -40
- package/lib/video-recap/skills/video-understanding/scripts/lib.py +30 -86
- package/lib/video-recap/skills/video-understanding/scripts/narration_lint.py +63 -39
- package/lib/video-recap/skills/video-understanding/scripts/speech_ownership.py +4 -4
- package/lib/video-recap/skills/video-understanding/scripts/storyboard.py +17 -79
- package/lib/video-recap/skills/video-understanding/scripts/understanding_brief.py +14 -40
- package/lib/video-recap/skills/video-understanding/scripts/understanding_cache.py +64 -46
- package/lib/video-recap/skills/video-understanding/scripts/understanding_runner.py +31 -11
- package/lib/video-recap/skills/video-understanding/scripts/understanding_storyboard.py +19 -14
- package/lib/video-recap/skills/video-understanding/scripts/vlm.py +80 -108
- package/lib/video-recap/skills/video-voiceover/SKILL.md +29 -19
- package/lib/video-recap/skills/video-voiceover/references/index-tts.md +31 -0
- package/lib/video-recap/skills/video-voiceover/scripts/approved_text_policy.py +123 -0
- package/lib/video-recap/skills/video-voiceover/scripts/dub.py +51 -59
- package/lib/video-recap/skills/video-voiceover/scripts/lib.py +8 -44
- package/lib/video-recap/skills/video-voiceover/scripts/providers/__init__.py +1 -0
- package/lib/video-recap/skills/video-voiceover/scripts/providers/index_tts.py +184 -0
- package/lib/video-recap/skills/video-voiceover/scripts/voiceover.py +212 -134
- package/package.json +45 -45
- package/lib/novel-to-game/skills/game-world-design/references/dialogue-design-method.md +0 -58
- package/lib/novel-to-game/skills/game-world-design/references/game-writing-craft.md +0 -53
- package/lib/novel-to-game/skills/novel-to-game/references/intake-benchmark-reference.md +0 -38
- package/lib/oh-story/skills/story-long-analyze/references/material-decomposition.md +0 -530
- package/lib/oh-story/skills/story-long-analyze/references/output-templates.md +0 -559
- package/lib/oh-story/skills/story-long-scan/references/scan-output-format.md +0 -207
- package/lib/oh-story/skills/story-long-write/references/state-tracking.md +0 -108
- package/lib/oh-story/skills/story-short-analyze/references/material-decomposition.md +0 -678
- package/lib/oh-story/skills/story-short-analyze/references/output-templates.md +0 -561
- package/lib/video-recap/skills/video-assemble/scripts/jianying_schema.py +0 -98
- package/lib/video-recap/skills/video-recap/references/creative-editing-playbook.md +0 -225
- package/lib/video-recap/skills/video-recap/references/research-guide.md +0 -74
- package/lib/video-recap/skills/video-recap/references/timeline-and-jianying.md +0 -250
- package/lib/video-recap/skills/video-script/scripts/brief_context.py +0 -348
- package/lib/video-recap/skills/video-script/scripts/narration.py +0 -12
- package/lib/video-recap/skills/video-understanding/scripts/agent_brief.py +0 -399
- package/lib/video-recap/skills/video-understanding/scripts/brief_inputs.py +0 -290
- package/lib/video-recap/skills/video-understanding/scripts/brief_timeline.py +0 -327
- /package/lib/video-recap/skills/video-assemble/scripts/{jianying_tracks.py → jianying/tracks.py} +0 -0
- /package/lib/video-recap/skills/video-recap/scripts/{mimo_qc_contract.py → qc/mimo_contract.py} +0 -0
- /package/lib/video-recap/skills/video-voiceover/scripts/{fish_audio.py → providers/fish_audio.py} +0 -0
|
@@ -5,7 +5,8 @@ Three subcommands, deliberately separated so a report can never borrow one's
|
|
|
5
5
|
evidence for another's claim:
|
|
6
6
|
|
|
7
7
|
``check`` parse and cross-check ``剪辑单.md`` against the project. No rendering.
|
|
8
|
-
``render`` cut, join, burn subtitles and
|
|
8
|
+
``render`` cut, join, mix sound effects, burn subtitles and screen text, and
|
|
9
|
+
normalize loudness into 制作成果/成片/.
|
|
9
10
|
``verify`` measure an already-rendered film and print the numbers.
|
|
10
11
|
|
|
11
12
|
``verify`` prints measurements, never verdicts. Whether the film is any good is
|
|
@@ -16,13 +17,14 @@ from __future__ import annotations
|
|
|
16
17
|
|
|
17
18
|
import argparse
|
|
18
19
|
import json
|
|
20
|
+
import operator
|
|
19
21
|
import re
|
|
20
22
|
import shutil
|
|
21
23
|
import subprocess
|
|
22
24
|
import sys
|
|
23
25
|
import tempfile
|
|
24
26
|
from pathlib import Path
|
|
25
|
-
from typing import Any, NamedTuple, Optional, Sequence
|
|
27
|
+
from typing import Any, Callable, NamedTuple, Optional, Sequence, Union
|
|
26
28
|
|
|
27
29
|
MINIMUM_PYTHON = (3, 9)
|
|
28
30
|
if sys.version_info < MINIMUM_PYTHON:
|
|
@@ -53,11 +55,20 @@ REMOTION_SOURCE_FILES = (
|
|
|
53
55
|
"src/index.ts",
|
|
54
56
|
"src/schema.ts",
|
|
55
57
|
"src/Root.tsx",
|
|
58
|
+
"src/Overlay.tsx",
|
|
56
59
|
"src/Subtitles.tsx",
|
|
57
60
|
"src/font.ts",
|
|
61
|
+
"src/rules.mjs",
|
|
62
|
+
"src/screen/ScreenText.tsx",
|
|
63
|
+
"src/screen/tokens.ts",
|
|
64
|
+
"src/screen/chrome.tsx",
|
|
65
|
+
"src/screen/Card.tsx",
|
|
66
|
+
"src/screen/SystemPanel.tsx",
|
|
67
|
+
"src/screen/TaskPanel.tsx",
|
|
68
|
+
"src/screen/CornerChip.tsx",
|
|
58
69
|
)
|
|
59
70
|
DEFAULT_REMOTION_WORKSPACE = Path.home() / ".cache" / "short-drama-edit" / "remotion"
|
|
60
|
-
REMOTION_COMPOSITION = "
|
|
71
|
+
REMOTION_COMPOSITION = "Overlay"
|
|
61
72
|
# Remotion defaults its worker count to the machine's core count, and each worker
|
|
62
73
|
# is a browser holding a full frame. On a 10-core / 8 GB laptop that took the
|
|
63
74
|
# machine down mid-render. Raise this only after measuring the host.
|
|
@@ -77,6 +88,54 @@ SUBTITLE_WINDOW = re.compile(r"^\s*([0-9.]+)\s*[-–~]\s*([0-9.]+)\s*$")
|
|
|
77
88
|
# Numbered fields keep them ordered and let each one state its own window.
|
|
78
89
|
SUBTITLE_FIELD = re.compile(r"^字幕(?:\s*(\d+))?$")
|
|
79
90
|
SUBTITLE_CUE = re.compile(r"^\s*([0-9.]+)\s*[-–~]\s*([0-9.]+)\s+(.+?)\s*$")
|
|
91
|
+
# Burned subtitles follow GY/T 288 and short-drama practice: no closing
|
|
92
|
+
# punctuation, a pause inside a line shows as a full-width space, and ? ! stay
|
|
93
|
+
# because they change how the line reads. Only the burned text changes: the cut
|
|
94
|
+
# list keeps the screenplay's punctuation, so the line is still checked against
|
|
95
|
+
# 剧本.md as written. ASCII marks are left alone mid-line: they sit inside
|
|
96
|
+
# numbers such as 1,000.
|
|
97
|
+
SUBTITLE_PAUSE_MARKS = ",、;:。"
|
|
98
|
+
SUBTITLE_KEPT_END_MARKS = "?!?!"
|
|
99
|
+
SUBTITLE_END_MARKS = "".join(
|
|
100
|
+
mark for mark in ",、;:。…—~!?,.;:!?~" if mark not in SUBTITLE_KEPT_END_MARKS
|
|
101
|
+
)
|
|
102
|
+
# One line on screen: a longer line is split at its pauses into cues shown one
|
|
103
|
+
# after another, each given a share of the window by its character count.
|
|
104
|
+
SUBTITLE_MAX_VISIBLE = 12
|
|
105
|
+
# 「字幕:… (重点:词|词)」 marks words to highlight; they must occur in the line.
|
|
106
|
+
SUBTITLE_KEYWORDS = re.compile(r"[((]重点[::]\s*(.+?)\s*[))]\s*$")
|
|
107
|
+
# Colour by who is speaking, read from 剧本.md rather than written again here:
|
|
108
|
+
# `[VO] 系统:` is the system voice, any other `[VO]` an inner or narrating voice.
|
|
109
|
+
SPOKEN_LINE = re.compile(
|
|
110
|
+
r"^\s*(?:\[(VO|OS)\]\s*)?([^\s::\[\]((]+)(?:[((][^))]*[))])?\s*[::]\s*(.+?)\s*$"
|
|
111
|
+
)
|
|
112
|
+
SYSTEM_SPEAKER = "系统"
|
|
113
|
+
# The same values as the Remotion layer's `src/screen/tokens.ts`.
|
|
114
|
+
SUBTITLE_COLOURS = {"line": "#FFFFFF", "vo": "#9FE8FF", "system": "#3FE0FF"}
|
|
115
|
+
SUBTITLE_KEYWORD_COLOUR = "#FFD400"
|
|
116
|
+
# Text the picture had to leave blank -- a system panel, a countdown, follower
|
|
117
|
+
# counts on a monitor -- because video models garble it. It is drawn here, over
|
|
118
|
+
# the finished picture, and every item must come from a [画面文字] line.
|
|
119
|
+
SCREEN_TEXT_FIELD = re.compile(r"^画面文字(?:\s*(\d+))?$")
|
|
120
|
+
SCREEN_TEXT_STYLES = {"卡片": "card", "系统面板": "system", "任务面板": "task", "角标": "corner"}
|
|
121
|
+
SCREEN_TEXT = re.compile(
|
|
122
|
+
r"^\s*([0-9.]+)\s*[-–~]\s*([0-9.]+)\s+(" + "|".join(SCREEN_TEXT_STYLES) + r")\s+(.+?)\s*$"
|
|
123
|
+
)
|
|
124
|
+
SCREEN_TEXT_ITEM_SEPARATOR = "|"
|
|
125
|
+
COUNTDOWN = re.compile(r"[((]倒计时[::]\s*([0-9]+(?:\.[0-9]+)?|接续)\s*[))]\s*$")
|
|
126
|
+
COUNTDOWN_STYLES = {"任务面板", "角标"}
|
|
127
|
+
# 「现金 10 万(传说)」: an item's rarity sets its colour. Unmarked items are 稀有.
|
|
128
|
+
RARITY = re.compile(r"[((](传说|史诗|稀有)[))]$")
|
|
129
|
+
RARITY_STYLES = {"系统面板", "任务面板"}
|
|
130
|
+
# The three panels share the top of the frame; the chip has its own corner.
|
|
131
|
+
SCREEN_TEXT_SLOTS = {"卡片": "面板", "系统面板": "面板", "任务面板": "面板", "角标": "角标"}
|
|
132
|
+
SCREENPLAY_SCREEN_TEXT = re.compile(r"^\s*\[画面文字\]\s*(.+?)\s*$", re.MULTILINE)
|
|
133
|
+
# Two pieces of the same screen text that meet at a cut are one piece on
|
|
134
|
+
# screen: a chip that persists across five shots must not re-enter five times.
|
|
135
|
+
SCREEN_TEXT_JOIN_GAP = 0.05
|
|
136
|
+
SOUND_EFFECT_FIELD = re.compile(r"^音效(?:\s*(\d+))?$")
|
|
137
|
+
GAIN = re.compile(r"[((]增益[::]\s*([+-]?[0-9]+(?:\.[0-9]+)?)\s*(?:dB)?\s*[))]\s*$", re.I)
|
|
138
|
+
GAIN_LIMITS = (-40.0, 6.0)
|
|
80
139
|
# A shot-match is three numbers and nothing else. Anything richer belongs in a
|
|
81
140
|
# grading tool, and anything implicit belongs nowhere: a correction the cut list
|
|
82
141
|
# does not state is a correction no reviewer can see.
|
|
@@ -90,13 +149,70 @@ PICTURE_LIMITS = {
|
|
|
90
149
|
"saturation": (0.0, 2.0),
|
|
91
150
|
"warmth": (-30.0, 30.0),
|
|
92
151
|
}
|
|
152
|
+
# 「画面:不校」 keeps a cut exactly as generated: no stated correction and no
|
|
153
|
+
# automatic one. 「画面:无」 only says nothing is stated.
|
|
154
|
+
PICTURE_UNTOUCHED = "不校"
|
|
155
|
+
# Clips are generated one by one and come back up to ~35 luma apart at a cut,
|
|
156
|
+
# which reads as a flash. Within one scene, render pulls each cut this far
|
|
157
|
+
# toward the scene's median per-channel mean and spread: close enough to stop
|
|
158
|
+
# the flash, short of flattening a deliberate change inside the scene.
|
|
159
|
+
SHOT_MATCH_STRENGTH = 0.7
|
|
160
|
+
# A near-flat clip (a black insert) has almost no spread; stretching it to the
|
|
161
|
+
# scene's would amplify noise, so the gain is held to a plausible grade.
|
|
162
|
+
SHOT_MATCH_GAIN_LIMITS = (0.6, 1.6)
|
|
163
|
+
SHOT_MATCH_SAMPLE = "fps=4,scale=64:-2"
|
|
164
|
+
STORYBOARD_DOCUMENT = "分镜.md"
|
|
165
|
+
SHOT_HEADING = re.compile(r"^##\s+(SHOT-[^\s·]+)")
|
|
166
|
+
SHOT_REFERENCE = re.compile(r"SHOT-[A-Za-z0-9-]+")
|
|
167
|
+
SCENE_ID = re.compile(r"[A-Za-z]+[0-9]+-SC[0-9]+")
|
|
168
|
+
# verify reads the delivered film at this size in grey, one value per pixel.
|
|
169
|
+
FRAME_PROBE_SIZE = (90, 160)
|
|
170
|
+
# A frame is suspect when it differs from both neighbours by more than this
|
|
171
|
+
# (mean absolute difference, 0-255) while the neighbours differ from each other
|
|
172
|
+
# by less than this share of it: a picture that belongs to neither side, such
|
|
173
|
+
# as a strip of another shot decoded into the top of the frame.
|
|
174
|
+
FLASH_FRAME_DIFFERENCE = 8.0
|
|
175
|
+
FLASH_NEIGHBOUR_SHARE = 0.5
|
|
176
|
+
# A panel entering over 2-3 frames of glitch trips the detector too; a suspect
|
|
177
|
+
# frame this soon after a screen text starts is labelled as that.
|
|
178
|
+
SCREEN_TEXT_ENTRANCE = 0.15
|
|
179
|
+
# Mean-luma change across a cut (0-255) above which verify asks for a look.
|
|
180
|
+
CUT_JUMP_NOTICE = 20.0
|
|
93
181
|
TOLERANCE = 0.005
|
|
182
|
+
# ffmpeg's `noise` strength runs to 100, which is snow, not grain. The useful
|
|
183
|
+
# band for a finished film is single digits; the cap keeps a typo from shipping
|
|
184
|
+
# a broken-signal look that measures perfectly fine.
|
|
185
|
+
GRAIN_LIMIT = 20.0
|
|
94
186
|
|
|
95
187
|
|
|
96
188
|
class EditError(Exception):
|
|
97
189
|
"""A defect in the cut list or its inputs, reported rather than raised through."""
|
|
98
190
|
|
|
99
191
|
|
|
192
|
+
class ScreenText(NamedTuple):
|
|
193
|
+
start: float
|
|
194
|
+
end: float
|
|
195
|
+
style: str
|
|
196
|
+
items: tuple[str, ...]
|
|
197
|
+
# Seconds left at `start`; None when nothing counts down.
|
|
198
|
+
countdown: Optional[float] = None
|
|
199
|
+
# 「倒计时:接续」 carries on from the latest earlier countdown in the film.
|
|
200
|
+
resume: bool = False
|
|
201
|
+
# One per item: 传说 / 史诗 / 稀有, or None where the item names none.
|
|
202
|
+
rarities: tuple[Optional[str], ...] = ()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# (start, end, text as in 剧本.md, words to highlight); times relative to the cut.
|
|
206
|
+
Subtitle = tuple[Optional[float], Optional[float], str, tuple[str, ...]]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class SoundEffect(NamedTuple):
|
|
210
|
+
start: float
|
|
211
|
+
end: float
|
|
212
|
+
path: str
|
|
213
|
+
gain_db: float = 0.0
|
|
214
|
+
|
|
215
|
+
|
|
100
216
|
class Cut(NamedTuple):
|
|
101
217
|
cut_id: str
|
|
102
218
|
title: str
|
|
@@ -105,9 +221,13 @@ class Cut(NamedTuple):
|
|
|
105
221
|
start: float
|
|
106
222
|
end: float
|
|
107
223
|
declared: float
|
|
108
|
-
subtitles: tuple[
|
|
224
|
+
subtitles: tuple[Subtitle, ...]
|
|
109
225
|
picture: dict[str, float]
|
|
110
226
|
line_number: int
|
|
227
|
+
screen_texts: tuple[ScreenText, ...] = ()
|
|
228
|
+
sound_effects: tuple[SoundEffect, ...] = ()
|
|
229
|
+
# 「画面:不校」: neither a stated nor an automatic correction.
|
|
230
|
+
untouched: bool = False
|
|
111
231
|
|
|
112
232
|
|
|
113
233
|
class Delivery(NamedTuple):
|
|
@@ -116,6 +236,23 @@ class Delivery(NamedTuple):
|
|
|
116
236
|
burn_subtitles: bool
|
|
117
237
|
frame_size: Optional[tuple[int, int]]
|
|
118
238
|
fps: Optional[float]
|
|
239
|
+
grain: Optional[float]
|
|
240
|
+
# 「接镜匹配:无」 turns the automatic within-scene match off.
|
|
241
|
+
shot_match: bool = True
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class ChannelStats(NamedTuple):
|
|
245
|
+
"""Per-channel (R, G, B) mean and standard deviation, 0-255."""
|
|
246
|
+
|
|
247
|
+
mean: tuple[float, ...]
|
|
248
|
+
spread: tuple[float, ...]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class ShotMatch(NamedTuple):
|
|
252
|
+
"""Per-channel `value * gain + offset`, applied to one cut."""
|
|
253
|
+
|
|
254
|
+
gains: tuple[float, ...]
|
|
255
|
+
offsets: tuple[float, ...]
|
|
119
256
|
|
|
120
257
|
|
|
121
258
|
def _seconds(raw: str, *, field: str, line: int) -> float:
|
|
@@ -131,6 +268,8 @@ def _parse_delivery(lines: Sequence[str]) -> Delivery:
|
|
|
131
268
|
burn = True
|
|
132
269
|
frame_size: Optional[tuple[int, int]] = None
|
|
133
270
|
fps: Optional[float] = None
|
|
271
|
+
grain: Optional[float] = None
|
|
272
|
+
shot_match = True
|
|
134
273
|
for raw in lines:
|
|
135
274
|
match = FIELD.match(raw)
|
|
136
275
|
if not match:
|
|
@@ -146,6 +285,25 @@ def _parse_delivery(lines: Sequence[str]) -> Delivery:
|
|
|
146
285
|
loudness = float(found.group(0))
|
|
147
286
|
elif name == "字幕":
|
|
148
287
|
burn = "无" != value.strip() and "不烧" not in value
|
|
288
|
+
elif name == "颗粒":
|
|
289
|
+
# Grain belongs to the delivery spec rather than to a cut, for the
|
|
290
|
+
# same reason loudness does: applied per cut it would become one
|
|
291
|
+
# more thing that differs between segments, which is the defect it
|
|
292
|
+
# is here to cover.
|
|
293
|
+
if value.strip() in {"无", "不加"}:
|
|
294
|
+
grain = None
|
|
295
|
+
else:
|
|
296
|
+
found = re.search(r"[0-9]+(?:\.[0-9]+)?", value)
|
|
297
|
+
if found:
|
|
298
|
+
amount = float(found.group(0))
|
|
299
|
+
if not 0.0 <= amount <= GRAIN_LIMIT:
|
|
300
|
+
raise EditError(
|
|
301
|
+
f"{CUT_LIST_NAME}: 颗粒 {amount} 超出 0–{GRAIN_LIMIT:g};"
|
|
302
|
+
"这一档以上不再像胶片,像信号故障"
|
|
303
|
+
)
|
|
304
|
+
grain = amount or None
|
|
305
|
+
elif name == "接镜匹配":
|
|
306
|
+
shot_match = value.strip() not in {"无", "关"}
|
|
149
307
|
elif name == "画幅与帧率":
|
|
150
308
|
size = re.search(r"([0-9]{2,5})\s*[×x*]\s*([0-9]{2,5})", value)
|
|
151
309
|
if size:
|
|
@@ -153,7 +311,7 @@ def _parse_delivery(lines: Sequence[str]) -> Delivery:
|
|
|
153
311
|
rate = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*fps", value, re.I)
|
|
154
312
|
if rate:
|
|
155
313
|
fps = float(rate.group(1))
|
|
156
|
-
return Delivery(target, loudness, burn, frame_size, fps)
|
|
314
|
+
return Delivery(target, loudness, burn, frame_size, fps, grain, shot_match)
|
|
157
315
|
|
|
158
316
|
|
|
159
317
|
def parse_cut_list(path: Path) -> tuple[Delivery, list[Cut], list[str]]:
|
|
@@ -197,11 +355,26 @@ def parse_cut_list(path: Path) -> tuple[Delivery, list[Cut], list[str]]:
|
|
|
197
355
|
continue
|
|
198
356
|
field = FIELD.match(raw)
|
|
199
357
|
if field:
|
|
200
|
-
|
|
358
|
+
name = field.group(1).strip()
|
|
359
|
+
if name in current["fields"]:
|
|
360
|
+
raise EditError(_repeated_field(name, current["cut_id"], number))
|
|
361
|
+
current["fields"][name] = (field.group(2).strip(), number)
|
|
201
362
|
close(current)
|
|
202
363
|
return _parse_delivery(preamble), cuts, unused
|
|
203
364
|
|
|
204
365
|
|
|
366
|
+
def _repeated_field(name: str, cut_id: str, line: int) -> str:
|
|
367
|
+
"""A field written twice in one cut: the second would silently replace the first."""
|
|
368
|
+
|
|
369
|
+
where = f"{CUT_LIST_NAME}:{line}: {cut_id} 的「{name}」写了两遍"
|
|
370
|
+
for pattern, label in (
|
|
371
|
+
(SUBTITLE_FIELD, "字幕"), (SCREEN_TEXT_FIELD, "画面文字"), (SOUND_EFFECT_FIELD, "音效"),
|
|
372
|
+
):
|
|
373
|
+
if pattern.match(name):
|
|
374
|
+
return f"{where};一段有多条{label}时全部编号,且编号不重复:「{label} 1」「{label} 2」…"
|
|
375
|
+
return f"{where};这一项一段只写一次"
|
|
376
|
+
|
|
377
|
+
|
|
205
378
|
def _finish_cut(pending: dict[str, Any]) -> Cut:
|
|
206
379
|
fields: dict[str, tuple[str, int]] = pending["fields"]
|
|
207
380
|
cut_id: str = pending["cut_id"]
|
|
@@ -225,9 +398,10 @@ def _finish_cut(pending: dict[str, Any]) -> Cut:
|
|
|
225
398
|
subtitles = _parse_subtitles(fields, cut_id=cut_id, line=line)
|
|
226
399
|
picture: dict[str, float] = {}
|
|
227
400
|
picture_raw = fields.get("画面")
|
|
401
|
+
untouched = picture_raw is not None and picture_raw[0].strip() == PICTURE_UNTOUCHED
|
|
228
402
|
if picture_raw is not None:
|
|
229
403
|
text, where = picture_raw
|
|
230
|
-
if text.strip() not in {"", "无",
|
|
404
|
+
if text.strip() not in {"", "无", PICTURE_UNTOUCHED}:
|
|
231
405
|
for label, value in PICTURE_TERM.findall(text):
|
|
232
406
|
key = PICTURE_KEYS[label]
|
|
233
407
|
number = float(value)
|
|
@@ -241,7 +415,8 @@ def _finish_cut(pending: dict[str, Any]) -> Cut:
|
|
|
241
415
|
if not picture:
|
|
242
416
|
raise EditError(
|
|
243
417
|
f"{CUT_LIST_NAME}:{where}: {cut_id} 的画面写了内容但没有可执行的项;"
|
|
244
|
-
"只认「亮度 <数>」「饱和 <数>」「色温
|
|
418
|
+
"只认「亮度 <数>」「饱和 <数>」「色温 <数>」,不需要校正时写「无」,"
|
|
419
|
+
f"连自动接镜也不要时写「{PICTURE_UNTOUCHED}」"
|
|
245
420
|
)
|
|
246
421
|
return Cut(
|
|
247
422
|
cut_id=cut_id,
|
|
@@ -254,24 +429,30 @@ def _finish_cut(pending: dict[str, Any]) -> Cut:
|
|
|
254
429
|
subtitles=subtitles,
|
|
255
430
|
picture=picture,
|
|
256
431
|
line_number=line,
|
|
432
|
+
screen_texts=_parse_screen_texts(fields, cut_id=cut_id, line=line),
|
|
433
|
+
sound_effects=_parse_sound_effects(fields, cut_id=cut_id, line=line),
|
|
434
|
+
untouched=untouched,
|
|
257
435
|
)
|
|
258
436
|
|
|
259
437
|
|
|
260
|
-
def
|
|
261
|
-
fields: dict[str, tuple[str, int]],
|
|
262
|
-
|
|
263
|
-
|
|
438
|
+
def _field_entries(
|
|
439
|
+
fields: dict[str, tuple[str, int]],
|
|
440
|
+
pattern: re.Pattern[str],
|
|
441
|
+
label: str,
|
|
442
|
+
*,
|
|
443
|
+
cut_id: str,
|
|
444
|
+
line: int,
|
|
445
|
+
) -> tuple[bool, list[tuple[int, str, int]]]:
|
|
446
|
+
"""Collect one kind of field, written once plain or as 「<label> 1..N」.
|
|
264
447
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
one of them silently drops the other two — the film then plays lines that
|
|
268
|
-
never reach the screen, and nothing reports it.
|
|
448
|
+
Returns whether the numbered form was used, and (number, value, line) rows
|
|
449
|
+
in number order. The plain form comes back as a single row numbered 1.
|
|
269
450
|
"""
|
|
270
451
|
|
|
271
452
|
numbered: list[tuple[int, str, int]] = []
|
|
272
453
|
plain: Optional[tuple[str, int]] = None
|
|
273
454
|
for key, (value, where) in fields.items():
|
|
274
|
-
match =
|
|
455
|
+
match = pattern.match(key.strip())
|
|
275
456
|
if not match:
|
|
276
457
|
continue
|
|
277
458
|
if match.group(1) is None:
|
|
@@ -281,27 +462,162 @@ def _parse_subtitles(
|
|
|
281
462
|
|
|
282
463
|
if numbered and plain is not None:
|
|
283
464
|
raise EditError(
|
|
284
|
-
f"{CUT_LIST_NAME}:{line}: {cut_id}
|
|
285
|
-
"
|
|
465
|
+
f"{CUT_LIST_NAME}:{line}: {cut_id} 同时写了「{label}」和「{label} N」;"
|
|
466
|
+
f"一段用一种写法——一条用「{label}」,多条全部编号"
|
|
286
467
|
)
|
|
468
|
+
if not numbered:
|
|
469
|
+
return False, [] if plain is None else [(1, plain[0], plain[1])]
|
|
470
|
+
numbered.sort()
|
|
471
|
+
expected = list(range(1, len(numbered) + 1))
|
|
472
|
+
if [item[0] for item in numbered] != expected:
|
|
473
|
+
raise EditError(
|
|
474
|
+
f"{CUT_LIST_NAME}:{line}: {cut_id} 的{label}编号要从 1 连续排到 "
|
|
475
|
+
f"{len(numbered)},当前是 {[item[0] for item in numbered]}"
|
|
476
|
+
)
|
|
477
|
+
return True, numbered
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _parse_screen_texts(
|
|
481
|
+
fields: dict[str, tuple[str, int]], *, cut_id: str, line: int
|
|
482
|
+
) -> tuple[ScreenText, ...]:
|
|
483
|
+
"""Read 「画面文字 N:<起>-<止> <样式> <项>|<项>…(倒计时:<秒>|接续)」 lines."""
|
|
484
|
+
|
|
485
|
+
_, entries = _field_entries(fields, SCREEN_TEXT_FIELD, "画面文字", cut_id=cut_id, line=line)
|
|
486
|
+
texts: list[ScreenText] = []
|
|
487
|
+
for index, value, where in entries:
|
|
488
|
+
if value.strip() == "无":
|
|
489
|
+
continue
|
|
490
|
+
countdown: Optional[float] = None
|
|
491
|
+
resume = False
|
|
492
|
+
counted = COUNTDOWN.search(value)
|
|
493
|
+
if counted:
|
|
494
|
+
value = value[: counted.start()]
|
|
495
|
+
if counted.group(1) == "接续":
|
|
496
|
+
resume = True
|
|
497
|
+
else:
|
|
498
|
+
countdown = float(counted.group(1))
|
|
499
|
+
found = SCREEN_TEXT.match(value)
|
|
500
|
+
if not found:
|
|
501
|
+
raise EditError(
|
|
502
|
+
f"{CUT_LIST_NAME}:{where}: {cut_id} 的「画面文字 {index}」要写成"
|
|
503
|
+
f"「<起>-<止> <样式> <文字>」,样式是 {'/'.join(SCREEN_TEXT_STYLES)}"
|
|
504
|
+
)
|
|
505
|
+
style = found.group(3)
|
|
506
|
+
items: list[str] = []
|
|
507
|
+
rarities: list[Optional[str]] = []
|
|
508
|
+
for raw in found.group(4).split(SCREEN_TEXT_ITEM_SEPARATOR):
|
|
509
|
+
item = raw.strip()
|
|
510
|
+
rare = RARITY.search(item)
|
|
511
|
+
if rare and style not in RARITY_STYLES:
|
|
512
|
+
raise EditError(
|
|
513
|
+
f"{CUT_LIST_NAME}:{where}: {cut_id} 的「画面文字 {index}」是{style},"
|
|
514
|
+
f"稀有度只用在{'或'.join(sorted(RARITY_STYLES))}上"
|
|
515
|
+
)
|
|
516
|
+
items.append(item[: rare.start()].strip() if rare else item)
|
|
517
|
+
rarities.append(rare.group(1) if rare else None)
|
|
518
|
+
if not all(items):
|
|
519
|
+
raise EditError(
|
|
520
|
+
f"{CUT_LIST_NAME}:{where}: {cut_id} 的「画面文字 {index}」有空项;"
|
|
521
|
+
f"「{SCREEN_TEXT_ITEM_SEPARATOR}」两边都要有文字"
|
|
522
|
+
)
|
|
523
|
+
if counted and style not in COUNTDOWN_STYLES:
|
|
524
|
+
raise EditError(
|
|
525
|
+
f"{CUT_LIST_NAME}:{where}: {cut_id} 的「画面文字 {index}」是{style},"
|
|
526
|
+
f"倒计时只放在{'或'.join(sorted(COUNTDOWN_STYLES))}上"
|
|
527
|
+
)
|
|
528
|
+
texts.append(ScreenText(
|
|
529
|
+
float(found.group(1)), float(found.group(2)), style, tuple(items),
|
|
530
|
+
countdown, resume, tuple(rarities),
|
|
531
|
+
))
|
|
532
|
+
|
|
533
|
+
by_slot: dict[str, list[ScreenText]] = {}
|
|
534
|
+
for text in texts:
|
|
535
|
+
by_slot.setdefault(SCREEN_TEXT_SLOTS[text.style], []).append(text)
|
|
536
|
+
for slot, group in by_slot.items():
|
|
537
|
+
ordered = sorted(group, key=lambda item: item.start)
|
|
538
|
+
for earlier, later in zip(ordered, ordered[1:]):
|
|
539
|
+
if later.start < earlier.end - TOLERANCE:
|
|
540
|
+
raise EditError(
|
|
541
|
+
f"{CUT_LIST_NAME}:{line}: {cut_id} 的画面文字时间重叠:同一位置({slot})"
|
|
542
|
+
f"{earlier.start:g}-{earlier.end:g} 与 {later.start:g}-{later.end:g}"
|
|
543
|
+
)
|
|
544
|
+
return tuple(texts)
|
|
287
545
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
546
|
+
|
|
547
|
+
def _parse_sound_effects(
|
|
548
|
+
fields: dict[str, tuple[str, int]], *, cut_id: str, line: int
|
|
549
|
+
) -> tuple[SoundEffect, ...]:
|
|
550
|
+
"""Read 「音效 N:<起>-<止> <文件>(增益:<dB>)」 lines."""
|
|
551
|
+
|
|
552
|
+
_, entries = _field_entries(fields, SOUND_EFFECT_FIELD, "音效", cut_id=cut_id, line=line)
|
|
553
|
+
effects: list[SoundEffect] = []
|
|
554
|
+
for index, value, where in entries:
|
|
555
|
+
if value.strip() == "无":
|
|
556
|
+
continue
|
|
557
|
+
gain = 0.0
|
|
558
|
+
stated = GAIN.search(value)
|
|
559
|
+
if stated:
|
|
560
|
+
value = value[: stated.start()]
|
|
561
|
+
gain = float(stated.group(1))
|
|
562
|
+
low, high = GAIN_LIMITS
|
|
563
|
+
if not low <= gain <= high:
|
|
564
|
+
raise EditError(
|
|
565
|
+
f"{CUT_LIST_NAME}:{where}: {cut_id} 的「音效 {index}」增益 {gain:g} dB "
|
|
566
|
+
f"超出 {low:g} 到 {high:g}"
|
|
567
|
+
)
|
|
568
|
+
found = SUBTITLE_CUE.match(value)
|
|
569
|
+
if not found:
|
|
292
570
|
raise EditError(
|
|
293
|
-
f"{CUT_LIST_NAME}:{
|
|
294
|
-
|
|
571
|
+
f"{CUT_LIST_NAME}:{where}: {cut_id} 的「音效 {index}」要写成"
|
|
572
|
+
"「<起>-<止> <项目相对路径>」"
|
|
295
573
|
)
|
|
296
|
-
|
|
297
|
-
|
|
574
|
+
effects.append(SoundEffect(float(found.group(1)), float(found.group(2)), found.group(3), gain))
|
|
575
|
+
return tuple(effects)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _parse_subtitles(
|
|
579
|
+
fields: dict[str, tuple[str, int]], *, cut_id: str, line: int
|
|
580
|
+
) -> tuple[Subtitle, ...]:
|
|
581
|
+
"""Read every subtitle a cut declares, in written order.
|
|
582
|
+
|
|
583
|
+
A shot is not one line. The exchange "就是什么 / 就是少了点东西 / 少了什么" is a
|
|
584
|
+
single over-shoulder shot carrying three, and a cut list that can hold only
|
|
585
|
+
one of them silently drops the other two — the film then plays lines that
|
|
586
|
+
never reach the screen, and nothing reports it.
|
|
587
|
+
"""
|
|
588
|
+
|
|
589
|
+
is_numbered, entries = _field_entries(
|
|
590
|
+
fields, SUBTITLE_FIELD, "字幕", cut_id=cut_id, line=line
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
def keywords(value: str, where: int) -> tuple[str, tuple[str, ...]]:
|
|
594
|
+
marked = SUBTITLE_KEYWORDS.search(value)
|
|
595
|
+
if not marked:
|
|
596
|
+
return value.strip(), ()
|
|
597
|
+
text = value[: marked.start()].strip()
|
|
598
|
+
words = tuple(
|
|
599
|
+
word.strip() for word in marked.group(1).split(SCREEN_TEXT_ITEM_SEPARATOR)
|
|
600
|
+
)
|
|
601
|
+
stray = [
|
|
602
|
+
word for word in words if word not in text or not _display_line(word)
|
|
603
|
+
]
|
|
604
|
+
if stray:
|
|
605
|
+
raise EditError(
|
|
606
|
+
f"{CUT_LIST_NAME}:{where}: {cut_id} 的字幕重点词不在这句里或只有标点: {stray}"
|
|
607
|
+
)
|
|
608
|
+
return text, words
|
|
609
|
+
|
|
610
|
+
if is_numbered:
|
|
611
|
+
cues: list[Subtitle] = []
|
|
612
|
+
for index, value, where in entries:
|
|
298
613
|
found = SUBTITLE_CUE.match(value)
|
|
299
614
|
if not found:
|
|
300
615
|
raise EditError(
|
|
301
616
|
f"{CUT_LIST_NAME}:{where}: {cut_id} 的「字幕 {index}」要写成"
|
|
302
617
|
"「<起>-<止> <台词>」,多句必须各自带时间"
|
|
303
618
|
)
|
|
304
|
-
|
|
619
|
+
text, words = keywords(found.group(3), where)
|
|
620
|
+
cues.append((float(found.group(1)), float(found.group(2)), text, words))
|
|
305
621
|
for earlier, later in zip(cues, cues[1:]):
|
|
306
622
|
if later[0] is not None and earlier[1] is not None and later[0] < earlier[1]:
|
|
307
623
|
raise EditError(
|
|
@@ -309,17 +625,18 @@ def _parse_subtitles(
|
|
|
309
625
|
)
|
|
310
626
|
return tuple(cues)
|
|
311
627
|
|
|
312
|
-
if
|
|
628
|
+
if not entries or entries[0][1].strip() == "无":
|
|
313
629
|
return ()
|
|
630
|
+
text, words = keywords(entries[0][1], entries[0][2])
|
|
314
631
|
window_raw = fields.get("字幕时间")
|
|
315
632
|
if window_raw is None:
|
|
316
|
-
return ((None, None,
|
|
633
|
+
return ((None, None, text, words),)
|
|
317
634
|
found = SUBTITLE_WINDOW.match(window_raw[0])
|
|
318
635
|
if not found:
|
|
319
636
|
raise EditError(
|
|
320
637
|
f"{CUT_LIST_NAME}:{window_raw[1]}: {cut_id} 的字幕时间要写成「起-止」秒数"
|
|
321
638
|
)
|
|
322
|
-
return ((float(found.group(1)), float(found.group(2)),
|
|
639
|
+
return ((float(found.group(1)), float(found.group(2)), text, words),)
|
|
323
640
|
|
|
324
641
|
|
|
325
642
|
def _which(name: str) -> Optional[str]:
|
|
@@ -371,8 +688,31 @@ def probe_stream(media: Path) -> dict[str, Any]:
|
|
|
371
688
|
}
|
|
372
689
|
|
|
373
690
|
|
|
691
|
+
def _unaccounted_shots(known: set[str], cuts: Sequence[Cut], unused: Sequence[str]) -> list[str]:
|
|
692
|
+
"""Require each source to be used or explicitly omitted with a reason."""
|
|
693
|
+
|
|
694
|
+
used = {cut.motion for cut in cuts}
|
|
695
|
+
excused = set()
|
|
696
|
+
for note in unused:
|
|
697
|
+
match = re.fullmatch(r"(MOTION-[\w-]+)\s*[((]理由[::]\s*(.+?)[))]", note.strip())
|
|
698
|
+
if match and match.group(2).strip():
|
|
699
|
+
excused.add(match.group(1))
|
|
700
|
+
missing = sorted(known - used - excused)
|
|
701
|
+
if not missing:
|
|
702
|
+
return []
|
|
703
|
+
return [
|
|
704
|
+
f"{CUT_LIST_NAME}: 以下镜头未采用,且缺少「未采用镜头」及理由:"
|
|
705
|
+
+ "、".join(missing)
|
|
706
|
+
]
|
|
707
|
+
|
|
708
|
+
|
|
374
709
|
def check_cuts(
|
|
375
|
-
episode: Path,
|
|
710
|
+
episode: Path,
|
|
711
|
+
cuts: Sequence[Cut],
|
|
712
|
+
project_root: Path,
|
|
713
|
+
*,
|
|
714
|
+
probe: bool,
|
|
715
|
+
unused: Sequence[str] = (),
|
|
376
716
|
) -> list[str]:
|
|
377
717
|
"""Every mechanical cross-check the cut list can be held to. Returns findings."""
|
|
378
718
|
|
|
@@ -403,6 +743,7 @@ def check_cuts(
|
|
|
403
743
|
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的来源 "
|
|
404
744
|
f"{cut.motion} 不在《{MOTION_DOCUMENT}》中"
|
|
405
745
|
)
|
|
746
|
+
findings.extend(_unaccounted_shots(known, cuts, unused))
|
|
406
747
|
else:
|
|
407
748
|
findings.append(f"没有 {motion_path},来源 MOTION 无法核对")
|
|
408
749
|
|
|
@@ -411,6 +752,7 @@ def check_cuts(
|
|
|
411
752
|
if screenplay_path.is_file():
|
|
412
753
|
screenplay = screenplay_path.read_text(encoding="utf-8")
|
|
413
754
|
|
|
755
|
+
media_format: Optional[tuple[Any, Any, float]] = None
|
|
414
756
|
for cut in cuts:
|
|
415
757
|
span = cut.end - cut.start
|
|
416
758
|
if cut.start < 0:
|
|
@@ -430,13 +772,23 @@ def check_cuts(
|
|
|
430
772
|
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的素材不存在: {cut.media}"
|
|
431
773
|
)
|
|
432
774
|
elif probe:
|
|
433
|
-
|
|
775
|
+
stream = probe_stream(media)
|
|
776
|
+
available = stream["duration"]
|
|
777
|
+
current_format = (stream["width"], stream["height"], stream["fps"])
|
|
778
|
+
if media_format is None:
|
|
779
|
+
media_format = current_format
|
|
780
|
+
elif current_format != media_format:
|
|
781
|
+
findings.append(
|
|
782
|
+
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的画幅或帧率 "
|
|
783
|
+
f"{current_format} 与首段 {media_format} 不一致;"
|
|
784
|
+
"先在外部统一素材规格,再更新来源路径与入出点"
|
|
785
|
+
)
|
|
434
786
|
if cut.end > available + TOLERANCE:
|
|
435
787
|
findings.append(
|
|
436
788
|
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 出点 {cut.end:.2f} "
|
|
437
789
|
f"超过素材实际时长 {available:.2f}"
|
|
438
790
|
)
|
|
439
|
-
for window_start, window_end, text in cut.subtitles:
|
|
791
|
+
for window_start, window_end, text, _ in cut.subtitles:
|
|
440
792
|
if screenplay and _normalize(text) not in _normalize(screenplay):
|
|
441
793
|
findings.append(
|
|
442
794
|
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的字幕在《"
|
|
@@ -449,12 +801,57 @@ def check_cuts(
|
|
|
449
801
|
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的字幕时间超出本段区间"
|
|
450
802
|
f": {window_start}-{window_end}"
|
|
451
803
|
)
|
|
804
|
+
for effect in cut.sound_effects:
|
|
805
|
+
if not 0 <= effect.start < effect.end <= span + TOLERANCE:
|
|
806
|
+
findings.append(
|
|
807
|
+
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的音效时间超出本段区间"
|
|
808
|
+
f": {effect.start:g}-{effect.end:g}"
|
|
809
|
+
)
|
|
810
|
+
if _resolve_media(episode, project_root, effect.path) is None:
|
|
811
|
+
findings.append(
|
|
812
|
+
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的音效文件不存在: "
|
|
813
|
+
f"{effect.path}"
|
|
814
|
+
)
|
|
452
815
|
|
|
816
|
+
findings.extend(_screen_text_findings(cuts, screenplay))
|
|
453
817
|
findings.extend(_overlap_findings(cuts))
|
|
454
818
|
findings.extend(_stale_window_findings(episode, project_root, cuts))
|
|
455
819
|
return findings
|
|
456
820
|
|
|
457
821
|
|
|
822
|
+
def _screen_text_findings(cuts: Sequence[Cut], screenplay: str) -> list[str]:
|
|
823
|
+
"""Screen text is held to 剧本.md's [画面文字] lines, the way subtitles are to dialogue.
|
|
824
|
+
|
|
825
|
+
Without a screenplay the check is skipped exactly as the subtitle one is;
|
|
826
|
+
with one, a screenplay that declares no screen text traces nothing.
|
|
827
|
+
"""
|
|
828
|
+
|
|
829
|
+
findings: list[str] = []
|
|
830
|
+
sources = [_normalize(body) for body in SCREENPLAY_SCREEN_TEXT.findall(screenplay)]
|
|
831
|
+
counting = False
|
|
832
|
+
for cut in cuts:
|
|
833
|
+
span = cut.end - cut.start
|
|
834
|
+
for text in sorted(cut.screen_texts, key=lambda item: item.start):
|
|
835
|
+
where = f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id}"
|
|
836
|
+
if not 0 <= text.start < text.end <= span + TOLERANCE:
|
|
837
|
+
findings.append(
|
|
838
|
+
f"{where} 的画面文字时间超出本段区间: {text.start:g}-{text.end:g}"
|
|
839
|
+
)
|
|
840
|
+
if screenplay:
|
|
841
|
+
for item in text.items:
|
|
842
|
+
if not any(_normalize(item) in source for source in sources):
|
|
843
|
+
findings.append(
|
|
844
|
+
f"{where} 的画面文字在《{SCREENPLAY_DOCUMENT}》的"
|
|
845
|
+
f"「[画面文字]」行里找不到: {item}"
|
|
846
|
+
)
|
|
847
|
+
if text.resume and not counting:
|
|
848
|
+
findings.append(
|
|
849
|
+
f"{where} 的画面文字写了「倒计时:接续」,但成片里在它之前没有倒计时"
|
|
850
|
+
)
|
|
851
|
+
counting = counting or text.countdown is not None or text.resume
|
|
852
|
+
return findings
|
|
853
|
+
|
|
854
|
+
|
|
458
855
|
def _stale_window_findings(
|
|
459
856
|
episode: Path, project_root: Path, cuts: Sequence[Cut]
|
|
460
857
|
) -> list[str]:
|
|
@@ -475,7 +872,12 @@ def _stale_window_findings(
|
|
|
475
872
|
return []
|
|
476
873
|
findings: list[str] = []
|
|
477
874
|
for cut in cuts:
|
|
478
|
-
|
|
875
|
+
timed = (
|
|
876
|
+
any(subtitle[0] is not None for subtitle in cut.subtitles)
|
|
877
|
+
or cut.screen_texts
|
|
878
|
+
or cut.sound_effects
|
|
879
|
+
)
|
|
880
|
+
if not timed:
|
|
479
881
|
continue
|
|
480
882
|
media = _resolve_media(episode, project_root, cut.media)
|
|
481
883
|
if media is None:
|
|
@@ -487,7 +889,7 @@ def _stale_window_findings(
|
|
|
487
889
|
if changed > authored + 1.0:
|
|
488
890
|
findings.append(
|
|
489
891
|
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的素材比剪辑单新"
|
|
490
|
-
f"({cut.media}
|
|
892
|
+
f"({cut.media});这一段的字幕、画面文字与音效时间是按旧素材反推的,"
|
|
491
893
|
"重出之后必须重测再改,不能沿用"
|
|
492
894
|
)
|
|
493
895
|
return findings
|
|
@@ -542,9 +944,16 @@ def render(
|
|
|
542
944
|
segments_root = output_root / SEGMENT_DIRECTORY
|
|
543
945
|
segments_root.mkdir(parents=True, exist_ok=True)
|
|
544
946
|
|
|
947
|
+
def measure(cut: Cut) -> Optional[ChannelStats]:
|
|
948
|
+
media = _resolve_media(episode, project_root, cut.media)
|
|
949
|
+
return None if media is None else _channel_stats(ffmpeg, media, cut.start, cut.end - cut.start)
|
|
950
|
+
|
|
951
|
+
scenes = _scene_keys(episode, cuts)
|
|
952
|
+
pictures, auto = _picture_plan(cuts, scenes, measure, enabled=delivery.shot_match)
|
|
953
|
+
|
|
545
954
|
segments: list[Path] = []
|
|
546
955
|
spans: list[float] = []
|
|
547
|
-
for cut in cuts:
|
|
956
|
+
for cut, match in zip(cuts, pictures):
|
|
548
957
|
media = _resolve_media(episode, project_root, cut.media)
|
|
549
958
|
if media is None:
|
|
550
959
|
raise EditError(f"{cut.cut_id} 的素材不存在: {cut.media}")
|
|
@@ -553,7 +962,6 @@ def render(
|
|
|
553
962
|
ffmpeg, "-hide_banner", "-loglevel", "error", "-y",
|
|
554
963
|
"-ss", f"{cut.start:.3f}", "-t", f"{cut.end - cut.start:.3f}", "-i", str(media),
|
|
555
964
|
]
|
|
556
|
-
match = _shot_match_filter(cut)
|
|
557
965
|
if match:
|
|
558
966
|
command += ["-vf", match]
|
|
559
967
|
command += [
|
|
@@ -573,30 +981,64 @@ def render(
|
|
|
573
981
|
_run([ffmpeg, "-hide_banner", "-loglevel", "error", "-y",
|
|
574
982
|
"-f", "concat", "-safe", "0", "-i", str(listing), "-c", "copy", str(joined)])
|
|
575
983
|
|
|
984
|
+
effects: list[tuple[float, float, Path, float]] = []
|
|
985
|
+
for start, duration, written, gain in _placed_sound_effects(cuts, spans):
|
|
986
|
+
resolved = _resolve_media(episode, project_root, written)
|
|
987
|
+
if resolved is None:
|
|
988
|
+
raise EditError(f"音效文件不存在: {written}")
|
|
989
|
+
effects.append((start, duration, resolved, gain))
|
|
990
|
+
if effects:
|
|
991
|
+
# Mixed into its own file first, so the loudness pass below measures
|
|
992
|
+
# the film the audience hears rather than the one before the chimes.
|
|
993
|
+
mixed = output_root / "成片-含音效.mp4"
|
|
994
|
+
_run(_sound_effect_command(ffmpeg, joined, effects, mixed))
|
|
995
|
+
joined = mixed
|
|
996
|
+
|
|
576
997
|
filters: list[str] = []
|
|
577
998
|
subtitle_path = None
|
|
578
999
|
styled_path = None
|
|
579
1000
|
overlay_path = None
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
1001
|
+
cues = (
|
|
1002
|
+
_display_cues(_subtitle_cues(cuts, spans), _screenplay_text(episode))
|
|
1003
|
+
if burn_subtitles and any(cut.subtitles for cut in cuts)
|
|
1004
|
+
else []
|
|
1005
|
+
)
|
|
1006
|
+
layers = _screen_text_layers(cuts, spans)
|
|
1007
|
+
canvas = probe_stream(segments[0]) if cues or layers else {}
|
|
1008
|
+
if cues:
|
|
583
1009
|
subtitle_path = output_root / "字幕.srt"
|
|
584
1010
|
subtitle_path.write_text(_build_srt(cues), encoding="utf-8")
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
)
|
|
599
|
-
|
|
1011
|
+
# Screen text has no ffmpeg route: panels, glow and a live countdown are
|
|
1012
|
+
# layout, not subtitle styling. So it always goes through the Remotion
|
|
1013
|
+
# pass, and on the ffmpeg subtitle route that pass carries no cues.
|
|
1014
|
+
remotion_cues = cues if renderer == "remotion" else []
|
|
1015
|
+
if remotion_cues or layers:
|
|
1016
|
+
overlay_path = _render_remotion_overlay(
|
|
1017
|
+
output_root, remotion_cues, layers, canvas, sum(spans), remotion_workspace,
|
|
1018
|
+
concurrency=remotion_concurrency,
|
|
1019
|
+
needed_for="画面文字" if layers else "字幕",
|
|
1020
|
+
)
|
|
1021
|
+
if cues and renderer != "remotion":
|
|
1022
|
+
styled = styled_path = output_root / "字幕.ass"
|
|
1023
|
+
styled.write_text(
|
|
1024
|
+
_build_ass(cues, canvas["width"] or 1080, canvas["height"] or 1920),
|
|
1025
|
+
encoding="utf-8",
|
|
1026
|
+
)
|
|
1027
|
+
escaped = (
|
|
1028
|
+
str(styled).replace("\\", "/").replace(":", r"\:").replace("'", r"\'")
|
|
1029
|
+
)
|
|
1030
|
+
filters.append(f"ass='{escaped}'")
|
|
1031
|
+
|
|
1032
|
+
# Grain goes on last, over the whole assembled film, so one texture sits
|
|
1033
|
+
# across every cut. `t` makes it move frame to frame — static noise reads
|
|
1034
|
+
# as dirt on the lens, not as film.
|
|
1035
|
+
grain = (
|
|
1036
|
+
f"noise=alls={delivery.grain:g}:allf=t+u"
|
|
1037
|
+
if delivery.grain is not None
|
|
1038
|
+
else None
|
|
1039
|
+
)
|
|
1040
|
+
if grain is not None:
|
|
1041
|
+
filters.append(grain)
|
|
600
1042
|
|
|
601
1043
|
final = output_root / "成片.mp4"
|
|
602
1044
|
command = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y", "-i", str(joined)]
|
|
@@ -605,9 +1047,12 @@ def render(
|
|
|
605
1047
|
# to be named: the default one drops it and the overlay arrives as
|
|
606
1048
|
# an opaque black rectangle.
|
|
607
1049
|
command += ["-c:v", "libvpx", "-i", str(overlay_path)]
|
|
1050
|
+
# ffmpeg-route subtitles and grain follow the composite, so the
|
|
1051
|
+
# subtitle sits above the screen text and one grain covers both.
|
|
1052
|
+
chain = ",".join(["[0:v][1:v]overlay=0:0:format=auto", *filters])
|
|
608
1053
|
command += [
|
|
609
1054
|
"-filter_complex",
|
|
610
|
-
"
|
|
1055
|
+
f"{chain},format=yuv420p[v]",
|
|
611
1056
|
"-map", "[v]", "-map", "0:a",
|
|
612
1057
|
"-c:v", "libx264", "-preset", "medium", "-crf", "18",
|
|
613
1058
|
]
|
|
@@ -618,7 +1063,7 @@ def render(
|
|
|
618
1063
|
command += ["-c:v", "copy"]
|
|
619
1064
|
if delivery.loudness_lufs is not None:
|
|
620
1065
|
command += ["-af", _loudnorm_filter(ffmpeg, joined, delivery.loudness_lufs)]
|
|
621
|
-
command += ["-c:a", "aac", "-b:a", "192k"]
|
|
1066
|
+
command += ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"]
|
|
622
1067
|
else:
|
|
623
1068
|
command += ["-c:a", "copy"]
|
|
624
1069
|
command.append(str(final))
|
|
@@ -629,22 +1074,18 @@ def render(
|
|
|
629
1074
|
"分段": [str(segment) for segment in segments],
|
|
630
1075
|
"字幕": str(subtitle_path) if subtitle_path else None,
|
|
631
1076
|
"压制字幕": str(styled_path) if styled_path else None,
|
|
632
|
-
"
|
|
1077
|
+
"叠层": str(overlay_path) if overlay_path else None,
|
|
633
1078
|
"字幕渲染": renderer if subtitle_path else None,
|
|
1079
|
+
"画面文字": len(layers),
|
|
1080
|
+
"音效": len(effects),
|
|
1081
|
+
"自动接镜": _shot_match_report(auto, scenes, enabled=delivery.shot_match),
|
|
634
1082
|
"段数": len(segments),
|
|
635
1083
|
"各段时长之和": round(sum(cut.end - cut.start for cut in cuts), 2),
|
|
636
1084
|
}
|
|
637
1085
|
|
|
638
1086
|
|
|
639
1087
|
def _shot_match_filter(cut: Cut) -> str:
|
|
640
|
-
"""
|
|
641
|
-
|
|
642
|
-
Generated shots drift: two takes of the same person at the same table come
|
|
643
|
-
back a stop apart and half a step of white balance away from each other, and
|
|
644
|
-
the join reads as a mistake rather than a cut. The correction stays a stated
|
|
645
|
-
creator decision — the tool never measures a clip and adjusts it on its own,
|
|
646
|
-
because a correction nobody wrote down is one nobody can review.
|
|
647
|
-
"""
|
|
1088
|
+
"""The correction a cut states in 「画面:」. It replaces the automatic match."""
|
|
648
1089
|
|
|
649
1090
|
stages: list[str] = []
|
|
650
1091
|
eq = [
|
|
@@ -662,12 +1103,197 @@ def _shot_match_filter(cut: Cut) -> str:
|
|
|
662
1103
|
return ",".join(stages)
|
|
663
1104
|
|
|
664
1105
|
|
|
1106
|
+
def _heading_fields(path: Path, heading: re.Pattern[str], field: str) -> dict[str, str]:
|
|
1107
|
+
"""`{ID: value}` for one `- <field>:` line under each matching `##` heading."""
|
|
1108
|
+
|
|
1109
|
+
if not path.is_file():
|
|
1110
|
+
return {}
|
|
1111
|
+
found: dict[str, str] = {}
|
|
1112
|
+
current: Optional[str] = None
|
|
1113
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
1114
|
+
match = heading.match(raw)
|
|
1115
|
+
if match:
|
|
1116
|
+
current = match.group(1)
|
|
1117
|
+
continue
|
|
1118
|
+
if raw.startswith("## "):
|
|
1119
|
+
current = None
|
|
1120
|
+
continue
|
|
1121
|
+
line = FIELD.match(raw)
|
|
1122
|
+
if current and line and line.group(1).strip() == field:
|
|
1123
|
+
found.setdefault(current, line.group(2).strip())
|
|
1124
|
+
return found
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def _scene_keys(episode: Path, cuts: Sequence[Cut]) -> list[Optional[tuple[str, ...]]]:
|
|
1128
|
+
"""Each cut's scene: MOTION → its 分镜 SHOT → the scene IDs that SHOT's 来源 names.
|
|
1129
|
+
|
|
1130
|
+
A shot drawing on two scenes keys on both, so it matches neither neighbour.
|
|
1131
|
+
None where the chain breaks; such a cut is never matched.
|
|
1132
|
+
"""
|
|
1133
|
+
|
|
1134
|
+
shots = _heading_fields(episode / MOTION_DOCUMENT, MOTION_HEADING, "分镜")
|
|
1135
|
+
sources = _heading_fields(episode / STORYBOARD_DOCUMENT, SHOT_HEADING, "来源")
|
|
1136
|
+
keys: list[Optional[tuple[str, ...]]] = []
|
|
1137
|
+
for cut in cuts:
|
|
1138
|
+
shot = SHOT_REFERENCE.search(shots.get(cut.motion, ""))
|
|
1139
|
+
scenes = tuple(SCENE_ID.findall(sources.get(shot.group(0), ""))) if shot else ()
|
|
1140
|
+
keys.append(scenes or None)
|
|
1141
|
+
return keys
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def _scene_runs(scenes: Sequence[Optional[tuple[str, ...]]]) -> list[list[int]]:
|
|
1145
|
+
"""Indexes of consecutive cuts from one scene. Intercut scenes form separate runs."""
|
|
1146
|
+
|
|
1147
|
+
runs: list[list[int]] = []
|
|
1148
|
+
for index, scene in enumerate(scenes):
|
|
1149
|
+
if scene is None:
|
|
1150
|
+
continue
|
|
1151
|
+
if runs and scenes[index - 1] == scene:
|
|
1152
|
+
runs[-1].append(index)
|
|
1153
|
+
else:
|
|
1154
|
+
runs.append([index])
|
|
1155
|
+
return runs
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def _median(values: Sequence[float]) -> float:
|
|
1159
|
+
ordered = sorted(values)
|
|
1160
|
+
middle = len(ordered) // 2
|
|
1161
|
+
return ordered[middle] if len(ordered) % 2 else (ordered[middle - 1] + ordered[middle]) / 2
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def _match_toward(
|
|
1165
|
+
stats: ChannelStats, reference: ChannelStats, strength: float = SHOT_MATCH_STRENGTH
|
|
1166
|
+
) -> ShotMatch:
|
|
1167
|
+
"""Gain and offset per channel moving mean and spread `strength` of the way to `reference`."""
|
|
1168
|
+
|
|
1169
|
+
low, high = SHOT_MATCH_GAIN_LIMITS
|
|
1170
|
+
gains: list[float] = []
|
|
1171
|
+
offsets: list[float] = []
|
|
1172
|
+
for mean, spread, target_mean, target_spread in zip(
|
|
1173
|
+
stats.mean, stats.spread, reference.mean, reference.spread
|
|
1174
|
+
):
|
|
1175
|
+
mean_to = mean + strength * (target_mean - mean)
|
|
1176
|
+
spread_to = spread + strength * (target_spread - spread)
|
|
1177
|
+
gain = min(high, max(low, spread_to / spread)) if spread > 1e-6 else 1.0
|
|
1178
|
+
gains.append(gain)
|
|
1179
|
+
offsets.append(mean_to - gain * mean)
|
|
1180
|
+
return ShotMatch(tuple(gains), tuple(offsets))
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def _plan_shot_match(
|
|
1184
|
+
cuts: Sequence[Cut],
|
|
1185
|
+
scenes: Sequence[Optional[tuple[str, ...]]],
|
|
1186
|
+
measure: Callable[[Cut], Optional[ChannelStats]],
|
|
1187
|
+
strength: float = SHOT_MATCH_STRENGTH,
|
|
1188
|
+
) -> dict[str, ShotMatch]:
|
|
1189
|
+
"""The automatic match for every cut it applies to, by CUT ID.
|
|
1190
|
+
|
|
1191
|
+
Within each run of one scene, the cuts that state no correction are pulled
|
|
1192
|
+
toward the median of those same cuts. A cut with 「画面:<校正>」 or
|
|
1193
|
+
「画面:不校」 is neither corrected nor counted in the median. `measure(cut)`
|
|
1194
|
+
returns ChannelStats, or None when the clip cannot be read.
|
|
1195
|
+
"""
|
|
1196
|
+
|
|
1197
|
+
plan: dict[str, ShotMatch] = {}
|
|
1198
|
+
for run in _scene_runs(scenes):
|
|
1199
|
+
eligible = [cuts[i] for i in run if not cuts[i].picture and not cuts[i].untouched]
|
|
1200
|
+
measured = [(cut, measure(cut)) for cut in eligible] if len(eligible) > 1 else []
|
|
1201
|
+
known = [(cut, stats) for cut, stats in measured if stats is not None]
|
|
1202
|
+
if len(known) < 2:
|
|
1203
|
+
continue
|
|
1204
|
+
reference = ChannelStats(
|
|
1205
|
+
tuple(_median([stats.mean[c] for _, stats in known]) for c in range(3)),
|
|
1206
|
+
tuple(_median([stats.spread[c] for _, stats in known]) for c in range(3)),
|
|
1207
|
+
)
|
|
1208
|
+
for cut, stats in known:
|
|
1209
|
+
plan[cut.cut_id] = _match_toward(stats, reference, strength)
|
|
1210
|
+
return plan
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def _auto_match_filter(match: ShotMatch) -> str:
|
|
1214
|
+
"""One `lutrgb` stage on 8-bit RGB; the lookup clips to 0-255 itself.
|
|
1215
|
+
|
|
1216
|
+
The numbers were measured on a 0-255 scale. Left to negotiate, a 10-bit
|
|
1217
|
+
source runs the lookup at 16 bits a channel and the offsets barely move it.
|
|
1218
|
+
"""
|
|
1219
|
+
|
|
1220
|
+
return "format=rgb24,lutrgb=" + ":".join(
|
|
1221
|
+
f"{channel}='val*{gain:.4f}{offset:+.3f}'"
|
|
1222
|
+
for channel, gain, offset in zip("rgb", match.gains, match.offsets)
|
|
1223
|
+
)
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def _picture_plan(
|
|
1227
|
+
cuts: Sequence[Cut],
|
|
1228
|
+
scenes: Sequence[Optional[tuple[str, ...]]],
|
|
1229
|
+
measure: Callable[[Cut], Optional[ChannelStats]],
|
|
1230
|
+
*,
|
|
1231
|
+
enabled: bool,
|
|
1232
|
+
) -> tuple[list[str], dict[str, ShotMatch]]:
|
|
1233
|
+
"""Each cut's picture filter: its stated correction, else the automatic match, else none."""
|
|
1234
|
+
|
|
1235
|
+
auto = _plan_shot_match(cuts, scenes, measure) if enabled else {}
|
|
1236
|
+
filters = [
|
|
1237
|
+
_shot_match_filter(cut) if cut.picture
|
|
1238
|
+
else _auto_match_filter(auto[cut.cut_id]) if cut.cut_id in auto
|
|
1239
|
+
else ""
|
|
1240
|
+
for cut in cuts
|
|
1241
|
+
]
|
|
1242
|
+
return filters, auto
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
def _shot_match_report(
|
|
1246
|
+
auto: dict[str, ShotMatch], scenes: Sequence[Optional[tuple[str, ...]]], *, enabled: bool
|
|
1247
|
+
) -> Any:
|
|
1248
|
+
"""What the automatic match did, so the correction is on the record."""
|
|
1249
|
+
|
|
1250
|
+
if not enabled:
|
|
1251
|
+
return "关(剪辑单写了「接镜匹配:无」)"
|
|
1252
|
+
if not any(scenes):
|
|
1253
|
+
return f"未执行(按《{MOTION_DOCUMENT}》与《{STORYBOARD_DOCUMENT}》找不到各段所属场景)"
|
|
1254
|
+
return {
|
|
1255
|
+
cut_id: {
|
|
1256
|
+
"增益": [round(gain, 3) for gain in match.gains],
|
|
1257
|
+
"偏移": [round(offset, 1) for offset in match.offsets],
|
|
1258
|
+
}
|
|
1259
|
+
for cut_id, match in auto.items()
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def _channel_stats(
|
|
1264
|
+
ffmpeg: str, media: Path, start: float, duration: float
|
|
1265
|
+
) -> Optional[ChannelStats]:
|
|
1266
|
+
"""Per-channel mean and spread of one cut's source range, sampled small."""
|
|
1267
|
+
|
|
1268
|
+
result = subprocess.run(
|
|
1269
|
+
[ffmpeg, "-v", "error", "-ss", f"{start:.3f}", "-t", f"{duration:.3f}",
|
|
1270
|
+
"-i", str(media), "-vf", SHOT_MATCH_SAMPLE,
|
|
1271
|
+
"-pix_fmt", "rgb24", "-f", "rawvideo", "-"],
|
|
1272
|
+
capture_output=True, check=False,
|
|
1273
|
+
)
|
|
1274
|
+
if result.returncode != 0 or len(result.stdout) < 3:
|
|
1275
|
+
return None
|
|
1276
|
+
return _rgb_stats(result.stdout)
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
def _rgb_stats(raw: bytes) -> ChannelStats:
|
|
1280
|
+
means: list[float] = []
|
|
1281
|
+
spreads: list[float] = []
|
|
1282
|
+
for channel in range(3):
|
|
1283
|
+
values = raw[channel::3]
|
|
1284
|
+
count = len(values)
|
|
1285
|
+
mean = sum(values) / count
|
|
1286
|
+
square = sum(map(operator.mul, values, values)) / count
|
|
1287
|
+
means.append(mean)
|
|
1288
|
+
spreads.append(max(0.0, square - mean * mean) ** 0.5)
|
|
1289
|
+
return ChannelStats(tuple(means), tuple(spreads))
|
|
1290
|
+
|
|
1291
|
+
|
|
665
1292
|
def _loudnorm_filter(ffmpeg: str, media: Path, target: float) -> str:
|
|
666
|
-
"""
|
|
1293
|
+
"""Measure EBU R128 before normalization; verify the encoded output afterward.
|
|
667
1294
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
the measurement back, and the second pass is linear.
|
|
1295
|
+
FFmpeg can fall back to dynamic processing when linear gain would exceed
|
|
1296
|
+
the peak or loudness-range target. Two passes do not guarantee target LUFS.
|
|
671
1297
|
"""
|
|
672
1298
|
|
|
673
1299
|
common = f"I={target}:TP=-1.5:LRA=11"
|
|
@@ -701,24 +1327,76 @@ def _sync_remotion_workspace(workspace: Path) -> Path:
|
|
|
701
1327
|
"""
|
|
702
1328
|
|
|
703
1329
|
workspace = workspace.expanduser().resolve()
|
|
704
|
-
(workspace / "src").mkdir(parents=True, exist_ok=True)
|
|
705
1330
|
for name in REMOTION_SOURCE_FILES:
|
|
706
1331
|
source = REMOTION_SOURCE / name
|
|
707
1332
|
if not source.is_file():
|
|
708
1333
|
raise EditError(f"技能里缺少 Remotion 源文件: {name}")
|
|
1334
|
+
(workspace / name).parent.mkdir(parents=True, exist_ok=True)
|
|
709
1335
|
shutil.copyfile(source, workspace / name)
|
|
710
1336
|
return workspace
|
|
711
1337
|
|
|
712
1338
|
|
|
1339
|
+
def _overlay_props(
|
|
1340
|
+
cues: Sequence[DisplayCue],
|
|
1341
|
+
layers: Sequence[dict[str, Any]],
|
|
1342
|
+
canvas: dict[str, Any],
|
|
1343
|
+
duration: float,
|
|
1344
|
+
) -> dict[str, Any]:
|
|
1345
|
+
"""The whole contract with the composition, in output seconds.
|
|
1346
|
+
|
|
1347
|
+
Typography -- faces, sizes, colours -- is the composition's own; only what
|
|
1348
|
+
to draw and when crosses this boundary.
|
|
1349
|
+
"""
|
|
1350
|
+
|
|
1351
|
+
return {
|
|
1352
|
+
"cues": [
|
|
1353
|
+
{"start": round(cue.start, 3), "end": round(cue.end, 3), "text": cue.text,
|
|
1354
|
+
"kind": cue.kind, "keys": list(cue.keys)}
|
|
1355
|
+
for cue in cues
|
|
1356
|
+
],
|
|
1357
|
+
"screenTexts": [
|
|
1358
|
+
{
|
|
1359
|
+
**layer,
|
|
1360
|
+
"start": round(layer["start"], 3),
|
|
1361
|
+
"end": round(layer["end"], 3),
|
|
1362
|
+
"countdown": (
|
|
1363
|
+
None if layer["countdown"] is None else round(layer["countdown"], 3)
|
|
1364
|
+
),
|
|
1365
|
+
}
|
|
1366
|
+
for layer in layers
|
|
1367
|
+
],
|
|
1368
|
+
"width": canvas.get("width") or 1080,
|
|
1369
|
+
"height": canvas.get("height") or 1920,
|
|
1370
|
+
"fps": canvas.get("fps") or 24,
|
|
1371
|
+
"durationInSeconds": round(duration, 3),
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
def _missing_remotion_packages(workspace: Path) -> list[str]:
|
|
1376
|
+
"""Dependencies the synced package.json names that the workspace has not installed.
|
|
1377
|
+
|
|
1378
|
+
The fonts are packages too: a workspace installed before they were added
|
|
1379
|
+
has `node_modules` but no faces, and the bundle would fail halfway in.
|
|
1380
|
+
"""
|
|
1381
|
+
|
|
1382
|
+
manifest = json.loads((workspace / "package.json").read_text(encoding="utf-8"))
|
|
1383
|
+
return sorted(
|
|
1384
|
+
name for name in manifest.get("dependencies", {})
|
|
1385
|
+
if not (workspace / "node_modules" / name / "package.json").is_file()
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
|
|
713
1389
|
def _render_remotion_overlay(
|
|
714
1390
|
output_root: Path,
|
|
715
|
-
cues: Sequence[
|
|
1391
|
+
cues: Sequence[DisplayCue],
|
|
1392
|
+
layers: Sequence[dict[str, Any]],
|
|
716
1393
|
canvas: dict[str, Any],
|
|
717
1394
|
duration: float,
|
|
718
1395
|
workspace_root: Path,
|
|
719
1396
|
concurrency: int = DEFAULT_REMOTION_CONCURRENCY,
|
|
1397
|
+
needed_for: str = "字幕",
|
|
720
1398
|
) -> Path:
|
|
721
|
-
"""Render
|
|
1399
|
+
"""Render subtitles and screen text as one transparent video with Remotion.
|
|
722
1400
|
|
|
723
1401
|
The picture is never re-drawn by the browser: only the type is, onto an
|
|
724
1402
|
empty frame, and ffmpeg composites that over untouched footage. So the
|
|
@@ -732,44 +1410,33 @@ def _render_remotion_overlay(
|
|
|
732
1410
|
"""
|
|
733
1411
|
|
|
734
1412
|
workspace = _sync_remotion_workspace(workspace_root)
|
|
735
|
-
|
|
1413
|
+
# Screen text has no other route, so the way out named here depends on why
|
|
1414
|
+
# the pass is running: dropping a flag cannot draw a system panel.
|
|
1415
|
+
alternative = (
|
|
1416
|
+
"剪辑单里有画面文字,它只能由这条路线绘制;不装就把画面文字行删掉或写「无」。"
|
|
1417
|
+
if needed_for == "画面文字"
|
|
1418
|
+
else "或改用默认的 ffmpeg 字幕(去掉 --subtitles remotion)。"
|
|
1419
|
+
)
|
|
1420
|
+
missing = _missing_remotion_packages(workspace)
|
|
1421
|
+
if missing:
|
|
736
1422
|
raise EditError(
|
|
737
|
-
f"Remotion
|
|
1423
|
+
f"Remotion 叠层的依赖没装全(缺 {'、'.join(missing)}),{needed_for}需要它们。先运行一次:\n"
|
|
738
1424
|
f" cd {workspace} && npm install\n"
|
|
739
|
-
"
|
|
1425
|
+
f"{alternative}\n"
|
|
740
1426
|
"注意 Remotion 有自己的许可证:个人与小团队免费,超出规模需要商业授权;"
|
|
741
1427
|
"本工具不会替你安装它。"
|
|
742
1428
|
)
|
|
743
1429
|
npx = _which("npx")
|
|
744
1430
|
if npx is None:
|
|
745
1431
|
raise EditError(
|
|
746
|
-
"PATH 上没有 npx,无法运行 Remotion
|
|
1432
|
+
f"PATH 上没有 npx,无法运行 Remotion 绘制{needed_for};先装好 Node.js。{alternative}"
|
|
747
1433
|
)
|
|
748
|
-
props = output_root / "
|
|
1434
|
+
props = output_root / "叠层.props.json"
|
|
749
1435
|
props.write_text(
|
|
750
|
-
json.dumps(
|
|
751
|
-
{
|
|
752
|
-
"cues": [
|
|
753
|
-
{"start": round(start, 3), "end": round(end, 3), "text": text}
|
|
754
|
-
for start, end, text in cues
|
|
755
|
-
],
|
|
756
|
-
"width": canvas["width"] or 1080,
|
|
757
|
-
"height": canvas["height"] or 1920,
|
|
758
|
-
"fps": canvas["fps"] or 24,
|
|
759
|
-
"durationInSeconds": round(duration, 3),
|
|
760
|
-
"fontScale": 0.034,
|
|
761
|
-
"bottomScale": 0.055,
|
|
762
|
-
"fontFamily": (
|
|
763
|
-
'"PingFang SC", "Noto Sans CJK SC", "Source Han Sans SC", '
|
|
764
|
-
'"Microsoft YaHei", sans-serif'
|
|
765
|
-
),
|
|
766
|
-
},
|
|
767
|
-
ensure_ascii=False,
|
|
768
|
-
indent=2,
|
|
769
|
-
),
|
|
1436
|
+
json.dumps(_overlay_props(cues, layers, canvas, duration), ensure_ascii=False, indent=2),
|
|
770
1437
|
encoding="utf-8",
|
|
771
1438
|
)
|
|
772
|
-
overlay = output_root / "
|
|
1439
|
+
overlay = output_root / "叠层.webm"
|
|
773
1440
|
result = subprocess.run(
|
|
774
1441
|
[npx, "remotion", "render", REMOTION_COMPOSITION, str(overlay),
|
|
775
1442
|
f"--props={props}", "--log=error",
|
|
@@ -790,7 +1457,7 @@ def _frame_geometry(media: Path) -> str:
|
|
|
790
1457
|
|
|
791
1458
|
def _subtitle_cues(
|
|
792
1459
|
cuts: Sequence[Cut], spans: Sequence[float]
|
|
793
|
-
) -> list[tuple[float, float, str]]:
|
|
1460
|
+
) -> list[tuple[float, float, str, tuple[str, ...]]]:
|
|
794
1461
|
"""Place each line in output time, measuring the segments that were written.
|
|
795
1462
|
|
|
796
1463
|
A segment lands on a frame boundary, so it is a few milliseconds longer than
|
|
@@ -799,33 +1466,308 @@ def _subtitle_cues(
|
|
|
799
1466
|
before the actor stops speaking. The rendered files are the timeline.
|
|
800
1467
|
"""
|
|
801
1468
|
|
|
802
|
-
cues: list[tuple[float, float, str]] = []
|
|
803
|
-
cursor
|
|
804
|
-
for cut, span in zip(cuts, spans):
|
|
1469
|
+
cues: list[tuple[float, float, str, tuple[str, ...]]] = []
|
|
1470
|
+
for cut, cursor, scale in _timeline(cuts, spans):
|
|
805
1471
|
declared = cut.end - cut.start
|
|
806
|
-
|
|
807
|
-
# place proportionally rather than letting the tail slip out.
|
|
808
|
-
scale = span / declared if declared > 0 else 1.0
|
|
809
|
-
for window_start, window_end, text in cut.subtitles:
|
|
1472
|
+
for window_start, window_end, text, words in cut.subtitles:
|
|
810
1473
|
start = 0.0 if window_start is None else window_start
|
|
811
1474
|
end = declared if window_end is None else window_end
|
|
812
|
-
cues.append((cursor + start * scale, cursor + end * scale, text))
|
|
813
|
-
cursor += span
|
|
1475
|
+
cues.append((cursor + start * scale, cursor + end * scale, text, words))
|
|
814
1476
|
return cues
|
|
815
1477
|
|
|
816
1478
|
|
|
817
|
-
def
|
|
818
|
-
|
|
1479
|
+
def _timeline(
|
|
1480
|
+
cuts: Sequence[Cut], spans: Sequence[float]
|
|
1481
|
+
) -> list[tuple[Cut, float, float]]:
|
|
1482
|
+
"""Each cut with its output start and the factor from declared to rendered time.
|
|
1483
|
+
|
|
1484
|
+
Windows were authored against the declared span; scaling holds them in
|
|
1485
|
+
place proportionally rather than letting the tail slip out.
|
|
1486
|
+
"""
|
|
1487
|
+
|
|
1488
|
+
placed: list[tuple[Cut, float, float]] = []
|
|
1489
|
+
cursor = 0.0
|
|
1490
|
+
for cut, span in zip(cuts, spans):
|
|
1491
|
+
declared = cut.end - cut.start
|
|
1492
|
+
placed.append((cut, cursor, span / declared if declared > 0 else 1.0))
|
|
1493
|
+
cursor += span
|
|
1494
|
+
return placed
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
class DisplayCue(NamedTuple):
|
|
1498
|
+
"""One subtitle as burned: output seconds, display text, speaker kind, highlights."""
|
|
1499
|
+
|
|
1500
|
+
start: float
|
|
1501
|
+
end: float
|
|
1502
|
+
text: str
|
|
1503
|
+
kind: str = "line"
|
|
1504
|
+
keys: tuple[str, ...] = ()
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
def _display_line(text: str) -> str:
|
|
1508
|
+
"""The burned form of a line: no closing punctuation, pauses as full-width spaces."""
|
|
1509
|
+
|
|
1510
|
+
shown = text.strip().rstrip(SUBTITLE_END_MARKS + " ")
|
|
1511
|
+
shown = re.sub(f"[{SUBTITLE_PAUSE_MARKS}]+[ ]*", " ", shown)
|
|
1512
|
+
return re.sub(r"[ ]{2,}", " ", shown).strip(" ")
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
def _visible(text: str) -> int:
|
|
1516
|
+
return len(re.sub(r"[ ]", "", text))
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
def _split_display(
|
|
1520
|
+
text: str, limit: int = SUBTITLE_MAX_VISIBLE, keep: Sequence[str] = ()
|
|
1521
|
+
) -> list[str]:
|
|
1522
|
+
"""Break a displayed line into one-line pieces of at most `limit` characters.
|
|
1523
|
+
|
|
1524
|
+
Pieces break at the line's own pauses (the full-width spaces, and after ?!)
|
|
1525
|
+
and are packed back together while they fit. A single phrase longer than
|
|
1526
|
+
the limit is cut into near-equal parts, which is the only place a break
|
|
1527
|
+
falls where the screenplay has no pause. No break falls inside a word in
|
|
1528
|
+
`keep`: a highlight is looked up in the piece it lands in, and half a word
|
|
1529
|
+
matches nothing.
|
|
1530
|
+
"""
|
|
1531
|
+
|
|
1532
|
+
if _visible(text) <= limit:
|
|
1533
|
+
return [text]
|
|
1534
|
+
held = {
|
|
1535
|
+
at
|
|
1536
|
+
for word in keep if word
|
|
1537
|
+
for found in re.finditer(re.escape(word), text)
|
|
1538
|
+
for at in range(found.start() + 1, found.end())
|
|
1539
|
+
}
|
|
1540
|
+
phrases: list[tuple[int, int]] = []
|
|
1541
|
+
for found in re.finditer(r"[^ ?!?!]+[?!?!]*|[?!?!]+", text):
|
|
1542
|
+
start, end = found.span()
|
|
1543
|
+
if phrases and any(at in held for at in range(phrases[-1][1], start + 1)):
|
|
1544
|
+
phrases[-1] = (phrases[-1][0], end)
|
|
1545
|
+
else:
|
|
1546
|
+
phrases.append((start, end))
|
|
1547
|
+
|
|
1548
|
+
def even_parts(start: int, end: int) -> list[tuple[int, int]]:
|
|
1549
|
+
count = _visible(text[start:end])
|
|
1550
|
+
if count <= limit:
|
|
1551
|
+
return [(start, end)]
|
|
1552
|
+
free = [at for at in range(start + 1, end) if at not in held]
|
|
1553
|
+
first: Optional[list[tuple[int, int]]] = None
|
|
1554
|
+
for parts in range(-(-count // limit), count + 1):
|
|
1555
|
+
size = -(-count // parts)
|
|
1556
|
+
cuts = [start]
|
|
1557
|
+
for index in range(1, parts):
|
|
1558
|
+
ideal = start + index * size
|
|
1559
|
+
later = [at for at in free if at > cuts[-1]]
|
|
1560
|
+
if not later:
|
|
1561
|
+
break
|
|
1562
|
+
cuts.append(min(later, key=lambda at: (abs(at - ideal), at)))
|
|
1563
|
+
spans = list(zip(cuts, cuts[1:] + [end]))
|
|
1564
|
+
if all(_visible(text[a:b]) <= limit for a, b in spans):
|
|
1565
|
+
return spans
|
|
1566
|
+
first = first or spans
|
|
1567
|
+
return first or [(start, end)]
|
|
1568
|
+
|
|
1569
|
+
packed: list[tuple[int, int]] = []
|
|
1570
|
+
for start, end in (span for phrase in phrases for span in even_parts(*phrase)):
|
|
1571
|
+
# Joining by slicing the line keeps whatever stood between the pieces.
|
|
1572
|
+
if packed and _visible(text[packed[-1][0]:end]) <= limit:
|
|
1573
|
+
packed[-1] = (packed[-1][0], end)
|
|
1574
|
+
else:
|
|
1575
|
+
packed.append((start, end))
|
|
1576
|
+
return [text[start:end].strip(" ") for start, end in packed]
|
|
1577
|
+
|
|
1578
|
+
|
|
1579
|
+
def _screenplay_text(episode: Path) -> str:
|
|
1580
|
+
path = episode / SCREENPLAY_DOCUMENT
|
|
1581
|
+
return path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
1582
|
+
|
|
1583
|
+
|
|
1584
|
+
def _line_kind(text: str, screenplay: str) -> str:
|
|
1585
|
+
"""`system`, `vo` or `line`, from the 剧本.md line the subtitle quotes."""
|
|
1586
|
+
|
|
1587
|
+
needle = _normalize(text)
|
|
1588
|
+
for raw in screenplay.splitlines():
|
|
1589
|
+
spoken = SPOKEN_LINE.match(raw)
|
|
1590
|
+
if not spoken or needle not in _normalize(spoken.group(3)):
|
|
1591
|
+
continue
|
|
1592
|
+
if spoken.group(1) == "VO":
|
|
1593
|
+
return "system" if SYSTEM_SPEAKER in spoken.group(2) else "vo"
|
|
1594
|
+
return "line"
|
|
1595
|
+
return "line"
|
|
1596
|
+
|
|
1597
|
+
|
|
1598
|
+
def _display_cues(
|
|
1599
|
+
cues: Sequence[tuple[float, float, str, tuple[str, ...]]],
|
|
1600
|
+
screenplay: str = "",
|
|
1601
|
+
) -> list[DisplayCue]:
|
|
1602
|
+
"""Turn placed lines into what is burned: display text, split, coloured by speaker.
|
|
1603
|
+
|
|
1604
|
+
A split line shares its window by character count, so each piece stays up
|
|
1605
|
+
about as long as it takes to read.
|
|
1606
|
+
"""
|
|
1607
|
+
|
|
1608
|
+
shown: list[DisplayCue] = []
|
|
1609
|
+
for start, end, text, words in cues:
|
|
1610
|
+
kind = _line_kind(text, screenplay)
|
|
1611
|
+
# Highlights are matched in their burned form: 「十万,现金」 shows as 「十万 现金」.
|
|
1612
|
+
burned = tuple(_display_line(word) for word in words)
|
|
1613
|
+
pieces = _split_display(_display_line(text), keep=burned)
|
|
1614
|
+
# A line that is all punctuation ("……") has nothing left to show.
|
|
1615
|
+
pieces = [piece for piece in pieces if _visible(piece)]
|
|
1616
|
+
lost = [word for word in burned if not word or not any(word in p for p in pieces)]
|
|
1617
|
+
if lost:
|
|
1618
|
+
raise EditError(f"字幕「{text}」的重点词在压制后的字幕里找不到: {lost}")
|
|
1619
|
+
total = sum(_visible(piece) for piece in pieces)
|
|
1620
|
+
cursor = start
|
|
1621
|
+
for piece in pieces:
|
|
1622
|
+
length = (end - start) * _visible(piece) / total
|
|
1623
|
+
keys = tuple(word for word in burned if word in piece)
|
|
1624
|
+
shown.append(DisplayCue(cursor, cursor + length, piece, kind, keys))
|
|
1625
|
+
cursor += length
|
|
1626
|
+
return shown
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
def _screen_text_layers(
|
|
1630
|
+
cuts: Sequence[Cut], spans: Sequence[float]
|
|
1631
|
+
) -> list[dict[str, Any]]:
|
|
1632
|
+
"""Place screen text in output time and resolve every countdown to a number.
|
|
1633
|
+
|
|
1634
|
+
「接续」 continues the latest earlier countdown: its value here is that one's
|
|
1635
|
+
value minus the output time between them, so a chip picks up exactly where
|
|
1636
|
+
the task panel left off however long the cuts in between came out. A piece
|
|
1637
|
+
that meets an identical piece at a cut is extended instead of re-entering.
|
|
1638
|
+
"""
|
|
1639
|
+
|
|
1640
|
+
layers: list[dict[str, Any]] = []
|
|
1641
|
+
latest: Optional[dict[str, Any]] = None
|
|
1642
|
+
for cut, cursor, scale in _timeline(cuts, spans):
|
|
1643
|
+
for text in sorted(cut.screen_texts, key=lambda item: item.start):
|
|
1644
|
+
start = cursor + text.start * scale
|
|
1645
|
+
end = cursor + text.end * scale
|
|
1646
|
+
countdown = text.countdown
|
|
1647
|
+
if text.resume:
|
|
1648
|
+
if latest is None:
|
|
1649
|
+
raise EditError(
|
|
1650
|
+
f"{cut.cut_id} 的画面文字写了「倒计时:接续」,但在它之前没有倒计时"
|
|
1651
|
+
)
|
|
1652
|
+
countdown = latest["countdown"] - (start - latest["start"])
|
|
1653
|
+
style = SCREEN_TEXT_STYLES[text.style]
|
|
1654
|
+
items = [
|
|
1655
|
+
{"text": item, "rarity": rarity}
|
|
1656
|
+
for item, rarity in zip(text.items, text.rarities or (None,) * len(text.items))
|
|
1657
|
+
]
|
|
1658
|
+
previous = next((layer for layer in reversed(layers) if layer["style"] == style), None)
|
|
1659
|
+
if (
|
|
1660
|
+
previous is not None
|
|
1661
|
+
and previous["items"] == items
|
|
1662
|
+
and abs(previous["end"] - start) <= SCREEN_TEXT_JOIN_GAP
|
|
1663
|
+
and (
|
|
1664
|
+
(text.resume and previous is latest)
|
|
1665
|
+
or (countdown is None and previous["countdown"] is None)
|
|
1666
|
+
)
|
|
1667
|
+
):
|
|
1668
|
+
previous["end"] = end
|
|
1669
|
+
continue
|
|
1670
|
+
layer = {
|
|
1671
|
+
"start": start,
|
|
1672
|
+
"end": end,
|
|
1673
|
+
"style": style,
|
|
1674
|
+
"items": items,
|
|
1675
|
+
"countdown": countdown,
|
|
1676
|
+
}
|
|
1677
|
+
layers.append(layer)
|
|
1678
|
+
if countdown is not None:
|
|
1679
|
+
latest = layer
|
|
1680
|
+
return layers
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
def _placed_sound_effects(
|
|
1684
|
+
cuts: Sequence[Cut], spans: Sequence[float]
|
|
1685
|
+
) -> list[tuple[float, float, str, float]]:
|
|
1686
|
+
"""Each effect as (output start, duration, path as written, gain dB)."""
|
|
1687
|
+
|
|
1688
|
+
return [
|
|
1689
|
+
(cursor + effect.start * scale, (effect.end - effect.start) * scale,
|
|
1690
|
+
effect.path, effect.gain_db)
|
|
1691
|
+
for cut, cursor, scale in _timeline(cuts, spans)
|
|
1692
|
+
for effect in cut.sound_effects
|
|
1693
|
+
]
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
def _sound_effect_command(
|
|
1697
|
+
ffmpeg: str,
|
|
1698
|
+
film: Path,
|
|
1699
|
+
effects: Sequence[tuple[float, float, Path, float]],
|
|
1700
|
+
output: Path,
|
|
1701
|
+
) -> list[str]:
|
|
1702
|
+
"""Lay each effect into the film's own audio at its output time.
|
|
1703
|
+
|
|
1704
|
+
`normalize=0` keeps the dialogue at its level: amix's default divides every
|
|
1705
|
+
input by the input count, and the film would get quieter each time a chime
|
|
1706
|
+
is added. A short fade closes each effect so a trimmed tail does not click.
|
|
1707
|
+
"""
|
|
1708
|
+
|
|
1709
|
+
command = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y", "-i", str(film)]
|
|
1710
|
+
# The film's channel layout is left as it is: forcing mono dialogue up to
|
|
1711
|
+
# stereo costs it 3 dB, and the effects are converted to match instead.
|
|
1712
|
+
stages = ["[0:a]aformat=sample_rates=48000[a0]"]
|
|
1713
|
+
labels = ["[a0]"]
|
|
1714
|
+
for index, (start, duration, media, gain) in enumerate(effects, start=1):
|
|
1715
|
+
command += ["-i", str(media)]
|
|
1716
|
+
fade = min(0.05, duration / 2)
|
|
1717
|
+
stages.append(
|
|
1718
|
+
f"[{index}:a]atrim=0:{duration:.3f},asetpts=PTS-STARTPTS,"
|
|
1719
|
+
f"afade=t=out:st={duration - fade:.3f}:d={fade:.3f},volume={gain:g}dB,"
|
|
1720
|
+
"aformat=sample_rates=48000,"
|
|
1721
|
+
f"adelay={round(start * 1000)}:all=1[s{index}]"
|
|
1722
|
+
)
|
|
1723
|
+
labels.append(f"[s{index}]")
|
|
1724
|
+
stages.append(
|
|
1725
|
+
f"{''.join(labels)}amix=inputs={len(labels)}:duration=first:"
|
|
1726
|
+
"dropout_transition=0:normalize=0[a]"
|
|
1727
|
+
)
|
|
1728
|
+
return command + [
|
|
1729
|
+
"-filter_complex", ";".join(stages),
|
|
1730
|
+
"-map", "0:v", "-map", "[a]",
|
|
1731
|
+
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-ar", "48000", str(output),
|
|
1732
|
+
]
|
|
1733
|
+
|
|
1734
|
+
|
|
1735
|
+
def _build_srt(cues: Sequence[DisplayCue]) -> str:
|
|
1736
|
+
"""The screenplay's words in their burned form; only the timing is ours."""
|
|
819
1737
|
|
|
820
1738
|
return "\n".join(
|
|
821
|
-
f"{index}\n{_timecode(start)} --> {_timecode(end)}\n{text}\n"
|
|
822
|
-
for index,
|
|
1739
|
+
f"{index}\n{_timecode(cue.start)} --> {_timecode(cue.end)}\n{cue.text}\n"
|
|
1740
|
+
for index, cue in enumerate(cues, start=1)
|
|
823
1741
|
)
|
|
824
1742
|
|
|
825
1743
|
|
|
826
|
-
def
|
|
827
|
-
|
|
828
|
-
|
|
1744
|
+
def _ass_colour(hex_rgb: str) -> str:
|
|
1745
|
+
"""`#RRGGBB` as ASS writes it: blue, green, red."""
|
|
1746
|
+
|
|
1747
|
+
red, green, blue = hex_rgb[1:3], hex_rgb[3:5], hex_rgb[5:7]
|
|
1748
|
+
return f"&H00{blue}{green}{red}&".upper()
|
|
1749
|
+
|
|
1750
|
+
|
|
1751
|
+
def _ass_line(cue: DisplayCue) -> str:
|
|
1752
|
+
"""Escaped text with the speaker's colour and the highlighted words in yellow."""
|
|
1753
|
+
|
|
1754
|
+
base = _ass_colour(SUBTITLE_COLOURS[cue.kind])
|
|
1755
|
+
marked = "{\\c" + base + "}" if cue.kind != "line" else ""
|
|
1756
|
+
rest = cue.text
|
|
1757
|
+
while rest:
|
|
1758
|
+
hits = [(rest.find(word), word) for word in cue.keys if word in rest]
|
|
1759
|
+
if not hits:
|
|
1760
|
+
marked += _ass_text(rest)
|
|
1761
|
+
break
|
|
1762
|
+
at, word = min(hits)
|
|
1763
|
+
marked += _ass_text(rest[:at])
|
|
1764
|
+
marked += "{\\c" + _ass_colour(SUBTITLE_KEYWORD_COLOUR) + "}" + _ass_text(word)
|
|
1765
|
+
marked += "{\\c" + base + "}"
|
|
1766
|
+
rest = rest[at + len(word):]
|
|
1767
|
+
return marked
|
|
1768
|
+
|
|
1769
|
+
|
|
1770
|
+
def _build_ass(cues: Sequence[DisplayCue], width: int, height: int) -> str:
|
|
829
1771
|
"""Author the ASS directly so the type size is stated in frame pixels.
|
|
830
1772
|
|
|
831
1773
|
Letting ffmpeg convert the SRT hands libass a 384-high canvas, and every
|
|
@@ -836,9 +1778,11 @@ def _build_ass(
|
|
|
836
1778
|
PlayRes as the frame removes the conversion, so one unit is one pixel.
|
|
837
1779
|
"""
|
|
838
1780
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1781
|
+
# The same measures as the Remotion layer: type 4.3% of frame height, a
|
|
1782
|
+
# 0.42% dark rim, and the baseline 24% up, clear of the platform's own UI.
|
|
1783
|
+
font_size = max(18, round(height * 0.043))
|
|
1784
|
+
outline = max(2, round(height * 0.0042))
|
|
1785
|
+
margin_v = max(24, round(height * 0.24))
|
|
842
1786
|
margin_h = max(24, round(width * 0.06))
|
|
843
1787
|
header = (
|
|
844
1788
|
"[Script Info]\n"
|
|
@@ -853,15 +1797,15 @@ def _build_ass(
|
|
|
853
1797
|
"ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, "
|
|
854
1798
|
"MarginL, MarginR, MarginV, Encoding\n"
|
|
855
1799
|
f"Style: 正片,{SUBTITLE_FONT},{font_size},&H00FFFFFF,&H00FFFFFF,"
|
|
856
|
-
f"&
|
|
1800
|
+
f"&H00120D0B,&H80000000,-1,0,0,0,100,100,0,0,1,{outline},1,2,"
|
|
857
1801
|
f"{margin_h},{margin_h},{margin_v},1\n"
|
|
858
1802
|
"\n[Events]\n"
|
|
859
1803
|
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, "
|
|
860
1804
|
"Effect, Text\n"
|
|
861
1805
|
)
|
|
862
1806
|
lines = [
|
|
863
|
-
f"Dialogue: 0,{_ass_time(start)},{_ass_time(end)},正片,,0,0,0,,{
|
|
864
|
-
for
|
|
1807
|
+
f"Dialogue: 0,{_ass_time(cue.start)},{_ass_time(cue.end)},正片,,0,0,0,,{_ass_line(cue)}"
|
|
1808
|
+
for cue in cues
|
|
865
1809
|
]
|
|
866
1810
|
return header + "\n".join(lines) + "\n"
|
|
867
1811
|
|
|
@@ -894,6 +1838,40 @@ def _timecode(seconds: float) -> str:
|
|
|
894
1838
|
return f"{hours:02d}:{minutes:02d}:{whole:02d},{milliseconds:03d}"
|
|
895
1839
|
|
|
896
1840
|
|
|
1841
|
+
def _segment_colour(ffmpeg: str, path: Path) -> Optional[tuple[float, float]]:
|
|
1842
|
+
"""Mean luma and blue-red difference of one segment, on a 0-255 scale."""
|
|
1843
|
+
|
|
1844
|
+
result = subprocess.run(
|
|
1845
|
+
[ffmpeg, "-v", "error", "-i", str(path), "-vf", "fps=2,scale=96:-1",
|
|
1846
|
+
"-pix_fmt", "rgb24", "-f", "rawvideo", "-"],
|
|
1847
|
+
capture_output=True,
|
|
1848
|
+
)
|
|
1849
|
+
raw = result.stdout
|
|
1850
|
+
if result.returncode != 0 or len(raw) < 3:
|
|
1851
|
+
return None
|
|
1852
|
+
count = len(raw) // 3
|
|
1853
|
+
red = sum(raw[i * 3] for i in range(count)) / count
|
|
1854
|
+
green = sum(raw[i * 3 + 1] for i in range(count)) / count
|
|
1855
|
+
blue = sum(raw[i * 3 + 2] for i in range(count)) / count
|
|
1856
|
+
return 0.299 * red + 0.587 * green + 0.114 * blue, blue - red
|
|
1857
|
+
|
|
1858
|
+
|
|
1859
|
+
def _segment_colours(ffmpeg: str, output_root: Path, cuts: Sequence[Cut]) -> list[dict[str, Any]]:
|
|
1860
|
+
"""Report current cut segments individually, without imposing a shared grade."""
|
|
1861
|
+
|
|
1862
|
+
rows: list[dict[str, Any]] = []
|
|
1863
|
+
for cut in cuts:
|
|
1864
|
+
path = output_root / SEGMENT_DIRECTORY / f"{cut.cut_id}.mp4"
|
|
1865
|
+
colour = _segment_colour(ffmpeg, path) if path.is_file() else None
|
|
1866
|
+
row: dict[str, Any] = {"分段": path.name}
|
|
1867
|
+
if colour is None:
|
|
1868
|
+
row["测量"] = "未测(分段缺失或不可读)"
|
|
1869
|
+
else:
|
|
1870
|
+
row.update({"平均亮度": round(colour[0], 1), "蓝减红": round(colour[1], 1)})
|
|
1871
|
+
rows.append(row)
|
|
1872
|
+
return rows
|
|
1873
|
+
|
|
1874
|
+
|
|
897
1875
|
def verify(episode: Path, cuts: Sequence[Cut], delivery: Delivery) -> dict[str, Any]:
|
|
898
1876
|
"""Measure the rendered film. Every entry is a number or an honest 未测."""
|
|
899
1877
|
|
|
@@ -931,11 +1909,177 @@ def verify(episode: Path, cuts: Sequence[Cut], delivery: Delivery) -> dict[str,
|
|
|
931
1909
|
else:
|
|
932
1910
|
measurements["实测响度 LUFS"] = "未测(loudnorm 没有返回可解析的测量结果)"
|
|
933
1911
|
measurements["交付响度目标"] = delivery.loudness_lufs
|
|
1912
|
+
measurements["分段色彩观测"] = _segment_colours(ffmpeg, episode / OUTPUT_DIRECTORY, cuts)
|
|
1913
|
+
measurements["画内可读文字"] = (
|
|
1914
|
+
"未测(抽有画内文字的帧,逐字对《剧本.md》的「画面文字」与提示词声明的内容)"
|
|
1915
|
+
)
|
|
1916
|
+
segments = [episode / OUTPUT_DIRECTORY / SEGMENT_DIRECTORY / f"{cut.cut_id}.mp4" for cut in cuts]
|
|
1917
|
+
spans = (
|
|
1918
|
+
[probe_duration(segment) for segment in segments]
|
|
1919
|
+
if all(segment.is_file() for segment in segments)
|
|
1920
|
+
else None
|
|
1921
|
+
)
|
|
1922
|
+
if any(cut.screen_texts or cut.sound_effects for cut in cuts):
|
|
1923
|
+
measurements.update(_placements_for_sampling(cuts, spans))
|
|
934
1924
|
measurements["台词完整性"] = "未测(本工具不做转写;在成片上转写后逐句对《剧本.md》原文)"
|
|
1925
|
+
measurements.update(_frame_report(
|
|
1926
|
+
_grey_frames(ffmpeg, final), stream["fps"], cuts, spans, _scene_keys(episode, cuts)
|
|
1927
|
+
))
|
|
935
1928
|
measurements["边界帧"] = "未测(抽剪辑点前后各一帧目视核对黑场/白场/半渲染帧)"
|
|
936
1929
|
return measurements
|
|
937
1930
|
|
|
938
1931
|
|
|
1932
|
+
def _grey_frames(ffmpeg: str, media: Path) -> Optional[list[bytes]]:
|
|
1933
|
+
"""Every frame of the film, grey, at FRAME_PROBE_SIZE."""
|
|
1934
|
+
|
|
1935
|
+
width, height = FRAME_PROBE_SIZE
|
|
1936
|
+
result = subprocess.run(
|
|
1937
|
+
[ffmpeg, "-v", "error", "-i", str(media), "-vf", f"scale={width}:{height},format=gray",
|
|
1938
|
+
"-f", "rawvideo", "-"],
|
|
1939
|
+
capture_output=True, check=False,
|
|
1940
|
+
)
|
|
1941
|
+
size = width * height
|
|
1942
|
+
if result.returncode != 0 or len(result.stdout) < size:
|
|
1943
|
+
return None
|
|
1944
|
+
raw = result.stdout
|
|
1945
|
+
return [raw[at:at + size] for at in range(0, len(raw) - size + 1, size)]
|
|
1946
|
+
|
|
1947
|
+
|
|
1948
|
+
def _mean_difference(first: bytes, second: bytes) -> float:
|
|
1949
|
+
return sum(map(abs, map(operator.sub, first, second))) / len(first)
|
|
1950
|
+
|
|
1951
|
+
|
|
1952
|
+
def _suspect_frames(frames: Sequence[bytes]) -> list[tuple[int, float, float, float]]:
|
|
1953
|
+
"""Frames unlike both neighbours while the neighbours are alike: (index, prev, next, across).
|
|
1954
|
+
|
|
1955
|
+
A hard cut differs from one side only; fast motion differs from both, but
|
|
1956
|
+
its neighbours differ from each other even more.
|
|
1957
|
+
"""
|
|
1958
|
+
|
|
1959
|
+
steps = [_mean_difference(a, b) for a, b in zip(frames, frames[1:])]
|
|
1960
|
+
suspects: list[tuple[int, float, float, float]] = []
|
|
1961
|
+
for index in range(1, len(frames) - 1):
|
|
1962
|
+
before, after = steps[index - 1], steps[index]
|
|
1963
|
+
if min(before, after) <= FLASH_FRAME_DIFFERENCE:
|
|
1964
|
+
continue
|
|
1965
|
+
across = _mean_difference(frames[index - 1], frames[index + 1])
|
|
1966
|
+
if across < FLASH_NEIGHBOUR_SHARE * min(before, after):
|
|
1967
|
+
suspects.append((index, before, after, across))
|
|
1968
|
+
return suspects
|
|
1969
|
+
|
|
1970
|
+
|
|
1971
|
+
def _cut_jumps(
|
|
1972
|
+
means: Sequence[float],
|
|
1973
|
+
fps: float,
|
|
1974
|
+
cuts: Sequence[Cut],
|
|
1975
|
+
spans: Sequence[float],
|
|
1976
|
+
scenes: Sequence[Optional[tuple[str, ...]]],
|
|
1977
|
+
) -> list[dict[str, Any]]:
|
|
1978
|
+
"""Mean-luma change from the last frame before each cut to the first after it."""
|
|
1979
|
+
|
|
1980
|
+
rows: list[dict[str, Any]] = []
|
|
1981
|
+
cursor = 0.0
|
|
1982
|
+
for index in range(1, len(cuts)):
|
|
1983
|
+
cursor += spans[index - 1]
|
|
1984
|
+
frame = round(cursor * fps)
|
|
1985
|
+
if not 0 < frame < len(means):
|
|
1986
|
+
continue
|
|
1987
|
+
before, after = scenes[index - 1], scenes[index]
|
|
1988
|
+
rows.append({
|
|
1989
|
+
"切点秒": round(cursor, 2),
|
|
1990
|
+
"前段": cuts[index - 1].cut_id,
|
|
1991
|
+
"后段": cuts[index].cut_id,
|
|
1992
|
+
"亮度变化": round(means[frame] - means[frame - 1], 1),
|
|
1993
|
+
"场景": "未知" if before is None or after is None
|
|
1994
|
+
else "同场" if before == after else "换场",
|
|
1995
|
+
})
|
|
1996
|
+
return rows
|
|
1997
|
+
|
|
1998
|
+
|
|
1999
|
+
def _frame_report(
|
|
2000
|
+
frames: Optional[Sequence[bytes]],
|
|
2001
|
+
fps: float,
|
|
2002
|
+
cuts: Sequence[Cut],
|
|
2003
|
+
spans: Optional[Sequence[float]],
|
|
2004
|
+
scenes: Sequence[Optional[tuple[str, ...]]],
|
|
2005
|
+
) -> dict[str, Any]:
|
|
2006
|
+
"""Luma jumps at cuts and suspect frames, with what to look at. Nothing here blocks."""
|
|
2007
|
+
|
|
2008
|
+
if frames is None or not fps:
|
|
2009
|
+
reason = "未测(读不出成片的逐帧画面)"
|
|
2010
|
+
return {"切点亮度变化": reason, "疑似坏帧": reason}
|
|
2011
|
+
notes: list[str] = []
|
|
2012
|
+
report: dict[str, Any] = {}
|
|
2013
|
+
if spans is None:
|
|
2014
|
+
report["切点亮度变化"] = "未测(分段缺失,无法换算切点时间)"
|
|
2015
|
+
entrances: list[float] = []
|
|
2016
|
+
else:
|
|
2017
|
+
jumps = _cut_jumps([sum(frame) / len(frame) for frame in frames], fps, cuts, spans, scenes)
|
|
2018
|
+
report["切点亮度变化"] = jumps
|
|
2019
|
+
notes.extend(
|
|
2020
|
+
f"{row['切点秒']:.2f} 秒 {row['前段']} → {row['后段']}({row['场景']})亮度变化 "
|
|
2021
|
+
f"{row['亮度变化']:+.1f}:看是否像闪了一下"
|
|
2022
|
+
for row in jumps if abs(row["亮度变化"]) > CUT_JUMP_NOTICE
|
|
2023
|
+
)
|
|
2024
|
+
placed = _placed_layers(cuts, spans)
|
|
2025
|
+
entrances = [] if isinstance(placed, str) else [layer["start"] for layer in placed]
|
|
2026
|
+
suspects = []
|
|
2027
|
+
for index, before, after, across in _suspect_frames(frames):
|
|
2028
|
+
seconds = index / fps
|
|
2029
|
+
entering = any(0 <= seconds - start <= SCREEN_TEXT_ENTRANCE for start in entrances)
|
|
2030
|
+
suspects.append({
|
|
2031
|
+
"秒": round(seconds, 3), "帧": index,
|
|
2032
|
+
"与前帧差": round(before, 1), "与后帧差": round(after, 1), "前后帧互差": round(across, 1),
|
|
2033
|
+
"画面文字入场": entering,
|
|
2034
|
+
})
|
|
2035
|
+
notes.append(
|
|
2036
|
+
f"{seconds:.3f} 秒(第 {index} 帧)与前后两帧都不像:"
|
|
2037
|
+
+ ("画面文字正在入场,确认是入场效果" if entering else "逐帧看是否有错位或串入别的画面")
|
|
2038
|
+
)
|
|
2039
|
+
report["疑似坏帧"] = suspects
|
|
2040
|
+
report["请逐帧查看"] = notes
|
|
2041
|
+
return report
|
|
2042
|
+
|
|
2043
|
+
|
|
2044
|
+
def _placed_layers(cuts: Sequence[Cut], spans: Sequence[float]) -> Union[list[dict[str, Any]], str]:
|
|
2045
|
+
"""The screen text layers, or why they cannot be placed: verify reports, it does not stop."""
|
|
2046
|
+
|
|
2047
|
+
try:
|
|
2048
|
+
return _screen_text_layers(cuts, spans)
|
|
2049
|
+
except EditError as error:
|
|
2050
|
+
return f"未测({error})"
|
|
2051
|
+
|
|
2052
|
+
|
|
2053
|
+
def _placements_for_sampling(cuts: Sequence[Cut], spans: Optional[Sequence[float]]) -> dict[str, Any]:
|
|
2054
|
+
"""Where the screen text and effects landed in the film, from the rendered segments.
|
|
2055
|
+
|
|
2056
|
+
These are the frames and moments to sample; whether the text reads and the
|
|
2057
|
+
chime sits right is still for someone to look and listen.
|
|
2058
|
+
"""
|
|
2059
|
+
|
|
2060
|
+
if spans is None:
|
|
2061
|
+
reason = "未测(分段缺失,无法换算成片时间)"
|
|
2062
|
+
return {"画面文字落点": reason, "音效落点": reason}
|
|
2063
|
+
placed = _placed_layers(cuts, spans)
|
|
2064
|
+
return {
|
|
2065
|
+
"画面文字落点": placed if isinstance(placed, str) else [
|
|
2066
|
+
{
|
|
2067
|
+
"起": round(layer["start"], 2),
|
|
2068
|
+
"止": round(layer["end"], 2),
|
|
2069
|
+
"样式": next(
|
|
2070
|
+
label for label, key in SCREEN_TEXT_STYLES.items() if key == layer["style"]
|
|
2071
|
+
),
|
|
2072
|
+
"文字": SCREEN_TEXT_ITEM_SEPARATOR.join(item["text"] for item in layer["items"]),
|
|
2073
|
+
}
|
|
2074
|
+
for layer in placed
|
|
2075
|
+
],
|
|
2076
|
+
"音效落点": [
|
|
2077
|
+
{"起": round(start, 2), "止": round(start + duration, 2), "文件": written}
|
|
2078
|
+
for start, duration, written, _ in _placed_sound_effects(cuts, spans)
|
|
2079
|
+
],
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
|
|
939
2083
|
def _matches_frame_size(delivery: Delivery, stream: dict[str, Any]) -> Any:
|
|
940
2084
|
if delivery.frame_size is None:
|
|
941
2085
|
return "未测(剪辑单没有声明画幅)"
|
|
@@ -1004,7 +2148,8 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
1004
2148
|
delivery, cuts, unused = parse_cut_list(episode / CUT_LIST_NAME)
|
|
1005
2149
|
if arguments.command == "check":
|
|
1006
2150
|
findings = check_cuts(
|
|
1007
|
-
episode, cuts, project_root,
|
|
2151
|
+
episode, cuts, project_root,
|
|
2152
|
+
probe=_which("ffprobe") is not None, unused=unused,
|
|
1008
2153
|
)
|
|
1009
2154
|
payload: dict[str, Any] = {
|
|
1010
2155
|
"段数": len(cuts),
|
|
@@ -1018,7 +2163,9 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
1018
2163
|
_emit(payload)
|
|
1019
2164
|
return 1 if findings else 0
|
|
1020
2165
|
if arguments.command == "render":
|
|
1021
|
-
findings = check_cuts(
|
|
2166
|
+
findings = check_cuts(
|
|
2167
|
+
episode, cuts, project_root, probe=True, unused=unused
|
|
2168
|
+
)
|
|
1022
2169
|
if findings:
|
|
1023
2170
|
_emit({"findings": findings, "已渲染": False})
|
|
1024
2171
|
return 1
|