@oh-story/dsh 0.1.10 → 0.1.12
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 +11 -11
- package/lib/client.js +7 -3
- package/lib/client.js.map +2 -2
- package/lib/drama/manifest.json +212 -142
- package/lib/drama/skills/short-drama/SKILL.md +14 -2
- 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/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/era-anchors.md +37 -0
- package/lib/drama/skills/short-drama-assets/references/stage-contract.md +2 -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 +43 -20
- 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 +4 -2
- package/lib/drama/skills/short-drama-edit/references/cut-craft.md +3 -1
- package/lib/drama/skills/short-drama-edit/references/delivery-verify.md +6 -2
- package/lib/drama/skills/short-drama-edit/references/generated-footage.md +12 -6
- package/lib/drama/skills/short-drama-edit/references/sound-and-subtitles.md +13 -8
- package/lib/drama/skills/short-drama-edit/references/stage-contract.md +2 -0
- package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +1153 -121
- package/lib/drama/skills/short-drama-edit/scripts/selftest.py +49 -9
- 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-review/SKILL.md +2 -2
- 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 +15 -0
- package/lib/drama/skills/short-drama-review/references/stage-contract.md +3 -0
- package/lib/drama/skills/short-drama-storyboard/SKILL.md +13 -5
- package/lib/drama/skills/short-drama-storyboard/references/blocking-playbooks.md +1 -1
- 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 +82 -14
- package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +4 -2
- package/lib/drama/skills/short-drama-video-prompts/SKILL.md +5 -3
- package/lib/drama/skills/short-drama-video-prompts/references/camera-audio-continuity.md +12 -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 +40 -1
- package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +20 -14
- package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +1 -1
- package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +8 -7
- package/lib/drama/skills/short-drama-video-prompts/references/review-and-fixtures.md +1 -1
- package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +1 -0
- package/lib/drama/skills/short-drama-video-prompts/references/target-model-profile.md +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 +8 -3
- 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 +71 -21
- package/lib/index.js.map +3 -3
- package/lib/oh-story/manifest.json +545 -310
- package/lib/oh-story/roles/chapter-extractor.md +11 -11
- package/lib/oh-story/roles/character-designer.md +1 -2
- package/lib/oh-story/roles/consistency-checker.md +11 -9
- package/lib/oh-story/roles/narrative-writer.md +24 -25
- 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 +6 -6
- 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 +40 -118
- package/lib/oh-story/skills/story/scripts/author_memory_commit.py +3 -3
- package/lib/oh-story/skills/story-deslop/SKILL.md +24 -25
- 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 +1 -1
- 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 +40 -118
- package/lib/oh-story/skills/story-deslop/references/banned-words.md +2 -2
- package/lib/oh-story/skills/story-deslop/references/deslop-gates.md +21 -4
- 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 +3 -3
- package/lib/oh-story/skills/story-deslop/scripts/check-ai-patterns.js +155 -8
- package/lib/oh-story/skills/story-import/SKILL.md +63 -579
- 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/structure-mapping-long.md +95 -91
- 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 +11 -9
- 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 +101 -14
- package/lib/oh-story/skills/story-import/scripts/tracking_commit.py +278 -23
- package/lib/oh-story/skills/story-import/scripts/wordcount_core.py +179 -23
- package/lib/oh-story/skills/story-long-analyze/SKILL.md +28 -50
- package/lib/oh-story/skills/story-long-analyze/references/author-facing.md +12 -5
- package/lib/oh-story/skills/story-long-analyze/references/deconstruction-notes.md +1 -1
- 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/pipeline-ops.md +21 -55
- 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 +17 -0
- package/lib/oh-story/skills/story-long-analyze/references/style-profile-protocol.md +1 -1
- package/lib/oh-story/skills/story-long-analyze/references/synthesis-inputs.md +39 -0
- package/lib/oh-story/skills/story-long-analyze/scripts/manage_analysis_run.py +47 -8
- package/lib/oh-story/skills/story-long-scan/SKILL.md +41 -193
- 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 +32 -73
- 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 +1 -1
- package/lib/oh-story/skills/story-long-write/references/artifact-protocols.md +18 -201
- 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 +40 -118
- 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 +6 -10
- package/lib/oh-story/skills/story-long-write/references/character-relations.md +49 -1
- package/lib/oh-story/skills/story-long-write/references/deslop-gates.md +171 -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/genre-prose-cards.md +1 -1
- package/lib/oh-story/skills/story-long-write/references/long-chapter-quality.md +3 -2
- 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 +10 -6
- package/lib/oh-story/skills/story-long-write/references/reader-contract-and-progression.md +1 -1
- package/lib/oh-story/skills/story-long-write/references/reference-index.md +4 -4
- 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 +11 -9
- package/lib/oh-story/skills/story-long-write/references/workflow-chapter.md +33 -44
- package/lib/oh-story/skills/story-long-write/references/workflow-daily.md +18 -51
- 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 +53 -67
- package/lib/oh-story/skills/story-long-write/references/workflow-setup.md +112 -251
- 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 +5 -3
- package/lib/oh-story/skills/story-long-write/references/writing-craft.md +18 -56
- package/lib/oh-story/skills/story-long-write/scripts/author_memory_commit.py +3 -3
- 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 +96 -16
- 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 +101 -14
- 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 +2 -1
- package/lib/oh-story/skills/story-long-write/scripts/storyctl.py +250 -50
- package/lib/oh-story/skills/story-long-write/scripts/tracking_commit.py +278 -23
- package/lib/oh-story/skills/story-long-write/scripts/wordcount_core.py +179 -23
- package/lib/oh-story/skills/story-review/SKILL.md +19 -273
- 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 +1 -1
- 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 +40 -118
- 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/review-quality.md +2 -1
- 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 +11 -9
- package/lib/oh-story/skills/story-review/scripts/author_memory_commit.py +3 -3
- 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 +278 -23
- package/lib/oh-story/skills/story-review/scripts/wordcount_core.py +179 -23
- package/lib/oh-story/skills/story-setup/SKILL.md +69 -458
- 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 +1 -1
- 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 +21 -4
- 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-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 +18 -56
- package/lib/oh-story/skills/story-setup/references/diagnostics.md +3 -3
- package/lib/oh-story/skills/story-short-analyze/SKILL.md +37 -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/{material-decomposition.md → stage4-6-reversal-summary.md} +303 -374
- package/lib/oh-story/skills/story-short-scan/SKILL.md +30 -64
- 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 -34
- 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 +40 -118
- 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/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 -49
- 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 +19 -12
- package/lib/oh-story/skills/story-short-write/references/workflow-draft.md +22 -32
- package/lib/oh-story/skills/story-short-write/references/workflow-revision.md +84 -17
- 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 +3 -3
- 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/video-studio.d.ts.map +1 -1
- package/lib/types/native-hooks.d.ts +2 -2
- package/lib/types/native-hooks.d.ts.map +1 -1
- 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-understanding/scripts/{agent_brief.py → briefing/builder.py} +38 -35
- package/lib/video-recap/skills/{video-script/scripts/brief_context.py → video-understanding/scripts/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-understanding/scripts/{brief_timeline.py → 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 +41 -41
- package/lib/oh-story/skills/story-long-analyze/references/material-decomposition.md +0 -538
- package/lib/oh-story/skills/story-long-analyze/references/output-templates.md +0 -603
- package/lib/oh-story/skills/story-long-scan/references/scan-output-format.md +0 -207
- 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/agent_brief.py +0 -399
- package/lib/video-recap/skills/video-script/scripts/brief_timeline.py +0 -327
- package/lib/video-recap/skills/video-script/scripts/narration.py +0 -12
- package/lib/video-recap/skills/video-understanding/scripts/brief_context.py +0 -348
- package/lib/video-recap/skills/video-understanding/scripts/brief_inputs.py +0 -290
- /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,6 +149,35 @@ 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
|
|
94
182
|
# ffmpeg's `noise` strength runs to 100, which is snow, not grain. The useful
|
|
95
183
|
# band for a finished film is single digits; the cap keeps a typo from shipping
|
|
@@ -101,6 +189,30 @@ class EditError(Exception):
|
|
|
101
189
|
"""A defect in the cut list or its inputs, reported rather than raised through."""
|
|
102
190
|
|
|
103
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
|
+
|
|
104
216
|
class Cut(NamedTuple):
|
|
105
217
|
cut_id: str
|
|
106
218
|
title: str
|
|
@@ -109,9 +221,13 @@ class Cut(NamedTuple):
|
|
|
109
221
|
start: float
|
|
110
222
|
end: float
|
|
111
223
|
declared: float
|
|
112
|
-
subtitles: tuple[
|
|
224
|
+
subtitles: tuple[Subtitle, ...]
|
|
113
225
|
picture: dict[str, float]
|
|
114
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
|
|
115
231
|
|
|
116
232
|
|
|
117
233
|
class Delivery(NamedTuple):
|
|
@@ -121,6 +237,22 @@ class Delivery(NamedTuple):
|
|
|
121
237
|
frame_size: Optional[tuple[int, int]]
|
|
122
238
|
fps: Optional[float]
|
|
123
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, ...]
|
|
124
256
|
|
|
125
257
|
|
|
126
258
|
def _seconds(raw: str, *, field: str, line: int) -> float:
|
|
@@ -137,6 +269,7 @@ def _parse_delivery(lines: Sequence[str]) -> Delivery:
|
|
|
137
269
|
frame_size: Optional[tuple[int, int]] = None
|
|
138
270
|
fps: Optional[float] = None
|
|
139
271
|
grain: Optional[float] = None
|
|
272
|
+
shot_match = True
|
|
140
273
|
for raw in lines:
|
|
141
274
|
match = FIELD.match(raw)
|
|
142
275
|
if not match:
|
|
@@ -169,6 +302,8 @@ def _parse_delivery(lines: Sequence[str]) -> Delivery:
|
|
|
169
302
|
"这一档以上不再像胶片,像信号故障"
|
|
170
303
|
)
|
|
171
304
|
grain = amount or None
|
|
305
|
+
elif name == "接镜匹配":
|
|
306
|
+
shot_match = value.strip() not in {"无", "关"}
|
|
172
307
|
elif name == "画幅与帧率":
|
|
173
308
|
size = re.search(r"([0-9]{2,5})\s*[×x*]\s*([0-9]{2,5})", value)
|
|
174
309
|
if size:
|
|
@@ -176,7 +311,7 @@ def _parse_delivery(lines: Sequence[str]) -> Delivery:
|
|
|
176
311
|
rate = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*fps", value, re.I)
|
|
177
312
|
if rate:
|
|
178
313
|
fps = float(rate.group(1))
|
|
179
|
-
return Delivery(target, loudness, burn, frame_size, fps, grain)
|
|
314
|
+
return Delivery(target, loudness, burn, frame_size, fps, grain, shot_match)
|
|
180
315
|
|
|
181
316
|
|
|
182
317
|
def parse_cut_list(path: Path) -> tuple[Delivery, list[Cut], list[str]]:
|
|
@@ -220,11 +355,26 @@ def parse_cut_list(path: Path) -> tuple[Delivery, list[Cut], list[str]]:
|
|
|
220
355
|
continue
|
|
221
356
|
field = FIELD.match(raw)
|
|
222
357
|
if field:
|
|
223
|
-
|
|
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)
|
|
224
362
|
close(current)
|
|
225
363
|
return _parse_delivery(preamble), cuts, unused
|
|
226
364
|
|
|
227
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
|
+
|
|
228
378
|
def _finish_cut(pending: dict[str, Any]) -> Cut:
|
|
229
379
|
fields: dict[str, tuple[str, int]] = pending["fields"]
|
|
230
380
|
cut_id: str = pending["cut_id"]
|
|
@@ -248,9 +398,10 @@ def _finish_cut(pending: dict[str, Any]) -> Cut:
|
|
|
248
398
|
subtitles = _parse_subtitles(fields, cut_id=cut_id, line=line)
|
|
249
399
|
picture: dict[str, float] = {}
|
|
250
400
|
picture_raw = fields.get("画面")
|
|
401
|
+
untouched = picture_raw is not None and picture_raw[0].strip() == PICTURE_UNTOUCHED
|
|
251
402
|
if picture_raw is not None:
|
|
252
403
|
text, where = picture_raw
|
|
253
|
-
if text.strip() not in {"", "无",
|
|
404
|
+
if text.strip() not in {"", "无", PICTURE_UNTOUCHED}:
|
|
254
405
|
for label, value in PICTURE_TERM.findall(text):
|
|
255
406
|
key = PICTURE_KEYS[label]
|
|
256
407
|
number = float(value)
|
|
@@ -264,7 +415,8 @@ def _finish_cut(pending: dict[str, Any]) -> Cut:
|
|
|
264
415
|
if not picture:
|
|
265
416
|
raise EditError(
|
|
266
417
|
f"{CUT_LIST_NAME}:{where}: {cut_id} 的画面写了内容但没有可执行的项;"
|
|
267
|
-
"只认「亮度 <数>」「饱和 <数>」「色温
|
|
418
|
+
"只认「亮度 <数>」「饱和 <数>」「色温 <数>」,不需要校正时写「无」,"
|
|
419
|
+
f"连自动接镜也不要时写「{PICTURE_UNTOUCHED}」"
|
|
268
420
|
)
|
|
269
421
|
return Cut(
|
|
270
422
|
cut_id=cut_id,
|
|
@@ -277,24 +429,30 @@ def _finish_cut(pending: dict[str, Any]) -> Cut:
|
|
|
277
429
|
subtitles=subtitles,
|
|
278
430
|
picture=picture,
|
|
279
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,
|
|
280
435
|
)
|
|
281
436
|
|
|
282
437
|
|
|
283
|
-
def
|
|
284
|
-
fields: dict[str, tuple[str, int]],
|
|
285
|
-
|
|
286
|
-
|
|
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」.
|
|
287
447
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
one of them silently drops the other two — the film then plays lines that
|
|
291
|
-
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.
|
|
292
450
|
"""
|
|
293
451
|
|
|
294
452
|
numbered: list[tuple[int, str, int]] = []
|
|
295
453
|
plain: Optional[tuple[str, int]] = None
|
|
296
454
|
for key, (value, where) in fields.items():
|
|
297
|
-
match =
|
|
455
|
+
match = pattern.match(key.strip())
|
|
298
456
|
if not match:
|
|
299
457
|
continue
|
|
300
458
|
if match.group(1) is None:
|
|
@@ -304,27 +462,162 @@ def _parse_subtitles(
|
|
|
304
462
|
|
|
305
463
|
if numbered and plain is not None:
|
|
306
464
|
raise EditError(
|
|
307
|
-
f"{CUT_LIST_NAME}:{line}: {cut_id}
|
|
308
|
-
"
|
|
465
|
+
f"{CUT_LIST_NAME}:{line}: {cut_id} 同时写了「{label}」和「{label} N」;"
|
|
466
|
+
f"一段用一种写法——一条用「{label}」,多条全部编号"
|
|
309
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)
|
|
545
|
+
|
|
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."""
|
|
310
551
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
if
|
|
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:
|
|
315
570
|
raise EditError(
|
|
316
|
-
f"{CUT_LIST_NAME}:{
|
|
317
|
-
|
|
571
|
+
f"{CUT_LIST_NAME}:{where}: {cut_id} 的「音效 {index}」要写成"
|
|
572
|
+
"「<起>-<止> <项目相对路径>」"
|
|
318
573
|
)
|
|
319
|
-
|
|
320
|
-
|
|
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:
|
|
321
613
|
found = SUBTITLE_CUE.match(value)
|
|
322
614
|
if not found:
|
|
323
615
|
raise EditError(
|
|
324
616
|
f"{CUT_LIST_NAME}:{where}: {cut_id} 的「字幕 {index}」要写成"
|
|
325
617
|
"「<起>-<止> <台词>」,多句必须各自带时间"
|
|
326
618
|
)
|
|
327
|
-
|
|
619
|
+
text, words = keywords(found.group(3), where)
|
|
620
|
+
cues.append((float(found.group(1)), float(found.group(2)), text, words))
|
|
328
621
|
for earlier, later in zip(cues, cues[1:]):
|
|
329
622
|
if later[0] is not None and earlier[1] is not None and later[0] < earlier[1]:
|
|
330
623
|
raise EditError(
|
|
@@ -332,17 +625,18 @@ def _parse_subtitles(
|
|
|
332
625
|
)
|
|
333
626
|
return tuple(cues)
|
|
334
627
|
|
|
335
|
-
if
|
|
628
|
+
if not entries or entries[0][1].strip() == "无":
|
|
336
629
|
return ()
|
|
630
|
+
text, words = keywords(entries[0][1], entries[0][2])
|
|
337
631
|
window_raw = fields.get("字幕时间")
|
|
338
632
|
if window_raw is None:
|
|
339
|
-
return ((None, None,
|
|
633
|
+
return ((None, None, text, words),)
|
|
340
634
|
found = SUBTITLE_WINDOW.match(window_raw[0])
|
|
341
635
|
if not found:
|
|
342
636
|
raise EditError(
|
|
343
637
|
f"{CUT_LIST_NAME}:{window_raw[1]}: {cut_id} 的字幕时间要写成「起-止」秒数"
|
|
344
638
|
)
|
|
345
|
-
return ((float(found.group(1)), float(found.group(2)),
|
|
639
|
+
return ((float(found.group(1)), float(found.group(2)), text, words),)
|
|
346
640
|
|
|
347
641
|
|
|
348
642
|
def _which(name: str) -> Optional[str]:
|
|
@@ -494,7 +788,7 @@ def check_cuts(
|
|
|
494
788
|
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 出点 {cut.end:.2f} "
|
|
495
789
|
f"超过素材实际时长 {available:.2f}"
|
|
496
790
|
)
|
|
497
|
-
for window_start, window_end, text in cut.subtitles:
|
|
791
|
+
for window_start, window_end, text, _ in cut.subtitles:
|
|
498
792
|
if screenplay and _normalize(text) not in _normalize(screenplay):
|
|
499
793
|
findings.append(
|
|
500
794
|
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的字幕在《"
|
|
@@ -507,12 +801,57 @@ def check_cuts(
|
|
|
507
801
|
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的字幕时间超出本段区间"
|
|
508
802
|
f": {window_start}-{window_end}"
|
|
509
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
|
+
)
|
|
510
815
|
|
|
816
|
+
findings.extend(_screen_text_findings(cuts, screenplay))
|
|
511
817
|
findings.extend(_overlap_findings(cuts))
|
|
512
818
|
findings.extend(_stale_window_findings(episode, project_root, cuts))
|
|
513
819
|
return findings
|
|
514
820
|
|
|
515
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
|
+
|
|
516
855
|
def _stale_window_findings(
|
|
517
856
|
episode: Path, project_root: Path, cuts: Sequence[Cut]
|
|
518
857
|
) -> list[str]:
|
|
@@ -533,7 +872,12 @@ def _stale_window_findings(
|
|
|
533
872
|
return []
|
|
534
873
|
findings: list[str] = []
|
|
535
874
|
for cut in cuts:
|
|
536
|
-
|
|
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:
|
|
537
881
|
continue
|
|
538
882
|
media = _resolve_media(episode, project_root, cut.media)
|
|
539
883
|
if media is None:
|
|
@@ -545,7 +889,7 @@ def _stale_window_findings(
|
|
|
545
889
|
if changed > authored + 1.0:
|
|
546
890
|
findings.append(
|
|
547
891
|
f"{CUT_LIST_NAME}:{cut.line_number}: {cut.cut_id} 的素材比剪辑单新"
|
|
548
|
-
f"({cut.media}
|
|
892
|
+
f"({cut.media});这一段的字幕、画面文字与音效时间是按旧素材反推的,"
|
|
549
893
|
"重出之后必须重测再改,不能沿用"
|
|
550
894
|
)
|
|
551
895
|
return findings
|
|
@@ -600,9 +944,16 @@ def render(
|
|
|
600
944
|
segments_root = output_root / SEGMENT_DIRECTORY
|
|
601
945
|
segments_root.mkdir(parents=True, exist_ok=True)
|
|
602
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
|
+
|
|
603
954
|
segments: list[Path] = []
|
|
604
955
|
spans: list[float] = []
|
|
605
|
-
for cut in cuts:
|
|
956
|
+
for cut, match in zip(cuts, pictures):
|
|
606
957
|
media = _resolve_media(episode, project_root, cut.media)
|
|
607
958
|
if media is None:
|
|
608
959
|
raise EditError(f"{cut.cut_id} 的素材不存在: {cut.media}")
|
|
@@ -611,7 +962,6 @@ def render(
|
|
|
611
962
|
ffmpeg, "-hide_banner", "-loglevel", "error", "-y",
|
|
612
963
|
"-ss", f"{cut.start:.3f}", "-t", f"{cut.end - cut.start:.3f}", "-i", str(media),
|
|
613
964
|
]
|
|
614
|
-
match = _shot_match_filter(cut)
|
|
615
965
|
if match:
|
|
616
966
|
command += ["-vf", match]
|
|
617
967
|
command += [
|
|
@@ -631,30 +981,53 @@ def render(
|
|
|
631
981
|
_run([ffmpeg, "-hide_banner", "-loglevel", "error", "-y",
|
|
632
982
|
"-f", "concat", "-safe", "0", "-i", str(listing), "-c", "copy", str(joined)])
|
|
633
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
|
+
|
|
634
997
|
filters: list[str] = []
|
|
635
998
|
subtitle_path = None
|
|
636
999
|
styled_path = None
|
|
637
1000
|
overlay_path = None
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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:
|
|
641
1009
|
subtitle_path = output_root / "字幕.srt"
|
|
642
1010
|
subtitle_path.write_text(_build_srt(cues), encoding="utf-8")
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
)
|
|
657
|
-
|
|
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}'")
|
|
658
1031
|
|
|
659
1032
|
# Grain goes on last, over the whole assembled film, so one texture sits
|
|
660
1033
|
# across every cut. `t` makes it move frame to frame — static noise reads
|
|
@@ -674,12 +1047,9 @@ def render(
|
|
|
674
1047
|
# to be named: the default one drops it and the overlay arrives as
|
|
675
1048
|
# an opaque black rectangle.
|
|
676
1049
|
command += ["-c:v", "libvpx", "-i", str(overlay_path)]
|
|
677
|
-
#
|
|
678
|
-
#
|
|
679
|
-
|
|
680
|
-
chain = "[0:v][1:v]overlay=0:0:format=auto"
|
|
681
|
-
if grain is not None:
|
|
682
|
-
chain += f",{grain}"
|
|
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])
|
|
683
1053
|
command += [
|
|
684
1054
|
"-filter_complex",
|
|
685
1055
|
f"{chain},format=yuv420p[v]",
|
|
@@ -704,22 +1074,18 @@ def render(
|
|
|
704
1074
|
"分段": [str(segment) for segment in segments],
|
|
705
1075
|
"字幕": str(subtitle_path) if subtitle_path else None,
|
|
706
1076
|
"压制字幕": str(styled_path) if styled_path else None,
|
|
707
|
-
"
|
|
1077
|
+
"叠层": str(overlay_path) if overlay_path else None,
|
|
708
1078
|
"字幕渲染": renderer if subtitle_path else None,
|
|
1079
|
+
"画面文字": len(layers),
|
|
1080
|
+
"音效": len(effects),
|
|
1081
|
+
"自动接镜": _shot_match_report(auto, scenes, enabled=delivery.shot_match),
|
|
709
1082
|
"段数": len(segments),
|
|
710
1083
|
"各段时长之和": round(sum(cut.end - cut.start for cut in cuts), 2),
|
|
711
1084
|
}
|
|
712
1085
|
|
|
713
1086
|
|
|
714
1087
|
def _shot_match_filter(cut: Cut) -> str:
|
|
715
|
-
"""
|
|
716
|
-
|
|
717
|
-
Generated shots drift: two takes of the same person at the same table come
|
|
718
|
-
back a stop apart and half a step of white balance away from each other, and
|
|
719
|
-
the join reads as a mistake rather than a cut. The correction stays a stated
|
|
720
|
-
creator decision — the tool never measures a clip and adjusts it on its own,
|
|
721
|
-
because a correction nobody wrote down is one nobody can review.
|
|
722
|
-
"""
|
|
1088
|
+
"""The correction a cut states in 「画面:」. It replaces the automatic match."""
|
|
723
1089
|
|
|
724
1090
|
stages: list[str] = []
|
|
725
1091
|
eq = [
|
|
@@ -737,6 +1103,192 @@ def _shot_match_filter(cut: Cut) -> str:
|
|
|
737
1103
|
return ",".join(stages)
|
|
738
1104
|
|
|
739
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
|
+
|
|
740
1292
|
def _loudnorm_filter(ffmpeg: str, media: Path, target: float) -> str:
|
|
741
1293
|
"""Measure EBU R128 before normalization; verify the encoded output afterward.
|
|
742
1294
|
|
|
@@ -775,24 +1327,76 @@ def _sync_remotion_workspace(workspace: Path) -> Path:
|
|
|
775
1327
|
"""
|
|
776
1328
|
|
|
777
1329
|
workspace = workspace.expanduser().resolve()
|
|
778
|
-
(workspace / "src").mkdir(parents=True, exist_ok=True)
|
|
779
1330
|
for name in REMOTION_SOURCE_FILES:
|
|
780
1331
|
source = REMOTION_SOURCE / name
|
|
781
1332
|
if not source.is_file():
|
|
782
1333
|
raise EditError(f"技能里缺少 Remotion 源文件: {name}")
|
|
1334
|
+
(workspace / name).parent.mkdir(parents=True, exist_ok=True)
|
|
783
1335
|
shutil.copyfile(source, workspace / name)
|
|
784
1336
|
return workspace
|
|
785
1337
|
|
|
786
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
|
+
|
|
787
1389
|
def _render_remotion_overlay(
|
|
788
1390
|
output_root: Path,
|
|
789
|
-
cues: Sequence[
|
|
1391
|
+
cues: Sequence[DisplayCue],
|
|
1392
|
+
layers: Sequence[dict[str, Any]],
|
|
790
1393
|
canvas: dict[str, Any],
|
|
791
1394
|
duration: float,
|
|
792
1395
|
workspace_root: Path,
|
|
793
1396
|
concurrency: int = DEFAULT_REMOTION_CONCURRENCY,
|
|
1397
|
+
needed_for: str = "字幕",
|
|
794
1398
|
) -> Path:
|
|
795
|
-
"""Render
|
|
1399
|
+
"""Render subtitles and screen text as one transparent video with Remotion.
|
|
796
1400
|
|
|
797
1401
|
The picture is never re-drawn by the browser: only the type is, onto an
|
|
798
1402
|
empty frame, and ffmpeg composites that over untouched footage. So the
|
|
@@ -806,44 +1410,33 @@ def _render_remotion_overlay(
|
|
|
806
1410
|
"""
|
|
807
1411
|
|
|
808
1412
|
workspace = _sync_remotion_workspace(workspace_root)
|
|
809
|
-
|
|
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:
|
|
810
1422
|
raise EditError(
|
|
811
|
-
f"Remotion
|
|
1423
|
+
f"Remotion 叠层的依赖没装全(缺 {'、'.join(missing)}),{needed_for}需要它们。先运行一次:\n"
|
|
812
1424
|
f" cd {workspace} && npm install\n"
|
|
813
|
-
"
|
|
1425
|
+
f"{alternative}\n"
|
|
814
1426
|
"注意 Remotion 有自己的许可证:个人与小团队免费,超出规模需要商业授权;"
|
|
815
1427
|
"本工具不会替你安装它。"
|
|
816
1428
|
)
|
|
817
1429
|
npx = _which("npx")
|
|
818
1430
|
if npx is None:
|
|
819
1431
|
raise EditError(
|
|
820
|
-
"PATH 上没有 npx,无法运行 Remotion
|
|
1432
|
+
f"PATH 上没有 npx,无法运行 Remotion 绘制{needed_for};先装好 Node.js。{alternative}"
|
|
821
1433
|
)
|
|
822
|
-
props = output_root / "
|
|
1434
|
+
props = output_root / "叠层.props.json"
|
|
823
1435
|
props.write_text(
|
|
824
|
-
json.dumps(
|
|
825
|
-
{
|
|
826
|
-
"cues": [
|
|
827
|
-
{"start": round(start, 3), "end": round(end, 3), "text": text}
|
|
828
|
-
for start, end, text in cues
|
|
829
|
-
],
|
|
830
|
-
"width": canvas["width"] or 1080,
|
|
831
|
-
"height": canvas["height"] or 1920,
|
|
832
|
-
"fps": canvas["fps"] or 24,
|
|
833
|
-
"durationInSeconds": round(duration, 3),
|
|
834
|
-
"fontScale": 0.034,
|
|
835
|
-
"bottomScale": 0.055,
|
|
836
|
-
"fontFamily": (
|
|
837
|
-
'"PingFang SC", "Noto Sans CJK SC", "Source Han Sans SC", '
|
|
838
|
-
'"Microsoft YaHei", sans-serif'
|
|
839
|
-
),
|
|
840
|
-
},
|
|
841
|
-
ensure_ascii=False,
|
|
842
|
-
indent=2,
|
|
843
|
-
),
|
|
1436
|
+
json.dumps(_overlay_props(cues, layers, canvas, duration), ensure_ascii=False, indent=2),
|
|
844
1437
|
encoding="utf-8",
|
|
845
1438
|
)
|
|
846
|
-
overlay = output_root / "
|
|
1439
|
+
overlay = output_root / "叠层.webm"
|
|
847
1440
|
result = subprocess.run(
|
|
848
1441
|
[npx, "remotion", "render", REMOTION_COMPOSITION, str(overlay),
|
|
849
1442
|
f"--props={props}", "--log=error",
|
|
@@ -864,7 +1457,7 @@ def _frame_geometry(media: Path) -> str:
|
|
|
864
1457
|
|
|
865
1458
|
def _subtitle_cues(
|
|
866
1459
|
cuts: Sequence[Cut], spans: Sequence[float]
|
|
867
|
-
) -> list[tuple[float, float, str]]:
|
|
1460
|
+
) -> list[tuple[float, float, str, tuple[str, ...]]]:
|
|
868
1461
|
"""Place each line in output time, measuring the segments that were written.
|
|
869
1462
|
|
|
870
1463
|
A segment lands on a frame boundary, so it is a few milliseconds longer than
|
|
@@ -873,33 +1466,308 @@ def _subtitle_cues(
|
|
|
873
1466
|
before the actor stops speaking. The rendered files are the timeline.
|
|
874
1467
|
"""
|
|
875
1468
|
|
|
876
|
-
cues: list[tuple[float, float, str]] = []
|
|
877
|
-
cursor
|
|
878
|
-
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):
|
|
879
1471
|
declared = cut.end - cut.start
|
|
880
|
-
|
|
881
|
-
# place proportionally rather than letting the tail slip out.
|
|
882
|
-
scale = span / declared if declared > 0 else 1.0
|
|
883
|
-
for window_start, window_end, text in cut.subtitles:
|
|
1472
|
+
for window_start, window_end, text, words in cut.subtitles:
|
|
884
1473
|
start = 0.0 if window_start is None else window_start
|
|
885
1474
|
end = declared if window_end is None else window_end
|
|
886
|
-
cues.append((cursor + start * scale, cursor + end * scale, text))
|
|
887
|
-
cursor += span
|
|
1475
|
+
cues.append((cursor + start * scale, cursor + end * scale, text, words))
|
|
888
1476
|
return cues
|
|
889
1477
|
|
|
890
1478
|
|
|
891
|
-
def
|
|
892
|
-
|
|
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."""
|
|
893
1737
|
|
|
894
1738
|
return "\n".join(
|
|
895
|
-
f"{index}\n{_timecode(start)} --> {_timecode(end)}\n{text}\n"
|
|
896
|
-
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)
|
|
897
1741
|
)
|
|
898
1742
|
|
|
899
1743
|
|
|
900
|
-
def
|
|
901
|
-
|
|
902
|
-
|
|
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:
|
|
903
1771
|
"""Author the ASS directly so the type size is stated in frame pixels.
|
|
904
1772
|
|
|
905
1773
|
Letting ffmpeg convert the SRT hands libass a 384-high canvas, and every
|
|
@@ -910,9 +1778,11 @@ def _build_ass(
|
|
|
910
1778
|
PlayRes as the frame removes the conversion, so one unit is one pixel.
|
|
911
1779
|
"""
|
|
912
1780
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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))
|
|
916
1786
|
margin_h = max(24, round(width * 0.06))
|
|
917
1787
|
header = (
|
|
918
1788
|
"[Script Info]\n"
|
|
@@ -927,15 +1797,15 @@ def _build_ass(
|
|
|
927
1797
|
"ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, "
|
|
928
1798
|
"MarginL, MarginR, MarginV, Encoding\n"
|
|
929
1799
|
f"Style: 正片,{SUBTITLE_FONT},{font_size},&H00FFFFFF,&H00FFFFFF,"
|
|
930
|
-
f"&
|
|
1800
|
+
f"&H00120D0B,&H80000000,-1,0,0,0,100,100,0,0,1,{outline},1,2,"
|
|
931
1801
|
f"{margin_h},{margin_h},{margin_v},1\n"
|
|
932
1802
|
"\n[Events]\n"
|
|
933
1803
|
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, "
|
|
934
1804
|
"Effect, Text\n"
|
|
935
1805
|
)
|
|
936
1806
|
lines = [
|
|
937
|
-
f"Dialogue: 0,{_ass_time(start)},{_ass_time(end)},正片,,0,0,0,,{
|
|
938
|
-
for
|
|
1807
|
+
f"Dialogue: 0,{_ass_time(cue.start)},{_ass_time(cue.end)},正片,,0,0,0,,{_ass_line(cue)}"
|
|
1808
|
+
for cue in cues
|
|
939
1809
|
]
|
|
940
1810
|
return header + "\n".join(lines) + "\n"
|
|
941
1811
|
|
|
@@ -1043,11 +1913,173 @@ def verify(episode: Path, cuts: Sequence[Cut], delivery: Delivery) -> dict[str,
|
|
|
1043
1913
|
measurements["画内可读文字"] = (
|
|
1044
1914
|
"未测(抽有画内文字的帧,逐字对《剧本.md》的「画面文字」与提示词声明的内容)"
|
|
1045
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))
|
|
1046
1924
|
measurements["台词完整性"] = "未测(本工具不做转写;在成片上转写后逐句对《剧本.md》原文)"
|
|
1925
|
+
measurements.update(_frame_report(
|
|
1926
|
+
_grey_frames(ffmpeg, final), stream["fps"], cuts, spans, _scene_keys(episode, cuts)
|
|
1927
|
+
))
|
|
1047
1928
|
measurements["边界帧"] = "未测(抽剪辑点前后各一帧目视核对黑场/白场/半渲染帧)"
|
|
1048
1929
|
return measurements
|
|
1049
1930
|
|
|
1050
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
|
+
|
|
1051
2083
|
def _matches_frame_size(delivery: Delivery, stream: dict[str, Any]) -> Any:
|
|
1052
2084
|
if delivery.frame_size is None:
|
|
1053
2085
|
return "未测(剪辑单没有声明画幅)"
|