@lemoncat7/dsh-partner 2.5.0 → 2.7.1

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 (653) hide show
  1. package/README.md +4 -0
  2. package/docs/THIRD_PARTY_LICENSES.md +13 -0
  3. package/docs/attachment-storage.md +18 -0
  4. package/docs/director-skills.md +24 -0
  5. package/docs/long-task-continuation.md +1 -1
  6. package/docs/releases/2.6.0.md +11 -0
  7. package/docs/releases/2.7.1.md +10 -0
  8. package/lib/agent-runtime.d.ts +3 -1
  9. package/lib/agent-runtime.d.ts.map +1 -1
  10. package/lib/agent-runtime.js +35 -11
  11. package/lib/agent-runtime.js.map +1 -1
  12. package/lib/api/features/attachments-api.d.ts.map +1 -1
  13. package/lib/api/features/attachments-api.js +31 -1
  14. package/lib/api/features/attachments-api.js.map +1 -1
  15. package/lib/api.d.ts +1 -1
  16. package/lib/api.d.ts.map +1 -1
  17. package/lib/api.js +1 -1
  18. package/lib/api.js.map +1 -1
  19. package/lib/attachments/grouped-storage.d.ts +5 -0
  20. package/lib/attachments/grouped-storage.d.ts.map +1 -0
  21. package/lib/attachments/grouped-storage.js +47 -0
  22. package/lib/attachments/grouped-storage.js.map +1 -0
  23. package/lib/attachments/service.d.ts +17 -0
  24. package/lib/attachments/service.d.ts.map +1 -1
  25. package/lib/attachments/service.js +100 -8
  26. package/lib/attachments/service.js.map +1 -1
  27. package/lib/attachments/shared-storage.d.ts +7 -0
  28. package/lib/attachments/shared-storage.d.ts.map +1 -0
  29. package/lib/attachments/shared-storage.js +44 -0
  30. package/lib/attachments/shared-storage.js.map +1 -0
  31. package/lib/attachments/storage-admin.d.ts +6 -0
  32. package/lib/attachments/storage-admin.d.ts.map +1 -0
  33. package/lib/attachments/storage-admin.js +43 -0
  34. package/lib/attachments/storage-admin.js.map +1 -0
  35. package/lib/attachments/storage-types.d.ts +33 -0
  36. package/lib/attachments/storage-types.d.ts.map +1 -0
  37. package/lib/attachments/storage-types.js +2 -0
  38. package/lib/attachments/storage-types.js.map +1 -0
  39. package/lib/attachments/tool.d.ts.map +1 -1
  40. package/lib/attachments/tool.js +16 -5
  41. package/lib/attachments/tool.js.map +1 -1
  42. package/lib/channels/delivery-policy.d.ts.map +1 -1
  43. package/lib/channels/delivery-policy.js +6 -4
  44. package/lib/channels/delivery-policy.js.map +1 -1
  45. package/lib/channels/direct/message-format.js +294 -157
  46. package/lib/channels/direct/message-format.js.map +4 -4
  47. package/lib/channels/manager.d.ts +6 -3
  48. package/lib/channels/manager.d.ts.map +1 -1
  49. package/lib/channels/manager.js +64 -44
  50. package/lib/channels/manager.js.map +1 -1
  51. package/lib/channels/weixin/api.d.ts.map +1 -1
  52. package/lib/channels/weixin/api.js +7 -2
  53. package/lib/channels/weixin/api.js.map +1 -1
  54. package/lib/channels/weixin/errors.d.ts +14 -0
  55. package/lib/channels/weixin/errors.d.ts.map +1 -0
  56. package/lib/channels/weixin/errors.js +29 -0
  57. package/lib/channels/weixin/errors.js.map +1 -0
  58. package/lib/channels/weixin/poll.d.ts +14 -0
  59. package/lib/channels/weixin/poll.d.ts.map +1 -0
  60. package/lib/channels/weixin/poll.js +50 -0
  61. package/lib/channels/weixin/poll.js.map +1 -0
  62. package/lib/client-controller.d.ts.map +1 -1
  63. package/lib/client-controller.js +3 -3
  64. package/lib/client-controller.js.map +1 -1
  65. package/lib/client.d.ts.map +1 -1
  66. package/lib/client.js +983 -575
  67. package/lib/client.js.map +4 -4
  68. package/lib/collaboration/composition.d.ts.map +1 -1
  69. package/lib/collaboration/composition.js +16 -6
  70. package/lib/collaboration/composition.js.map +1 -1
  71. package/lib/collaboration/domain.d.ts +6 -0
  72. package/lib/collaboration/domain.d.ts.map +1 -1
  73. package/lib/collaboration/domain.js.map +1 -1
  74. package/lib/collaboration/service.d.ts.map +1 -1
  75. package/lib/collaboration/service.js +33 -3
  76. package/lib/collaboration/service.js.map +1 -1
  77. package/lib/concern-notification.d.ts.map +1 -1
  78. package/lib/concern-notification.js +2 -1
  79. package/lib/concern-notification.js.map +1 -1
  80. package/lib/concern-tool.js +1 -1
  81. package/lib/concern-tool.js.map +1 -1
  82. package/lib/execution/reply-progress.d.ts.map +1 -1
  83. package/lib/execution/reply-progress.js +4 -2
  84. package/lib/execution/reply-progress.js.map +1 -1
  85. package/lib/execution/service.d.ts +1 -1
  86. package/lib/execution/service.d.ts.map +1 -1
  87. package/lib/execution/service.js +4 -7
  88. package/lib/execution/service.js.map +1 -1
  89. package/lib/index.d.ts.map +1 -1
  90. package/lib/index.js +3 -1
  91. package/lib/index.js.map +1 -1
  92. package/lib/memory-reflection.js +2 -2
  93. package/lib/memory-reflection.js.map +1 -1
  94. package/lib/message-source.d.ts +25 -0
  95. package/lib/message-source.d.ts.map +1 -0
  96. package/lib/message-source.js +8 -0
  97. package/lib/message-source.js.map +1 -0
  98. package/lib/observation-loop.js +4 -4
  99. package/lib/observation-loop.js.map +1 -1
  100. package/lib/pendant/reader.js +1 -1
  101. package/lib/pendant/reader.js.map +1 -1
  102. package/lib/pendant/renderer.d.ts.map +1 -1
  103. package/lib/pendant/renderer.js +18 -2
  104. package/lib/pendant/renderer.js.map +1 -1
  105. package/lib/pendant/settings-panel.js +1 -1
  106. package/lib/pendant/settings-panel.js.map +1 -1
  107. package/lib/pendant/startup.d.ts +4 -0
  108. package/lib/pendant/startup.d.ts.map +1 -0
  109. package/lib/pendant/startup.js +64 -0
  110. package/lib/pendant/startup.js.map +1 -0
  111. package/lib/pendant/widget.d.ts.map +1 -1
  112. package/lib/pendant/widget.js +2 -1
  113. package/lib/pendant/widget.js.map +1 -1
  114. package/lib/pendant-renderer.js +169 -169
  115. package/lib/persona/service.js +1 -1
  116. package/lib/persona/service.js.map +1 -1
  117. package/lib/requirements/attachments.d.ts +7 -0
  118. package/lib/requirements/attachments.d.ts.map +1 -0
  119. package/lib/requirements/attachments.js +26 -0
  120. package/lib/requirements/attachments.js.map +1 -0
  121. package/lib/requirements/domain.d.ts +1 -1
  122. package/lib/requirements/domain.d.ts.map +1 -1
  123. package/lib/requirements/service.d.ts.map +1 -1
  124. package/lib/requirements/service.js +4 -1
  125. package/lib/requirements/service.js.map +1 -1
  126. package/lib/scheduler/channel-origin.d.ts +7 -0
  127. package/lib/scheduler/channel-origin.d.ts.map +1 -0
  128. package/lib/scheduler/channel-origin.js +27 -0
  129. package/lib/scheduler/channel-origin.js.map +1 -0
  130. package/lib/scheduler/continuations.d.ts +2 -2
  131. package/lib/scheduler/continuations.d.ts.map +1 -1
  132. package/lib/scheduler/continuations.js +28 -8
  133. package/lib/scheduler/continuations.js.map +1 -1
  134. package/lib/scheduler/domain.d.ts +13 -0
  135. package/lib/scheduler/domain.d.ts.map +1 -1
  136. package/lib/scheduler/final-reply.d.ts +13 -0
  137. package/lib/scheduler/final-reply.d.ts.map +1 -0
  138. package/lib/scheduler/final-reply.js +21 -0
  139. package/lib/scheduler/final-reply.js.map +1 -0
  140. package/lib/skills/builtin-resources.d.ts +6 -0
  141. package/lib/skills/builtin-resources.d.ts.map +1 -0
  142. package/lib/skills/builtin-resources.js +47 -0
  143. package/lib/skills/builtin-resources.js.map +1 -0
  144. package/lib/skills/builtin.d.ts +2 -5
  145. package/lib/skills/builtin.d.ts.map +1 -1
  146. package/lib/skills/builtin.js +6 -0
  147. package/lib/skills/builtin.js.map +1 -1
  148. package/lib/skills/director-builtins.d.ts +8 -0
  149. package/lib/skills/director-builtins.d.ts.map +1 -0
  150. package/lib/skills/director-builtins.js +65 -0
  151. package/lib/skills/director-builtins.js.map +1 -0
  152. package/lib/skills/drama-builtin.d.ts +3 -0
  153. package/lib/skills/drama-builtin.d.ts.map +1 -0
  154. package/lib/skills/drama-builtin.js +49 -0
  155. package/lib/skills/drama-builtin.js.map +1 -0
  156. package/lib/skills/long-task-continuation.d.ts +2 -2
  157. package/lib/skills/long-task-continuation.d.ts.map +1 -1
  158. package/lib/skills/long-task-continuation.js +2 -2
  159. package/lib/skills/service.d.ts.map +1 -1
  160. package/lib/skills/service.js +8 -3
  161. package/lib/skills/service.js.map +1 -1
  162. package/lib/skills/viora-canvas-builtin.d.ts +3 -0
  163. package/lib/skills/viora-canvas-builtin.d.ts.map +1 -0
  164. package/lib/skills/viora-canvas-builtin.js +19 -0
  165. package/lib/skills/viora-canvas-builtin.js.map +1 -0
  166. package/lib/storage/migrate-layout.d.ts.map +1 -1
  167. package/lib/storage/migrate-layout.js +13 -4
  168. package/lib/storage/migrate-layout.js.map +1 -1
  169. package/lib/tasks/context.js +1 -0
  170. package/lib/tasks/context.js.map +1 -1
  171. package/lib/tasks/continuation.d.ts.map +1 -1
  172. package/lib/tasks/continuation.js +3 -0
  173. package/lib/tasks/continuation.js.map +1 -1
  174. package/lib/tasks/domain.d.ts +2 -0
  175. package/lib/tasks/domain.d.ts.map +1 -1
  176. package/lib/tasks/service.d.ts +2 -1
  177. package/lib/tasks/service.d.ts.map +1 -1
  178. package/lib/tasks/service.js +15 -1
  179. package/lib/tasks/service.js.map +1 -1
  180. package/lib/ui/attachment-storage.d.ts +2 -0
  181. package/lib/ui/attachment-storage.d.ts.map +1 -0
  182. package/lib/ui/attachment-storage.js +59 -0
  183. package/lib/ui/attachment-storage.js.map +1 -0
  184. package/lib/ui/capability-editor.js +1 -1
  185. package/lib/ui/capability-editor.js.map +1 -1
  186. package/lib/ui/companion-access-panel.js +1 -1
  187. package/lib/ui/companion-access-panel.js.map +1 -1
  188. package/lib/ui/companion-create.js +1 -1
  189. package/lib/ui/companion-create.js.map +1 -1
  190. package/lib/ui/companion-picker.js +1 -1
  191. package/lib/ui/companion-picker.js.map +1 -1
  192. package/lib/ui/identity-editor.js +1 -1
  193. package/lib/ui/identity-editor.js.map +1 -1
  194. package/lib/ui/mcp-capabilities.js +1 -1
  195. package/lib/ui/mcp-capabilities.js.map +1 -1
  196. package/lib/ui/mcp-panel.js +1 -1
  197. package/lib/ui/mcp-panel.js.map +1 -1
  198. package/lib/ui/memory/concern-board.js +2 -2
  199. package/lib/ui/memory/concern-board.js.map +1 -1
  200. package/lib/ui/memory/concern-deletion.d.ts +1 -1
  201. package/lib/ui/memory/concern-deletion.d.ts.map +1 -1
  202. package/lib/ui/memory/concern-deletion.js +9 -4
  203. package/lib/ui/memory/concern-deletion.js.map +1 -1
  204. package/lib/ui/memory/concern-panel.js +1 -1
  205. package/lib/ui/memory/concern-panel.js.map +1 -1
  206. package/lib/ui/memory/memory-library.js +1 -1
  207. package/lib/ui/memory/memory-library.js.map +1 -1
  208. package/lib/ui/memory/memory-panel.js +1 -1
  209. package/lib/ui/memory/memory-panel.js.map +1 -1
  210. package/lib/ui/partner-components.js +1 -1
  211. package/lib/ui/partner-components.js.map +1 -1
  212. package/lib/ui/requirement-board.js +1 -1
  213. package/lib/ui/requirement-board.js.map +1 -1
  214. package/lib/ui/requirement-dialog.js +1 -1
  215. package/lib/ui/requirement-dialog.js.map +1 -1
  216. package/lib/ui/requirement-tasks-panel.js +1 -1
  217. package/lib/ui/requirement-tasks-panel.js.map +1 -1
  218. package/lib/ui/schedule-panel.js +1 -1
  219. package/lib/ui/schedule-panel.js.map +1 -1
  220. package/lib/ui/skills-panel.js +1 -1
  221. package/lib/ui/skills-panel.js.map +1 -1
  222. package/lib/ui/storage-settings.d.ts.map +1 -1
  223. package/lib/ui/storage-settings.js +2 -1
  224. package/lib/ui/storage-settings.js.map +1 -1
  225. package/lib/ui/task-board-stage.js +1 -1
  226. package/lib/ui/task-board-stage.js.map +1 -1
  227. package/lib/ui/workspace-components.js +1 -1
  228. package/lib/ui/workspace-components.js.map +1 -1
  229. package/lib/ui/workspace-navigation.d.ts +16 -16
  230. package/lib/ui/workspace-navigation.js +1 -1
  231. package/lib/ui/workspace-navigation.js.map +1 -1
  232. package/package.json +53 -41
  233. package/resources/skills/cinematic-director/manifest.json +205 -0
  234. package/resources/skills/cinematic-director/upstream/CHANGELOG.md +213 -0
  235. package/resources/skills/cinematic-director/upstream/CONTRIBUTING.md +316 -0
  236. package/resources/skills/cinematic-director/upstream/LICENSE +21 -0
  237. package/resources/skills/cinematic-director/upstream/README.md +507 -0
  238. package/resources/skills/cinematic-director/upstream/SKILL.md +420 -0
  239. package/resources/skills/cinematic-director/upstream/assets/beat-sheet-template.md +97 -0
  240. package/resources/skills/cinematic-director/upstream/assets/director-book-template.md +254 -0
  241. package/resources/skills/cinematic-director/upstream/assets/edit-timeline-template.md +147 -0
  242. package/resources/skills/cinematic-director/upstream/assets/keyframe-prompt-template.md +334 -0
  243. package/resources/skills/cinematic-director/upstream/assets/qc-checklist.md +153 -0
  244. package/resources/skills/cinematic-director/upstream/assets/shot-plan-template.md +95 -0
  245. package/resources/skills/cinematic-director/upstream/assets/sound-plan-template.md +181 -0
  246. package/resources/skills/cinematic-director/upstream/assets/video-prompt-template.md +305 -0
  247. package/resources/skills/cinematic-director/upstream/evals/run.md +69 -0
  248. package/resources/skills/cinematic-director/upstream/references/ai-video-tool-adapters.md +527 -0
  249. package/resources/skills/cinematic-director/upstream/references/blocking-and-staging.md +424 -0
  250. package/resources/skills/cinematic-director/upstream/references/cinematic-language.md +595 -0
  251. package/resources/skills/cinematic-director/upstream/references/continuity-bible.md +410 -0
  252. package/resources/skills/cinematic-director/upstream/references/director_styles/01_spielberg.md +192 -0
  253. package/resources/skills/cinematic-director/upstream/references/director_styles/02_hitchcock.md +193 -0
  254. package/resources/skills/cinematic-director/upstream/references/director_styles/03_kubrick.md +196 -0
  255. package/resources/skills/cinematic-director/upstream/references/director_styles/04_kurosawa.md +196 -0
  256. package/resources/skills/cinematic-director/upstream/references/director_styles/05_scorsese.md +195 -0
  257. package/resources/skills/cinematic-director/upstream/references/director_styles/06_fellini.md +197 -0
  258. package/resources/skills/cinematic-director/upstream/references/director_styles/07_bergman.md +197 -0
  259. package/resources/skills/cinematic-director/upstream/references/director_styles/08_tarkovsky.md +184 -0
  260. package/resources/skills/cinematic-director/upstream/references/director_styles/09_wong_kar_wai.md +189 -0
  261. package/resources/skills/cinematic-director/upstream/references/director_styles/10_nolan.md +185 -0
  262. package/resources/skills/cinematic-director/upstream/references/director_styles/11_villeneuve.md +188 -0
  263. package/resources/skills/cinematic-director/upstream/references/director_styles/12_fincher.md +185 -0
  264. package/resources/skills/cinematic-director/upstream/references/director_styles/13_refn.md +186 -0
  265. package/resources/skills/cinematic-director/upstream/references/director_styles/14_bi_gan.md +188 -0
  266. package/resources/skills/cinematic-director/upstream/references/director_styles/15_zhang_yimou.md +192 -0
  267. package/resources/skills/cinematic-director/upstream/references/director_styles/16_hou_hsiao_hsien.md +192 -0
  268. package/resources/skills/cinematic-director/upstream/references/director_styles/17_park_chan_wook.md +192 -0
  269. package/resources/skills/cinematic-director/upstream/references/director_styles/18_malick.md +191 -0
  270. package/resources/skills/cinematic-director/upstream/references/director_styles/19_michael_mann.md +192 -0
  271. package/resources/skills/cinematic-director/upstream/references/director_styles/20_coen_brothers.md +194 -0
  272. package/resources/skills/cinematic-director/upstream/references/director_styles/README.md +89 -0
  273. package/resources/skills/cinematic-director/upstream/references/director_styles/example_comparisons.md +140 -0
  274. package/resources/skills/cinematic-director/upstream/references/editing-and-assembly.md +263 -0
  275. package/resources/skills/cinematic-director/upstream/references/failure-modes.md +594 -0
  276. package/resources/skills/cinematic-director/upstream/references/genre-playbooks.md +407 -0
  277. package/resources/skills/cinematic-director/upstream/references/image-model-adapters.md +290 -0
  278. package/resources/skills/cinematic-director/upstream/references/lighting-and-color.md +349 -0
  279. package/resources/skills/cinematic-director/upstream/references/product-and-macro.md +207 -0
  280. package/resources/skills/cinematic-director/upstream/references/production-workflow.md +238 -0
  281. package/resources/skills/cinematic-director/upstream/references/prompt-lexicon.md +602 -0
  282. package/resources/skills/cinematic-director/upstream/references/sound-and-dialogue.md +364 -0
  283. package/resources/skills/director-skills/manifest.json +541 -0
  284. package/resources/skills/director-skills/upstream/CHANGELOG.md +32 -0
  285. package/resources/skills/director-skills/upstream/LICENSE +21 -0
  286. package/resources/skills/director-skills/upstream/MANIFEST.md +366 -0
  287. package/resources/skills/director-skills/upstream/README.md +251 -0
  288. package/resources/skills/director-skills/upstream/RESEARCH_PROVENANCE.md +37 -0
  289. package/resources/skills/director-skills/upstream/SKILL.md +114 -0
  290. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/SKILL.md +81 -0
  291. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/checklists/continuity_checklist.md +8 -0
  292. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/examples/character_continuity_update.md +9 -0
  293. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/examples/location_continuity_update.md +9 -0
  294. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/continuity_system.md +21 -0
  295. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/golden_image_workflow.md +29 -0
  296. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/identity_anchors.md +14 -0
  297. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/visual_bible_system.md +15 -0
  298. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/character_profile.md +15 -0
  299. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/continuity_checklist.md +11 -0
  300. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/costume_profile.md +12 -0
  301. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/location_profile.md +14 -0
  302. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/prop_profile.md +13 -0
  303. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/visual_style_bible.md +15 -0
  304. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/tests/continuity_tests.md +6 -0
  305. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/SKILL.md +80 -0
  306. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/checklists/image_prompt_quality_checklist.md +10 -0
  307. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/examples/shot_list_to_image_prompts.md +4 -0
  308. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/image_prompt_components.md +22 -0
  309. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/lighting_and_photorealism.md +19 -0
  310. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/negative_prompt_patterns.md +18 -0
  311. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/prompt_anatomy.md +18 -0
  312. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/character_reference_sheet_prompt.md +5 -0
  313. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/cinematic_still_prompt.md +3 -0
  314. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/keyframe_prompt.md +6 -0
  315. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/location_reference_prompt.md +3 -0
  316. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/prop_prompt.md +3 -0
  317. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/tests/image_prompt_completeness_tests.md +5 -0
  318. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/SKILL.md +84 -0
  319. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/checklists/video_prompt_quality_checklist.md +9 -0
  320. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/examples/image_prompt_to_video_prompt.md +3 -0
  321. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/ai_film_production_pipeline.md +21 -0
  322. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/audio_motion_timing.md +16 -0
  323. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/camera_lens_movement.md +26 -0
  324. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/negative_prompt_patterns.md +18 -0
  325. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/prompt_anatomy.md +18 -0
  326. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/video_prompt_components.md +25 -0
  327. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/templates/image_to_video_prompt.md +5 -0
  328. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/templates/text_to_video_prompt.md +5 -0
  329. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/templates/video_beat_prompt.md +9 -0
  330. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/tests/video_prompt_completeness_tests.md +5 -0
  331. package/resources/skills/director-skills/upstream/skills/model-adaptation/SKILL.md +82 -0
  332. package/resources/skills/director-skills/upstream/skills/model-adaptation/checklists/model_export_checklist.md +9 -0
  333. package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/failed_model_transfer_diagnosis.md +5 -0
  334. package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/image_prompt_translation.md +10 -0
  335. package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/same_scene_across_models.md +9 -0
  336. package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/universal_prompt_to_model_specific_exports.md +14 -0
  337. package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/video_prompt_translation.md +9 -0
  338. package/resources/skills/director-skills/upstream/skills/model-adaptation/references/failure_modes_by_model.md +13 -0
  339. package/resources/skills/director-skills/upstream/skills/model-adaptation/references/image_model_profiles.md +226 -0
  340. package/resources/skills/director-skills/upstream/skills/model-adaptation/references/model_capability_matrix.md +24 -0
  341. package/resources/skills/director-skills/upstream/skills/model-adaptation/references/model_update_protocol.md +10 -0
  342. package/resources/skills/director-skills/upstream/skills/model-adaptation/references/parameter_compatibility.md +20 -0
  343. package/resources/skills/director-skills/upstream/skills/model-adaptation/references/prompt_translation_rules.md +18 -0
  344. package/resources/skills/director-skills/upstream/skills/model-adaptation/references/video_model_profiles.md +251 -0
  345. package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/model_comparison_table.md +4 -0
  346. package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/model_specific_prompt_export.md +21 -0
  347. package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/multi_model_export_package.md +18 -0
  348. package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/prompt_translation_report.md +13 -0
  349. package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/universal_prompt_brief.md +25 -0
  350. package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/model_routing_tests.md +8 -0
  351. package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/prompt_translation_tests.md +7 -0
  352. package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/research_provenance_tests.md +6 -0
  353. package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/unknown_capability_tests.md +7 -0
  354. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/SKILL.md +82 -0
  355. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/checklists/diagnostic_checklist.md +8 -0
  356. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/checklists/release_quality_checklist.md +9 -0
  357. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/examples/failed_output_to_revised_prompt.md +10 -0
  358. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/examples/final_prompt_package_export.md +10 -0
  359. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/failure_modes.md +13 -0
  360. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/iteration_diagnostics.md +17 -0
  361. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/revision_loops.md +17 -0
  362. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/story_voice_release_qc.md +30 -0
  363. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/templates/failed_output_intake.md +13 -0
  364. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/templates/final_production_package.md +11 -0
  365. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/templates/prompt_revision_report.md +17 -0
  366. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/tests/prompt_quality_checklist_tests.md +6 -0
  367. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/SKILL.md +81 -0
  368. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/checklists/scene_quality_checklist.md +8 -0
  369. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/examples/scene_to_screenplay_excerpt.md +18 -0
  370. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/dialogue_and_subtext.md +15 -0
  371. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/fountain_workflow.md +29 -0
  372. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/scene_construction.md +20 -0
  373. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/screenplay_format.md +18 -0
  374. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/beat_sheet.md +6 -0
  375. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/dialogue_pass.md +9 -0
  376. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/fountain_scene.md +25 -0
  377. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/screenplay_scene.md +19 -0
  378. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/tests/screenplay_scene_tests.md +6 -0
  379. package/resources/skills/director-skills/upstream/skills/short-film-development/SKILL.md +83 -0
  380. package/resources/skills/director-skills/upstream/skills/short-film-development/checklists/concept_quality_checklist.md +8 -0
  381. package/resources/skills/director-skills/upstream/skills/short-film-development/examples/idea_to_logline.md +13 -0
  382. package/resources/skills/director-skills/upstream/skills/short-film-development/examples/logline_to_outline.md +10 -0
  383. package/resources/skills/director-skills/upstream/skills/short-film-development/references/ai_storytelling_workflow.md +46 -0
  384. package/resources/skills/director-skills/upstream/skills/short-film-development/references/continuity_handoff.md +8 -0
  385. package/resources/skills/director-skills/upstream/skills/short-film-development/references/screenplay_structure.md +19 -0
  386. package/resources/skills/director-skills/upstream/skills/short-film-development/references/story_development_workflow.md +23 -0
  387. package/resources/skills/director-skills/upstream/skills/short-film-development/templates/beat_sheet.md +7 -0
  388. package/resources/skills/director-skills/upstream/skills/short-film-development/templates/idea_intake.md +16 -0
  389. package/resources/skills/director-skills/upstream/skills/short-film-development/templates/logline.md +10 -0
  390. package/resources/skills/director-skills/upstream/skills/short-film-development/templates/short_film_treatment.md +23 -0
  391. package/resources/skills/director-skills/upstream/skills/short-film-development/templates/short_story_plan.md +22 -0
  392. package/resources/skills/director-skills/upstream/skills/short-film-development/templates/story_premise.md +19 -0
  393. package/resources/skills/director-skills/upstream/skills/short-film-development/templates/story_revision_audit.md +22 -0
  394. package/resources/skills/director-skills/upstream/skills/short-film-development/tests/output_format_tests.md +3 -0
  395. package/resources/skills/director-skills/upstream/skills/short-film-development/tests/routing_tests.md +7 -0
  396. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/SKILL.md +77 -0
  397. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/checklists/shotlist_quality_checklist.md +8 -0
  398. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/examples/screenplay_scene_to_shot_list.md +8 -0
  399. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/references/camera_plan.md +11 -0
  400. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/references/production_breakdown.md +11 -0
  401. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/references/shotlist_schema.md +21 -0
  402. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/asset_list.md +7 -0
  403. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/camera_plan.md +10 -0
  404. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/scene_breakdown.md +16 -0
  405. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/shot_list.md +5 -0
  406. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/tests/screenplay_to_shotlist_tests.md +5 -0
  407. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/SKILL.md +77 -0
  408. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/checklists/style_quality_checklist.md +8 -0
  409. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/examples/genre_to_visual_direction.md +15 -0
  410. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/camera_lens_movement.md +26 -0
  411. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/cinematic_language.md +32 -0
  412. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/genre_style_tone.md +17 -0
  413. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/lighting_and_photorealism.md +19 -0
  414. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/templates/visual_style_bible.md +33 -0
  415. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/tests/style_translation_tests.md +5 -0
  416. package/resources/skills/director-skills/upstream/tests/master_router_tests.md +80 -0
  417. package/resources/skills/drama-production/manifest.json +933 -0
  418. package/resources/skills/drama-production/upstream/LICENSE +21 -0
  419. package/resources/skills/drama-production/upstream/skills/short-drama/SKILL.md +153 -0
  420. package/resources/skills/drama-production/upstream/skills/short-drama/agents/openai.yaml +4 -0
  421. package/resources/skills/drama-production/upstream/skills/short-drama/assets/creator-decision.example.jsonl +4 -0
  422. package/resources/skills/drama-production/upstream/skills/short-drama/assets/dashboard/app.js +1263 -0
  423. package/resources/skills/drama-production/upstream/skills/short-drama/assets/dashboard/index.html +114 -0
  424. package/resources/skills/drama-production/upstream/skills/short-drama/assets/dashboard/styles.css +297 -0
  425. package/resources/skills/drama-production/upstream/skills/short-drama/assets/production-observation.example.jsonl +2 -0
  426. package/resources/skills/drama-production/upstream/skills/short-drama/assets/project-template/short-drama.json +36 -0
  427. package/resources/skills/drama-production/upstream/skills/short-drama/assets/reference-observation.example.jsonl +2 -0
  428. package/resources/skills/drama-production/upstream/skills/short-drama/references/audience-reveal.md +21 -0
  429. package/resources/skills/drama-production/upstream/skills/short-drama/references/contract-and-ownership.md +45 -0
  430. package/resources/skills/drama-production/upstream/skills/short-drama/references/creator-documents.md +230 -0
  431. package/resources/skills/drama-production/upstream/skills/short-drama/references/creator-workflow.md +98 -0
  432. package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards/Q/347/211/210/350/241/250/350/276/276.md +73 -0
  433. package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//344/272/214/347/273/264/345/212/250/346/200/201/346/274/253.md +74 -0
  434. package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//345/233/275/346/274/253/344/272/214/346/254/241/345/205/203.md +79 -0
  435. package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//345/256/236/346/213/215.md +75 -0
  436. package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//346/260/264/345/242/250/347/254/224/350/247/246.md +85 -0
  437. package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//351/243/216/346/240/274/345/214/226/344/270/211/347/273/264.md +72 -0
  438. package/resources/skills/drama-production/upstream/skills/short-drama/references/knowhow-index.md +85 -0
  439. package/resources/skills/drama-production/upstream/skills/short-drama/references/look-development.md +51 -0
  440. package/resources/skills/drama-production/upstream/skills/short-drama/references/pickup-and-alternate.md +24 -0
  441. package/resources/skills/drama-production/upstream/skills/short-drama/references/production-form-profiles.md +126 -0
  442. package/resources/skills/drama-production/upstream/skills/short-drama/references/reference-roles.md +53 -0
  443. package/resources/skills/drama-production/upstream/skills/short-drama/references/runtime-preflight.md +24 -0
  444. package/resources/skills/drama-production/upstream/skills/short-drama/scripts/creator_markdown_check.py +1413 -0
  445. package/resources/skills/drama-production/upstream/skills/short-drama/scripts/dashboard_server.py +1798 -0
  446. package/resources/skills/drama-production/upstream/skills/short-drama/scripts/project_tool.py +2454 -0
  447. package/resources/skills/drama-production/upstream/skills/short-drama/scripts/selftest.py +287 -0
  448. package/resources/skills/drama-production/upstream/skills/short-drama-assets/SKILL.md +76 -0
  449. package/resources/skills/drama-production/upstream/skills/short-drama-assets/agents/openai.yaml +4 -0
  450. package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/character-look.example.jsonl +3 -0
  451. package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/continuity.example.jsonl +4 -0
  452. package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/decisions.example.jsonl +5 -0
  453. package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/location-view.example.jsonl +3 -0
  454. package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/occurrences.example.jsonl +5 -0
  455. package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/prop-state.example.jsonl +3 -0
  456. package/resources/skills/drama-production/upstream/skills/short-drama-assets/examples/minimal/characters.jsonl +2 -0
  457. package/resources/skills/drama-production/upstream/skills/short-drama-assets/examples/minimal/looks.jsonl +2 -0
  458. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/asset-review-checklist.md +82 -0
  459. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/character-and-look.md +190 -0
  460. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/continuity-delta.md +120 -0
  461. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/continuity-lock.md +122 -0
  462. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/identity-vs-variant.md +140 -0
  463. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/location-and-view.md +84 -0
  464. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/occurrence-extraction.md +108 -0
  465. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/prop-and-state.md +126 -0
  466. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/stage-contract.md +43 -0
  467. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/voice-direction.md +179 -0
  468. package/resources/skills/drama-production/upstream/skills/short-drama-assets/scripts/asset_check.py +267 -0
  469. package/resources/skills/drama-production/upstream/skills/short-drama-assets/scripts/selftest.py +89 -0
  470. package/resources/skills/drama-production/upstream/skills/short-drama-develop/SKILL.md +157 -0
  471. package/resources/skills/drama-production/upstream/skills/short-drama-develop/agents/openai.yaml +4 -0
  472. package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/adaptation-map.example.jsonl +1 -0
  473. package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/creative-brief.md +120 -0
  474. package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/episode-map.jsonl +1 -0
  475. package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/story-engine.md +181 -0
  476. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/adaptation-craft.md +219 -0
  477. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/creative-reference-intake.md +57 -0
  478. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/director-brief-craft.md +113 -0
  479. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/episode-design.md +509 -0
  480. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-and-hook-playbook.md +240 -0
  481. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//344/272/262/345/255/220/351/232/220/347/247/230.md +112 -0
  482. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//344/273/231/344/276/240/344/277/256/347/234/237.md +51 -0
  483. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//345/212/250/344/275/234/344/273/273/345/212/241.md +51 -0
  484. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//345/217/244/350/243/205/346/235/203/350/260/213.md +51 -0
  485. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//345/244/215/344/273/207/346/211/223/350/204/270.md +51 -0
  486. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//345/256/266/345/272/255/345/205/263/347/263/273.md +51 -0
  487. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//346/202/254/347/226/221/350/247/204/345/210/231.md +51 -0
  488. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//347/224/237/346/264/273/346/265/201.md +51 -0
  489. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//347/240/264/351/225/234/351/207/215/345/234/206.md +109 -0
  490. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//350/201/214/345/234/272/345/226/234/345/211/247.md +51 -0
  491. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//350/261/252/351/227/250/345/251/232/346/201/213.md +51 -0
  492. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//350/272/253/344/273/275/351/224/231/344/275/215.md +51 -0
  493. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards.md +92 -0
  494. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/mechanism-loop.md +282 -0
  495. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/multi-episode-intake.md +128 -0
  496. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/premise-devices.md +211 -0
  497. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/reveal-reversal-payoff.md +99 -0
  498. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/serial-character-and-memory.md +293 -0
  499. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/stage-contract.md +88 -0
  500. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/story-craft.md +359 -0
  501. package/resources/skills/drama-production/upstream/skills/short-drama-develop/scripts/episode_intake.py +699 -0
  502. package/resources/skills/drama-production/upstream/skills/short-drama-develop/scripts/selftest.py +47 -0
  503. package/resources/skills/drama-production/upstream/skills/short-drama-edit/SKILL.md +146 -0
  504. package/resources/skills/drama-production/upstream/skills/short-drama-edit/agents/openai.yaml +4 -0
  505. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/README.md +60 -0
  506. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/package.json +15 -0
  507. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/remotion.config.ts +6 -0
  508. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/Root.tsx +26 -0
  509. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/Subtitles.tsx +88 -0
  510. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/font.ts +46 -0
  511. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/index.ts +4 -0
  512. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/schema.ts +32 -0
  513. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/tsconfig.json +13 -0
  514. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets//345/211/252/350/276/221/345/215/225.md +43 -0
  515. package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/cut-craft.md +38 -0
  516. package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/delivery-verify.md +29 -0
  517. package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/generated-footage.md +54 -0
  518. package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/sound-and-subtitles.md +60 -0
  519. package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/stage-contract.md +51 -0
  520. package/resources/skills/drama-production/upstream/skills/short-drama-edit/scripts/edit_tool.py +1156 -0
  521. package/resources/skills/drama-production/upstream/skills/short-drama-edit/scripts/selftest.py +520 -0
  522. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/SKILL.md +73 -0
  523. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/agents/openai.yaml +4 -0
  524. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/image-prompt-spec.jsonl.md +187 -0
  525. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/image-prompts.md +42 -0
  526. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/lookdev-frame-spec.jsonl.md +128 -0
  527. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/lookdev-prompts.md +34 -0
  528. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/examples/minimal-image-prompt-specs.jsonl +2 -0
  529. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/character-and-look.md +153 -0
  530. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/common-recipe.md +241 -0
  531. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/edit-and-revision.md +110 -0
  532. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/location-plate.md +151 -0
  533. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/look-and-state-variant.md +97 -0
  534. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/lookdev-frame.md +34 -0
  535. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/production-sheet-recipes.md +177 -0
  536. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/prop-plate.md +137 -0
  537. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/review-and-fixtures.md +92 -0
  538. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/stage-contract.md +32 -0
  539. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/scripts/image_prompt_check.py +443 -0
  540. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/scripts/selftest.py +95 -0
  541. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/SKILL.md +245 -0
  542. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/agents/openai.yaml +4 -0
  543. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/assets/episode-candidate.example.jsonl +3 -0
  544. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/adaptation-triage.md +116 -0
  545. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/adaptation-value.md +124 -0
  546. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +155 -0
  547. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/chapter-extraction.md +144 -0
  548. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/stage-contract.md +67 -0
  549. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/scripts/novel_index.py +651 -0
  550. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/scripts/selftest.py +51 -0
  551. package/resources/skills/drama-production/upstream/skills/short-drama-produce/SKILL.md +183 -0
  552. package/resources/skills/drama-production/upstream/skills/short-drama-produce/agents/openai.yaml +4 -0
  553. package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/adapter-contract.md +219 -0
  554. package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/gpt-image-2.md +24 -0
  555. package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/minimax-h3-video.md +86 -0
  556. package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/minimax-music.md +25 -0
  557. package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/minimax-speech.md +41 -0
  558. package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/seedance.md +64 -0
  559. package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/fixture_adapter.py +77 -0
  560. package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/production_tool.py +2205 -0
  561. package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/provider_adapters.py +1747 -0
  562. package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/selftest.py +135 -0
  563. package/resources/skills/drama-production/upstream/skills/short-drama-review/SKILL.md +118 -0
  564. package/resources/skills/drama-production/upstream/skills/short-drama-review/agents/openai.yaml +4 -0
  565. package/resources/skills/drama-production/upstream/skills/short-drama-review/assets/finding-template.jsonl +2 -0
  566. package/resources/skills/drama-production/upstream/skills/short-drama-review/assets/supersession-decision.example.json +42 -0
  567. package/resources/skills/drama-production/upstream/skills/short-drama-review/assets/verdict-template.json +32 -0
  568. package/resources/skills/drama-production/upstream/skills/short-drama-review/examples/minimal-findings.jsonl +2 -0
  569. package/resources/skills/drama-production/upstream/skills/short-drama-review/examples/minimal-verdict.json +34 -0
  570. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/anti-template-repair.md +109 -0
  571. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/production-quality-gates.md +276 -0
  572. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/project-calibration.md +83 -0
  573. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/review-method.md +81 -0
  574. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-assets-prompts.md +111 -0
  575. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-source-analysis.md +108 -0
  576. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-story-script.md +264 -0
  577. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-visual-motion.md +184 -0
  578. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/stage-contract.md +28 -0
  579. package/resources/skills/drama-production/upstream/skills/short-drama-review/scripts/review_check.py +325 -0
  580. package/resources/skills/drama-production/upstream/skills/short-drama-review/scripts/selftest.py +87 -0
  581. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/SKILL.md +175 -0
  582. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/agents/openai.yaml +4 -0
  583. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/coverage-audition.example.jsonl +2 -0
  584. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/coverage-template.json +49 -0
  585. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/keyframe-prompts.md +36 -0
  586. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/keyframe-template.jsonl +2 -0
  587. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/revision-lineage.fragment.json +12 -0
  588. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/scene-visual-plan.example.jsonl +2 -0
  589. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/shot-template.jsonl +2 -0
  590. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/blocking-playbooks.md +216 -0
  591. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/comic-keyframe-lexicon.md +156 -0
  592. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/coverage-audition.md +34 -0
  593. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/keyframe-craft.md +174 -0
  594. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/lighting-craft.md +39 -0
  595. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/production-shot-grammar.md +279 -0
  596. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/review-and-fixtures.md +36 -0
  597. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/scene-visual-plan.md +30 -0
  598. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/screenplay-to-keyframe-example.md +155 -0
  599. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/shot-craft.md +266 -0
  600. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/shot-revision-identity.md +25 -0
  601. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/stage-contract.md +57 -0
  602. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/scripts/selftest.py +191 -0
  603. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/scripts/storyboard_check.py +824 -0
  604. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/SKILL.md +154 -0
  605. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/agents/openai.yaml +4 -0
  606. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/coverage-scope.fragment.json +21 -0
  607. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/delivery-container.jsonl.md +101 -0
  608. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/motion-spec.jsonl.md +217 -0
  609. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/motion-terminal.example.jsonl +2 -0
  610. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/music-spec.jsonl.md +31 -0
  611. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/performance.fragment.json +38 -0
  612. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/video-prompts.md +67 -0
  613. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/examples/minimal-music-specs.jsonl +2 -0
  614. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/camera-audio-continuity.md +259 -0
  615. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/delivery-profile.md +273 -0
  616. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/generability.md +93 -0
  617. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/minimax-h3.md +253 -0
  618. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/motion-recipe.md +315 -0
  619. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/performance-action-timing.md +209 -0
  620. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/production-prompt-grammar.md +304 -0
  621. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/review-and-fixtures.md +139 -0
  622. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/seedance-2.0.md +50 -0
  623. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/seedance-2.5.md +122 -0
  624. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/stage-contract.md +60 -0
  625. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/target-model-profile.md +116 -0
  626. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/container_check.py +329 -0
  627. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/motion_timing_check.py +450 -0
  628. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/music_spec_check.py +327 -0
  629. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/selftest.py +82 -0
  630. package/resources/skills/drama-production/upstream/skills/short-drama-write/SKILL.md +99 -0
  631. package/resources/skills/drama-production/upstream/skills/short-drama-write/agents/openai.yaml +4 -0
  632. package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/beats.jsonl +2 -0
  633. package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/episode-card-standalone.json +120 -0
  634. package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/episode-card.json +55 -0
  635. package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/screenplay.md +36 -0
  636. package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/voice-record-sheet.jsonl.md +89 -0
  637. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/dialogue-craft.md +526 -0
  638. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/production-format-dialect.md +273 -0
  639. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/scene-handoff-capsule.md +25 -0
  640. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/scene-sound-dramaturgy.md +43 -0
  641. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/screenplay-format.md +266 -0
  642. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/script-craft.md +745 -0
  643. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/stage-contract.md +36 -0
  644. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/substitutable-realization.md +107 -0
  645. package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/duration_estimate.py +309 -0
  646. package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/screenplay_index.py +1091 -0
  647. package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/selftest.py +229 -0
  648. package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/voice_sheet_check.py +416 -0
  649. package/resources/skills/viora-canvas-production/manifest.json +23 -0
  650. package/resources/skills/viora-canvas-production/references/canvas-contract.md +50 -0
  651. package/resources/skills/viora-canvas-production/references/creative-production.md +50 -0
  652. package/resources/skills/viora-canvas-production/references/generation-delivery.md +36 -0
  653. package/resources/skills/viora-canvas-production/references/production-ledger.md +25 -0
@@ -0,0 +1,2205 @@
1
+ #!/usr/bin/env python3
2
+ """Prepare and execute explicitly confirmed short-drama media jobs.
3
+
4
+ A configured adapter receives one bounded JSON job on stdin and returns local
5
+ output files on stdout. The adapter is launched without a shell and only after
6
+ a confirmation bound to the exact job and current project inputs. Optional
7
+ provider adapters can ship with this skill, but credentials and adapter config
8
+ remain outside creator projects.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import contextlib
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import re
19
+ import stat
20
+ import subprocess
21
+ import sys
22
+ import tempfile
23
+ import uuid
24
+ from collections.abc import Iterator, Mapping
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path, PurePosixPath
27
+ from typing import Any, BinaryIO
28
+
29
+ MINIMUM_PYTHON = (3, 9)
30
+ if sys.version_info < MINIMUM_PYTHON:
31
+ raise SystemExit(
32
+ "short-drama-produce needs Python {}.{} or newer".format(*MINIMUM_PYTHON)
33
+ )
34
+
35
+ PROJECT_FILE = "short-drama.json"
36
+ PRODUCTION_ROOT = Path(".short-drama/production")
37
+ JOB_SCHEMA = "1.0"
38
+ JOB_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,79}")
39
+ MAX_JOB_BYTES = 256 * 1024
40
+ MAX_RUN_RECORD_BYTES = 256 * 1024
41
+ MAX_ADAPTER_RESPONSE_BYTES = 1024 * 1024
42
+ MAX_OUTPUT_BYTES = 512 * 1024 * 1024
43
+ MAX_INPUT_BYTES = 50 * 1024 * 1024
44
+ MAX_TOTAL_INPUT_BYTES = 200 * 1024 * 1024
45
+ MAX_TIMEOUT_SECONDS = 3600
46
+ PUBLIC_ERROR_CATEGORIES = {
47
+ "authentication",
48
+ "configuration",
49
+ "contract",
50
+ "invalid_request",
51
+ "network",
52
+ "permission",
53
+ "provider_response",
54
+ "rate_limit",
55
+ "server",
56
+ "timeout",
57
+ }
58
+ PUBLIC_ERROR_TOKEN_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,199}")
59
+ ALLOWED_JOB_KEYS = {
60
+ "schema_version",
61
+ "job_id",
62
+ "modality",
63
+ "adapter",
64
+ "prompt",
65
+ "source",
66
+ "source_entry",
67
+ "references",
68
+ "reference_bindings",
69
+ "outputs",
70
+ "parameters",
71
+ "overwrite",
72
+ }
73
+ STORED_EXECUTION_KEYS = ALLOWED_JOB_KEYS | {"inputs"}
74
+ STORED_JOB_KEYS = STORED_EXECUTION_KEYS | {"fingerprint", "prepared_at"}
75
+ LEGACY_STORED_EXECUTION_KEYS = STORED_EXECUTION_KEYS - {
76
+ "source_entry",
77
+ "reference_bindings",
78
+ }
79
+ LEGACY_STORED_JOB_KEYS = LEGACY_STORED_EXECUTION_KEYS | {
80
+ "fingerprint",
81
+ "prepared_at",
82
+ }
83
+ SECRET_KEYS = {
84
+ "authorization",
85
+ "credential",
86
+ "credentials",
87
+ "password",
88
+ "secret",
89
+ "token",
90
+ "access_token",
91
+ "api_key",
92
+ "apikey",
93
+ }
94
+ MEDIA_EXTENSIONS = {
95
+ "image": {".png", ".jpg", ".jpeg", ".webp"},
96
+ "video": {".mp4", ".mov", ".webm"},
97
+ "tts": {".wav", ".mp3", ".m4a", ".aac", ".flac", ".opus"},
98
+ "music": {".wav", ".mp3", ".m4a", ".aac", ".flac", ".opus"},
99
+ }
100
+ MEDIA_TYPES = {
101
+ ".png": "image/png",
102
+ ".jpg": "image/jpeg",
103
+ ".jpeg": "image/jpeg",
104
+ ".webp": "image/webp",
105
+ ".mp4": "video/mp4",
106
+ ".mov": "video/quicktime",
107
+ ".webm": "video/webm",
108
+ ".wav": "audio/wav",
109
+ ".mp3": "audio/mpeg",
110
+ ".m4a": "audio/mp4",
111
+ ".aac": "audio/aac",
112
+ ".flac": "audio/flac",
113
+ ".opus": "audio/ogg",
114
+ }
115
+ CREATOR_SOURCE_NAMES = {
116
+ "图片提示词.md": "image",
117
+ "视频提示词.md": "video",
118
+ # A shot's frozen keyframe is a producible image and lives nowhere else, so
119
+ # without this the start frame a video job wants to bind has no stage that
120
+ # can render it.
121
+ "分镜.md": "image",
122
+ }
123
+ # Which H2 IDs a creator source may select, and where that entry keeps its
124
+ # copyable body.
125
+ CREATOR_SOURCE_ENTRIES = {
126
+ "图片提示词.md": ("IMG-", r"可复制(?:通用)?提示词", "参考"),
127
+ "视频提示词.md": ("MOTION-", r"可复制(?:通用)?提示词", "输入参考图"),
128
+ "分镜.md": ("SHOT-", r"冻结关键帧提示词", "输入参考图"),
129
+ }
130
+
131
+ REF_SLOT_RE = re.compile(r"REF-[A-Z0-9][A-Z0-9-]{0,79}")
132
+ # A slot head, not a bare substring: a real reference file may legitimately be
133
+ # named `plan-sheet.png`, and that is not a creator-supplied declaration.
134
+ PLAN_SLOT_HEAD_RE = re.compile(r"PLAN-[A-Za-z0-9][A-Za-z0-9-]{0,79}(顺序:")
135
+ PLAN_SLOT_RE = re.compile(
136
+ r"PLAN-[A-Za-z0-9][A-Za-z0-9-]{0,79}(顺序:[1-9]\d*)· "
137
+ r"(?:IMG|SHOT)-[A-Za-z0-9-]+《[^》\n]+》([^)\n]*)"
138
+ )
139
+ SOURCE_ENTRY_RE = re.compile(r"[A-Z][A-Z0-9-]{1,99}")
140
+ REFERENCE_SUFFIX_RE = r"(?:png|jpe?g|webp)"
141
+ # 用途 is the creator-facing question "what does this picture decide here"; the
142
+ # job's own `role` is its production translation. The segment is optional so a
143
+ # declaration written before that field existed still prepares, and so the
144
+ # creator-side diagnostic stays with creator_markdown_check.py.
145
+ REFERENCE_LINE_RE = re.compile(
146
+ rf"(REF-[A-Z0-9][A-Z0-9-]{{0,79}})(顺序:([1-9]\d*))· "
147
+ rf"([^;\n]+?\.{REFERENCE_SUFFIX_RE})《([^》\n]+)》"
148
+ r"((?:用途:[^;)\n]+;)?控制:([^;)\n]+);不得控制:([^)\n]+))",
149
+ re.IGNORECASE,
150
+ )
151
+
152
+
153
+ class ConfirmationRequiredError(RuntimeError):
154
+ """The exact current job has not been explicitly confirmed."""
155
+
156
+
157
+ class AdapterError(RuntimeError):
158
+ """A configured media adapter failed or broke its output contract."""
159
+
160
+ def __init__(
161
+ self, message: str, *, public_error: Mapping[str, Any] | None = None
162
+ ) -> None:
163
+ super().__init__(message)
164
+ self.public_error = dict(public_error) if public_error is not None else None
165
+
166
+
167
+ def utc_now() -> str:
168
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
169
+
170
+
171
+ def sha256_bytes(content: bytes) -> str:
172
+ return hashlib.sha256(content).hexdigest()
173
+
174
+
175
+ def find_project(start: Path) -> Path:
176
+ candidate = start.expanduser().resolve()
177
+ if candidate.is_file():
178
+ candidate = candidate.parent
179
+ for directory in (candidate, *candidate.parents):
180
+ if (directory / PROJECT_FILE).is_file():
181
+ return directory
182
+ raise FileNotFoundError(f"no {PROJECT_FILE} found from {start}")
183
+
184
+
185
+ def _relative_path(value: object, *, output: bool = False) -> str:
186
+ if not isinstance(value, str):
187
+ raise ValueError("project paths must be strings")
188
+ raw = value.replace("\\", "/")
189
+ pure = PurePosixPath(raw)
190
+ if not raw or pure.is_absolute() or any(part in {"", ".", ".."} for part in pure.parts):
191
+ raise ValueError(f"unsafe project-relative path: {value}")
192
+ if pure.parts[0].casefold() == ".short-drama" or pure.name.casefold() == PROJECT_FILE:
193
+ raise ValueError(f"operational project path is not allowed: {value}")
194
+ if output:
195
+ parts = pure.parts
196
+ top_level_production = len(parts) >= 2 and parts[0].casefold() == "production"
197
+ episode_production = (
198
+ len(parts) >= 4
199
+ and parts[0] in {"剧集", "episodes"}
200
+ and re.fullmatch(r"EP\d{3,}", parts[1], re.IGNORECASE) is not None
201
+ and parts[2] in {"制作成果", "production"}
202
+ )
203
+ if not top_level_production and not episode_production:
204
+ raise ValueError(
205
+ "media outputs must use top-level production/ or "
206
+ "剧集|episodes/<EP>/制作成果|production/"
207
+ )
208
+ return pure.as_posix()
209
+
210
+
211
+ def _project_file(root: Path, relative: str, *, create_parent: bool = False) -> Path:
212
+ target = root / relative
213
+ current = root
214
+ for part in PurePosixPath(relative).parts[:-1]:
215
+ current /= part
216
+ if current.exists() and (current.is_symlink() or not current.is_dir()):
217
+ raise ValueError(f"unsafe project directory: {part}")
218
+ if create_parent and not current.exists():
219
+ current.mkdir()
220
+ if target.exists() and (target.is_symlink() or not target.is_file()):
221
+ raise ValueError(f"unsafe project file: {relative}")
222
+ if not target.parent.resolve().is_relative_to(root):
223
+ raise ValueError(f"path escapes project root: {relative}")
224
+ return target
225
+
226
+
227
+ def _is_link_or_reparse(details: os.stat_result) -> bool:
228
+ attributes = getattr(details, "st_file_attributes", 0)
229
+ reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
230
+ return stat.S_ISLNK(details.st_mode) or bool(attributes & reparse_flag)
231
+
232
+
233
+ @contextlib.contextmanager
234
+ def _open_project_input(root: Path, relative: str) -> Iterator[BinaryIO]:
235
+ """Open one regular project input without following path components on POSIX."""
236
+ parts = PurePosixPath(relative).parts
237
+ if not parts:
238
+ raise ValueError("job input path is empty")
239
+ if os.name != "nt" and os.open in os.supports_dir_fd:
240
+ directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
241
+ nofollow = getattr(os, "O_NOFOLLOW", 0)
242
+ directory_fd = os.open(root, directory_flags)
243
+ file_fd: int | None = None
244
+ try:
245
+ for part in parts[:-1]:
246
+ next_fd = os.open(
247
+ part,
248
+ directory_flags | nofollow,
249
+ dir_fd=directory_fd,
250
+ )
251
+ os.close(directory_fd)
252
+ directory_fd = next_fd
253
+ file_fd = os.open(
254
+ parts[-1], os.O_RDONLY | nofollow, dir_fd=directory_fd
255
+ )
256
+ details = os.fstat(file_fd)
257
+ if not stat.S_ISREG(details.st_mode):
258
+ raise ValueError(f"job input is not a regular file: {relative}")
259
+ with os.fdopen(file_fd, "rb", closefd=True) as handle:
260
+ file_fd = None
261
+ yield handle
262
+ except FileNotFoundError as exc:
263
+ raise FileNotFoundError(f"job input is missing: {relative}") from exc
264
+ except OSError as exc:
265
+ raise ValueError(f"unsafe job input path: {relative}") from exc
266
+ finally:
267
+ if file_fd is not None:
268
+ os.close(file_fd)
269
+ os.close(directory_fd)
270
+ return
271
+
272
+ # Windows lacks portable openat/O_NOFOLLOW support. Reject reparse/symlink
273
+ # components, pin the final file handle, and verify its identity before use.
274
+ path = root
275
+ for part in parts:
276
+ path /= part
277
+ try:
278
+ details = path.lstat()
279
+ except FileNotFoundError as exc:
280
+ raise FileNotFoundError(f"job input is missing: {relative}") from exc
281
+ if _is_link_or_reparse(details):
282
+ raise ValueError(f"unsafe job input path: {relative}")
283
+ if not path.resolve().is_relative_to(root):
284
+ raise ValueError(f"job input escapes project root: {relative}")
285
+ before = path.stat(follow_symlinks=False)
286
+ if not stat.S_ISREG(before.st_mode):
287
+ raise ValueError(f"job input is not a regular file: {relative}")
288
+ with path.open("rb") as handle:
289
+ opened = os.fstat(handle.fileno())
290
+ if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino):
291
+ raise ValueError(f"job input changed while opening: {relative}")
292
+ yield handle
293
+
294
+
295
+ def _hash_project_file(root: Path, relative: str) -> str:
296
+ digest = hashlib.sha256()
297
+ size = 0
298
+ with _open_project_input(root, relative) as handle:
299
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
300
+ size += len(chunk)
301
+ if size > MAX_INPUT_BYTES:
302
+ raise ValueError(f"job input exceeds the size limit: {relative}")
303
+ digest.update(chunk)
304
+ return digest.hexdigest()
305
+
306
+
307
+ def _snapshot_inputs(
308
+ root: Path, job: Mapping[str, Any], snapshot_root: Path
309
+ ) -> None:
310
+ inputs = job.get("inputs")
311
+ if not isinstance(inputs, Mapping):
312
+ raise ConfirmationRequiredError("stored job inputs are invalid")
313
+ total = 0
314
+ for relative_value, expected_value in inputs.items():
315
+ relative = _relative_path(relative_value)
316
+ if not isinstance(expected_value, str) or re.fullmatch(
317
+ r"[0-9a-f]{64}", expected_value
318
+ ) is None:
319
+ raise ConfirmationRequiredError("stored job input hash is invalid")
320
+ target = snapshot_root.joinpath(*PurePosixPath(relative).parts)
321
+ target.parent.mkdir(parents=True, exist_ok=True)
322
+ digest = hashlib.sha256()
323
+ size = 0
324
+ try:
325
+ with _open_project_input(root, relative) as incoming, target.open(
326
+ "xb"
327
+ ) as outgoing:
328
+ for chunk in iter(lambda: incoming.read(1024 * 1024), b""):
329
+ size += len(chunk)
330
+ total += len(chunk)
331
+ if size > MAX_INPUT_BYTES or total > MAX_TOTAL_INPUT_BYTES:
332
+ raise ConfirmationRequiredError(
333
+ "job inputs exceed the production size limit"
334
+ )
335
+ digest.update(chunk)
336
+ outgoing.write(chunk)
337
+ except (FileNotFoundError, OSError, ValueError) as exc:
338
+ raise ConfirmationRequiredError(
339
+ "job inputs changed; prepare and confirm again"
340
+ ) from exc
341
+ if digest.hexdigest() != expected_value:
342
+ raise ConfirmationRequiredError(
343
+ "job inputs changed; prepare and confirm again"
344
+ )
345
+
346
+
347
+ def _canonical(document: object) -> bytes:
348
+ return json.dumps(
349
+ document, ensure_ascii=False, sort_keys=True, separators=(",", ":")
350
+ ).encode("utf-8")
351
+
352
+
353
+ def _check_metadata_parts(parts: tuple[str, ...]) -> None:
354
+ if any(
355
+ not part
356
+ or part in {".", ".."}
357
+ or "/" in part
358
+ or "\\" in part
359
+ for part in parts
360
+ ):
361
+ raise ValueError("production metadata path is invalid")
362
+
363
+
364
+ @contextlib.contextmanager
365
+ def _metadata_directory(
366
+ root: Path, parts: tuple[str, ...], *, create: bool
367
+ ) -> Iterator[tuple[Path, int | None]]:
368
+ """Pin one metadata directory without following project-controlled parents."""
369
+ root = root.resolve()
370
+ _check_metadata_parts(parts)
371
+ all_parts = (".short-drama", "production", *parts)
372
+ directory = root.joinpath(*all_parts)
373
+ if os.name != "nt" and os.open in os.supports_dir_fd:
374
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
375
+ nofollow = getattr(os, "O_NOFOLLOW", 0)
376
+ directory_fd = os.open(root, flags)
377
+ try:
378
+ try:
379
+ for part in all_parts:
380
+ try:
381
+ next_fd = os.open(
382
+ part, flags | nofollow, dir_fd=directory_fd
383
+ )
384
+ except FileNotFoundError:
385
+ if not create:
386
+ raise
387
+ try:
388
+ os.mkdir(part, mode=0o700, dir_fd=directory_fd)
389
+ except FileExistsError:
390
+ pass
391
+ next_fd = os.open(
392
+ part, flags | nofollow, dir_fd=directory_fd
393
+ )
394
+ details = os.fstat(next_fd)
395
+ if not stat.S_ISDIR(details.st_mode):
396
+ os.close(next_fd)
397
+ raise ValueError("production metadata directory is unsafe")
398
+ os.close(directory_fd)
399
+ directory_fd = next_fd
400
+ except FileNotFoundError:
401
+ raise
402
+ except OSError as exc:
403
+ raise ValueError("production metadata directory is unsafe") from exc
404
+ yield directory, directory_fd
405
+ finally:
406
+ os.close(directory_fd)
407
+ return
408
+
409
+ current = root
410
+ for part in all_parts:
411
+ current /= part
412
+ try:
413
+ details = current.lstat()
414
+ except FileNotFoundError:
415
+ if not create:
416
+ raise
417
+ try:
418
+ current.mkdir(mode=0o700)
419
+ except FileExistsError:
420
+ pass
421
+ details = current.lstat()
422
+ if _is_link_or_reparse(details) or not stat.S_ISDIR(details.st_mode):
423
+ raise ValueError("production metadata directory is unsafe")
424
+ if not current.resolve().is_relative_to(root):
425
+ raise ValueError("production metadata directory escapes the project")
426
+ yield current, None
427
+
428
+
429
+ def _metadata_atomic_json(
430
+ root: Path, directory_parts: tuple[str, ...], name: str, document: Mapping[str, Any]
431
+ ) -> None:
432
+ _check_metadata_parts((name,))
433
+ content = _canonical(document) + b"\n"
434
+ temporary_name = f".{name}.{uuid.uuid4().hex}.tmp"
435
+ with _metadata_directory(root, directory_parts, create=True) as (
436
+ directory,
437
+ directory_fd,
438
+ ):
439
+ if directory_fd is not None:
440
+ descriptor = -1
441
+ try:
442
+ descriptor = os.open(
443
+ temporary_name,
444
+ os.O_WRONLY
445
+ | os.O_CREAT
446
+ | os.O_EXCL
447
+ | getattr(os, "O_NOFOLLOW", 0),
448
+ 0o600,
449
+ dir_fd=directory_fd,
450
+ )
451
+ with os.fdopen(descriptor, "wb", closefd=True) as handle:
452
+ descriptor = -1
453
+ handle.write(content)
454
+ handle.flush()
455
+ os.fsync(handle.fileno())
456
+ os.replace(
457
+ temporary_name,
458
+ name,
459
+ src_dir_fd=directory_fd,
460
+ dst_dir_fd=directory_fd,
461
+ )
462
+ os.fsync(directory_fd)
463
+ finally:
464
+ if descriptor >= 0:
465
+ os.close(descriptor)
466
+ try:
467
+ os.unlink(temporary_name, dir_fd=directory_fd)
468
+ except FileNotFoundError:
469
+ pass
470
+ return
471
+
472
+ before = directory.stat(follow_symlinks=False)
473
+ temporary = directory / temporary_name
474
+ try:
475
+ with temporary.open("xb") as handle:
476
+ opened = os.fstat(handle.fileno())
477
+ if not stat.S_ISREG(opened.st_mode):
478
+ raise ValueError("production metadata file is unsafe")
479
+ handle.write(content)
480
+ handle.flush()
481
+ os.fsync(handle.fileno())
482
+ after = directory.lstat()
483
+ if (
484
+ _is_link_or_reparse(after)
485
+ or not stat.S_ISDIR(after.st_mode)
486
+ or (after.st_dev, after.st_ino) != (before.st_dev, before.st_ino)
487
+ ):
488
+ raise ValueError("production metadata directory changed")
489
+ os.replace(temporary, directory / name)
490
+ finally:
491
+ try:
492
+ temporary.unlink()
493
+ except FileNotFoundError:
494
+ pass
495
+
496
+
497
+ def _metadata_read_json(
498
+ root: Path, directory_parts: tuple[str, ...], name: str, *, maximum: int
499
+ ) -> object:
500
+ _check_metadata_parts((name,))
501
+ with _metadata_directory(root, directory_parts, create=False) as (
502
+ directory,
503
+ directory_fd,
504
+ ):
505
+ if directory_fd is not None:
506
+ descriptor = os.open(
507
+ name,
508
+ os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0),
509
+ dir_fd=directory_fd,
510
+ )
511
+ with os.fdopen(descriptor, "rb", closefd=True) as handle:
512
+ details = os.fstat(handle.fileno())
513
+ if not stat.S_ISREG(details.st_mode) or details.st_size > maximum:
514
+ raise ValueError("production metadata file is unsafe")
515
+ raw = handle.read(maximum + 1)
516
+ else:
517
+ path = directory / name
518
+ before = path.lstat()
519
+ if (
520
+ _is_link_or_reparse(before)
521
+ or not stat.S_ISREG(before.st_mode)
522
+ or before.st_size > maximum
523
+ ):
524
+ raise ValueError("production metadata file is unsafe")
525
+ with path.open("rb") as handle:
526
+ opened = os.fstat(handle.fileno())
527
+ if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino):
528
+ raise ValueError("production metadata file changed while opening")
529
+ raw = handle.read(maximum + 1)
530
+ if len(raw) > maximum:
531
+ raise ValueError("production metadata file is too large")
532
+ return json.loads(raw.decode("utf-8"))
533
+
534
+
535
+ def _metadata_unlink(
536
+ root: Path, directory_parts: tuple[str, ...], name: str
537
+ ) -> None:
538
+ _check_metadata_parts((name,))
539
+ with _metadata_directory(root, directory_parts, create=False) as (
540
+ directory,
541
+ directory_fd,
542
+ ):
543
+ if directory_fd is not None:
544
+ os.unlink(name, dir_fd=directory_fd)
545
+ else:
546
+ path = directory / name
547
+ details = path.lstat()
548
+ if _is_link_or_reparse(details) or not stat.S_ISREG(details.st_mode):
549
+ raise ValueError("production metadata file is unsafe")
550
+ path.unlink()
551
+
552
+
553
+ def _metadata_json_names(root: Path, directory_parts: tuple[str, ...]) -> list[str]:
554
+ try:
555
+ with _metadata_directory(root, directory_parts, create=False) as (
556
+ directory,
557
+ directory_fd,
558
+ ):
559
+ names = os.listdir(directory_fd if directory_fd is not None else directory)
560
+ except FileNotFoundError:
561
+ return []
562
+ return sorted(
563
+ name
564
+ for name in names
565
+ if isinstance(name, str)
566
+ and name.endswith(".json")
567
+ and "/" not in name
568
+ and "\\" not in name
569
+ )
570
+
571
+
572
+ @contextlib.contextmanager
573
+ def _project_lock(root: Path) -> Iterator[None]:
574
+ with _metadata_directory(root, (), create=True) as (directory, directory_fd):
575
+ if directory_fd is not None:
576
+ descriptor = os.open(
577
+ "lock",
578
+ os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0),
579
+ 0o600,
580
+ dir_fd=directory_fd,
581
+ )
582
+ details = os.fstat(descriptor)
583
+ if not stat.S_ISREG(details.st_mode):
584
+ os.close(descriptor)
585
+ raise ValueError("production lock is unsafe")
586
+ handle_context = os.fdopen(descriptor, "a+b", closefd=True)
587
+ else:
588
+ lock_path = directory / "lock"
589
+ before: os.stat_result | None
590
+ try:
591
+ before = lock_path.lstat()
592
+ except FileNotFoundError:
593
+ before = None
594
+ else:
595
+ if _is_link_or_reparse(before) or not stat.S_ISREG(before.st_mode):
596
+ raise ValueError("production lock is unsafe")
597
+ handle_context = lock_path.open("a+b")
598
+ opened = os.fstat(handle_context.fileno())
599
+ after = lock_path.lstat()
600
+ if (
601
+ not stat.S_ISREG(opened.st_mode)
602
+ or _is_link_or_reparse(after)
603
+ or not stat.S_ISREG(after.st_mode)
604
+ or (opened.st_dev, opened.st_ino) != (after.st_dev, after.st_ino)
605
+ or (
606
+ before is not None
607
+ and (before.st_dev, before.st_ino)
608
+ != (opened.st_dev, opened.st_ino)
609
+ )
610
+ ):
611
+ handle_context.close()
612
+ raise ValueError("production lock changed while opening")
613
+ with handle_context as handle:
614
+ if os.name == "nt":
615
+ import msvcrt
616
+
617
+ handle.seek(0, os.SEEK_END)
618
+ if handle.tell() == 0:
619
+ handle.write(b"0")
620
+ handle.flush()
621
+ handle.seek(0)
622
+ locking = getattr(msvcrt, "locking")
623
+ lock = getattr(msvcrt, "LK_LOCK")
624
+ unlock = getattr(msvcrt, "LK_UNLCK")
625
+ locking(handle.fileno(), lock, 1)
626
+ try:
627
+ yield
628
+ finally:
629
+ handle.seek(0)
630
+ locking(handle.fileno(), unlock, 1)
631
+ else:
632
+ import fcntl
633
+
634
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
635
+ try:
636
+ yield
637
+ finally:
638
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
639
+
640
+
641
+ def _job_key(job_id: str) -> str:
642
+ return sha256_bytes(job_id.encode("utf-8"))[:24]
643
+
644
+
645
+ def _job_path(root: Path, job_id: str) -> Path:
646
+ return root / PRODUCTION_ROOT / "jobs" / f"{_job_key(job_id)}.json"
647
+
648
+
649
+ def _confirmation_path(root: Path, job_id: str) -> Path:
650
+ return root / PRODUCTION_ROOT / "confirmations" / f"{_job_key(job_id)}.json"
651
+
652
+
653
+ def _run_directory(root: Path, job_id: str) -> Path:
654
+ return root / PRODUCTION_ROOT / "runs" / _job_key(job_id)
655
+
656
+
657
+ def _contains_secret_key(value: object) -> bool:
658
+ if isinstance(value, Mapping):
659
+ return any(
660
+ str(key).casefold() in SECRET_KEYS or _contains_secret_key(child)
661
+ for key, child in value.items()
662
+ )
663
+ if isinstance(value, list):
664
+ return any(_contains_secret_key(child) for child in value)
665
+ return False
666
+
667
+
668
+ def _string_list(value: object, *, label: str, limit: int = 32) -> list[str]:
669
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
670
+ raise ValueError(f"{label} must be a string list")
671
+ if len(value) > limit:
672
+ raise ValueError(f"{label} has too many entries")
673
+ return list(value)
674
+
675
+
676
+ def _scope_list(value: object, *, label: str) -> list[str]:
677
+ items = _string_list(value, label=label, limit=32)
678
+ if not items or any(not item.strip() or len(item) > 200 for item in items):
679
+ raise ValueError(f"{label} must contain non-empty bounded text")
680
+ normalized = [item.strip() for item in items]
681
+ folded = [item.casefold() for item in normalized]
682
+ if len(folded) != len(set(folded)):
683
+ raise ValueError(f"{label} must not contain duplicates")
684
+ return normalized
685
+
686
+
687
+ def _canonical_creator_source_modality(source: str | None) -> str | None:
688
+ if source is None:
689
+ return None
690
+ path = PurePosixPath(source)
691
+ if (
692
+ len(path.parts) != 3
693
+ or path.parts[0] not in {"剧集", "episodes"}
694
+ or not path.parts[1]
695
+ ):
696
+ return None
697
+ return CREATOR_SOURCE_NAMES.get(path.name)
698
+
699
+
700
+ def _normalize_reference_bindings(value: object) -> list[dict[str, Any]]:
701
+ if value is None:
702
+ return []
703
+ if not isinstance(value, list) or len(value) > 16:
704
+ raise ValueError("reference_bindings must be a list of at most sixteen entries")
705
+ normalized: list[dict[str, Any]] = []
706
+ for index, binding in enumerate(value, 1):
707
+ label = f"reference_bindings[{index}]"
708
+ if not isinstance(binding, Mapping) or set(binding) != {
709
+ "slot_id",
710
+ "order",
711
+ "path",
712
+ "label",
713
+ "role",
714
+ "may_control",
715
+ "must_not_control",
716
+ }:
717
+ raise ValueError(f"{label} fields are invalid")
718
+ slot_id = binding.get("slot_id")
719
+ order = binding.get("order")
720
+ raw_label = binding.get("label")
721
+ role = binding.get("role")
722
+ if not isinstance(slot_id, str) or REF_SLOT_RE.fullmatch(slot_id) is None:
723
+ raise ValueError(f"{label} slot_id is invalid")
724
+ if not isinstance(order, int) or isinstance(order, bool) or order < 1:
725
+ raise ValueError(f"{label} order is invalid")
726
+ if (
727
+ not isinstance(raw_label, str)
728
+ or not raw_label.strip()
729
+ or len(raw_label) > 200
730
+ or re.search(r"[\u3400-\u9fff]", raw_label) is None
731
+ ):
732
+ raise ValueError(f"{label} label must contain Chinese text")
733
+ if not isinstance(role, str) or not role.strip() or len(role) > 80:
734
+ raise ValueError(f"{label} role is invalid")
735
+ may_control = _scope_list(
736
+ binding.get("may_control"), label=f"{label}.may_control"
737
+ )
738
+ must_not_control = _scope_list(
739
+ binding.get("must_not_control"), label=f"{label}.must_not_control"
740
+ )
741
+ if {item.casefold() for item in may_control} & {
742
+ item.casefold() for item in must_not_control
743
+ }:
744
+ raise ValueError(f"{label} control scopes must not overlap")
745
+ normalized.append(
746
+ {
747
+ "slot_id": slot_id,
748
+ "order": order,
749
+ "path": _relative_path(binding.get("path")),
750
+ "label": raw_label.strip(),
751
+ "role": role.strip(),
752
+ "may_control": may_control,
753
+ "must_not_control": must_not_control,
754
+ }
755
+ )
756
+ normalized.sort(key=lambda item: int(item["order"]))
757
+ slots = [str(item["slot_id"]) for item in normalized]
758
+ orders = [int(item["order"]) for item in normalized]
759
+ paths = [str(item["path"]) for item in normalized]
760
+ if len(slots) != len(set(slots)):
761
+ raise ValueError("reference binding slot_ids must be unique")
762
+ if orders != list(range(1, len(orders) + 1)):
763
+ raise ValueError("reference binding order must be unique and contiguous from 1")
764
+ if len(paths) != len(set(paths)):
765
+ raise ValueError("reference binding paths must be unique")
766
+ return normalized
767
+
768
+
769
+ def _markdown_section(document: str, source_entry: str) -> str:
770
+ heading = re.compile(
771
+ rf"^##\s+`?{re.escape(source_entry)}`?(?:\s+·.*)?\s*$", re.MULTILINE
772
+ )
773
+ matches = list(heading.finditer(document))
774
+ if not matches:
775
+ raise ValueError(f"source entry is missing from Markdown: {source_entry}")
776
+ if len(matches) != 1:
777
+ raise ValueError(f"source entry is duplicated in Markdown: {source_entry}")
778
+ match = matches[0]
779
+ next_heading = re.search(r"^##\s+", document[match.end() :], re.MULTILINE)
780
+ end = match.end() + next_heading.start() if next_heading is not None else len(document)
781
+ return document[match.start() : end]
782
+
783
+
784
+ def _copyable_prompt(
785
+ section: str, heading: str = r"可复制(?:通用)?提示词"
786
+ ) -> str:
787
+ markers = list(
788
+ re.finditer(rf"^###\s+{heading}\s*$", section, re.MULTILINE)
789
+ )
790
+ if not markers:
791
+ raise ValueError("source entry has no copyable prompt")
792
+ if len(markers) != 1:
793
+ raise ValueError("source entry has duplicate copyable prompts")
794
+ marker = markers[0]
795
+ body = section[marker.end() :]
796
+ following = re.search(r"^###\s+|^##\s+", body, re.MULTILINE)
797
+ if following is not None:
798
+ body = body[: following.start()]
799
+ body_lines = [line for line in body.splitlines() if line.strip()]
800
+ if any(not line.startswith(">") for line in body_lines):
801
+ raise ValueError("source entry copyable prompt contains unquoted content")
802
+ lines = [line[1:].lstrip() for line in body_lines]
803
+ if not lines or not "\n".join(lines).strip():
804
+ raise ValueError("source entry copyable prompt is empty")
805
+ return "\n".join(lines).strip()
806
+
807
+
808
+ def _scope_items(value: str) -> list[str]:
809
+ return [item.strip() for item in re.split(r"[、,,]", value) if item.strip()]
810
+
811
+
812
+ def _contains_ref_token(value: str) -> bool:
813
+ return "ref-" in value.casefold()
814
+
815
+
816
+ def _contains_plan_token(value: str) -> bool:
817
+ return "plan-" in value.casefold()
818
+
819
+
820
+ def _markdown_reference_bindings(
821
+ section: str, *, field_name: str, creator_supplied_ok: bool = False
822
+ ) -> list[dict[str, Any]]:
823
+ lines = re.findall(
824
+ rf"^- {re.escape(field_name)}:(.+)$", section, re.MULTILINE
825
+ )
826
+ if not lines:
827
+ raise ValueError(f"source entry has no {field_name} declaration")
828
+ if len(lines) != 1:
829
+ raise ValueError(f"source entry has duplicate {field_name} declarations")
830
+ value = lines[0].strip()
831
+ if (
832
+ field_name == "输入参考图"
833
+ and not _contains_ref_token(value)
834
+ and re.fullmatch(r"无(?:([^)\n]+))?。?", value)
835
+ ):
836
+ return []
837
+ if (
838
+ field_name == "参考"
839
+ and not _contains_ref_token(value)
840
+ and re.fullmatch(r"无(?:外部参考)?(?:;[^\n]*)?。?", value)
841
+ ):
842
+ return []
843
+ # A `PLAN-...` slot is a picture the creator attaches in their own tool, so
844
+ # this suite has no file to send. The rendering job that draws a shot's own
845
+ # start frame is unaffected -- it works from the frozen keyframe text, the
846
+ # way it already does for 「待补参考图」 -- so only the job that would have
847
+ # sent these pictures as model inputs refuses. Matched as a slot head rather
848
+ # than a bare substring, or a real file named `plan-sheet.png` would refuse.
849
+ if PLAN_SLOT_HEAD_RE.search(value):
850
+ if not creator_supplied_ok:
851
+ raise ValueError(
852
+ "source entry declares creator-supplied references (PLAN-...); "
853
+ "bind the real files as REF-... before producing"
854
+ )
855
+ value = PLAN_SLOT_RE.sub("", value).strip(";;。 ")
856
+ if not value:
857
+ return []
858
+ matches = list(REFERENCE_LINE_RE.finditer(value))
859
+ if not matches:
860
+ raise ValueError("source entry input-reference declaration is invalid")
861
+ cursor = 0
862
+ for index, match in enumerate(matches):
863
+ separator = value[cursor : match.start()]
864
+ if separator != ("" if index == 0 else ";"):
865
+ raise ValueError("source entry input-reference declaration is invalid")
866
+ cursor = match.end()
867
+ if value[cursor:] not in {"", "。"}:
868
+ raise ValueError("source entry input-reference declaration is invalid")
869
+ return [
870
+ {
871
+ "slot_id": match.group(1),
872
+ "order": int(match.group(2)),
873
+ "path": _relative_path(match.group(3)),
874
+ "label": match.group(4).strip(),
875
+ "may_control": _scope_items(match.group(5)),
876
+ "must_not_control": _scope_items(match.group(6)),
877
+ }
878
+ for match in matches
879
+ ]
880
+
881
+
882
+ def _verify_markdown_source(
883
+ root: Path,
884
+ *,
885
+ source: str,
886
+ source_entry: str,
887
+ prompt: str,
888
+ bindings: list[dict[str, Any]],
889
+ ) -> None:
890
+ source_path = _project_file(root, source)
891
+ if source_path.suffix.casefold() != ".md":
892
+ raise ValueError("source_entry requires a Markdown source")
893
+ document = source_path.read_text(encoding="utf-8")
894
+ section = _markdown_section(document, source_entry)
895
+ _, heading, field_name = CREATOR_SOURCE_ENTRIES[source_path.name]
896
+ if _copyable_prompt(section, heading) != prompt.strip():
897
+ raise ValueError("job prompt does not match the selected source entry")
898
+ declared = _markdown_reference_bindings(
899
+ section,
900
+ field_name=field_name,
901
+ # 分镜.md is the entry that renders a shot's own start frame. Its
902
+ # reference field records creative intent, not this job's inputs.
903
+ creator_supplied_ok=source_path.name == "分镜.md",
904
+ )
905
+ comparable = [
906
+ {key: binding[key] for key in (
907
+ "slot_id",
908
+ "order",
909
+ "path",
910
+ "label",
911
+ "may_control",
912
+ "must_not_control",
913
+ )}
914
+ for binding in bindings
915
+ ]
916
+ if declared != comparable:
917
+ raise ValueError("job reference bindings do not match the selected source entry")
918
+
919
+
920
+ def _normalize_job(root: Path, raw: object) -> dict[str, Any]:
921
+ if not isinstance(raw, Mapping) or set(raw) - ALLOWED_JOB_KEYS:
922
+ raise ValueError("job contains unsupported fields")
923
+ if raw.get("schema_version", JOB_SCHEMA) != JOB_SCHEMA:
924
+ raise ValueError("unsupported job schema")
925
+ job_id = raw.get("job_id")
926
+ if not isinstance(job_id, str) or JOB_ID_RE.fullmatch(job_id) is None:
927
+ raise ValueError("job_id must be a portable 1-80 character identifier")
928
+ modality = raw.get("modality")
929
+ if modality not in MEDIA_EXTENSIONS:
930
+ raise ValueError("modality must be image, video, tts, or music")
931
+ adapter = raw.get("adapter")
932
+ if not isinstance(adapter, str) or JOB_ID_RE.fullmatch(adapter) is None:
933
+ raise ValueError("adapter must be a portable profile name")
934
+ prompt = raw.get("prompt")
935
+ if not isinstance(prompt, str) or not prompt.strip() or len(prompt) > 100_000:
936
+ raise ValueError("prompt must be non-empty and at most 100000 characters")
937
+ source_raw = raw.get("source")
938
+ source = _relative_path(source_raw) if source_raw is not None else None
939
+ source_entry = raw.get("source_entry")
940
+ if source_entry is not None and (
941
+ not isinstance(source_entry, str)
942
+ or SOURCE_ENTRY_RE.fullmatch(source_entry) is None
943
+ ):
944
+ raise ValueError("source_entry must be a visible uppercase Markdown entry ID")
945
+ if source_entry is not None and source is None:
946
+ raise ValueError("source_entry requires source")
947
+ creator_source_modality = _canonical_creator_source_modality(source)
948
+ if creator_source_modality == modality and source_entry is None:
949
+ raise ValueError("creator Markdown source requires source_entry")
950
+ if creator_source_modality is not None and modality != creator_source_modality and not (
951
+ creator_source_modality == "video"
952
+ and modality == "music"
953
+ and source_entry is None
954
+ ):
955
+ raise ValueError("creator Markdown source does not match the job modality")
956
+ expected_entry_prefix = (
957
+ CREATOR_SOURCE_ENTRIES[PurePosixPath(source).name][0]
958
+ if source is not None
959
+ and PurePosixPath(source).name in CREATOR_SOURCE_ENTRIES
960
+ and creator_source_modality == modality
961
+ else None
962
+ )
963
+ if source_entry is not None and creator_source_modality != modality:
964
+ raise ValueError("source_entry requires the canonical creator Markdown path")
965
+ if source_entry is not None and (
966
+ expected_entry_prefix is None
967
+ or not source_entry.startswith(expected_entry_prefix)
968
+ ):
969
+ raise ValueError("source_entry does not match the job modality")
970
+ references_supplied = "references" in raw
971
+ supplied_references = [
972
+ _relative_path(path)
973
+ for path in _string_list(raw.get("references", []), label="references", limit=16)
974
+ ]
975
+ reference_bindings = _normalize_reference_bindings(raw.get("reference_bindings"))
976
+ binding_references = [str(binding["path"]) for binding in reference_bindings]
977
+ if (
978
+ references_supplied
979
+ and (reference_bindings or source_entry is not None)
980
+ and supplied_references != binding_references
981
+ ):
982
+ raise ValueError("references must match reference_bindings order")
983
+ references = binding_references if reference_bindings else supplied_references
984
+ if source_entry is not None:
985
+ _verify_markdown_source(
986
+ root,
987
+ source=str(source),
988
+ source_entry=source_entry,
989
+ prompt=prompt,
990
+ bindings=reference_bindings,
991
+ )
992
+ outputs = [
993
+ _relative_path(path, output=True)
994
+ for path in _string_list(raw.get("outputs"), label="outputs", limit=16)
995
+ ]
996
+ if not outputs or len(set(outputs)) != len(outputs):
997
+ raise ValueError("outputs must contain unique target paths")
998
+ for output_path in outputs:
999
+ if PurePosixPath(output_path).suffix.casefold() not in MEDIA_EXTENSIONS[str(modality)]:
1000
+ raise ValueError(f"output extension does not match {modality}: {output_path}")
1001
+ _project_file(root, output_path)
1002
+ parameters = raw.get("parameters", {})
1003
+ if not isinstance(parameters, Mapping):
1004
+ raise ValueError("parameters must be an object")
1005
+ if _contains_secret_key(parameters):
1006
+ raise ValueError("job parameters must not contain credentials or secrets")
1007
+ if len(_canonical(parameters)) > 64 * 1024:
1008
+ raise ValueError("job parameters are too large")
1009
+ input_paths = ([source] if source is not None else []) + references
1010
+ if len(set(input_paths)) != len(input_paths):
1011
+ raise ValueError("source and references must be unique")
1012
+ input_hashes = {path: _hash_project_file(root, path) for path in input_paths}
1013
+ execution = {
1014
+ "schema_version": JOB_SCHEMA,
1015
+ "job_id": job_id,
1016
+ "modality": modality,
1017
+ "adapter": adapter,
1018
+ "prompt": prompt,
1019
+ "source": source,
1020
+ "source_entry": source_entry,
1021
+ "references": references,
1022
+ "reference_bindings": reference_bindings,
1023
+ "outputs": outputs,
1024
+ "parameters": dict(parameters),
1025
+ "overwrite": raw.get("overwrite", False),
1026
+ "inputs": input_hashes,
1027
+ }
1028
+ if not isinstance(execution["overwrite"], bool):
1029
+ raise ValueError("overwrite must be a boolean")
1030
+ execution["fingerprint"] = sha256_bytes(_canonical(execution))
1031
+ execution["prepared_at"] = utc_now()
1032
+ return execution
1033
+
1034
+
1035
+ def prepare_job(root: Path, job_file: Path) -> dict[str, Any]:
1036
+ root = find_project(root)
1037
+ if job_file.stat().st_size > MAX_JOB_BYTES:
1038
+ raise ValueError("job file is too large")
1039
+ raw = json.loads(job_file.read_text(encoding="utf-8"))
1040
+ job = _normalize_job(root, raw)
1041
+ with _project_lock(root):
1042
+ if _active_run(root, str(job["job_id"])) is not None:
1043
+ raise RuntimeError("this job is already running")
1044
+ job_name = f"{_job_key(str(job['job_id']))}.json"
1045
+ _metadata_atomic_json(root, ("jobs",), job_name, job)
1046
+ try:
1047
+ _metadata_unlink(root, ("confirmations",), job_name)
1048
+ except FileNotFoundError:
1049
+ pass
1050
+ return _preview(job)
1051
+
1052
+
1053
+ def _validate_stored_job(
1054
+ root: Path, document: object, *, expected_job_id: str | None = None
1055
+ ) -> dict[str, Any]:
1056
+ root = root.resolve()
1057
+ if not isinstance(document, dict):
1058
+ raise ValueError("stored job fields are invalid")
1059
+ stored_keys = set(document)
1060
+ legacy = stored_keys == LEGACY_STORED_JOB_KEYS
1061
+ if not legacy and stored_keys != STORED_JOB_KEYS:
1062
+ raise ValueError("stored job fields are invalid")
1063
+ original = document
1064
+ document = dict(document)
1065
+ if legacy:
1066
+ document["source_entry"] = None
1067
+ document["reference_bindings"] = []
1068
+ job_id = document.get("job_id")
1069
+ if (
1070
+ not isinstance(job_id, str)
1071
+ or JOB_ID_RE.fullmatch(job_id) is None
1072
+ or (expected_job_id is not None and job_id != expected_job_id)
1073
+ ):
1074
+ raise ValueError("stored job id is invalid")
1075
+ if document.get("schema_version") != JOB_SCHEMA:
1076
+ raise ValueError("stored job schema is invalid")
1077
+ modality = document.get("modality")
1078
+ if modality not in MEDIA_EXTENSIONS:
1079
+ raise ValueError("stored job modality is invalid")
1080
+ adapter = document.get("adapter")
1081
+ if not isinstance(adapter, str) or JOB_ID_RE.fullmatch(adapter) is None:
1082
+ raise ValueError("stored job adapter is invalid")
1083
+ prompt = document.get("prompt")
1084
+ if not isinstance(prompt, str) or not prompt.strip() or len(prompt) > 100_000:
1085
+ raise ValueError("stored job prompt is invalid")
1086
+ source = document.get("source")
1087
+ if source is not None and _relative_path(source) != source:
1088
+ raise ValueError("stored job source is invalid")
1089
+ source_entry = document.get("source_entry")
1090
+ if source_entry is not None and (
1091
+ not isinstance(source_entry, str)
1092
+ or SOURCE_ENTRY_RE.fullmatch(source_entry) is None
1093
+ or source is None
1094
+ ):
1095
+ raise ValueError("stored job source entry is invalid")
1096
+ expected_entry_prefix = {"image": "IMG-", "video": "MOTION-"}.get(
1097
+ str(modality)
1098
+ )
1099
+ if source_entry is not None and (
1100
+ expected_entry_prefix is None
1101
+ or not source_entry.startswith(expected_entry_prefix)
1102
+ ):
1103
+ raise ValueError("stored job source entry has the wrong modality")
1104
+ creator_source_modality = _canonical_creator_source_modality(
1105
+ str(source) if source is not None else None
1106
+ )
1107
+ if not legacy and creator_source_modality == modality and source_entry is None:
1108
+ raise ValueError("stored creator Markdown job has no source entry")
1109
+ if creator_source_modality is not None and modality != creator_source_modality and not (
1110
+ creator_source_modality == "video"
1111
+ and modality == "music"
1112
+ and source_entry is None
1113
+ ):
1114
+ raise ValueError("stored creator Markdown source has the wrong modality")
1115
+ if source_entry is not None and creator_source_modality != modality:
1116
+ raise ValueError("stored job creator source path is invalid")
1117
+ references = _string_list(
1118
+ document.get("references"), label="stored references", limit=16
1119
+ )
1120
+ if any(_relative_path(reference) != reference for reference in references):
1121
+ raise ValueError("stored job references are invalid")
1122
+ reference_bindings = _normalize_reference_bindings(
1123
+ document.get("reference_bindings")
1124
+ )
1125
+ binding_references = [binding["path"] for binding in reference_bindings]
1126
+ if (
1127
+ source_entry is not None or reference_bindings
1128
+ ) and binding_references != references:
1129
+ raise ValueError("stored job references do not match reference bindings")
1130
+ outputs = _string_list(document.get("outputs"), label="stored outputs", limit=16)
1131
+ if not outputs or len(outputs) != len(set(outputs)):
1132
+ raise ValueError("stored job outputs are invalid")
1133
+ for output in outputs:
1134
+ if _relative_path(output, output=True) != output:
1135
+ raise ValueError("stored job output is invalid")
1136
+ if PurePosixPath(output).suffix.casefold() not in MEDIA_EXTENSIONS[str(modality)]:
1137
+ raise ValueError("stored job output extension is invalid")
1138
+ _project_file(root, output)
1139
+ parameters = document.get("parameters")
1140
+ if (
1141
+ not isinstance(parameters, Mapping)
1142
+ or _contains_secret_key(parameters)
1143
+ or len(_canonical(parameters)) > 64 * 1024
1144
+ ):
1145
+ raise ValueError("stored job parameters are invalid")
1146
+ if not isinstance(document.get("overwrite"), bool):
1147
+ raise ValueError("stored job overwrite flag is invalid")
1148
+ input_paths = ([source] if source is not None else []) + references
1149
+ if len(input_paths) != len(set(input_paths)):
1150
+ raise ValueError("stored job inputs are duplicated")
1151
+ inputs = document.get("inputs")
1152
+ if not isinstance(inputs, Mapping) or set(inputs) != set(input_paths):
1153
+ raise ValueError("stored job input hashes are invalid")
1154
+ if any(
1155
+ not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None
1156
+ for digest in inputs.values()
1157
+ ):
1158
+ raise ValueError("stored job input hash is invalid")
1159
+ fingerprint = document.get("fingerprint")
1160
+ execution_keys = (
1161
+ LEGACY_STORED_EXECUTION_KEYS if legacy else STORED_EXECUTION_KEYS
1162
+ )
1163
+ execution = {key: original[key] for key in execution_keys}
1164
+ if (
1165
+ not isinstance(fingerprint, str)
1166
+ or re.fullmatch(r"[0-9a-f]{64}", fingerprint) is None
1167
+ or sha256_bytes(_canonical(execution)) != fingerprint
1168
+ ):
1169
+ raise ValueError("stored job fingerprint is invalid")
1170
+ _parse_run_timestamp(document.get("prepared_at"), label="prepared_at")
1171
+ return document
1172
+
1173
+
1174
+ def _read_job(root: Path, job_id: str) -> dict[str, Any]:
1175
+ if JOB_ID_RE.fullmatch(job_id) is None:
1176
+ raise ValueError("invalid job_id")
1177
+ document = _metadata_read_json(
1178
+ root,
1179
+ ("jobs",),
1180
+ f"{_job_key(job_id)}.json",
1181
+ maximum=MAX_JOB_BYTES,
1182
+ )
1183
+ return _validate_stored_job(root, document, expected_job_id=job_id)
1184
+
1185
+
1186
+ def _preview(job: Mapping[str, Any]) -> dict[str, Any]:
1187
+ confirmation = f"CONFIRM {job['job_id']} {str(job['fingerprint'])[:12]}"
1188
+ return {
1189
+ "job_id": job["job_id"],
1190
+ "modality": job["modality"],
1191
+ "adapter": job["adapter"],
1192
+ "count": len(job["outputs"]),
1193
+ "prompt": job["prompt"],
1194
+ "source": job["source"],
1195
+ "source_entry": job["source_entry"],
1196
+ "references": job["references"],
1197
+ "reference_bindings": job["reference_bindings"],
1198
+ "outputs": job["outputs"],
1199
+ "parameters": job["parameters"],
1200
+ "overwrite": job["overwrite"],
1201
+ "confirmation": confirmation,
1202
+ "state": "needs_confirmation",
1203
+ }
1204
+
1205
+
1206
+ def confirm_job(root: Path, *, job_id: str, confirmation: str) -> dict[str, Any]:
1207
+ root = find_project(root)
1208
+ with _project_lock(root):
1209
+ if _active_run(root, job_id) is not None:
1210
+ raise RuntimeError("this job is already running")
1211
+ job = _read_job(root, job_id)
1212
+ expected = _preview(job)["confirmation"]
1213
+ if confirmation != expected:
1214
+ raise ConfirmationRequiredError("confirmation does not match the exact current job")
1215
+ receipt = {
1216
+ "schema_version": JOB_SCHEMA,
1217
+ "job_id": job_id,
1218
+ "fingerprint": job["fingerprint"],
1219
+ "confirmed_at": utc_now(),
1220
+ "consumed_at": None,
1221
+ "run_id": None,
1222
+ }
1223
+ _metadata_atomic_json(
1224
+ root, ("confirmations",), f"{_job_key(job_id)}.json", receipt
1225
+ )
1226
+ return {"job_id": job_id, "state": "confirmed"}
1227
+
1228
+
1229
+ def _inputs_current(root: Path, job: Mapping[str, Any]) -> bool:
1230
+ inputs = job.get("inputs")
1231
+ if not isinstance(inputs, Mapping):
1232
+ return False
1233
+ try:
1234
+ return all(_hash_project_file(root, str(path)) == digest for path, digest in inputs.items())
1235
+ except (FileNotFoundError, OSError, ValueError):
1236
+ return False
1237
+
1238
+
1239
+ def _load_adapter(config_path: Path, profile: str, root: Path) -> tuple[list[str], int]:
1240
+ resolved = config_path.expanduser().resolve()
1241
+ if resolved.is_relative_to(root):
1242
+ raise ValueError("adapter config must live outside the project")
1243
+ document = json.loads(resolved.read_text(encoding="utf-8"))
1244
+ adapters = document.get("adapters") if isinstance(document, Mapping) else None
1245
+ selected = adapters.get(profile) if isinstance(adapters, Mapping) else None
1246
+ if not isinstance(selected, Mapping) or set(selected) - {"command", "timeout_seconds"}:
1247
+ raise ValueError(f"adapter profile is missing or invalid: {profile}")
1248
+ command = _string_list(selected.get("command"), label="adapter command", limit=32)
1249
+ if not command or any(not part for part in command):
1250
+ raise ValueError("adapter command must be a non-empty argv list")
1251
+ timeout = selected.get("timeout_seconds", 300)
1252
+ if not isinstance(timeout, int) or isinstance(timeout, bool) or not 1 <= timeout <= MAX_TIMEOUT_SECONDS:
1253
+ raise ValueError(f"adapter timeout must be 1-{MAX_TIMEOUT_SECONDS} seconds")
1254
+ return command, timeout
1255
+
1256
+
1257
+ def _generic_adapter_error(
1258
+ profile: str,
1259
+ *,
1260
+ category: str,
1261
+ code: str,
1262
+ retryable: bool,
1263
+ ) -> dict[str, Any]:
1264
+ return {
1265
+ "provider": profile,
1266
+ "category": category,
1267
+ "code": code,
1268
+ "retryable": retryable,
1269
+ }
1270
+
1271
+
1272
+ def _parse_public_adapter_error(
1273
+ raw: bytes, *, profile: str, returncode: int
1274
+ ) -> dict[str, Any]:
1275
+ fallback = _generic_adapter_error(
1276
+ profile,
1277
+ category="provider_response",
1278
+ code=f"adapter_exit_{returncode}",
1279
+ retryable=False,
1280
+ )
1281
+ if len(raw) > MAX_ADAPTER_RESPONSE_BYTES:
1282
+ return fallback
1283
+ try:
1284
+ document = json.loads(raw.decode("utf-8"))
1285
+ except (UnicodeError, json.JSONDecodeError):
1286
+ return fallback
1287
+ error = document.get("error") if isinstance(document, Mapping) else None
1288
+ if not isinstance(error, Mapping) or set(error) - {
1289
+ "provider",
1290
+ "category",
1291
+ "code",
1292
+ "http_status",
1293
+ "request_id",
1294
+ "retryable",
1295
+ }:
1296
+ return fallback
1297
+ provider = error.get("provider")
1298
+ category = error.get("category")
1299
+ code = error.get("code")
1300
+ retryable = error.get("retryable")
1301
+ if (
1302
+ provider != profile
1303
+ or category not in PUBLIC_ERROR_CATEGORIES
1304
+ or not isinstance(code, str)
1305
+ or PUBLIC_ERROR_TOKEN_RE.fullmatch(code) is None
1306
+ or not isinstance(retryable, bool)
1307
+ ):
1308
+ return fallback
1309
+ result: dict[str, Any] = {
1310
+ "provider": provider,
1311
+ "category": category,
1312
+ "code": code,
1313
+ "retryable": retryable,
1314
+ }
1315
+ status = error.get("http_status")
1316
+ if status is not None:
1317
+ if not isinstance(status, int) or isinstance(status, bool) or not 100 <= status <= 599:
1318
+ return fallback
1319
+ result["http_status"] = status
1320
+ request_id = error.get("request_id")
1321
+ if request_id is not None:
1322
+ if (
1323
+ not isinstance(request_id, str)
1324
+ or PUBLIC_ERROR_TOKEN_RE.fullmatch(request_id) is None
1325
+ ):
1326
+ return fallback
1327
+ result["request_id"] = request_id
1328
+ return result
1329
+
1330
+
1331
+ def _run_adapter(command: list[str], timeout: int, payload: Mapping[str, Any], root: Path) -> dict[str, Any]:
1332
+ with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr:
1333
+ try:
1334
+ completed = subprocess.run(
1335
+ command,
1336
+ input=_canonical(payload),
1337
+ stdout=stdout,
1338
+ stderr=stderr,
1339
+ cwd=root,
1340
+ timeout=timeout,
1341
+ check=False,
1342
+ )
1343
+ except subprocess.TimeoutExpired as exc:
1344
+ raise AdapterError(
1345
+ "adapter timed out; confirmation was consumed",
1346
+ public_error=_generic_adapter_error(
1347
+ str(payload["adapter"]),
1348
+ category="timeout",
1349
+ code="adapter_timeout",
1350
+ retryable=True,
1351
+ ),
1352
+ ) from exc
1353
+ except OSError as exc:
1354
+ raise AdapterError(
1355
+ "adapter could not be started; confirmation was consumed",
1356
+ public_error=_generic_adapter_error(
1357
+ str(payload["adapter"]),
1358
+ category="configuration",
1359
+ code="adapter_start_failed",
1360
+ retryable=False,
1361
+ ),
1362
+ ) from exc
1363
+ if completed.returncode != 0:
1364
+ size = stdout.tell()
1365
+ stdout.seek(0)
1366
+ raw_error = stdout.read(MAX_ADAPTER_RESPONSE_BYTES + 1)
1367
+ public_error = _parse_public_adapter_error(
1368
+ raw_error if size <= MAX_ADAPTER_RESPONSE_BYTES else b"",
1369
+ profile=str(payload["adapter"]),
1370
+ returncode=completed.returncode,
1371
+ )
1372
+ raise AdapterError(
1373
+ f"adapter exited with code {completed.returncode}; confirmation was consumed",
1374
+ public_error=public_error,
1375
+ )
1376
+ size = stdout.tell()
1377
+ if size > MAX_ADAPTER_RESPONSE_BYTES:
1378
+ raise AdapterError("adapter response is too large; confirmation was consumed")
1379
+ stdout.seek(0)
1380
+ try:
1381
+ response = json.loads(stdout.read().decode("utf-8"))
1382
+ except (UnicodeError, json.JSONDecodeError) as exc:
1383
+ raise AdapterError("adapter returned invalid JSON; confirmation was consumed") from exc
1384
+ if not isinstance(response, dict):
1385
+ raise AdapterError("adapter response must be an object; confirmation was consumed")
1386
+ return response
1387
+
1388
+
1389
+ def _validate_adapter_outputs(
1390
+ job: Mapping[str, Any], response: Mapping[str, Any], output_root: Path
1391
+ ) -> list[tuple[str, Path]]:
1392
+ entries = response.get("outputs")
1393
+ if not isinstance(entries, list) or not all(isinstance(entry, Mapping) for entry in entries):
1394
+ raise AdapterError("adapter outputs are invalid; confirmation was consumed")
1395
+ result: list[tuple[str, Path]] = []
1396
+ for entry in entries:
1397
+ if set(entry) != {"target", "source"}:
1398
+ raise AdapterError("adapter output fields are invalid; confirmation was consumed")
1399
+ target = entry.get("target")
1400
+ source = entry.get("source")
1401
+ if not isinstance(target, str) or not isinstance(source, str):
1402
+ raise AdapterError("adapter output paths are invalid; confirmation was consumed")
1403
+ path = Path(source)
1404
+ if not path.is_absolute() or path.parent != output_root or path.name in {"", ".", ".."}:
1405
+ raise AdapterError(
1406
+ "adapter output must use the run staging directory; confirmation was consumed"
1407
+ )
1408
+ result.append((target, path))
1409
+ expected = list(job["outputs"])
1410
+ if [target for target, _ in result] != expected:
1411
+ raise AdapterError("adapter outputs do not match the confirmed targets; confirmation was consumed")
1412
+ return result
1413
+
1414
+
1415
+ def _copy_output(
1416
+ source: Path, target: Path, *, overwrite: bool
1417
+ ) -> tuple[str, int]:
1418
+ temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp")
1419
+ target.parent.mkdir(parents=True, exist_ok=True)
1420
+ digest = hashlib.sha256()
1421
+ size = 0
1422
+ descriptor = -1
1423
+ try:
1424
+ try:
1425
+ before = source.lstat()
1426
+ except OSError as exc:
1427
+ raise AdapterError(
1428
+ "adapter output file is missing; confirmation was consumed"
1429
+ ) from exc
1430
+ if _is_link_or_reparse(before) or not stat.S_ISREG(before.st_mode):
1431
+ raise AdapterError("adapter output file is unsafe; confirmation was consumed")
1432
+ descriptor = os.open(source, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
1433
+ opened = os.fstat(descriptor)
1434
+ if (
1435
+ not stat.S_ISREG(opened.st_mode)
1436
+ or (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino)
1437
+ ):
1438
+ raise AdapterError("adapter output changed while opening; confirmation was consumed")
1439
+ if opened.st_size > MAX_OUTPUT_BYTES:
1440
+ raise AdapterError("adapter output file is too large; confirmation was consumed")
1441
+ with os.fdopen(descriptor, "rb", closefd=True) as incoming, temporary.open(
1442
+ "xb"
1443
+ ) as outgoing:
1444
+ descriptor = -1
1445
+ for chunk in iter(lambda: incoming.read(1024 * 1024), b""):
1446
+ size += len(chunk)
1447
+ if size > MAX_OUTPUT_BYTES:
1448
+ raise AdapterError("adapter output exceeded the size limit")
1449
+ digest.update(chunk)
1450
+ outgoing.write(chunk)
1451
+ outgoing.flush()
1452
+ os.fsync(outgoing.fileno())
1453
+ if overwrite:
1454
+ os.replace(temporary, target)
1455
+ else:
1456
+ try:
1457
+ os.link(temporary, target, follow_symlinks=False)
1458
+ except FileExistsError as exc:
1459
+ raise FileExistsError(
1460
+ f"output appeared while production was running: {target.name}"
1461
+ ) from exc
1462
+ temporary.unlink()
1463
+ finally:
1464
+ if descriptor >= 0:
1465
+ os.close(descriptor)
1466
+ try:
1467
+ temporary.unlink()
1468
+ except FileNotFoundError:
1469
+ pass
1470
+ return digest.hexdigest(), size
1471
+
1472
+
1473
+ def _latest_run(root: Path, job_id: str) -> dict[str, Any] | None:
1474
+ history = _read_run_history(root, job_id)
1475
+ return history[-1] if history else None
1476
+
1477
+
1478
+ def _parse_run_timestamp(value: object, *, label: str) -> datetime:
1479
+ if not isinstance(value, str) or not value:
1480
+ raise ValueError(f"production run {label} is invalid")
1481
+ normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
1482
+ try:
1483
+ parsed = datetime.fromisoformat(normalized)
1484
+ except ValueError as exc:
1485
+ raise ValueError(f"production run {label} is invalid") from exc
1486
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
1487
+ raise ValueError(f"production run {label} is invalid")
1488
+ return parsed.astimezone(timezone.utc)
1489
+
1490
+
1491
+ def _completed_run_order(run: Mapping[str, Any]) -> tuple[datetime, str]:
1492
+ return (
1493
+ _parse_run_timestamp(run.get("finished_at"), label="finished_at"),
1494
+ str(run["run_id"]),
1495
+ )
1496
+
1497
+
1498
+ def _read_run_history(root: Path, job_id: str) -> list[dict[str, Any]]:
1499
+ directory_parts = ("runs", _job_key(job_id))
1500
+ history: list[dict[str, Any]] = []
1501
+ for name in _metadata_json_names(root, directory_parts):
1502
+ document = _metadata_read_json(
1503
+ root, directory_parts, name, maximum=MAX_RUN_RECORD_BYTES
1504
+ )
1505
+ if (
1506
+ not isinstance(document, dict)
1507
+ or document.get("job_id") != job_id
1508
+ or not isinstance(document.get("run_id"), str)
1509
+ or document.get("status") not in {"running", "succeeded", "failed"}
1510
+ or not isinstance(document.get("fingerprint"), str)
1511
+ or re.fullmatch(r"[0-9a-f]{64}", str(document.get("fingerprint"))) is None
1512
+ ):
1513
+ raise ValueError("production run record is invalid")
1514
+ started_at = _parse_run_timestamp(
1515
+ document.get("started_at"), label="started_at"
1516
+ )
1517
+ finished_at = document.get("finished_at")
1518
+ if document["status"] == "running":
1519
+ if finished_at is not None:
1520
+ raise ValueError("running production run has finished_at")
1521
+ else:
1522
+ completed_at = _parse_run_timestamp(finished_at, label="finished_at")
1523
+ if completed_at < started_at:
1524
+ raise ValueError("production run finished before it started")
1525
+ history.append(document)
1526
+ # Completed runs are ordered by completion because that is when their
1527
+ # terminal state and output claim become authoritative. Any unresolved
1528
+ # running attempt sorts last so status cannot hide it behind a later start.
1529
+ history.sort(
1530
+ key=lambda run: (
1531
+ run["status"] == "running",
1532
+ (
1533
+ _parse_run_timestamp(run.get("started_at"), label="started_at")
1534
+ if run["status"] == "running"
1535
+ else _completed_run_order(run)[0]
1536
+ ),
1537
+ str(run["run_id"]),
1538
+ )
1539
+ )
1540
+ return history
1541
+
1542
+
1543
+ def _active_run(root: Path, job_id: str) -> dict[str, Any] | None:
1544
+ running = [
1545
+ run for run in _read_run_history(root, job_id) if run["status"] == "running"
1546
+ ]
1547
+ return running[-1] if running else None
1548
+
1549
+
1550
+ def _read_provider_handle(root: Path, job_id: str, run_id: str) -> str | None:
1551
+ """The provider job id an adapter recorded before it started polling.
1552
+
1553
+ A video task is billed the moment it is submitted, not when it is collected.
1554
+ If the adapter is then interrupted — killed, disconnected, the machine
1555
+ sleeps — the run record would otherwise say `running` with no way back to a
1556
+ task that is alive and already paid for, and the only move left is to submit
1557
+ (and pay) again. The adapter writes the id here as soon as it has one, into
1558
+ project metadata rather than the attempt's temporary directory, so it
1559
+ outlives the process that created it.
1560
+ """
1561
+
1562
+ try:
1563
+ document = _metadata_read_json(
1564
+ root, ("handles", _job_key(job_id)), f"{run_id}.json",
1565
+ maximum=MAX_RUN_RECORD_BYTES,
1566
+ )
1567
+ except (FileNotFoundError, ValueError, json.JSONDecodeError):
1568
+ return None
1569
+ if not isinstance(document, Mapping):
1570
+ return None
1571
+ value = document.get("provider_job_id")
1572
+ if isinstance(value, str) and 0 < len(value) <= 200:
1573
+ return value
1574
+ return None
1575
+
1576
+
1577
+ def _handle_metadata_path(root: Path, job_id: str, run_id: str) -> Path:
1578
+ return root / PRODUCTION_ROOT / "handles" / _job_key(job_id) / f"{run_id}.json"
1579
+
1580
+
1581
+ def _write_run(root: Path, job_id: str, run: Mapping[str, Any]) -> None:
1582
+ _metadata_atomic_json(
1583
+ root,
1584
+ ("runs", _job_key(job_id)),
1585
+ f"{run['run_id']}.json",
1586
+ run,
1587
+ )
1588
+
1589
+
1590
+ def run_job(root: Path, *, job_id: str, adapter_config: Path) -> dict[str, Any]:
1591
+ root = find_project(root)
1592
+ with tempfile.TemporaryDirectory(prefix="short-drama-inputs-") as directory:
1593
+ attempt_root = Path(directory)
1594
+ snapshot_root = attempt_root / "inputs"
1595
+ output_root = attempt_root / "outputs"
1596
+ snapshot_root.mkdir()
1597
+ output_root.mkdir()
1598
+ with _project_lock(root):
1599
+ if _active_run(root, job_id) is not None:
1600
+ raise RuntimeError("this job is already running")
1601
+ job = _read_job(root, job_id)
1602
+ command, timeout = _load_adapter(adapter_config, str(job["adapter"]), root)
1603
+ try:
1604
+ receipt = _metadata_read_json(
1605
+ root,
1606
+ ("confirmations",),
1607
+ f"{_job_key(job_id)}.json",
1608
+ maximum=MAX_RUN_RECORD_BYTES,
1609
+ )
1610
+ except FileNotFoundError as exc:
1611
+ raise ConfirmationRequiredError("job needs explicit confirmation") from exc
1612
+ if (
1613
+ not isinstance(receipt, dict)
1614
+ or receipt.get("fingerprint") != job.get("fingerprint")
1615
+ or receipt.get("consumed_at") is not None
1616
+ ):
1617
+ raise ConfirmationRequiredError("job needs a new explicit confirmation")
1618
+ for output in job["outputs"]:
1619
+ target = _project_file(root, output)
1620
+ if target.exists() and not job["overwrite"]:
1621
+ raise FileExistsError(f"output exists and overwrite is false: {output}")
1622
+ # Pin every confirmed input into a private immutable snapshot before
1623
+ # consuming confirmation. Provider adapters never reopen live project paths.
1624
+ _snapshot_inputs(root, job, snapshot_root)
1625
+ run_id = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}-{uuid.uuid4().hex[:8]}"
1626
+ receipt["consumed_at"] = utc_now()
1627
+ receipt["run_id"] = run_id
1628
+ _metadata_atomic_json(
1629
+ root,
1630
+ ("confirmations",),
1631
+ f"{_job_key(job_id)}.json",
1632
+ receipt,
1633
+ )
1634
+ run = {
1635
+ "schema_version": JOB_SCHEMA,
1636
+ "run_id": run_id,
1637
+ "job_id": job_id,
1638
+ "fingerprint": job["fingerprint"],
1639
+ "modality": job["modality"],
1640
+ "adapter": job["adapter"],
1641
+ "status": "running",
1642
+ "started_at": utc_now(),
1643
+ "finished_at": None,
1644
+ "outputs": [],
1645
+ }
1646
+ _write_run(root, job_id, run)
1647
+
1648
+ payload = {key: job[key] for key in ALLOWED_JOB_KEYS if key in job}
1649
+ payload.update(
1650
+ {
1651
+ "run_id": run_id,
1652
+ "project_root": str(snapshot_root),
1653
+ "output_root": str(output_root),
1654
+ # Where to record the provider job id the instant it exists, so
1655
+ # an interrupted attempt can be collected instead of re-billed.
1656
+ "handle_path": str(_handle_metadata_path(root, job_id, run_id)),
1657
+ }
1658
+ )
1659
+ try:
1660
+ response = _run_adapter(command, timeout, payload, root)
1661
+ adapter_outputs = _validate_adapter_outputs(job, response, output_root)
1662
+ written: list[dict[str, Any]] = []
1663
+ with _project_lock(root):
1664
+ for target_name, source in adapter_outputs:
1665
+ target = _project_file(root, target_name, create_parent=True)
1666
+ digest, size = _copy_output(
1667
+ source, target, overwrite=bool(job["overwrite"])
1668
+ )
1669
+ written.append(
1670
+ {
1671
+ "path": target_name,
1672
+ "media_type": MEDIA_TYPES[PurePosixPath(target_name).suffix.casefold()],
1673
+ "bytes": size,
1674
+ "sha256": digest,
1675
+ }
1676
+ )
1677
+ if _run_status(root, job_id, run_id) == "succeeded":
1678
+ # `collect` reached the same provider task first and its
1679
+ # outputs are already in the project. Leave that record
1680
+ # alone rather than replacing a collected attempt.
1681
+ raise RuntimeError(
1682
+ "这次尝试已经被 collect 取回并标为成功;本次运行不覆盖它"
1683
+ )
1684
+ run["status"] = "succeeded"
1685
+ run["finished_at"] = utc_now()
1686
+ run["outputs"] = written
1687
+ provider_job_id = response.get("provider_job_id") or _read_provider_handle(
1688
+ root, job_id, run_id
1689
+ )
1690
+ if isinstance(provider_job_id, str) and 0 < len(provider_job_id) <= 200:
1691
+ run["provider_job_id"] = provider_job_id
1692
+ _write_run(root, job_id, run)
1693
+ except Exception as exc:
1694
+ with _project_lock(root):
1695
+ run["status"] = "failed"
1696
+ run["finished_at"] = utc_now()
1697
+ if isinstance(exc, AdapterError) and exc.public_error is not None:
1698
+ run["error"] = exc.public_error
1699
+ # A timeout or a killed adapter does not cancel the provider's
1700
+ # task. Carry the handle onto the failed record so `audit` can
1701
+ # say "collect this" instead of the creator paying twice.
1702
+ orphan = _read_provider_handle(root, job_id, run_id)
1703
+ if orphan is not None:
1704
+ run["provider_job_id"] = orphan
1705
+ _write_run(root, job_id, run)
1706
+ raise
1707
+ return {
1708
+ "job_id": job_id,
1709
+ "run_id": run_id,
1710
+ "state": "succeeded",
1711
+ "outputs": run["outputs"],
1712
+ }
1713
+
1714
+
1715
+ def _run_status(root: Path, job_id: str, run_id: str) -> str | None:
1716
+ """The status one attempt currently has on disk, re-read rather than cached."""
1717
+
1718
+ for run in _read_run_history(root, job_id):
1719
+ if str(run.get("run_id")) == run_id:
1720
+ status = run.get("status")
1721
+ return status if isinstance(status, str) else None
1722
+ return None
1723
+
1724
+
1725
+ def collect_job(root: Path, *, job_id: str, adapter_config: Path) -> dict[str, Any]:
1726
+ """Collect an already-submitted task's result without submitting a new one.
1727
+
1728
+ This is deliberately outside the confirmation gate. The gate exists to stop
1729
+ an unintended charge; collecting costs nothing, because the charge already
1730
+ happened when the task was submitted. Requiring a fresh confirmation here
1731
+ would mean the cheapest way out of an interrupted attempt is to pay again,
1732
+ which is exactly the outcome the gate is meant to prevent.
1733
+ """
1734
+
1735
+ root = find_project(root)
1736
+ with tempfile.TemporaryDirectory(prefix="short-drama-collect-") as directory:
1737
+ output_root = Path(directory) / "outputs"
1738
+ output_root.mkdir(parents=True)
1739
+ with _project_lock(root):
1740
+ job = _read_job(root, job_id)
1741
+ command, timeout = _load_adapter(adapter_config, str(job["adapter"]), root)
1742
+ target_run = None
1743
+ for run in reversed(_read_run_history(root, job_id)):
1744
+ if run.get("status") == "succeeded":
1745
+ continue
1746
+ handle = run.get("provider_job_id") or _read_provider_handle(
1747
+ root, job_id, str(run["run_id"])
1748
+ )
1749
+ if handle:
1750
+ target_run = (run, handle)
1751
+ break
1752
+ if target_run is None:
1753
+ raise RuntimeError(
1754
+ "no unfinished attempt of this job carries a provider job id; "
1755
+ "there is nothing to collect"
1756
+ )
1757
+ run, provider_job_id = target_run
1758
+
1759
+ payload = {key: job[key] for key in ALLOWED_JOB_KEYS if key in job}
1760
+ payload.update(
1761
+ {
1762
+ "run_id": str(run["run_id"]),
1763
+ "output_root": str(output_root),
1764
+ "collect_provider_job_id": provider_job_id,
1765
+ }
1766
+ )
1767
+ response = _run_adapter(command, timeout, payload, root)
1768
+ adapter_outputs = _validate_adapter_outputs(job, response, output_root)
1769
+ written: list[dict[str, Any]] = []
1770
+ with _project_lock(root):
1771
+ # A live `run_job` on the same provider task is indistinguishable
1772
+ # from a dead one — both sit at `running`. Re-read before writing,
1773
+ # or two writers race onto the same bytes and one record is lost.
1774
+ if _run_status(root, job_id, str(run["run_id"])) == "succeeded":
1775
+ raise RuntimeError(
1776
+ "这次尝试在取回期间已经自己完成了,产物已经落进项目;"
1777
+ "没有覆盖它——先看 status,确认无误就不需要再做别的"
1778
+ )
1779
+ for target_name, source in adapter_outputs:
1780
+ target = _project_file(root, target_name, create_parent=True)
1781
+ digest, size = _copy_output(
1782
+ source, target, overwrite=bool(job["overwrite"])
1783
+ )
1784
+ written.append(
1785
+ {
1786
+ "path": target_name,
1787
+ "media_type": MEDIA_TYPES[PurePosixPath(target_name).suffix.casefold()],
1788
+ "bytes": size,
1789
+ "sha256": digest,
1790
+ }
1791
+ )
1792
+ collected = dict(run)
1793
+ collected["status"] = "succeeded"
1794
+ collected["finished_at"] = utc_now()
1795
+ collected["outputs"] = written
1796
+ collected["provider_job_id"] = provider_job_id
1797
+ collected["collected"] = True
1798
+ collected.pop("error", None)
1799
+ _write_run(root, job_id, collected)
1800
+ return {
1801
+ "job_id": job_id,
1802
+ "run_id": str(run["run_id"]),
1803
+ "state": "succeeded",
1804
+ "collected": True,
1805
+ "provider_job_id": provider_job_id,
1806
+ "outputs": written,
1807
+ }
1808
+
1809
+
1810
+ def job_status(root: Path, *, job_id: str) -> dict[str, Any]:
1811
+ root = find_project(root)
1812
+ job = _read_job(root, job_id)
1813
+ latest = _latest_run(root, job_id)
1814
+ if not _inputs_current(root, job):
1815
+ state = "needs_reconfirmation"
1816
+ else:
1817
+ try:
1818
+ receipt = _metadata_read_json(
1819
+ root,
1820
+ ("confirmations",),
1821
+ f"{_job_key(job_id)}.json",
1822
+ maximum=MAX_RUN_RECORD_BYTES,
1823
+ )
1824
+ except FileNotFoundError:
1825
+ receipt = None
1826
+ if latest and latest.get("status") == "running":
1827
+ state = "running"
1828
+ elif latest and latest.get("status") in {"succeeded", "failed"}:
1829
+ state = str(latest["status"])
1830
+ elif isinstance(receipt, Mapping) and receipt.get("consumed_at") is None:
1831
+ state = "confirmed"
1832
+ else:
1833
+ state = "needs_confirmation"
1834
+ return {
1835
+ "job_id": job_id,
1836
+ "modality": job["modality"],
1837
+ "adapter": job["adapter"],
1838
+ "outputs": job["outputs"],
1839
+ "state": state,
1840
+ "latest_run": latest,
1841
+ }
1842
+
1843
+
1844
+ def _hash_production_output(root: Path, relative: str) -> tuple[str, int]:
1845
+ digest = hashlib.sha256()
1846
+ size = 0
1847
+ with _open_project_input(root, relative) as handle:
1848
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
1849
+ size += len(chunk)
1850
+ if size > MAX_OUTPUT_BYTES:
1851
+ raise ValueError("production output exceeds the size limit")
1852
+ digest.update(chunk)
1853
+ return digest.hexdigest(), size
1854
+
1855
+
1856
+ def _validated_succeeded_outputs(
1857
+ job: Mapping[str, Any], run: Mapping[str, Any]
1858
+ ) -> list[tuple[str, str, int]]:
1859
+ expected = job.get("outputs")
1860
+ entries = run.get("outputs")
1861
+ if (
1862
+ not isinstance(expected, list)
1863
+ or not isinstance(entries, list)
1864
+ or len(entries) != len(expected)
1865
+ ):
1866
+ raise ValueError("succeeded outputs do not match the current job")
1867
+ validated: list[tuple[str, str, int]] = []
1868
+ # ``zip(strict=True)`` needs 3.10; the suite's floor is 3.9, so the length
1869
+ # agreement is asserted directly rather than by the zip flag.
1870
+ if len(expected) != len(entries):
1871
+ raise ValueError("expected %d outputs, got %d" % (len(expected), len(entries)))
1872
+ for expected_path, output in zip(expected, entries):
1873
+ if not isinstance(output, Mapping) or set(output) != {
1874
+ "path",
1875
+ "media_type",
1876
+ "bytes",
1877
+ "sha256",
1878
+ }:
1879
+ raise ValueError("succeeded output record fields are invalid")
1880
+ output_path = output.get("path")
1881
+ digest = output.get("sha256")
1882
+ size = output.get("bytes")
1883
+ media_type = output.get("media_type")
1884
+ if (
1885
+ not isinstance(expected_path, str)
1886
+ or output_path != expected_path
1887
+ or not isinstance(digest, str)
1888
+ or re.fullmatch(r"[0-9a-f]{64}", digest) is None
1889
+ or not isinstance(size, int)
1890
+ or isinstance(size, bool)
1891
+ or size < 0
1892
+ or media_type
1893
+ != MEDIA_TYPES.get(PurePosixPath(expected_path).suffix.casefold())
1894
+ ):
1895
+ raise ValueError("succeeded output record is invalid")
1896
+ validated.append((output_path, digest, size))
1897
+ return validated
1898
+
1899
+
1900
+ def audit_project(root: Path) -> dict[str, Any]:
1901
+ """Reconcile local attempt history and current output bytes, not media quality."""
1902
+ root = find_project(root)
1903
+ jobs: list[dict[str, Any]] = []
1904
+ problems: list[dict[str, Any]] = []
1905
+ for name in _metadata_json_names(root, ("jobs",)):
1906
+ try:
1907
+ document = _metadata_read_json(
1908
+ root, ("jobs",), name, maximum=MAX_JOB_BYTES
1909
+ )
1910
+ job = _validate_stored_job(root, document)
1911
+ job_id = job["job_id"]
1912
+ if not isinstance(job_id, str) or name != f"{_job_key(job_id)}.json":
1913
+ raise ValueError("stored job is invalid")
1914
+ jobs.append(job)
1915
+ except (OSError, ValueError, json.JSONDecodeError):
1916
+ problems.append(
1917
+ {
1918
+ "code": "invalid_job_record",
1919
+ "record": name,
1920
+ "action": "repair_production_metadata",
1921
+ }
1922
+ )
1923
+
1924
+ attempts_total = 0
1925
+ attempts_succeeded = 0
1926
+ attempts_failed = 0
1927
+ attempts_running = 0
1928
+ attempts_superseded = 0
1929
+ repeated_content = 0
1930
+ recovered_jobs = 0
1931
+ running_jobs = 0
1932
+ terminal_failed_jobs = 0
1933
+ retryable_terminal_failed_jobs = 0
1934
+ current_output_claims: dict[
1935
+ str, tuple[tuple[datetime, str], str, int, str, str]
1936
+ ] = {}
1937
+
1938
+ for job in jobs:
1939
+ job_id = str(job["job_id"])
1940
+ try:
1941
+ history = _read_run_history(root, job_id)
1942
+ except (OSError, ValueError, json.JSONDecodeError):
1943
+ problems.append(
1944
+ {
1945
+ "code": "invalid_run_history",
1946
+ "job_id": job_id,
1947
+ "action": "repair_production_metadata",
1948
+ }
1949
+ )
1950
+ continue
1951
+ attempts_total += len(history)
1952
+ completed = [run for run in history if run["status"] != "running"]
1953
+ running = [run for run in history if run["status"] == "running"]
1954
+ current = [
1955
+ run for run in history if run["fingerprint"] == job["fingerprint"]
1956
+ ]
1957
+ current_completed = [run for run in current if run["status"] != "running"]
1958
+ current_running = [run for run in current if run["status"] == "running"]
1959
+ attempts_succeeded += sum(run["status"] == "succeeded" for run in completed)
1960
+ attempts_failed += sum(run["status"] == "failed" for run in completed)
1961
+ attempts_running += len(running)
1962
+ attempts_superseded += len(history) - len(current)
1963
+ for run in current:
1964
+ if run.get("status") == "succeeded":
1965
+ continue
1966
+ handle = run.get("provider_job_id") or _read_provider_handle(
1967
+ root, job_id, str(run["run_id"])
1968
+ )
1969
+ if not handle:
1970
+ continue
1971
+ problems.append(
1972
+ {
1973
+ "code": "orphaned_provider_job",
1974
+ "job_id": job_id,
1975
+ "run_id": run["run_id"],
1976
+ "provider_job_id": handle,
1977
+ "action": "collect_before_retry",
1978
+ # A live attempt and a dead one both sit at `running`, so
1979
+ # this finding cannot tell them apart. Collecting is still
1980
+ # the right first move — it is the only one that costs
1981
+ # nothing — and it refuses safely if the attempt finished.
1982
+ "note": "status 为 running 时它可能仍在正常轮询;collect 会先确认再写",
1983
+ }
1984
+ )
1985
+ if running:
1986
+ running_jobs += 1
1987
+ for run in running:
1988
+ problems.append(
1989
+ {
1990
+ "code": "running_attempt",
1991
+ "job_id": job_id,
1992
+ "run_id": str(run["run_id"]),
1993
+ "action": "wait_or_investigate_running_attempt",
1994
+ }
1995
+ )
1996
+ fingerprints = [
1997
+ str(run.get("fingerprint"))
1998
+ for run in history
1999
+ if isinstance(run.get("fingerprint"), str)
2000
+ ]
2001
+ repeated_content += len(fingerprints) - len(set(fingerprints))
2002
+ succeeded_indexes = [
2003
+ index
2004
+ for index, run in enumerate(current_completed)
2005
+ if run["status"] == "succeeded"
2006
+ ]
2007
+ failed_indexes = [
2008
+ index
2009
+ for index, run in enumerate(current_completed)
2010
+ if run["status"] == "failed"
2011
+ ]
2012
+ if succeeded_indexes and failed_indexes and min(failed_indexes) < max(succeeded_indexes):
2013
+ recovered_jobs += 1
2014
+ if (
2015
+ not current_running
2016
+ and current_completed
2017
+ and current_completed[-1]["status"] == "failed"
2018
+ ):
2019
+ terminal_failed_jobs += 1
2020
+ error = current_completed[-1].get("error")
2021
+ retryable = isinstance(error, Mapping) and error.get("retryable") is True
2022
+ if retryable:
2023
+ retryable_terminal_failed_jobs += 1
2024
+ problems.append(
2025
+ {
2026
+ "code": (
2027
+ "terminal_retryable_failure" if retryable else "terminal_failure"
2028
+ ),
2029
+ "job_id": job_id,
2030
+ "run_id": current_completed[-1]["run_id"],
2031
+ "action": (
2032
+ "inspect_then_reconfirm_retry" if retryable else "inspect_failure"
2033
+ ),
2034
+ }
2035
+ )
2036
+ for run in current:
2037
+ if run["status"] != "succeeded":
2038
+ continue
2039
+ try:
2040
+ outputs = _validated_succeeded_outputs(job, run)
2041
+ except ValueError:
2042
+ problems.append(
2043
+ {
2044
+ "code": "invalid_succeeded_output_record",
2045
+ "job_id": job_id,
2046
+ "run_id": str(run["run_id"]),
2047
+ "action": "repair_production_metadata",
2048
+ }
2049
+ )
2050
+ continue
2051
+ for output_path, digest, size in outputs:
2052
+ run_id = str(run["run_id"])
2053
+ claim_order = _completed_run_order(run)
2054
+ claim = current_output_claims.get(output_path)
2055
+ if claim is None or claim_order > claim[0]:
2056
+ current_output_claims[output_path] = (
2057
+ claim_order,
2058
+ digest,
2059
+ size,
2060
+ job_id,
2061
+ run_id,
2062
+ )
2063
+
2064
+ if not _inputs_current(root, job):
2065
+ problems.append(
2066
+ {
2067
+ "code": "job_inputs_changed",
2068
+ "job_id": job_id,
2069
+ "action": "prepare_and_confirm_again",
2070
+ }
2071
+ )
2072
+
2073
+ output_verified = 0
2074
+ output_missing = 0
2075
+ output_modified = 0
2076
+ for relative, (
2077
+ _claim_order,
2078
+ expected_digest,
2079
+ expected_size,
2080
+ job_id,
2081
+ run_id,
2082
+ ) in sorted(current_output_claims.items()):
2083
+ try:
2084
+ safe_relative = _relative_path(relative, output=True)
2085
+ digest, size = _hash_production_output(root, safe_relative)
2086
+ except (FileNotFoundError, OSError, ValueError):
2087
+ output_missing += 1
2088
+ problems.append(
2089
+ {
2090
+ "code": "output_missing_or_unsafe",
2091
+ "job_id": job_id,
2092
+ "run_id": run_id,
2093
+ "path": relative,
2094
+ "action": "restore_or_reproduce_output",
2095
+ }
2096
+ )
2097
+ continue
2098
+ if digest != expected_digest or size != expected_size:
2099
+ output_modified += 1
2100
+ problems.append(
2101
+ {
2102
+ "code": "output_digest_mismatch",
2103
+ "job_id": job_id,
2104
+ "run_id": run_id,
2105
+ "path": relative,
2106
+ "action": "review_current_bytes_or_reproduce_output",
2107
+ }
2108
+ )
2109
+ else:
2110
+ output_verified += 1
2111
+
2112
+ return {
2113
+ "status": "attention" if problems else "pass",
2114
+ "scope": "operational_evidence_only",
2115
+ "quality_verdict": "not_assessed",
2116
+ "jobs": {
2117
+ "total": len(jobs),
2118
+ "running": running_jobs,
2119
+ "recovered": recovered_jobs,
2120
+ "terminal_failed": terminal_failed_jobs,
2121
+ "retryable_terminal_failed": retryable_terminal_failed_jobs,
2122
+ },
2123
+ "attempts": {
2124
+ "total": attempts_total,
2125
+ "succeeded": attempts_succeeded,
2126
+ "failed": attempts_failed,
2127
+ "running": attempts_running,
2128
+ "superseded": attempts_superseded,
2129
+ "repeated_content": repeated_content,
2130
+ },
2131
+ "outputs": {
2132
+ "claimed_current": len(current_output_claims),
2133
+ "verified": output_verified,
2134
+ "missing": output_missing,
2135
+ "modified": output_modified,
2136
+ },
2137
+ "problems": problems,
2138
+ }
2139
+
2140
+
2141
+ def build_parser() -> argparse.ArgumentParser:
2142
+ parser = argparse.ArgumentParser(description="Run confirmed short-drama media jobs.")
2143
+ commands = parser.add_subparsers(dest="command", required=True)
2144
+ prepare = commands.add_parser("prepare", help="Validate and preview a media job.")
2145
+ prepare.add_argument("project")
2146
+ prepare.add_argument("--job", required=True)
2147
+ confirm = commands.add_parser("confirm", help="Confirm the exact prepared job.")
2148
+ confirm.add_argument("project")
2149
+ confirm.add_argument("--job-id", required=True)
2150
+ confirm.add_argument("--confirmation", required=True)
2151
+ run = commands.add_parser("run", help="Execute a confirmed job through an adapter.")
2152
+ run.add_argument("project")
2153
+ run.add_argument("--job-id", required=True)
2154
+ run.add_argument("--adapter-config", required=True)
2155
+ collect = commands.add_parser(
2156
+ "collect",
2157
+ help="Fetch the result of an already-submitted provider task without paying again.",
2158
+ )
2159
+ collect.add_argument("project")
2160
+ collect.add_argument("--job-id", required=True)
2161
+ collect.add_argument("--adapter-config", required=True)
2162
+ status = commands.add_parser("status", help="Show one media job state.")
2163
+ status.add_argument("project")
2164
+ status.add_argument("--job-id", required=True)
2165
+ audit = commands.add_parser(
2166
+ "audit", help="Reconcile attempt history and current output bytes."
2167
+ )
2168
+ audit.add_argument("project")
2169
+ return parser
2170
+
2171
+
2172
+ def main(argv: list[str] | None = None) -> int:
2173
+ args = build_parser().parse_args(argv)
2174
+ try:
2175
+ if args.command == "prepare":
2176
+ result = prepare_job(Path(args.project), Path(args.job))
2177
+ elif args.command == "confirm":
2178
+ result = confirm_job(
2179
+ Path(args.project), job_id=args.job_id, confirmation=args.confirmation
2180
+ )
2181
+ elif args.command == "run":
2182
+ result = run_job(
2183
+ Path(args.project),
2184
+ job_id=args.job_id,
2185
+ adapter_config=Path(args.adapter_config),
2186
+ )
2187
+ elif args.command == "collect":
2188
+ result = collect_job(
2189
+ Path(args.project),
2190
+ job_id=args.job_id,
2191
+ adapter_config=Path(args.adapter_config),
2192
+ )
2193
+ elif args.command == "status":
2194
+ result = job_status(Path(args.project), job_id=args.job_id)
2195
+ else:
2196
+ result = audit_project(Path(args.project))
2197
+ print(json.dumps(result, ensure_ascii=True, sort_keys=True))
2198
+ return 0
2199
+ except Exception as exc:
2200
+ print(str(exc), file=sys.stderr)
2201
+ return 2
2202
+
2203
+
2204
+ if __name__ == "__main__":
2205
+ raise SystemExit(main())