@oh-story/dsh 0.1.7 → 0.1.9

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.
Files changed (272) hide show
  1. package/README.md +30 -15
  2. package/lib/client.js +93 -16
  3. package/lib/client.js.map +2 -2
  4. package/lib/drama/manifest.json +165 -69
  5. package/lib/drama/skills/short-drama/SKILL.md +5 -1
  6. package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +126 -9
  7. package/lib/drama/skills/short-drama/scripts/project_tool.py +11 -0
  8. package/lib/drama/skills/short-drama-assets/references/character-and-look.md +18 -0
  9. package/lib/drama/skills/short-drama-assets/references/continuity-lock.md +10 -1
  10. package/lib/drama/skills/short-drama-edit/SKILL.md +148 -0
  11. package/lib/drama/skills/short-drama-edit/agents/openai.yaml +4 -0
  12. package/lib/drama/skills/short-drama-edit/assets/remotion/README.md +60 -0
  13. package/lib/drama/skills/short-drama-edit/assets/remotion/package.json +15 -0
  14. package/lib/drama/skills/short-drama-edit/assets/remotion/remotion.config.ts +6 -0
  15. package/lib/drama/skills/short-drama-edit/assets/remotion/src/Root.tsx +26 -0
  16. package/lib/drama/skills/short-drama-edit/assets/remotion/src/Subtitles.tsx +88 -0
  17. package/lib/drama/skills/short-drama-edit/assets/remotion/src/font.ts +46 -0
  18. package/lib/drama/skills/short-drama-edit/assets/remotion/src/index.ts +4 -0
  19. package/lib/drama/skills/short-drama-edit/assets/remotion/src/schema.ts +32 -0
  20. package/lib/drama/skills/short-drama-edit/assets/remotion/tsconfig.json +13 -0
  21. package/lib/drama/skills/short-drama-edit/assets//345/211/252/350/276/221/345/215/225.md +43 -0
  22. package/lib/drama/skills/short-drama-edit/references/cut-craft.md +81 -0
  23. package/lib/drama/skills/short-drama-edit/references/delivery-verify.md +58 -0
  24. package/lib/drama/skills/short-drama-edit/references/generated-footage.md +96 -0
  25. package/lib/drama/skills/short-drama-edit/references/sound-and-subtitles.md +113 -0
  26. package/lib/drama/skills/short-drama-edit/references/stage-contract.md +55 -0
  27. package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +1041 -0
  28. package/lib/drama/skills/short-drama-edit/scripts/selftest.py +456 -0
  29. package/lib/drama/skills/short-drama-image-prompts/assets/image-prompts.md +5 -0
  30. package/lib/drama/skills/short-drama-image-prompts/assets/lookdev-prompts.md +3 -0
  31. package/lib/drama/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +2 -2
  32. package/lib/drama/skills/short-drama-novel-analyze/references/chapter-extraction.md +4 -1
  33. package/lib/drama/skills/short-drama-produce/SKILL.md +24 -3
  34. package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +28 -0
  35. package/lib/drama/skills/short-drama-produce/references/providers/minimax-h3-video.md +24 -5
  36. package/lib/drama/skills/short-drama-produce/references/providers/seedance.md +7 -0
  37. package/lib/drama/skills/short-drama-produce/scripts/fixture_adapter.py +18 -1
  38. package/lib/drama/skills/short-drama-produce/scripts/production_tool.py +181 -2
  39. package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +57 -2
  40. package/lib/drama/skills/short-drama-review/SKILL.md +3 -0
  41. package/lib/drama/skills/short-drama-review/references/production-quality-gates.md +20 -0
  42. package/lib/drama/skills/short-drama-review/references/rubric-visual-motion.md +36 -0
  43. package/lib/drama/skills/short-drama-storyboard/SKILL.md +7 -0
  44. package/lib/drama/skills/short-drama-storyboard/assets/keyframe-prompts.md +4 -0
  45. package/lib/drama/skills/short-drama-storyboard/references/shot-craft.md +39 -0
  46. package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +2 -0
  47. package/lib/drama/skills/short-drama-video-prompts/SKILL.md +7 -1
  48. package/lib/drama/skills/short-drama-video-prompts/references/minimax-h3.md +101 -6
  49. package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +7 -1
  50. package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +35 -0
  51. package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +3 -1
  52. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.0.md +13 -3
  53. package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +35 -4
  54. package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +1 -1
  55. package/lib/drama/skills/short-drama-write/SKILL.md +13 -2
  56. package/lib/drama/skills/short-drama-write/references/screenplay-format.md +6 -0
  57. package/lib/drama/skills/short-drama-write/references/stage-contract.md +1 -0
  58. package/lib/index.js +4 -2
  59. package/lib/index.js.map +2 -2
  60. package/lib/novel-to-game/VERSION +1 -1
  61. package/lib/novel-to-game/manifest.json +28 -28
  62. package/lib/novel-to-game/skills/game-art-direction/SKILL.md +6 -27
  63. package/lib/novel-to-game/skills/game-build/SKILL.md +12 -29
  64. package/lib/novel-to-game/skills/game-concept/SKILL.md +10 -26
  65. package/lib/novel-to-game/skills/game-qa/SKILL.md +5 -15
  66. package/lib/novel-to-game/skills/game-qa/references/test-design-method.md +1 -1
  67. package/lib/novel-to-game/skills/game-world-design/SKILL.md +19 -28
  68. package/lib/novel-to-game/skills/game-world-design/references/dialogue-design-method.md +1 -2
  69. package/lib/novel-to-game/skills/novel-game-analyze/SKILL.md +6 -9
  70. package/lib/novel-to-game/skills/novel-to-game/SKILL.md +6 -11
  71. package/lib/novel-to-game/skills/novel-to-game/references/intake-benchmark-reference.md +0 -4
  72. package/lib/novel-to-game/skills/novel-to-game/references/intake-method.md +1 -3
  73. package/lib/novel-to-game/skills/novel-to-game/references/pipeline-contract.md +3 -5
  74. package/lib/oh-story/manifest.json +292 -172
  75. package/lib/oh-story/roles/character-designer.md +1 -1
  76. package/lib/oh-story/roles/narrative-writer.md +56 -242
  77. package/lib/oh-story/roles/story-architect.md +1 -1
  78. package/lib/oh-story/skills/story/SKILL.md +1 -1
  79. package/lib/oh-story/skills/story/VERSION +1 -1
  80. package/lib/oh-story/skills/story/references/author-memory.md +10 -8
  81. package/lib/oh-story/skills/story-deslop/SKILL.md +20 -182
  82. package/lib/oh-story/skills/story-deslop/references/anti-ai-writing.md +19 -47
  83. package/lib/oh-story/skills/story-deslop/references/author-memory.md +10 -8
  84. package/lib/oh-story/skills/story-deslop/references/banned-words.md +3 -1
  85. package/lib/oh-story/skills/story-deslop/references/deslop-gates.md +154 -0
  86. package/lib/oh-story/skills/story-deslop/references/style-resolution.md +27 -0
  87. package/lib/oh-story/skills/story-deslop/scripts/check-ai-patterns.js +6 -1
  88. package/lib/oh-story/skills/story-deslop/scripts/check-degeneration.js +39 -20
  89. package/lib/oh-story/skills/story-deslop/scripts/normalize-punctuation.js +10 -5
  90. package/lib/oh-story/skills/story-deslop/scripts/style-whitelist.js +39 -0
  91. package/lib/oh-story/skills/story-import/SKILL.md +2 -2
  92. package/lib/oh-story/skills/story-import/references/format-and-structure.md +2 -2
  93. package/lib/oh-story/skills/story-import/references/tracking-initialization.md +31 -0
  94. package/lib/oh-story/skills/story-import/references/tracking-transaction.md +2 -29
  95. package/lib/oh-story/skills/story-import/scripts/check-outline-contract.js +176 -12
  96. package/lib/oh-story/skills/story-long-analyze/SKILL.md +1 -1
  97. package/lib/oh-story/skills/story-long-write/SKILL.md +20 -193
  98. package/lib/oh-story/skills/story-long-write/references/anti-ai-writing.md +19 -47
  99. package/lib/oh-story/skills/story-long-write/references/artifact-protocols.md +62 -7
  100. package/lib/oh-story/skills/story-long-write/references/author-memory.md +10 -8
  101. package/lib/oh-story/skills/story-long-write/references/banned-words.md +3 -1
  102. package/lib/oh-story/skills/story-long-write/references/benchmark-recall.md +20 -0
  103. package/lib/oh-story/skills/story-long-write/references/dialogue-mastery.md +6 -4
  104. package/lib/oh-story/skills/story-long-write/references/long-chapter-quality.md +2 -2
  105. package/lib/oh-story/skills/story-long-write/references/long-format.md +2 -0
  106. package/lib/oh-story/skills/story-long-write/references/outline-structure-theory.md +2 -0
  107. package/lib/oh-story/skills/story-long-write/references/project-files.md +108 -0
  108. package/lib/oh-story/skills/story-long-write/references/reference-index.md +87 -0
  109. package/lib/oh-story/skills/story-long-write/references/style-combat-face.md +1 -1
  110. package/lib/oh-story/skills/story-long-write/references/style-craft.md +9 -29
  111. package/lib/oh-story/skills/story-long-write/references/style-genre-modules.md +1 -1
  112. package/lib/oh-story/skills/story-long-write/references/style-resolution.md +27 -0
  113. package/lib/oh-story/skills/story-long-write/references/tracking-initialization.md +31 -0
  114. package/lib/oh-story/skills/story-long-write/references/tracking-transaction.md +2 -29
  115. package/lib/oh-story/skills/story-long-write/references/workflow-chapter.md +51 -52
  116. package/lib/oh-story/skills/story-long-write/references/workflow-daily.md +8 -7
  117. package/lib/oh-story/skills/story-long-write/references/workflow-setup.md +36 -15
  118. package/lib/oh-story/skills/story-long-write/references/writer-prompt-fallback.md +22 -0
  119. package/lib/oh-story/skills/story-long-write/references/writing-craft.md +69 -2
  120. package/lib/oh-story/skills/story-long-write/scripts/build_writer_prompt.py +410 -0
  121. package/lib/oh-story/skills/story-long-write/scripts/check-ai-patterns.js +6 -1
  122. package/lib/oh-story/skills/story-long-write/scripts/check-degeneration.js +39 -20
  123. package/lib/oh-story/skills/story-long-write/scripts/check-outline-contract.js +176 -12
  124. package/lib/oh-story/skills/story-long-write/scripts/check-outline-copy.js +90 -23
  125. package/lib/oh-story/skills/story-long-write/scripts/normalize-punctuation.js +10 -5
  126. package/lib/oh-story/skills/story-long-write/scripts/outline_view.py +283 -0
  127. package/lib/oh-story/skills/story-long-write/scripts/style-whitelist.js +39 -0
  128. package/lib/oh-story/skills/story-review/SKILL.md +13 -3
  129. package/lib/oh-story/skills/story-review/references/anti-ai-writing.md +19 -47
  130. package/lib/oh-story/skills/story-review/references/author-memory.md +10 -8
  131. package/lib/oh-story/skills/story-review/references/banned-words.md +3 -1
  132. package/lib/oh-story/skills/story-review/references/dialogue-mastery.md +6 -4
  133. package/lib/oh-story/skills/story-review/references/review-quality.md +2 -2
  134. package/lib/oh-story/skills/story-review/references/style-resolution.md +27 -0
  135. package/lib/oh-story/skills/story-review/references/tracking-initialization.md +31 -0
  136. package/lib/oh-story/skills/story-review/references/tracking-transaction.md +2 -29
  137. package/lib/oh-story/skills/story-review/scripts/check-ai-patterns.js +6 -1
  138. package/lib/oh-story/skills/story-review/scripts/check-degeneration.js +39 -20
  139. package/lib/oh-story/skills/story-review/scripts/normalize-punctuation.js +10 -5
  140. package/lib/oh-story/skills/story-review/scripts/style-whitelist.js +39 -0
  141. package/lib/oh-story/skills/story-setup/SKILL.md +17 -10
  142. package/lib/oh-story/skills/story-setup/references/agent-references/anti-ai-writing.md +19 -47
  143. package/lib/oh-story/skills/story-setup/references/agent-references/banned-words.md +3 -1
  144. package/lib/oh-story/skills/story-setup/references/agent-references/deslop-gates.md +154 -0
  145. package/lib/oh-story/skills/story-setup/references/agent-references/dialogue-mastery.md +6 -4
  146. package/lib/oh-story/skills/story-setup/references/agent-references/format-and-structure.md +2 -2
  147. package/lib/oh-story/skills/story-setup/references/agent-references/short-genre-formulas.md +1 -1
  148. package/lib/oh-story/skills/story-setup/references/agent-references/style-combat-face.md +1 -1
  149. package/lib/oh-story/skills/story-setup/references/agent-references/style-genre-modules.md +1 -1
  150. package/lib/oh-story/skills/story-setup/references/agent-references/style-resolution.md +27 -0
  151. package/lib/oh-story/skills/story-setup/references/agent-references/writing-craft.md +69 -2
  152. package/lib/oh-story/skills/story-setup/references/diagnostics.md +38 -0
  153. package/lib/oh-story/skills/story-short-analyze/references/analysis-writing-techniques.md +1 -1
  154. package/lib/oh-story/skills/story-short-analyze/references/source-story-quality.md +2 -2
  155. package/lib/oh-story/skills/story-short-write/SKILL.md +18 -186
  156. package/lib/oh-story/skills/story-short-write/references/author-memory.md +10 -8
  157. package/lib/oh-story/skills/story-short-write/references/banned-words.md +3 -1
  158. package/lib/oh-story/skills/story-short-write/references/benchmark-recall.md +60 -0
  159. package/lib/oh-story/skills/story-short-write/references/dialogue-mastery.md +6 -4
  160. package/lib/oh-story/skills/story-short-write/references/genre-styles//344/270/226/346/203/205/346/211/223/350/204/270.md +2 -2
  161. package/lib/oh-story/skills/story-short-write/references/genre-styles//345/217/214/347/224/267/344/270/273.md +1 -1
  162. package/lib/oh-story/skills/story-short-write/references/genre-styles//345/244/215/344/273/207/346/211/223/350/204/270.md +3 -3
  163. package/lib/oh-story/skills/story-short-write/references/genre-styles//345/256/205/346/226/227/345/256/253/346/226/227.md +3 -3
  164. package/lib/oh-story/skills/story-short-write/references/genre-styles//346/200/273/350/243/201/350/261/252/351/227/250.md +3 -3
  165. package/lib/oh-story/skills/story-short-write/references/genre-styles//346/202/254/347/226/221.md +5 -5
  166. package/lib/oh-story/skills/story-short-write/references/genre-styles//346/260/221/344/277/227/346/200/252/350/260/210.md +4 -4
  167. package/lib/oh-story/skills/story-short-write/references/genre-styles//347/224/234/345/256/240.md +5 -5
  168. package/lib/oh-story/skills/story-short-write/references/genre-styles//350/277/275/345/246/273/347/201/253/350/221/254/345/234/272.md +3 -3
  169. package/lib/oh-story/skills/story-short-write/references/genre-writing-formulas.md +1 -1
  170. package/lib/oh-story/skills/story-short-write/references/genre-writing-techniques.md +1 -1
  171. package/lib/oh-story/skills/story-short-write/references/reference-index.md +53 -0
  172. package/lib/oh-story/skills/story-short-write/references/short-craft.md +12 -14
  173. package/lib/oh-story/skills/story-short-write/references/short-deslop.md +10 -10
  174. package/lib/oh-story/skills/story-short-write/references/short-format.md +3 -1
  175. package/lib/oh-story/skills/story-short-write/references/short-prose-quality.md +2 -2
  176. package/lib/oh-story/skills/story-short-write/references/style-resolution.md +27 -0
  177. package/lib/oh-story/skills/story-short-write/references/workflow-design.md +77 -0
  178. package/lib/oh-story/skills/story-short-write/references/writing-workflow.md +5 -5
  179. package/lib/oh-story/skills/story-short-write/scripts/check-ai-patterns.js +6 -1
  180. package/lib/oh-story/skills/story-short-write/scripts/check-degeneration.js +39 -20
  181. package/lib/oh-story/skills/story-short-write/scripts/check-delivery-contract.js +34 -5
  182. package/lib/oh-story/skills/story-short-write/scripts/check-outline-copy.js +90 -23
  183. package/lib/oh-story/skills/story-short-write/scripts/normalize-punctuation.js +10 -5
  184. package/lib/oh-story/skills/story-short-write/scripts/style-whitelist.js +39 -0
  185. package/lib/types/client/drama-production-view.d.ts.map +1 -1
  186. package/lib/types/client/index.d.ts.map +1 -1
  187. package/lib/types/client/production-prompts.d.ts.map +1 -1
  188. package/lib/types/client/production-runtime.d.ts +17 -0
  189. package/lib/types/client/production-runtime.d.ts.map +1 -1
  190. package/lib/types/skill-provider.d.ts.map +1 -1
  191. package/lib/video-recap/CHANGELOG.md +31 -1
  192. package/lib/video-recap/manifest.json +162 -162
  193. package/lib/video-recap/skills/video-assemble/scripts/artifacts.py +4 -9
  194. package/lib/video-recap/skills/video-assemble/scripts/assemble.py +47 -60
  195. package/lib/video-recap/skills/video-assemble/scripts/assembly_contract.py +77 -115
  196. package/lib/video-recap/skills/video-assemble/scripts/assembly_settings.py +41 -40
  197. package/lib/video-recap/skills/video-assemble/scripts/audio_automation.py +22 -32
  198. package/lib/video-recap/skills/video-assemble/scripts/audio_mix.py +102 -192
  199. package/lib/video-recap/skills/video-assemble/scripts/jianying_builders.py +14 -35
  200. package/lib/video-recap/skills/video-assemble/scripts/jianying_model.py +14 -12
  201. package/lib/video-recap/skills/video-assemble/scripts/jianying_optional.py +8 -9
  202. package/lib/video-recap/skills/video-assemble/scripts/jianying_schema.py +3 -14
  203. package/lib/video-recap/skills/video-assemble/scripts/jianying_timeline_contract.py +23 -12
  204. package/lib/video-recap/skills/video-assemble/scripts/jianying_tracks.py +1 -4
  205. package/lib/video-recap/skills/video-assemble/scripts/jianying_writer.py +24 -25
  206. package/lib/video-recap/skills/video-assemble/scripts/lib.py +31 -85
  207. package/lib/video-recap/skills/video-assemble/scripts/media.py +100 -144
  208. package/lib/video-recap/skills/video-assemble/scripts/narration_audio.py +101 -195
  209. package/lib/video-recap/skills/video-assemble/scripts/render_preflight.py +10 -21
  210. package/lib/video-recap/skills/video-assemble/scripts/source_subtitles.py +90 -169
  211. package/lib/video-recap/skills/video-assemble/scripts/subtitle_core.py +69 -122
  212. package/lib/video-recap/skills/video-assemble/scripts/subtitle_render.py +10 -17
  213. package/lib/video-recap/skills/video-assemble/scripts/timeline.py +33 -48
  214. package/lib/video-recap/skills/video-assemble/scripts/timeline_emit.py +14 -20
  215. package/lib/video-recap/skills/video-assemble/scripts/visual_render.py +135 -213
  216. package/lib/video-recap/skills/video-cut/scripts/cut_cli.py +49 -57
  217. package/lib/video-recap/skills/video-cut/scripts/cut_contract.py +38 -74
  218. package/lib/video-recap/skills/video-cut/scripts/cut_render.py +46 -95
  219. package/lib/video-recap/skills/video-cut/scripts/lib.py +23 -25
  220. package/lib/video-recap/skills/video-cut/scripts/media_geometry.py +70 -173
  221. package/lib/video-recap/skills/video-cut/scripts/narration_mapping.py +28 -53
  222. package/lib/video-recap/skills/video-cut/scripts/sentence_boundaries.py +162 -322
  223. package/lib/video-recap/skills/video-recap/references/timeline-and-jianying.md +6 -7
  224. package/lib/video-recap/skills/video-recap/scripts/doctor.py +104 -124
  225. package/lib/video-recap/skills/video-recap/scripts/final_qc.py +255 -412
  226. package/lib/video-recap/skills/video-recap/scripts/lib.py +30 -52
  227. package/lib/video-recap/skills/video-recap/scripts/materials.py +97 -120
  228. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_client.py +7 -12
  229. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_evidence.py +46 -97
  230. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_observations.py +18 -36
  231. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_payload.py +29 -47
  232. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_report.py +43 -76
  233. package/lib/video-recap/skills/video-recap/scripts/mimo_qc_runner.py +2 -1
  234. package/lib/video-recap/skills/video-recap/scripts/qc_contract.py +2 -1
  235. package/lib/video-recap/skills/video-recap/scripts/recap_cli.py +2 -2
  236. package/lib/video-recap/skills/video-recap/scripts/recap_inspect.py +129 -145
  237. package/lib/video-recap/skills/video-recap/scripts/recap_review.py +27 -20
  238. package/lib/video-recap/skills/video-recap/scripts/recap_runner.py +20 -31
  239. package/lib/video-recap/skills/video-recap/scripts/recap_runtime.py +31 -106
  240. package/lib/video-recap/skills/video-recap/scripts/recap_stage_qc.py +43 -113
  241. package/lib/video-recap/skills/video-recap/scripts/recap_timeline.py +196 -377
  242. package/lib/video-recap/skills/video-script/scripts/agent_brief.py +1 -1
  243. package/lib/video-recap/skills/video-script/scripts/agent_text.py +63 -172
  244. package/lib/video-recap/skills/video-script/scripts/brief_context.py +80 -135
  245. package/lib/video-recap/skills/video-script/scripts/brief_inputs.py +125 -167
  246. package/lib/video-recap/skills/video-script/scripts/brief_timeline.py +93 -208
  247. package/lib/video-recap/skills/video-script/scripts/deslop_qc.py +20 -46
  248. package/lib/video-recap/skills/video-script/scripts/lib.py +30 -26
  249. package/lib/video-recap/skills/video-script/scripts/narration_lint.py +145 -140
  250. package/lib/video-recap/skills/video-script/scripts/review_response.py +12 -42
  251. package/lib/video-recap/skills/video-script/scripts/review_runner.py +0 -6
  252. package/lib/video-recap/skills/video-script/scripts/speech_ownership.py +39 -100
  253. package/lib/video-recap/skills/video-script/scripts/timeline_fusion.py +33 -124
  254. package/lib/video-recap/skills/video-understanding/scripts/agent_brief.py +1 -1
  255. package/lib/video-recap/skills/video-understanding/scripts/agent_text.py +63 -172
  256. package/lib/video-recap/skills/video-understanding/scripts/asr.py +1 -2
  257. package/lib/video-recap/skills/video-understanding/scripts/brief_context.py +80 -135
  258. package/lib/video-recap/skills/video-understanding/scripts/brief_inputs.py +125 -167
  259. package/lib/video-recap/skills/video-understanding/scripts/brief_timeline.py +93 -208
  260. package/lib/video-recap/skills/video-understanding/scripts/consolidate.py +1 -1
  261. package/lib/video-recap/skills/video-understanding/scripts/deslop_qc.py +20 -46
  262. package/lib/video-recap/skills/video-understanding/scripts/lib.py +31 -26
  263. package/lib/video-recap/skills/video-understanding/scripts/narration_lint.py +145 -140
  264. package/lib/video-recap/skills/video-understanding/scripts/speech_ownership.py +39 -100
  265. package/lib/video-recap/skills/video-understanding/scripts/timeline_fusion.py +33 -124
  266. package/lib/video-recap/skills/video-understanding/scripts/vlm.py +1 -4
  267. package/lib/video-recap/skills/video-voiceover/scripts/dub.py +7 -13
  268. package/lib/video-recap/skills/video-voiceover/scripts/fish_audio.py +12 -30
  269. package/lib/video-recap/skills/video-voiceover/scripts/lib.py +98 -131
  270. package/lib/video-recap/skills/video-voiceover/scripts/tts_audio.py +17 -28
  271. package/lib/video-recap/skills/video-voiceover/scripts/voiceover.py +151 -240
  272. package/package.json +41 -41
@@ -44,26 +44,25 @@ def _subtitle_style_config(canvas=None):
44
44
  16:9 source (or the 1280x720 default) reproduces the legacy values exactly.
45
45
  """
46
46
  style = {
47
- "font_name": CONFIG.get("subtitle_font_name", "Arial"),
48
- "font_size": CONFIG.get("subtitle_font_size", 42),
49
- "primary_color": CONFIG.get("subtitle_primary_color", "&H00FFFFFF"),
50
- "outline_color": CONFIG.get("subtitle_outline_color", "&H00000000"),
51
- "outline": CONFIG.get("subtitle_outline", 2),
52
- "shadow": CONFIG.get("subtitle_shadow", 1),
53
- "alignment": CONFIG.get("subtitle_alignment", 2),
54
- "margin_l": CONFIG.get("subtitle_margin_l", 40),
55
- "margin_r": CONFIG.get("subtitle_margin_r", 40),
56
- "margin_v": CONFIG.get("subtitle_margin_v", 30),
57
- "max_chars": CONFIG.get("subtitle_max_chars", 20),
58
- "play_res_x": CONFIG.get("subtitle_play_res_x", 1280),
59
- "play_res_y": CONFIG.get("subtitle_play_res_y", 720),
47
+ "font_name": CONFIG["subtitle_font_name"],
48
+ "font_size": CONFIG["subtitle_font_size"],
49
+ "primary_color": CONFIG["subtitle_primary_color"],
50
+ "outline_color": CONFIG["subtitle_outline_color"],
51
+ "outline": CONFIG["subtitle_outline"],
52
+ "shadow": CONFIG["subtitle_shadow"],
53
+ "alignment": CONFIG["subtitle_alignment"],
54
+ "margin_l": CONFIG["subtitle_margin_l"],
55
+ "margin_r": CONFIG["subtitle_margin_r"],
56
+ "margin_v": CONFIG["subtitle_margin_v"],
57
+ "max_chars": CONFIG["subtitle_max_chars"],
58
+ "play_res_x": CONFIG["subtitle_play_res_x"],
59
+ "play_res_y": CONFIG["subtitle_play_res_y"],
60
60
  }
61
61
  pinned = "SUBTITLE_PLAY_RES_X" in os.environ or "SUBTITLE_PLAY_RES_Y" in os.environ
62
- cw = int((canvas or {}).get("width", 0) or 0)
63
- ch = int((canvas or {}).get("height", 0) or 0)
64
- if canvas is None or pinned or cw <= 0 or ch <= 0:
62
+ if canvas is None or pinned:
65
63
  return style # legacy / manually-pinned: unchanged
66
64
 
65
+ cw, ch = canvas["width"], canvas["height"]
67
66
  base_font = float(style["font_size"])
68
67
  kx = cw / float(SUBTITLE_STYLE_REF_W) # horizontal metrics ∝ width
69
68
  ky = ch / float(SUBTITLE_STYLE_REF_H) # vertical metrics ∝ height
@@ -73,9 +72,9 @@ def _subtitle_style_config(canvas=None):
73
72
  # height-proportional size, then cap so a full line of CJK glyphs (≈1em wide) fits the
74
73
  # usable width — this is what keeps portrait text on-screen instead of overflowing.
75
74
  usable_w = max(1.0, cw - margin_l - margin_r)
76
- width_cap = usable_w / max(1, int(style["max_chars"]))
75
+ width_cap = usable_w / int(style["max_chars"])
77
76
  font_size = max(1, int(min(base_font * ky, width_cap))) # floor so a full line never overflows
78
- font_scale = font_size / base_font if base_font else 1.0
77
+ font_scale = font_size / base_font
79
78
  style.update({
80
79
  "font_size": font_size,
81
80
  "outline": max(0, round(float(style["outline"]) * font_scale)),
@@ -89,48 +88,45 @@ def _subtitle_style_config(canvas=None):
89
88
  return style
90
89
 
91
90
 
92
- def _validate_measured_subtitle_coordinate_domain(canvas=None):
93
- y_top = int(CONFIG.get("subtitle_y_top", -1))
94
- y_bot = int(CONFIG.get("subtitle_y_bot", -1))
91
+ def _measured_subtitle_band(canvas):
92
+ """Explicit (y_top, y_bot) display-frame subtitle band validated against the canvas, or None."""
93
+ y_top = CONFIG["subtitle_y_top"]
94
+ y_bot = CONFIG["subtitle_y_bot"]
95
95
  if y_top < 0 and y_bot < 0:
96
- return
97
- sar_text = str((canvas or {}).get("sample_aspect_ratio") or "1:1")
96
+ return None
97
+ sar_text = canvas["sample_aspect_ratio"]
98
98
  if abs(_ratio_to_float(sar_text, 0.0) - 1.0) >= 1e-9:
99
99
  raise ValueError(
100
100
  f"字幕带坐标仅支持方形像素画布 (SAR 1:1);当前 SAR={sar_text}"
101
101
  )
102
-
103
-
104
- def _style_for_measured_subtitle_band(style, canvas=None):
105
- """Fit the ASS baseline and font into explicit auto-rotated display-frame Y coordinates."""
106
- style = dict(style)
107
- y_top = int(CONFIG.get("subtitle_y_top", -1))
108
- y_bot = int(CONFIG.get("subtitle_y_bot", -1))
109
- canvas_h = int((canvas or {}).get("height", 0) or 0)
110
- if y_top < 0 and y_bot < 0:
111
- return style
112
- _validate_measured_subtitle_coordinate_domain(canvas)
113
- if canvas_h <= 0:
114
- return style
102
+ canvas_h = canvas["height"]
115
103
  if not 0 <= y_top < y_bot <= canvas_h:
116
104
  raise ValueError(
117
105
  f"字幕带坐标无效: top={y_top}, bot={y_bot}, 画布高度={canvas_h};"
118
106
  "必须满足 0 <= top < bot <= height"
119
107
  )
120
- alignment = int(style.get("alignment", 2))
108
+ return y_top, y_bot
109
+
110
+
111
+ def _style_for_measured_subtitle_band(style, canvas):
112
+ """Fit the ASS baseline and font into explicit auto-rotated display-frame Y coordinates."""
113
+ style = dict(style)
114
+ safe_area = _measured_subtitle_safe_area(style, canvas)
115
+ if safe_area is None:
116
+ return style
117
+ alignment = style["alignment"]
121
118
  if alignment not in {1, 2, 3}:
122
119
  raise ValueError(
123
120
  "measured subtitle coordinates require a bottom-aligned ASS style "
124
121
  f"(SUBTITLE_ALIGNMENT 1/2/3); got {alignment}"
125
122
  )
123
+ canvas_h = canvas["height"]
126
124
  scale_y = float(style["play_res_y"]) / canvas_h
127
- style["margin_v"] = max(0, round((canvas_h - y_bot) * scale_y))
128
- current_font = max(1, int(style["font_size"]))
129
- current_outline = float(style.get("outline", 0) or 0)
130
- current_shadow = float(style.get("shadow", 0) or 0)
131
- safe_area = _measured_subtitle_safe_area(style, canvas)
132
- available_height = int((safe_area or {}).get("height", 0) or 0)
133
- fitted_font = current_font
125
+ style["margin_v"] = max(0, round((canvas_h - CONFIG["subtitle_y_bot"]) * scale_y))
126
+ current_font = int(style["font_size"])
127
+ current_outline = float(style["outline"])
128
+ current_shadow = float(style["shadow"])
129
+ available_height = safe_area["height"]
134
130
  for candidate in range(current_font, 7, -1):
135
131
  scale = candidate / current_font
136
132
  outline = max(1 if current_outline > 0 else 0, round(current_outline * scale))
@@ -151,39 +147,27 @@ def _style_for_measured_subtitle_band(style, canvas=None):
151
147
  return style
152
148
 
153
149
 
154
- def _measured_subtitle_safe_area(style, canvas=None):
150
+ def _measured_subtitle_safe_area(style, canvas):
155
151
  """Return the padded measured band in ASS PlayRes coordinates, or None."""
156
- y_top = int(CONFIG.get("subtitle_y_top", -1))
157
- y_bot = int(CONFIG.get("subtitle_y_bot", -1))
158
- canvas_w = int((canvas or {}).get("width", 0) or 0)
159
- canvas_h = int((canvas or {}).get("height", 0) or 0)
160
- if y_top < 0 and y_bot < 0:
152
+ band = _measured_subtitle_band(canvas)
153
+ if band is None:
161
154
  return None
162
- _validate_measured_subtitle_coordinate_domain(canvas)
163
- if canvas_w <= 0 or canvas_h <= 0:
164
- return None
165
- if not 0 <= y_top < y_bot <= canvas_h:
166
- raise ValueError(
167
- f"字幕带坐标无效: top={y_top}, bot={y_bot}, 画布高度={canvas_h};"
168
- "必须满足 0 <= top < bot <= height"
169
- )
170
- padding = max(0, int(CONFIG.get("subtitle_mask_padding", 4) or 0))
171
- safe_top = max(0, y_top - padding)
155
+ y_top, y_bot = band
156
+ canvas_h = canvas["height"]
157
+ safe_top = max(0, y_top - CONFIG["subtitle_mask_padding"])
172
158
  # The ASS style remains bottom-anchored at the measured y_bot. Bottom mask padding hides
173
159
  # source glyph edges but is not usable subtitle layout space; only top padding can extend
174
160
  # the line box without moving its baseline below the measured band.
175
- safe_bot = y_bot
176
- play_x = int(style.get("play_res_x") or canvas_w)
177
- play_y = int(style.get("play_res_y") or canvas_h)
178
- scale_y = play_y / canvas_h
179
- margin_l = int(style.get("margin_l") or 0)
180
- margin_r = int(style.get("margin_r") or 0)
161
+ play_x = int(style["play_res_x"])
162
+ scale_y = int(style["play_res_y"]) / canvas_h
163
+ margin_l = int(style["margin_l"])
164
+ margin_r = int(style["margin_r"])
181
165
  return {
182
166
  "x": margin_l,
183
167
  "y": round(safe_top * scale_y),
184
168
  "width": max(1, play_x - margin_l - margin_r),
185
- "height": max(1, round((safe_bot - safe_top) * scale_y)),
186
- "bottom_margin": max(0, round((canvas_h - safe_bot) * scale_y)),
169
+ "height": max(1, round((y_bot - safe_top) * scale_y)),
170
+ "bottom_margin": max(0, round((canvas_h - y_bot) * scale_y)),
187
171
  }
188
172
 
189
173
 
@@ -193,9 +177,7 @@ def _subtitle_display_text(text):
193
177
  Narration/TTS source text stays untouched; this is applied only to SRT/ASS cue text.
194
178
  Closing quotes/brackets are preserved, so 「原声台词。」 renders as 「原声台词」.
195
179
  """
196
- text = str(text or "").strip()
197
- if not text:
198
- return ""
180
+ text = text.strip()
199
181
  suffix = ""
200
182
  while text and text[-1] in _SUBTITLE_CLOSING_QUOTES:
201
183
  suffix = text[-1] + suffix
@@ -206,8 +188,7 @@ def _subtitle_display_text(text):
206
188
 
207
189
  def _subtitle_chunk_weight(text):
208
190
  """Weight raw subtitle chunks for timing, independent of display punctuation cleanup."""
209
- core = re.sub(r"\s+", "", str(text or ""))
210
- return max(1, len(core))
191
+ return max(1, len(re.sub(r"\s+", "", text)))
211
192
 
212
193
 
213
194
  def _subtitle_entry_chunks(raw_chunks):
@@ -217,9 +198,8 @@ def _subtitle_entry_chunks(raw_chunks):
217
198
  on the emitted text, while quote-only suffix chunks are folded into the previous cue
218
199
  so a closing bracket never renders alone.
219
200
  """
220
- chunks = [str(c).strip() for c in (raw_chunks or []) if str(c).strip()]
221
201
  out = []
222
- for i, chunk in enumerate(chunks):
202
+ for chunk in raw_chunks:
223
203
  display = _subtitle_display_text(chunk)
224
204
  if not display:
225
205
  continue
@@ -231,16 +211,11 @@ def _subtitle_entry_chunks(raw_chunks):
231
211
  return out
232
212
 
233
213
 
234
- def _normalize_subtitle_text(s):
214
+ def _normalize_subtitle_text(text):
235
215
  """Normalize Chinese em-dashes in burned subtitle text: a run of one-or-more "—" (incl. "——")
236
216
  collapses to a single ",". Then collapse any resulting double commas (",,"→",") so the dash
237
- swap never leaves a doubled comma. Empty/None passes through as "" unchanged."""
238
- text = str(s or "")
239
- if not text:
240
- return text
241
- text = re.sub(r"—+", ",", text)
242
- text = re.sub(r",{2,}", ",", text)
243
- return text
217
+ swap never leaves a doubled comma."""
218
+ return re.sub(r",{2,}", ",", re.sub(r"—+", ",", text))
244
219
 
245
220
 
246
221
  def _split_subtitle_chunks(text, max_chars):
@@ -252,7 +227,7 @@ def _split_subtitle_chunks(text, max_chars):
252
227
  chunks of at most `max_chars` — each chunk renders as ONE readable line synced to its slice of
253
228
  the block's audio. Punctuation stays attached here for lossless splitting; the display layer
254
229
  strips terminal sentence marks per subtitle-cue style."""
255
- text = str(text).strip()
230
+ text = text.strip()
256
231
  if not text:
257
232
  return []
258
233
  breakers = ",。!?、;:…—,.!?;:"
@@ -298,42 +273,14 @@ def _subtitle_entries(narration):
298
273
  Each placed segment is split into short one-line chunks and its played window
299
274
  [actual_place_start, actual_place_end] is distributed across them in proportion to character
300
275
  count — karaoke-style timing that keeps each line on screen only while it is roughly being
301
- spoken, instead of holding a whole paragraph for the segment's full duration."""
302
- max_chars = int(CONFIG.get("subtitle_max_chars", 20))
276
+ spoken, instead of holding a whole paragraph for the segment's full duration. Segments that
277
+ were not placed have a zero-width window and therefore produce no cue."""
278
+ max_chars = CONFIG["subtitle_max_chars"]
303
279
  entries = []
304
280
  for seg in narration:
305
- if not isinstance(seg, dict):
306
- continue
307
- text = str(seg.get("spoken_text") or seg.get("narration", "")).strip()
308
- if not text:
309
- continue
310
- try:
311
- start = float(seg.get("actual_place_start", seg["start"]))
312
- end = float(seg.get("actual_place_end", seg["end"]))
313
- except (KeyError, TypeError, ValueError):
314
- continue
315
- if end - start < 0.1:
316
- continue
317
- chunks = _subtitle_entry_chunks(_split_subtitle_chunks(text, max_chars))
318
- if not chunks:
319
- continue
320
- if len(chunks) == 1:
321
- entries.append({"start": start, "end": end, "text": chunks[0]["text"]})
322
- continue
323
- total_chars = sum(_subtitle_chunk_weight(c["raw"]) for c in chunks) or 1
324
- span = end - start
325
- cursor = start
326
- for i, chunk in enumerate(chunks):
327
- weight = _subtitle_chunk_weight(chunk["raw"])
328
- chunk_end = end if i == len(chunks) - 1 else cursor + span * (weight / total_chars)
329
- if i > 0 and chunk_end - cursor < 0.05:
330
- # slice too short to show on its own — fold the text into the previous line of THIS
331
- # block (i>0) and extend its end, so no chunk is ever silently dropped.
332
- entries[-1]["text"] += chunk["text"]
333
- entries[-1]["end"] = chunk_end
334
- else:
335
- entries.append({"start": cursor, "end": chunk_end, "text": chunk["text"]})
336
- cursor = chunk_end
281
+ text = seg.get("spoken_text", seg["narration"])
282
+ start, end = float(seg["actual_place_start"]), float(seg["actual_place_end"])
283
+ entries.extend(_distribute_chunks(_split_subtitle_chunks(text, max_chars), start, end))
337
284
  return entries
338
285
 
339
286
 
@@ -341,14 +288,15 @@ def _distribute_chunks(chunks, start, end):
341
288
  """Distribute [start,end] across raw chunks while emitting display-clean text.
342
289
 
343
290
  Terminal subtitle punctuation is visual-only: it is stripped from final cue text,
344
- but the raw split chunks remain the timing topology.
291
+ but the raw split chunks remain the timing topology. A slice too short to show on its
292
+ own is folded into the previous line of the same block, so no chunk is ever dropped.
345
293
  """
346
294
  chunks = _subtitle_entry_chunks(chunks)
347
295
  if not chunks or end - start < 0.1:
348
296
  return []
349
297
  if len(chunks) == 1:
350
298
  return [{"start": start, "end": end, "text": chunks[0]["text"]}]
351
- total_chars = sum(_subtitle_chunk_weight(c["raw"]) for c in chunks) or 1
299
+ total_chars = sum(_subtitle_chunk_weight(c["raw"]) for c in chunks)
352
300
  span = end - start
353
301
  out, cursor = [], start
354
302
  for i, chunk in enumerate(chunks):
@@ -365,12 +313,11 @@ def _distribute_chunks(chunks, start, end):
365
313
 
366
314
  def _bracketed_original_chunks(text, start, end, max_chars):
367
315
  """Split original dialogue into timed chunks wrapped in 「」 for visual distinction."""
368
- raw = str(text).strip()
316
+ raw = text.strip()
369
317
  if raw.startswith("「") and raw.endswith("」"):
370
318
  raw = raw[1:-1].strip()
371
319
  chunks = _split_subtitle_chunks(raw, max_chars)
372
320
  if chunks:
373
- chunks = list(chunks)
374
321
  chunks[0] = "「" + chunks[0]
375
322
  chunks[-1] = chunks[-1] + "」"
376
323
  return _distribute_chunks(chunks, start, end)
@@ -6,21 +6,16 @@ from subtitle_core import (
6
6
  _seconds_to_ass_time,
7
7
  _seconds_to_srt_time,
8
8
  _style_for_measured_subtitle_band,
9
- _subtitle_entries,
10
9
  _subtitle_style_config,
11
10
  )
12
11
 
13
- def _generate_srt(narration, work_dir, video_duration=None):
14
- """将解说脚本转为 SRT 字幕文件,使用实际音频放置时间。video_duration 给定时,原声留白处补烧原声字幕。"""
12
+ def _generate_srt(narration, work_dir, video_duration):
13
+ """将解说脚本转为 SRT 字幕文件,使用实际音频放置时间;原声留白处补烧原声字幕。"""
15
14
  srt_lines = []
16
- entries = (_subtitle_entries(narration) if video_duration is None
17
- else _combined_subtitle_entries(narration, work_dir, video_duration))
18
15
  # entries are already split into short one-line chunks, so no wrapping here.
19
- for idx, entry in enumerate(entries, start=1):
20
- start_ts = _seconds_to_srt_time(entry["start"])
21
- end_ts = _seconds_to_srt_time(entry["end"])
16
+ for idx, entry in enumerate(_combined_subtitle_entries(narration, work_dir, video_duration), start=1):
22
17
  srt_lines.append(str(idx))
23
- srt_lines.append(f"{start_ts} --> {end_ts}")
18
+ srt_lines.append(f"{_seconds_to_srt_time(entry['start'])} --> {_seconds_to_srt_time(entry['end'])}")
24
19
  srt_lines.append(_normalize_subtitle_text(entry["text"]))
25
20
  srt_lines.append("")
26
21
  srt_path = work_dir / "subtitles.srt"
@@ -31,7 +26,7 @@ def _generate_srt(narration, work_dir, video_duration=None):
31
26
  def _escape_ass_text(text):
32
27
  """Escape user text for an ASS dialogue Text field."""
33
28
  return (
34
- str(text)
29
+ text
35
30
  .replace("\\", "\\\\")
36
31
  .replace("{", "\\{")
37
32
  .replace("}", "\\}")
@@ -41,10 +36,10 @@ def _escape_ass_text(text):
41
36
  )
42
37
 
43
38
 
44
- def _generate_ass(narration, work_dir, video_duration=None, canvas=None):
45
- """Generate an ASS subtitle file for readable hard-sub rendering. video_duration given ⇒ also
46
- burn the original dialogue (from ASR) during the original-audio gaps. canvas ({"width","height"})
47
- scales the style to the real frame so portrait/竖屏 subtitles are not stretched."""
39
+ def _generate_ass(narration, work_dir, video_duration, canvas):
40
+ """Generate an ASS subtitle file for readable hard-sub rendering, including the original
41
+ dialogue during the original-audio gaps. canvas ({"width","height"}) scales the style to the
42
+ real frame so portrait/竖屏 subtitles are not stretched."""
48
43
  style = _style_for_measured_subtitle_band(_subtitle_style_config(canvas), canvas)
49
44
  ass_lines = [
50
45
  "[Script Info]",
@@ -72,10 +67,8 @@ def _generate_ass(narration, work_dir, video_duration=None, canvas=None):
72
67
  "[Events]",
73
68
  "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
74
69
  ]
75
- entries = (_subtitle_entries(narration) if video_duration is None
76
- else _combined_subtitle_entries(narration, work_dir, video_duration))
77
70
  # entries are already split into short one-line chunks, so no wrapping here.
78
- for entry in entries:
71
+ for entry in _combined_subtitle_entries(narration, work_dir, video_duration):
79
72
  text = _escape_ass_text(_normalize_subtitle_text(entry["text"]))
80
73
  ass_lines.append(
81
74
  "Dialogue: 0,"
@@ -38,8 +38,8 @@ def _ceil_time(value, digits=4):
38
38
 
39
39
  def build_timeline(canvas, duration_s, video_clips, narration_segments,
40
40
  bgm=None, ducking=None, subtitle_segments=None,
41
- image_segments=None, resource_packages=None,
42
- style_presets=None, extra_tracks=None):
41
+ image_segments=(), resource_packages=None,
42
+ style_presets=None, extra_tracks=()):
43
43
  """Assemble a Timeline dict from resolved placement data.
44
44
 
45
45
  canvas: {"width", "height", "fps"}
@@ -48,7 +48,8 @@ def build_timeline(canvas, duration_s, video_clips, narration_segments,
48
48
  "timeline_start", "timeline_end"}] (cut mode: one per clip;
49
49
  full mode: a single clip spanning the whole video).
50
50
  narration_segments: placed beats [{"source_path", "timeline_start",
51
- "timeline_end", "text", "overlaps_speech", "gain"?}].
51
+ "timeline_end", "text", "overlaps_speech", "gain"?}]; a zero-width
52
+ beat (an unplaced segment) is skipped.
52
53
  subtitle_segments: optional display-ready text cues [{"text", "timeline_start",
53
54
  "timeline_end"}]. When present, this is authoritative for the
54
55
  subtitle/text track; narration segment text remains raw editor metadata.
@@ -57,27 +58,21 @@ def build_timeline(canvas, duration_s, video_clips, narration_segments,
57
58
  automation; None disables original ducking (flat original). `bridge` holds
58
59
  the duck across inter-beat gaps shorter than it (defaults to 2*fade).
59
60
  """
60
- windows = [(float(s["timeline_start"]), float(s["timeline_end"]))
61
- for s in narration_segments
62
- if s.get("timeline_end", 0) > s.get("timeline_start", 0)]
63
- duck_windows = [
64
- (
65
- float(s["timeline_start"]),
66
- max(float(s["timeline_end"]), float(s.get("source_duck_end", s["timeline_end"]))),
67
- float((ducking or {}).get("speech" if s.get("overlaps_speech", True) else "quiet", 1.0)),
68
- max(
69
- float(s["timeline_end"]),
70
- float(s.get("source_duck_end", s["timeline_end"])),
71
- float(s.get(
72
- "source_restore_at",
73
- max(float(s["timeline_end"]), float(s.get("source_duck_end", s["timeline_end"])))
74
- + float((ducking or {}).get("fade", 0.0)),
75
- )),
76
- ),
77
- )
78
- for s in narration_segments
79
- if s.get("timeline_end", 0) > s.get("timeline_start", 0)
61
+ placed = [
62
+ s for s in narration_segments
63
+ if float(s["timeline_end"]) > float(s["timeline_start"])
80
64
  ]
65
+ windows = [(float(s["timeline_start"]), float(s["timeline_end"])) for s in placed]
66
+ duck_windows = []
67
+ if ducking is not None:
68
+ for s in placed:
69
+ end = float(s["timeline_end"])
70
+ hold_end = max(end, float(s.get("source_duck_end", end)))
71
+ restore_at = max(
72
+ hold_end, float(s.get("source_restore_at", hold_end + float(ducking["fade"])))
73
+ )
74
+ level = float(ducking["speech" if s.get("overlaps_speech", True) else "quiet"])
75
+ duck_windows.append((float(s["timeline_start"]), hold_end, level, restore_at))
81
76
 
82
77
  # --- video track: each clip carries its original audio + ducking automation
83
78
  video_clip_objs = []
@@ -112,14 +107,11 @@ def build_timeline(canvas, duration_s, video_clips, narration_segments,
112
107
 
113
108
  # --- narration track
114
109
  narr_segs = []
115
- for s in narration_segments:
116
- ts, te = float(s["timeline_start"]), float(s["timeline_end"])
117
- if te <= ts:
118
- continue
110
+ for s in placed:
119
111
  narration = {
120
112
  "source_path": s["source_path"],
121
- "timeline_start": round(ts, 4),
122
- "timeline_end": _ceil_time(te, 4),
113
+ "timeline_start": round(float(s["timeline_start"]), 4),
114
+ "timeline_end": _ceil_time(s["timeline_end"], 4),
123
115
  "gain": round(float(s.get("gain", 1.0)), 4),
124
116
  "text": s.get("text", ""),
125
117
  "overlaps_speech": bool(s.get("overlaps_speech", True)),
@@ -138,7 +130,7 @@ def build_timeline(canvas, duration_s, video_clips, narration_segments,
138
130
  if bgm and bgm.get("source_path"):
139
131
  base = float(bgm.get("volume", 0.18))
140
132
  duck = float(bgm.get("ducking_volume", 0.10))
141
- fade = float(bgm.get("fade") or (ducking or {}).get("fade", 0.25))
133
+ fade = float(bgm.get("fade", (ducking or {}).get("fade", 0.25)))
142
134
  kfs = ducking_keyframes(windows, base, duck, fade, 0.0, duration_s,
143
135
  bridge=(ducking or {}).get("bridge"))
144
136
  tracks.append({
@@ -152,21 +144,17 @@ def build_timeline(canvas, duration_s, video_clips, narration_segments,
152
144
  }],
153
145
  })
154
146
 
155
- # --- subtitle (text) track
147
+ # --- subtitle (text) track: empty cues carry nothing to display
156
148
  text_source = subtitle_segments if subtitle_segments is not None else narration_segments
157
149
  text_segs = []
158
- for s in text_source or []:
159
- if not isinstance(s, dict) or not s.get("text"):
160
- continue
161
- try:
162
- ts = float(s["timeline_start"])
163
- te = float(s["timeline_end"])
164
- except (KeyError, TypeError, ValueError):
150
+ for s in text_source:
151
+ if not s.get("text"):
165
152
  continue
153
+ ts, te = float(s["timeline_start"]), float(s["timeline_end"])
166
154
  if te <= ts:
167
155
  continue
168
156
  text_segment = {
169
- "text": s.get("text", ""),
157
+ "text": s["text"],
170
158
  "timeline_start": round(ts, 4),
171
159
  "timeline_end": round(te, 4),
172
160
  }
@@ -179,15 +167,15 @@ def build_timeline(canvas, duration_s, video_clips, narration_segments,
179
167
 
180
168
  # --- local image overlays (optional, timeline schema v2)
181
169
  images = []
182
- for segment in image_segments or []:
170
+ for segment in image_segments:
183
171
  if not isinstance(segment, dict) or not segment.get("source_path"):
184
172
  continue
185
173
  try:
186
- ts = float(segment["timeline_start"])
187
- te = float(segment["timeline_end"])
174
+ ts = max(0.0, float(segment["timeline_start"]))
175
+ te = min(float(duration_s), float(segment["timeline_end"]))
188
176
  except (KeyError, TypeError, ValueError):
189
177
  continue
190
- if te <= ts:
178
+ if not math.isfinite(ts) or not math.isfinite(te) or te <= ts:
191
179
  continue
192
180
  scale = segment.get("scale") if isinstance(segment.get("scale"), dict) else {}
193
181
  position = segment.get("position") if isinstance(segment.get("position"), dict) else {}
@@ -218,15 +206,12 @@ def build_timeline(canvas, duration_s, video_clips, narration_segments,
218
206
  if images:
219
207
  tracks.append({"kind": "image", "name": "image", "segments": images})
220
208
 
221
- for track in extra_tracks or []:
222
- if not isinstance(track, dict):
223
- raise TypeError("extra_tracks entries must be objects")
224
- tracks.append(deepcopy(track))
209
+ tracks.extend(deepcopy(track) for track in extra_tracks)
225
210
 
226
211
  timeline = {
227
212
  "schema_version": SCHEMA_VERSION,
228
213
  "canvas": {"width": int(canvas["width"]), "height": int(canvas["height"]),
229
- "fps": float(canvas.get("fps", 30))},
214
+ "fps": float(canvas["fps"])},
230
215
  "duration": round(float(duration_s), 4),
231
216
  "tracks": tracks,
232
217
  }
@@ -4,7 +4,7 @@ from pathlib import Path
4
4
 
5
5
  from audio_mix import _seg_place_window
6
6
  from lib import CONFIG, log
7
- from media import _build_video_clips, _probe_canvas
7
+ from media import _build_video_clips
8
8
  from source_subtitles import _combined_subtitle_entries
9
9
  from timeline import build_timeline, save_timeline
10
10
 
@@ -25,14 +25,11 @@ def _timeline_subtitle_segments(tts_segments, work_dir, duration_s):
25
25
  ]
26
26
 
27
27
 
28
- def _emit_timeline(input_video, tts_segments, work_dir, duration_s, has_bgm):
28
+ def _emit_timeline(input_video, tts_segments, work_dir, duration_s, canvas, has_bgm):
29
29
  """Build and persist the backend-neutral multi-track timeline.json."""
30
- canvas = _probe_canvas(input_video)
31
30
  video_clips = _build_video_clips(input_video, work_dir, duration_s)
32
31
  narration_segments = []
33
32
  for seg in tts_segments:
34
- if not isinstance(seg, dict):
35
- continue
36
33
  s, e = _seg_place_window(seg)
37
34
  if e <= s:
38
35
  continue
@@ -40,9 +37,9 @@ def _emit_timeline(input_video, tts_segments, work_dir, duration_s, has_bgm):
40
37
  # JianYing must consume the exact WAV written into narration.wav. In
41
38
  # particular, a tempo-adjusted beat cannot reference its longer pre-fit
42
39
  # source or the editor will trim its final words at timeline_end.
43
- "source_path": seg.get("placed_audio_path") or seg.get("audio_path", ""),
40
+ "source_path": seg["placed_audio_path"],
44
41
  "timeline_start": s, "timeline_end": e,
45
- "text": seg.get("narration", ""),
42
+ "text": seg["narration"],
46
43
  "overlaps_speech": seg.get("overlaps_speech", True),
47
44
  "gain": 1.0,
48
45
  }
@@ -50,31 +47,28 @@ def _emit_timeline(input_video, tts_segments, work_dir, duration_s, has_bgm):
50
47
  if key in seg:
51
48
  narration_item[key] = seg[key]
52
49
  narration_segments.append(narration_item)
53
- fade = CONFIG.get("duck_fade_seconds", 0.3)
50
+ fade = CONFIG["duck_fade_seconds"]
54
51
  bgm = None
55
52
  if has_bgm:
56
- bgm = {"source_path": CONFIG.get("bgm_path", ""),
57
- "volume": CONFIG.get("bgm_volume", 0.18),
58
- "ducking_volume": CONFIG.get("bgm_ducking_volume", 0.10),
53
+ bgm = {"source_path": CONFIG["bgm_path"],
54
+ "volume": CONFIG["bgm_volume"],
55
+ "ducking_volume": CONFIG["bgm_ducking_volume"],
59
56
  "fade": fade}
60
57
  # carry ducking automation whenever ducking is on at all; even under sidechain
61
58
  # mode the draft gets editable volume keyframes (ffmpeg stays the canonical mix)
62
59
  ducking = None
63
- if CONFIG.get("ducking_mode", "fixed") != "none":
64
- ducking = {"idle": CONFIG.get("idle_orig_volume", 1.0),
65
- "speech": CONFIG.get("speech_ducking_volume", 0.2),
66
- "quiet": CONFIG.get("zone_ducking_volume", 0.12),
60
+ if CONFIG["ducking_mode"] != "none":
61
+ ducking = {"idle": CONFIG["idle_orig_volume"],
62
+ "speech": CONFIG["speech_ducking_volume"],
63
+ "quiet": CONFIG["zone_ducking_volume"],
67
64
  "fade": fade,
68
- "bridge": CONFIG.get("duck_bridge_seconds", 1.5)}
65
+ "bridge": CONFIG["duck_bridge_seconds"]}
69
66
  subtitle_segments = _timeline_subtitle_segments(tts_segments, work_dir, duration_s)
70
67
  timeline = build_timeline(canvas, duration_s, video_clips,
71
68
  narration_segments, bgm=bgm, ducking=ducking,
72
69
  subtitle_segments=subtitle_segments)
73
70
  degraded = [
74
- {
75
- "source_path": clip.get("source_path"),
76
- "reason": clip.get("provenance_reason") or "unknown",
77
- }
71
+ {"source_path": clip["source_path"], "reason": clip["provenance_reason"]}
78
72
  for clip in video_clips
79
73
  if clip.get("provenance_degraded")
80
74
  ]