diffusers-workflow 0.4.0__py3-none-any.whl

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 (260) hide show
  1. diffusers_workflow-0.4.0.dist-info/METADATA +318 -0
  2. diffusers_workflow-0.4.0.dist-info/RECORD +260 -0
  3. diffusers_workflow-0.4.0.dist-info/WHEEL +5 -0
  4. diffusers_workflow-0.4.0.dist-info/entry_points.txt +7 -0
  5. diffusers_workflow-0.4.0.dist-info/licenses/LICENSE +201 -0
  6. diffusers_workflow-0.4.0.dist-info/top_level.txt +2 -0
  7. dw/__init__.py +440 -0
  8. dw/adapter_compatibility.py +226 -0
  9. dw/arguments.py +1231 -0
  10. dw/assessment_rules.py +159 -0
  11. dw/assets.py +130 -0
  12. dw/cache_blocks.json +16 -0
  13. dw/cache_blocks.py +146 -0
  14. dw/community_pipelines/pipeline_flux_rf_inversion.py +1184 -0
  15. dw/content_types.py +150 -0
  16. dw/dissolve_frame_errors.py +121 -0
  17. dw/docs/ACCELERATION.md +352 -0
  18. dw/docs/AGENT_LOOP.md +95 -0
  19. dw/docs/DEPENDENCIES.md +91 -0
  20. dw/docs/IP_ADAPTER.md +109 -0
  21. dw/docs/LORAS.md +131 -0
  22. dw/docs/MCP.md +517 -0
  23. dw/docs/PROMPT_WEIGHTING.md +78 -0
  24. dw/docs/QUANTIZATION.md +230 -0
  25. dw/docs/RECIPES_24GB.md +201 -0
  26. dw/docs/RELEASING.md +195 -0
  27. dw/docs/REMOTE.md +140 -0
  28. dw/docs/REPL_COMMANDS.md +121 -0
  29. dw/docs/REPL_WORKER_GUIDE.md +51 -0
  30. dw/docs/SECURITY.md +272 -0
  31. dw/docs/SECURITY_QUICKREF.md +112 -0
  32. dw/docs/SERVER.md +679 -0
  33. dw/docs/TASKS.md +1741 -0
  34. dw/docs/TESTING.md +71 -0
  35. dw/docs/WORKFLOW_GUIDE.md +2038 -0
  36. dw/docs/WORKSPACES.md +316 -0
  37. dw/download_watch.py +335 -0
  38. dw/elision.py +306 -0
  39. dw/events.py +275 -0
  40. dw/for_each.py +409 -0
  41. dw/host_memory.py +258 -0
  42. dw/host_memory_projection.py +230 -0
  43. dw/hub_cache.py +432 -0
  44. dw/introspection.py +1228 -0
  45. dw/kernel_availability.py +208 -0
  46. dw/locations.py +599 -0
  47. dw/log_setup.py +45 -0
  48. dw/loudness.py +82 -0
  49. dw/media_audio.py +217 -0
  50. dw/media_frames.py +367 -0
  51. dw/media_info.py +297 -0
  52. dw/pipeline_processors/chain.py +821 -0
  53. dw/pipeline_processors/config_objects.py +237 -0
  54. dw/pipeline_processors/pipeline.py +2297 -0
  55. dw/pipeline_processors/remote.py +46 -0
  56. dw/plan.py +920 -0
  57. dw/previous_results.py +411 -0
  58. dw/probe_paths.py +59 -0
  59. dw/prompt_schema.json +48 -0
  60. dw/prompt_weighting.py +378 -0
  61. dw/prompts.py +159 -0
  62. dw/realize.py +250 -0
  63. dw/reference_limits.py +215 -0
  64. dw/reference_names.py +125 -0
  65. dw/repl.py +338 -0
  66. dw/repl_commands.py +836 -0
  67. dw/repl_worker.py +159 -0
  68. dw/result.py +1720 -0
  69. dw/result_fps.py +82 -0
  70. dw/run.py +162 -0
  71. dw/runs.py +768 -0
  72. dw/scalar_result_validation.py +97 -0
  73. dw/schema.py +283 -0
  74. dw/security.py +1038 -0
  75. dw/select_validation.py +115 -0
  76. dw/serve.py +277 -0
  77. dw/server/__init__.py +2 -0
  78. dw/server/app.py +4586 -0
  79. dw/server/assess.py +132 -0
  80. dw/server/catalog_shape.py +487 -0
  81. dw/server/enhancers.py +129 -0
  82. dw/server/exports.py +480 -0
  83. dw/server/guides.py +257 -0
  84. dw/server/jobs.py +1561 -0
  85. dw/server/mcp_mount.py +95 -0
  86. dw/server/netinfo.py +124 -0
  87. dw/server/observed_cost.py +379 -0
  88. dw/server/sysinfo.py +71 -0
  89. dw/server/ui/assets/abap-08VXUWAP.js +1 -0
  90. dw/server/ui/assets/apex-BWPQTe0t.js +1 -0
  91. dw/server/ui/assets/azcli-Bc_sGQ0U.js +1 -0
  92. dw/server/ui/assets/bat-i0X4ZdIN.js +1 -0
  93. dw/server/ui/assets/bicep-B5-_aFwp.js +2 -0
  94. dw/server/ui/assets/cameligo-DMUM7wLl.js +1 -0
  95. dw/server/ui/assets/clojure-Cm7r79vr.js +1 -0
  96. dw/server/ui/assets/codicon-Brq4_Ui5.ttf +0 -0
  97. dw/server/ui/assets/coffee-Ba7i2nA0.js +1 -0
  98. dw/server/ui/assets/cpp-C7h46wYY.js +1 -0
  99. dw/server/ui/assets/csharp-BKxtCVv1.js +1 -0
  100. dw/server/ui/assets/csp-bTuwJoIa.js +1 -0
  101. dw/server/ui/assets/css-DIMkf-bt.js +3 -0
  102. dw/server/ui/assets/css.worker-B3ciXF_0.js +93 -0
  103. dw/server/ui/assets/cssMode-CPznxfY8.js +1 -0
  104. dw/server/ui/assets/cypher-CVaqCwHa.js +1 -0
  105. dw/server/ui/assets/dart-onAF5SnQ.js +1 -0
  106. dw/server/ui/assets/dockerfile-DZFCIeNp.js +1 -0
  107. dw/server/ui/assets/ecl-D05T4iGw.js +1 -0
  108. dw/server/ui/assets/editor-jjEx9u7D.css +1 -0
  109. dw/server/ui/assets/editor.api-CpWcotrd.js +847 -0
  110. dw/server/ui/assets/editor.worker-q-txB4vs.js +30 -0
  111. dw/server/ui/assets/elixir-6RTg0lbw.js +1 -0
  112. dw/server/ui/assets/flow9-C5_-GSwl.js +1 -0
  113. dw/server/ui/assets/freemarker2-CXtRM8N4.js +3 -0
  114. dw/server/ui/assets/fsharp-C8Ef5oNN.js +1 -0
  115. dw/server/ui/assets/go-C-y9NEjX.js +1 -0
  116. dw/server/ui/assets/graphql-fmXr3nnJ.js +1 -0
  117. dw/server/ui/assets/handlebars-N7x-6NMY.js +1 -0
  118. dw/server/ui/assets/hcl-CpzslTdj.js +1 -0
  119. dw/server/ui/assets/html-PhsdjHSr.js +1 -0
  120. dw/server/ui/assets/html.worker-C93Ht9o9.js +506 -0
  121. dw/server/ui/assets/htmlMode-Dgj0SEok.js +1 -0
  122. dw/server/ui/assets/index-3Vw6WAPW.css +1 -0
  123. dw/server/ui/assets/index-DgrYhQd9.js +43 -0
  124. dw/server/ui/assets/ini-sBoK_t0W.js +1 -0
  125. dw/server/ui/assets/java-BEtHBSE6.js +1 -0
  126. dw/server/ui/assets/javascript-BJqN9Qhv.js +1 -0
  127. dw/server/ui/assets/json.worker-B2V3pomh.js +62 -0
  128. dw/server/ui/assets/jsonMode-DbM4SWSv.js +7 -0
  129. dw/server/ui/assets/julia-Bri6UV-V.js +1 -0
  130. dw/server/ui/assets/kotlin-BOotOW0E.js +1 -0
  131. dw/server/ui/assets/less-B9JPFI3C.js +2 -0
  132. dw/server/ui/assets/lexon-CfSJPG6W.js +1 -0
  133. dw/server/ui/assets/liquid-BWr8lEc4.js +1 -0
  134. dw/server/ui/assets/lspLanguageFeatures-C1iGuDyZ.js +4 -0
  135. dw/server/ui/assets/lua-CsQS60Ue.js +1 -0
  136. dw/server/ui/assets/m3-D-oSqn_W.js +1 -0
  137. dw/server/ui/assets/markdown-Cimd5fb3.js +1 -0
  138. dw/server/ui/assets/mdx-DAdMi_0p.js +1 -0
  139. dw/server/ui/assets/mips-CIPQ_RoX.js +1 -0
  140. dw/server/ui/assets/monaco--ixms01u.css +1 -0
  141. dw/server/ui/assets/monaco-BGCeEqaw.js +56 -0
  142. dw/server/ui/assets/msdax-DauUninz.js +1 -0
  143. dw/server/ui/assets/mysql-SOo6toE5.js +1 -0
  144. dw/server/ui/assets/objective-c-FvmIjYaQ.js +1 -0
  145. dw/server/ui/assets/pascal-DrH0SRf2.js +1 -0
  146. dw/server/ui/assets/pascaligo-D-ptJ9y-.js +1 -0
  147. dw/server/ui/assets/perl-oz_6vUea.js +1 -0
  148. dw/server/ui/assets/pgsql-DTj74zXo.js +1 -0
  149. dw/server/ui/assets/php-nr791fC2.js +1 -0
  150. dw/server/ui/assets/pla-CopQ2nXW.js +1 -0
  151. dw/server/ui/assets/postiats-43DmfD33.js +1 -0
  152. dw/server/ui/assets/powerquery-D3hlyOfw.js +1 -0
  153. dw/server/ui/assets/powershell-DmHpPYUd.js +1 -0
  154. dw/server/ui/assets/protobuf-C531GsRP.js +2 -0
  155. dw/server/ui/assets/pug-Z5eAx3Zn.js +1 -0
  156. dw/server/ui/assets/python-Bcn70HdC.js +1 -0
  157. dw/server/ui/assets/qsharp-DkqhCAOL.js +1 -0
  158. dw/server/ui/assets/r-BwWrilGY.js +1 -0
  159. dw/server/ui/assets/razor-D1HmNnby.js +1 -0
  160. dw/server/ui/assets/redis-ClamHrr6.js +1 -0
  161. dw/server/ui/assets/redshift-DT7zqm-g.js +1 -0
  162. dw/server/ui/assets/restructuredtext-BYgofb2h.js +1 -0
  163. dw/server/ui/assets/ruby-DezsRK8O.js +1 -0
  164. dw/server/ui/assets/rust-DdL9SqIa.js +1 -0
  165. dw/server/ui/assets/sb-CcwsVR0C.js +1 -0
  166. dw/server/ui/assets/scala-DHpiXF5c.js +1 -0
  167. dw/server/ui/assets/scheme-BeGwcela.js +1 -0
  168. dw/server/ui/assets/scss-gp-XZpBa.js +3 -0
  169. dw/server/ui/assets/shell-CC2rA5mh.js +1 -0
  170. dw/server/ui/assets/solidity-BEEn4gHE.js +1 -0
  171. dw/server/ui/assets/sophia-CRfGWb83.js +1 -0
  172. dw/server/ui/assets/sparql-D_Lu-MrJ.js +1 -0
  173. dw/server/ui/assets/sql-NEE52Syq.js +1 -0
  174. dw/server/ui/assets/st-DbInun42.js +1 -0
  175. dw/server/ui/assets/swift-Bxkupp3x.js +1 -0
  176. dw/server/ui/assets/systemverilog-Bz4Y3fRF.js +1 -0
  177. dw/server/ui/assets/tcl-DISqw1ZD.js +1 -0
  178. dw/server/ui/assets/ts.worker-D7T1-Ig5.js +67738 -0
  179. dw/server/ui/assets/tsMode-D6u0XmOW.js +11 -0
  180. dw/server/ui/assets/twig-De2hgUGE.js +1 -0
  181. dw/server/ui/assets/typescript-BU6v-LMV.js +1 -0
  182. dw/server/ui/assets/typespec-B8J7ngcE.js +1 -0
  183. dw/server/ui/assets/vb-DV3o63ZY.js +1 -0
  184. dw/server/ui/assets/wgsl-DpFanUEy.js +298 -0
  185. dw/server/ui/assets/workers-Cn7cTUKr.js +1 -0
  186. dw/server/ui/assets/xml--0LP2Lwk.js +1 -0
  187. dw/server/ui/assets/yaml-mpBg9jnt.js +1 -0
  188. dw/server/ui/index.html +17 -0
  189. dw/server/updater.py +192 -0
  190. dw/settings.py +98 -0
  191. dw/shot_span_preflight.py +116 -0
  192. dw/shots.py +359 -0
  193. dw/slice_preflight.py +148 -0
  194. dw/step.py +187 -0
  195. dw/step_cache.py +442 -0
  196. dw/subfolders.py +107 -0
  197. dw/task_domains.py +307 -0
  198. dw/tasks/assess.py +826 -0
  199. dw/tasks/audio_transcription.py +88 -0
  200. dw/tasks/audio_utils.py +1862 -0
  201. dw/tasks/background_remover.py +43 -0
  202. dw/tasks/borders.py +113 -0
  203. dw/tasks/compose_text.py +74 -0
  204. dw/tasks/concat_videos.py +300 -0
  205. dw/tasks/depth_estimator.py +54 -0
  206. dw/tasks/diffusion_upscale.py +109 -0
  207. dw/tasks/dissolve_videos.py +342 -0
  208. dw/tasks/format_messages.py +24 -0
  209. dw/tasks/gather.py +173 -0
  210. dw/tasks/grade.py +97 -0
  211. dw/tasks/image_to_text.py +43 -0
  212. dw/tasks/image_utils.py +764 -0
  213. dw/tasks/interpolate_frames.py +252 -0
  214. dw/tasks/judge.py +68 -0
  215. dw/tasks/model_cache.py +55 -0
  216. dw/tasks/pair_audio.py +268 -0
  217. dw/tasks/qr_code.py +19 -0
  218. dw/tasks/restore_faces.py +175 -0
  219. dw/tasks/rife_model.py +192 -0
  220. dw/tasks/segment.py +121 -0
  221. dw/tasks/select.py +111 -0
  222. dw/tasks/speech_generation.py +228 -0
  223. dw/tasks/stabilize.py +129 -0
  224. dw/tasks/task.py +920 -0
  225. dw/tasks/tensor_image.py +57 -0
  226. dw/tasks/text_generation.py +169 -0
  227. dw/tasks/text_sections.py +80 -0
  228. dw/tasks/upscale.py +203 -0
  229. dw/tasks/video_utils.py +624 -0
  230. dw/tasks/zoe_depth.py +71 -0
  231. dw/teacache.py +381 -0
  232. dw/teacache_models.json +99 -0
  233. dw/test.py +29 -0
  234. dw/type_helpers.py +231 -0
  235. dw/validate.py +68 -0
  236. dw/variable_constraints.py +444 -0
  237. dw/variables.py +443 -0
  238. dw/video_extensions.py +141 -0
  239. dw/vram_estimate.py +116 -0
  240. dw/worker.py +764 -0
  241. dw/workflow.py +2007 -0
  242. dw/workflow_schema.json +1346 -0
  243. dw/workflow_sources.py +383 -0
  244. dw/workflows/h3_context_ir.json +57 -0
  245. dw/workflows/test.json +31 -0
  246. dw/workspace.py +730 -0
  247. dw_mcp/__init__.py +6 -0
  248. dw_mcp/__main__.py +133 -0
  249. dw_mcp/assets.py +336 -0
  250. dw_mcp/authoring.py +114 -0
  251. dw_mcp/catalog.py +360 -0
  252. dw_mcp/client.py +486 -0
  253. dw_mcp/diagnose.py +371 -0
  254. dw_mcp/exports.py +84 -0
  255. dw_mcp/guides.py +35 -0
  256. dw_mcp/media.py +638 -0
  257. dw_mcp/models.py +97 -0
  258. dw_mcp/prompts.py +104 -0
  259. dw_mcp/server.py +1343 -0
  260. dw_mcp/workspaces.py +212 -0
dw/settings.py ADDED
@@ -0,0 +1,98 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+
5
+
6
+ class Settings:
7
+ log_level: str = "WARNING"
8
+ log_filename: str = "log/dw.log"
9
+ log_to_console: bool = False
10
+
11
+ # Device to run on - None detects the best one available. Set to pick a specific
12
+ # accelerator ('cuda:1') or force a backend ('cpu', 'mps'). The DW_DEVICE
13
+ # environment variable overrides this for a single run.
14
+ device: str = None
15
+
16
+ # Directory holding this user's workflows, prompts, assets and outputs.
17
+ # None resolves it - see dw/workspace.py for the order, which ends at the
18
+ # working directory when it looks like a workspace, then ~/diffusers-workspace
19
+ workspace: str = None
20
+
21
+ # How generated files are laid out under the output directory: "run"
22
+ # gives each execution its own directory, "flat" keeps the pre-workspace
23
+ # layout. See dw/runs.py
24
+ output_layout: str = "run"
25
+
26
+ # PyTorch optimization settings
27
+ enable_tf32: bool = True # TensorFloat-32 for faster matmul on Ampere+ GPUs
28
+ cudnn_benchmark: bool = True # cuDNN autotuner (faster for fixed sizes)
29
+ cudnn_deterministic: bool = False # Set True for reproducibility
30
+
31
+ # This server's public origin (e.g. "https://dw.example.com"), for a
32
+ # client that can't otherwise turn a served path into a URL it can open
33
+ # itself. None (the default) means no such origin is configured, so
34
+ # nothing composes one - see dw/server/app.py's `_served_url`. The
35
+ # DW_PUBLIC_URL environment variable overrides this for a single run.
36
+ public_url: str = None
37
+
38
+
39
+ def load_settings():
40
+ settings = Settings()
41
+ try:
42
+ with open(get_settings_full_path(), "r") as file:
43
+ settings_dict = json.load(file)
44
+ except FileNotFoundError:
45
+ settings_dict = {}
46
+ except json.JSONDecodeError:
47
+ print("invalid settings file")
48
+ settings_dict = {}
49
+
50
+ settings.log_level = settings_dict.get("log_level", "WARNING")
51
+ settings.log_filename = settings_dict.get("log_filename", "log/dw.log")
52
+ settings.log_to_console = settings_dict.get("log_to_console", False)
53
+
54
+ settings.device = settings_dict.get("device", None)
55
+ settings.workspace = settings_dict.get("workspace", None)
56
+ settings.output_layout = settings_dict.get("output_layout", "run")
57
+
58
+ # PyTorch optimization settings
59
+ settings.enable_tf32 = settings_dict.get("enable_tf32", True)
60
+ settings.cudnn_benchmark = settings_dict.get("cudnn_benchmark", True)
61
+ settings.cudnn_deterministic = settings_dict.get("cudnn_deterministic", False)
62
+
63
+ settings.public_url = settings_dict.get("public_url", None)
64
+
65
+ return settings
66
+
67
+
68
+ def save_settings(settings):
69
+ settings_dict = settings.__dict__
70
+ with open(get_settings_full_path(), "w") as file:
71
+ json.dump(settings_dict, file, indent=2)
72
+
73
+
74
+ def settings_exist():
75
+ return get_settings_full_path().is_file()
76
+
77
+
78
+ def resolve_path(path):
79
+ full_path = get_settings_dir().joinpath(path)
80
+ # make the directory if it doesn't exist
81
+ full_path.parent.mkdir(parents=True, exist_ok=True)
82
+
83
+ return full_path
84
+
85
+
86
+ def get_settings_dir():
87
+ dir_path = os.environ.get("DIFFUSERS_HELPER_ROOT") or "~/.diffusers_helper/"
88
+
89
+ return Path(dir_path).expanduser()
90
+
91
+
92
+ def save_file(data, filename):
93
+ with open(resolve_path(filename), "w") as file:
94
+ json.dump(data, file, indent=2)
95
+
96
+
97
+ def get_settings_full_path():
98
+ return resolve_path("settings.json")
@@ -0,0 +1,116 @@
1
+ """A `shots` argument to an assessment probe (`analyze_shots`,
2
+ `analyze_seams`, `analyze_sync_drift`) whose frame span already runs past a
3
+ statically-knowable video's real length, warned about before the run (#425).
4
+
5
+ The probes silently clip an overrunning shot record to the file (`_clip` in
6
+ `dw/tasks/assess.py`) and only say so at run time
7
+ (`_shot_span_findings`, same module) - a message correct but late once the
8
+ run has already spent the decode. Mirrors `slice_preflight.py` (#402): walk
9
+ the expanded definition, `resolve_path_references` an `asset:`/`output:`
10
+ video into a real path, and `probe_media` it - the same resolution and
11
+ decode the run itself would do, just ahead of the queue.
12
+
13
+ Deliberately narrower than the run-time check, same as #402's: a
14
+ `previous_result:` video (nothing written yet), a remote URL, a literal path
15
+ outside the directories the run may read, or a source `probe_media` cannot
16
+ read, all answer "unknown" rather than guessing - silence here is correct,
17
+ not a gap, since the run-time warning still fires once the file exists. Only
18
+ the `shots` argument's `start_frame`/`num_frames` are checked; a `shots`
19
+ argument sourced from a `variable:`/`previous_result:`/`gather:` reference
20
+ names no records yet and is left to the run-time check.
21
+ """
22
+
23
+ from .for_each import MEMBER_SEPARATOR, render_path
24
+ from .media_info import probe_media
25
+ from .probe_paths import resolve_probe_path
26
+
27
+ PROBE_COMMANDS = ("analyze_shots", "analyze_seams", "analyze_sync_drift")
28
+
29
+
30
+ def _frame_count(path):
31
+ """The frame count a probe would see for this file, or None when it
32
+ cannot be probed or carries no video stream."""
33
+ info = probe_media(path)
34
+ if info is None or info.get("kind") != "video":
35
+ return None
36
+ return info.get("frame_count")
37
+
38
+
39
+ def _as_number(value):
40
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
41
+ return None
42
+ return value
43
+
44
+
45
+ def shot_span_warnings(workflow_definition, source_indices=None, base_dir=None):
46
+ """Every assessment-probe step whose `shots` argument already reaches
47
+ past a statically-resolvable video's real frame count, as messages.
48
+
49
+ Walks the substituted, expanded definition, the same convention
50
+ `slice_past_end_warnings` follows: `source_indices` maps an expanded step
51
+ back to the one the author wrote, and a path inside a `for_each` member
52
+ names the member.
53
+ """
54
+ steps = workflow_definition.get("steps")
55
+ if not isinstance(steps, list):
56
+ return []
57
+
58
+ warnings = []
59
+ for index, step in enumerate(steps):
60
+ if not isinstance(step, dict):
61
+ continue
62
+ task = step.get("task")
63
+ if not isinstance(task, dict) or task.get("command") not in PROBE_COMMANDS:
64
+ continue
65
+ task_args = task.get("arguments")
66
+ if not isinstance(task_args, dict):
67
+ continue
68
+ shots = task_args.get("shots")
69
+ if not isinstance(shots, list) or not shots:
70
+ continue
71
+
72
+ path = resolve_probe_path(task_args.get("video"), base_dir, "a video argument")
73
+ if path is None:
74
+ continue
75
+ frame_count = _frame_count(path)
76
+ if frame_count is None:
77
+ continue
78
+
79
+ problems = []
80
+ for shot in shots:
81
+ if not isinstance(shot, dict):
82
+ continue
83
+ start_frame = _as_number(shot.get("start_frame", 0))
84
+ num_frames = _as_number(shot.get("num_frames"))
85
+ if start_frame is None or num_frames is None:
86
+ continue
87
+ end = start_frame + num_frames
88
+ if end > frame_count:
89
+ problems.append(
90
+ f"shot {shot.get('name')!r} reaches frame {int(end)}, "
91
+ f"{int(end - frame_count)} past the file's {frame_count} frames"
92
+ )
93
+ if not problems:
94
+ continue
95
+
96
+ source = (
97
+ source_indices[index]
98
+ if source_indices is not None and index < len(source_indices)
99
+ else index
100
+ )
101
+ name = step.get("name")
102
+ where = (
103
+ f" in member '{name}'"
104
+ if isinstance(name, str) and MEMBER_SEPARATOR in name
105
+ else ""
106
+ )
107
+ path_str = render_path(("steps", source, "task", "arguments", "shots"))
108
+ warnings.append(
109
+ f"{path_str}: {task['command']} will clip {'; '.join(problems)}{where} "
110
+ "- the probe measures a shorter window than the record asks for, "
111
+ "silently, unless the record is corrected"
112
+ )
113
+ return warnings
114
+
115
+
116
+ __all__ = ["shot_span_warnings"]
dw/shots.py ADDED
@@ -0,0 +1,359 @@
1
+ """Shot boundaries a joined video carries: where each input landed in it.
2
+
3
+ A step that joins shots - `concat_videos`, `dissolve_videos`, a chained
4
+ pipeline - knows exactly where every seam fell, in frames and in samples, and
5
+ used to throw that away: a consumer checking a cut had to re-derive the seams
6
+ from arguments, and a shot whose track ran 267 samples long drifted the rest
7
+ of the cut with nothing saying where (#378). The join now records one entry
8
+ per shot on the `AudioVideo` it returns (`AudioVideo.shots`), the step's
9
+ manifest entry carries them, and `get_gallery_metadata` reads them back.
10
+
11
+ A shot is a dict:
12
+
13
+ - `name` - which input it was: `shot@<key>` when the step named a `for_each`
14
+ member, else the path it was given, else `video N` / `segment N`
15
+ - `start_frame`, `num_frames` - its place on the joined picture. The shots
16
+ partition the frames: the counts add up to the file's frame count
17
+ - `start_sample`, `num_samples` - its place on the joined track, *measured*
18
+ from the waveform the join built rather than derived from the frame
19
+ numbers, so an overrun shows up as a count that disagrees with the frames'.
20
+ None when the video has no track, or when the track is one the join did
21
+ not build shot by shot (a chain's `match_audio`)
22
+ - `overlap_frames` - a dissolve's head: the frames at its start that are
23
+ blended with the shot before it
24
+
25
+ Every other `AudioVideo` constructor either carries the list (same frames),
26
+ rescales it (`interpolate_frames`), re-measures the sample side for a new
27
+ track (`pair_audio`), or builds a video with no shots at all.
28
+ `tests/test_shots.py` fails on a constructor site nobody decided for.
29
+ """
30
+
31
+ import copy
32
+
33
+ from .arguments import PREVIOUS_RESULT_PREFIX
34
+
35
+ # The step names a for_each member as `<group>@<entry>`; only a member of the
36
+ # group conventionally called `shot` names a shot
37
+ SHOT_REFERENCE_PREFIX = f"{PREVIOUS_RESULT_PREFIX}shot@"
38
+
39
+
40
+ def shot_record(
41
+ name, start_frame, num_frames, start_sample=None, num_samples=None, **extra
42
+ ):
43
+ """One shot's entry, in the key order the manifest shows."""
44
+ record = {
45
+ "name": name,
46
+ "start_frame": int(start_frame),
47
+ "num_frames": int(num_frames),
48
+ "start_sample": None if start_sample is None else int(start_sample),
49
+ "num_samples": None if num_samples is None else int(num_samples),
50
+ }
51
+ record.update(extra)
52
+ return record
53
+
54
+
55
+ def carried_shots(source):
56
+ """The shots of a video whose frames a step kept one for one, copied."""
57
+ shots = getattr(source, "shots", None)
58
+ return copy.deepcopy(shots) if shots else None
59
+
60
+
61
+ def without_samples(shots):
62
+ """The shots with their sample side cleared - a track that is gone."""
63
+ return [{**shot, "start_sample": None, "num_samples": None} for shot in shots]
64
+
65
+
66
+ def rescaled_shots(shots, multiplier):
67
+ """The shots of a video whose frames were multiplied by interpolation.
68
+
69
+ N frames become (N - 1) * multiplier + 1: every frame but the last gains
70
+ multiplier - 1 frames after it. A shot starting at frame s now starts at
71
+ s * multiplier, and the last shot keeps the one frame nothing follows.
72
+ Interpolation drops the track, so the sample side goes with it.
73
+ """
74
+ if not shots:
75
+ return None
76
+ total = sum(shot["num_frames"] for shot in shots)
77
+ rescaled = []
78
+ for shot in shots:
79
+ start = shot["start_frame"] * multiplier
80
+ end = shot["start_frame"] + shot["num_frames"]
81
+ new_end = (end - 1) * multiplier + 1 if end == total else end * multiplier
82
+ entry = {
83
+ **shot,
84
+ "start_frame": start,
85
+ "num_frames": new_end - start,
86
+ "start_sample": None,
87
+ "num_samples": None,
88
+ }
89
+ if shot.get("overlap_frames"):
90
+ entry["overlap_frames"] = shot["overlap_frames"] * multiplier
91
+ rescaled.append(entry)
92
+ return rescaled
93
+
94
+
95
+ def remeasured_shots(shots, fps, sample_rate, total_samples):
96
+ """The shots of a video laid over a new track by pair_audio.
97
+
98
+ The frame side is unchanged. The new track was not built shot by shot, so
99
+ each shot's samples are the stretch of the track its frames play over: a
100
+ shot starts at start_frame / fps seconds, and the last one runs to the end
101
+ of the track as written - with `fit: "video"` that is the fitted length.
102
+ Without a frame rate there is no way to place a frame on the track, so the
103
+ sample side is cleared rather than guessed.
104
+
105
+ This deliberately makes the last shot the one place `num_samples` can
106
+ disagree with `round(num_frames * sample_rate / fps)` (#423): every other
107
+ boundary is a frame position rounded onto the new rate, but the last one
108
+ is the track's actual end, whatever the audio chain that built it (a
109
+ resample, a mix, a normalize) landed on - usually the same figure, but a
110
+ sample or two off is rounding slop, not a dropped or invented sample.
111
+ `pair_audio` already warns separately (`audio_video_length_mismatch`,
112
+ `audio_padded_to_video`, `audio_trimmed_to_video`) when a track disagrees
113
+ with its video by more than a frame's worth, so a real overrun is never
114
+ silent; this is only ever the sub-frame remainder landing on the last
115
+ shot instead of being unaccounted for.
116
+ """
117
+ if not shots:
118
+ return None
119
+ if not fps or not sample_rate or total_samples is None:
120
+ return without_samples(shots)
121
+ starts = [
122
+ min(int(round(shot["start_frame"] / fps * sample_rate)), total_samples)
123
+ for shot in shots
124
+ ]
125
+ ends = starts[1:] + [total_samples]
126
+ return [
127
+ {**shot, "start_sample": start, "num_samples": max(end - start, 0)}
128
+ for shot, start, end in zip(shots, starts, ends)
129
+ ]
130
+
131
+
132
+ def trimmed_shots(shots, head_trim):
133
+ """The shots of a video after dropping `head_trim` frames off its start.
134
+
135
+ concat_videos trims the head of every video after the first before
136
+ joining it. A shot entirely inside the trim never reaches the joined
137
+ picture and is dropped; one straddling the cut survives, clipped to what
138
+ is left and re-based to start at 0, so a later frame offset places it
139
+ correctly. The crossfade drawn from the trimmed material makes the
140
+ surviving samples' position in the joined track unmeasurable, so the
141
+ sample side is cleared regardless of rate.
142
+ """
143
+ if not head_trim:
144
+ return shots
145
+ clipped = []
146
+ for shot in shots:
147
+ end = shot["start_frame"] + shot["num_frames"]
148
+ if end <= head_trim:
149
+ continue
150
+ start = max(shot["start_frame"], head_trim)
151
+ clipped.append(
152
+ {
153
+ **shot,
154
+ "start_frame": start - head_trim,
155
+ "num_frames": end - start,
156
+ "start_sample": None,
157
+ "num_samples": None,
158
+ }
159
+ )
160
+ return clipped
161
+
162
+
163
+ def nested_shots(
164
+ shots, frame_offset, sample_offset, native_rate, target_rate, fps=None
165
+ ):
166
+ """An input's own shots, offset onto where the whole input landed in a join.
167
+
168
+ Frames are exact: a join only ever adds frames before an input, never
169
+ inside it, so `start_frame + frame_offset` is where each inner shot now
170
+ sits. Samples are only ever offset when the join measured where the
171
+ input's own track landed (`sample_offset`) and both rates are known -
172
+ resampling a partial waveform inside the crossfaded region is not a
173
+ measurement, so trimmed_shots already clears those before this runs.
174
+ Otherwise the sample side is cleared, same as without_samples.
175
+
176
+ A caller that knows the joined track's frame rate (`fps`) can have the
177
+ sample side *derived* from each shot's new frame position
178
+ (frames_to_samples) instead of rescaled from its own already-rounded
179
+ `start_sample` - the same choice #401 made for the top-level seam
180
+ position, because rescaling a stored value compounds whatever rounding
181
+ an earlier join already did, drifting a sample or two off what a later
182
+ pair_audio would measure for the same boundary (#405). concat_videos
183
+ does not pass `fps` here: its sample_offset is a measurement of the
184
+ real, unevenly-spaced crossfades it drew, not a multiple of a frame
185
+ rate, so deriving from frame position would disagree with the track it
186
+ actually built.
187
+ """
188
+ rescale = (
189
+ target_rate / native_rate
190
+ if sample_offset is not None and native_rate and target_rate
191
+ else None
192
+ )
193
+ derive = fps and target_rate and sample_offset is not None
194
+ offset = []
195
+ for shot in shots:
196
+ entry = {**shot, "start_frame": shot["start_frame"] + frame_offset}
197
+ start_sample = shot.get("start_sample")
198
+ if derive:
199
+ entry["start_sample"] = round(entry["start_frame"] / fps * target_rate)
200
+ elif rescale is not None and start_sample is not None:
201
+ entry["start_sample"] = sample_offset + round(start_sample * rescale)
202
+ else:
203
+ entry["start_sample"] = None
204
+ entry["num_samples"] = None
205
+ offset.append(entry)
206
+ return offset
207
+
208
+
209
+ def measured_num_samples(shots, total_samples):
210
+ """Fill each shot's `num_samples` from where the next measured one starts.
211
+
212
+ A shot's track runs up to wherever the next shot with a known
213
+ `start_sample` begins, or to the end of the joined track for the last
214
+ one - shared by concat_videos and dissolve_videos so nesting an input's
215
+ shots (which can leave some entries with no `start_sample`) is handled
216
+ the same way in both.
217
+ """
218
+ for index, shot in enumerate(shots):
219
+ if total_samples is None or shot["start_sample"] is None:
220
+ shot["num_samples"] = None
221
+ if total_samples is None:
222
+ shot["start_sample"] = None
223
+ continue
224
+ end = total_samples
225
+ for following in shots[index + 1 :]:
226
+ if following["start_sample"] is not None:
227
+ end = following["start_sample"]
228
+ break
229
+ shot["num_samples"] = end - shot["start_sample"]
230
+ return shots
231
+
232
+
233
+ def shot_reference_names(references):
234
+ """A name per entry of a step's list naming a shot, else None.
235
+
236
+ A step's `videos` argument is written as a list of `previous_result:`
237
+ references; by the time the task runs `gather:` has expanded into exactly
238
+ such a list, so the entry at position i names the video the join put at
239
+ position i. A reference to a `shot@` member keeps that name (the
240
+ for_each entry, not the field read off it); any other `previous_result:`
241
+ reference is named after the step it points at, so a shot generated by an
242
+ ordinary step (a `pair_audio`, a chain) is traceable in the manifest and
243
+ in a probe finding the same way (#396). Anything else (an `asset:` path,
244
+ a literal video) keeps the name the join gave it.
245
+ """
246
+ if not isinstance(references, list):
247
+ return None
248
+ names = []
249
+ for reference in references:
250
+ if isinstance(reference, str) and reference.startswith(SHOT_REFERENCE_PREFIX):
251
+ # `previous_result:shot@x.field` names the member, not the field
252
+ member = reference[len(PREVIOUS_RESULT_PREFIX) :]
253
+ names.append(member.split(".", 1)[0])
254
+ elif isinstance(reference, str) and reference.startswith(
255
+ PREVIOUS_RESULT_PREFIX
256
+ ):
257
+ # `previous_result:step.field` names the step, not the field
258
+ step = reference[len(PREVIOUS_RESULT_PREFIX) :]
259
+ names.append(step.split(".", 1)[0])
260
+ else:
261
+ names.append(None)
262
+ return names
263
+
264
+
265
+ def named_shots(shots, names):
266
+ """The shots with each renamed where the step named its source input.
267
+
268
+ `names` is one name per input the join was given (`shot_reference_names`
269
+ over the step's `videos` argument); a flattened shot is matched to it by
270
+ the `source_index` the join stamped on it - not by position in `shots`,
271
+ which is a different length from `names` as soon as one input nests more
272
+ than one shot of its own (#432: the old positional zip then silently
273
+ skipped every rename past that input, since the length guard refused the
274
+ whole list rather than the one entry it could not place). Only an input
275
+ that contributed exactly one shot takes the override; one that nested
276
+ keeps the names its own inner shots already carry - renaming all of them
277
+ to the same one name would collide them. A shot with no `source_index` (a
278
+ flat list built by hand rather than by concat_videos/dissolve_videos, as
279
+ a for_each join's `gather:` result is) falls back to its position in
280
+ `shots`, which is exactly what `source_index` means for a list with no
281
+ nesting.
282
+ """
283
+ if not shots:
284
+ return shots
285
+ indices = [
286
+ shot["source_index"] if "source_index" in shot else position
287
+ for position, shot in enumerate(shots)
288
+ ]
289
+ counts = {}
290
+ for index in indices:
291
+ counts[index] = counts.get(index, 0) + 1
292
+ renamed = []
293
+ for shot, index in zip(shots, indices):
294
+ entry = {key: value for key, value in shot.items() if key != "source_index"}
295
+ name = names[index] if names and index < len(names) else None
296
+ if name and counts.get(index) == 1:
297
+ entry["name"] = name
298
+ renamed.append(entry)
299
+ return renamed
300
+
301
+
302
+ def _rename_in_place(shots, names):
303
+ """Write the step-named `name`s back onto the shot dicts themselves, and
304
+ drop the transient `source_index` tag named_shots placed them by.
305
+
306
+ `Result.save` stores `saved_shots[path]` as the artifact's own `.shots`
307
+ list, not a copy (`self._artifacts_for` / `getattr(artifact, "shots")`),
308
+ and that same artifact is what a later `previous_result:` step reads
309
+ (`results[step.name]` holds it directly). Renaming only the manifest's
310
+ deep copy left that artifact carrying the join's `video N` fallback, so
311
+ a probe reading `previous_result:cut` still saw the unnamed shot even
312
+ after the manifest was fixed (#396 follow-up). Mutating here reaches
313
+ both.
314
+ """
315
+ for shot, named in zip(shots, named_shots(shots, names)):
316
+ shot["name"] = named["name"]
317
+ shot.pop("source_index", None)
318
+
319
+
320
+ def step_shots(saved_shots, saved_files, references=None):
321
+ """The `shots` a step's manifest entry and step_end carry, or None.
322
+
323
+ `saved_shots` maps each file the step wrote to the shots its video
324
+ carried (`Result.saved_shots`). A step that wrote one file lists that
325
+ file's shots; one that wrote several marks each shot with the `file` it
326
+ belongs to, so no shot is ever read against the wrong file. `references`
327
+ is the step's `videos` argument as written, which names the shots.
328
+ """
329
+ if not saved_shots:
330
+ return None
331
+ names = shot_reference_names(references)
332
+ files = [path for path in saved_files or [] if path in saved_shots]
333
+ for path in files:
334
+ _rename_in_place(saved_shots[path], names)
335
+ if len(files) == 1 and len(saved_files) == 1:
336
+ return copy.deepcopy(saved_shots[files[0]])
337
+ return [
338
+ {**shot, "file": path}
339
+ for path in files
340
+ for shot in copy.deepcopy(saved_shots[path])
341
+ ]
342
+
343
+
344
+ def shots_for_file(shots, path, step_files):
345
+ """The shots of one file out of a manifest entry's `shots`, or None.
346
+
347
+ `path` and `step_files` are as the manifest records them, so a
348
+ run-relative path matches a run-relative `file`.
349
+ """
350
+ if not shots:
351
+ return None
352
+ if any("file" in shot for shot in shots):
353
+ own = [
354
+ {key: value for key, value in shot.items() if key != "file"}
355
+ for shot in shots
356
+ if shot.get("file") == path
357
+ ]
358
+ return own or None
359
+ return shots if list(step_files or []) == [path] else None