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.
- diffusers_workflow-0.4.0.dist-info/METADATA +318 -0
- diffusers_workflow-0.4.0.dist-info/RECORD +260 -0
- diffusers_workflow-0.4.0.dist-info/WHEEL +5 -0
- diffusers_workflow-0.4.0.dist-info/entry_points.txt +7 -0
- diffusers_workflow-0.4.0.dist-info/licenses/LICENSE +201 -0
- diffusers_workflow-0.4.0.dist-info/top_level.txt +2 -0
- dw/__init__.py +440 -0
- dw/adapter_compatibility.py +226 -0
- dw/arguments.py +1231 -0
- dw/assessment_rules.py +159 -0
- dw/assets.py +130 -0
- dw/cache_blocks.json +16 -0
- dw/cache_blocks.py +146 -0
- dw/community_pipelines/pipeline_flux_rf_inversion.py +1184 -0
- dw/content_types.py +150 -0
- dw/dissolve_frame_errors.py +121 -0
- dw/docs/ACCELERATION.md +352 -0
- dw/docs/AGENT_LOOP.md +95 -0
- dw/docs/DEPENDENCIES.md +91 -0
- dw/docs/IP_ADAPTER.md +109 -0
- dw/docs/LORAS.md +131 -0
- dw/docs/MCP.md +517 -0
- dw/docs/PROMPT_WEIGHTING.md +78 -0
- dw/docs/QUANTIZATION.md +230 -0
- dw/docs/RECIPES_24GB.md +201 -0
- dw/docs/RELEASING.md +195 -0
- dw/docs/REMOTE.md +140 -0
- dw/docs/REPL_COMMANDS.md +121 -0
- dw/docs/REPL_WORKER_GUIDE.md +51 -0
- dw/docs/SECURITY.md +272 -0
- dw/docs/SECURITY_QUICKREF.md +112 -0
- dw/docs/SERVER.md +679 -0
- dw/docs/TASKS.md +1741 -0
- dw/docs/TESTING.md +71 -0
- dw/docs/WORKFLOW_GUIDE.md +2038 -0
- dw/docs/WORKSPACES.md +316 -0
- dw/download_watch.py +335 -0
- dw/elision.py +306 -0
- dw/events.py +275 -0
- dw/for_each.py +409 -0
- dw/host_memory.py +258 -0
- dw/host_memory_projection.py +230 -0
- dw/hub_cache.py +432 -0
- dw/introspection.py +1228 -0
- dw/kernel_availability.py +208 -0
- dw/locations.py +599 -0
- dw/log_setup.py +45 -0
- dw/loudness.py +82 -0
- dw/media_audio.py +217 -0
- dw/media_frames.py +367 -0
- dw/media_info.py +297 -0
- dw/pipeline_processors/chain.py +821 -0
- dw/pipeline_processors/config_objects.py +237 -0
- dw/pipeline_processors/pipeline.py +2297 -0
- dw/pipeline_processors/remote.py +46 -0
- dw/plan.py +920 -0
- dw/previous_results.py +411 -0
- dw/probe_paths.py +59 -0
- dw/prompt_schema.json +48 -0
- dw/prompt_weighting.py +378 -0
- dw/prompts.py +159 -0
- dw/realize.py +250 -0
- dw/reference_limits.py +215 -0
- dw/reference_names.py +125 -0
- dw/repl.py +338 -0
- dw/repl_commands.py +836 -0
- dw/repl_worker.py +159 -0
- dw/result.py +1720 -0
- dw/result_fps.py +82 -0
- dw/run.py +162 -0
- dw/runs.py +768 -0
- dw/scalar_result_validation.py +97 -0
- dw/schema.py +283 -0
- dw/security.py +1038 -0
- dw/select_validation.py +115 -0
- dw/serve.py +277 -0
- dw/server/__init__.py +2 -0
- dw/server/app.py +4586 -0
- dw/server/assess.py +132 -0
- dw/server/catalog_shape.py +487 -0
- dw/server/enhancers.py +129 -0
- dw/server/exports.py +480 -0
- dw/server/guides.py +257 -0
- dw/server/jobs.py +1561 -0
- dw/server/mcp_mount.py +95 -0
- dw/server/netinfo.py +124 -0
- dw/server/observed_cost.py +379 -0
- dw/server/sysinfo.py +71 -0
- dw/server/ui/assets/abap-08VXUWAP.js +1 -0
- dw/server/ui/assets/apex-BWPQTe0t.js +1 -0
- dw/server/ui/assets/azcli-Bc_sGQ0U.js +1 -0
- dw/server/ui/assets/bat-i0X4ZdIN.js +1 -0
- dw/server/ui/assets/bicep-B5-_aFwp.js +2 -0
- dw/server/ui/assets/cameligo-DMUM7wLl.js +1 -0
- dw/server/ui/assets/clojure-Cm7r79vr.js +1 -0
- dw/server/ui/assets/codicon-Brq4_Ui5.ttf +0 -0
- dw/server/ui/assets/coffee-Ba7i2nA0.js +1 -0
- dw/server/ui/assets/cpp-C7h46wYY.js +1 -0
- dw/server/ui/assets/csharp-BKxtCVv1.js +1 -0
- dw/server/ui/assets/csp-bTuwJoIa.js +1 -0
- dw/server/ui/assets/css-DIMkf-bt.js +3 -0
- dw/server/ui/assets/css.worker-B3ciXF_0.js +93 -0
- dw/server/ui/assets/cssMode-CPznxfY8.js +1 -0
- dw/server/ui/assets/cypher-CVaqCwHa.js +1 -0
- dw/server/ui/assets/dart-onAF5SnQ.js +1 -0
- dw/server/ui/assets/dockerfile-DZFCIeNp.js +1 -0
- dw/server/ui/assets/ecl-D05T4iGw.js +1 -0
- dw/server/ui/assets/editor-jjEx9u7D.css +1 -0
- dw/server/ui/assets/editor.api-CpWcotrd.js +847 -0
- dw/server/ui/assets/editor.worker-q-txB4vs.js +30 -0
- dw/server/ui/assets/elixir-6RTg0lbw.js +1 -0
- dw/server/ui/assets/flow9-C5_-GSwl.js +1 -0
- dw/server/ui/assets/freemarker2-CXtRM8N4.js +3 -0
- dw/server/ui/assets/fsharp-C8Ef5oNN.js +1 -0
- dw/server/ui/assets/go-C-y9NEjX.js +1 -0
- dw/server/ui/assets/graphql-fmXr3nnJ.js +1 -0
- dw/server/ui/assets/handlebars-N7x-6NMY.js +1 -0
- dw/server/ui/assets/hcl-CpzslTdj.js +1 -0
- dw/server/ui/assets/html-PhsdjHSr.js +1 -0
- dw/server/ui/assets/html.worker-C93Ht9o9.js +506 -0
- dw/server/ui/assets/htmlMode-Dgj0SEok.js +1 -0
- dw/server/ui/assets/index-3Vw6WAPW.css +1 -0
- dw/server/ui/assets/index-DgrYhQd9.js +43 -0
- dw/server/ui/assets/ini-sBoK_t0W.js +1 -0
- dw/server/ui/assets/java-BEtHBSE6.js +1 -0
- dw/server/ui/assets/javascript-BJqN9Qhv.js +1 -0
- dw/server/ui/assets/json.worker-B2V3pomh.js +62 -0
- dw/server/ui/assets/jsonMode-DbM4SWSv.js +7 -0
- dw/server/ui/assets/julia-Bri6UV-V.js +1 -0
- dw/server/ui/assets/kotlin-BOotOW0E.js +1 -0
- dw/server/ui/assets/less-B9JPFI3C.js +2 -0
- dw/server/ui/assets/lexon-CfSJPG6W.js +1 -0
- dw/server/ui/assets/liquid-BWr8lEc4.js +1 -0
- dw/server/ui/assets/lspLanguageFeatures-C1iGuDyZ.js +4 -0
- dw/server/ui/assets/lua-CsQS60Ue.js +1 -0
- dw/server/ui/assets/m3-D-oSqn_W.js +1 -0
- dw/server/ui/assets/markdown-Cimd5fb3.js +1 -0
- dw/server/ui/assets/mdx-DAdMi_0p.js +1 -0
- dw/server/ui/assets/mips-CIPQ_RoX.js +1 -0
- dw/server/ui/assets/monaco--ixms01u.css +1 -0
- dw/server/ui/assets/monaco-BGCeEqaw.js +56 -0
- dw/server/ui/assets/msdax-DauUninz.js +1 -0
- dw/server/ui/assets/mysql-SOo6toE5.js +1 -0
- dw/server/ui/assets/objective-c-FvmIjYaQ.js +1 -0
- dw/server/ui/assets/pascal-DrH0SRf2.js +1 -0
- dw/server/ui/assets/pascaligo-D-ptJ9y-.js +1 -0
- dw/server/ui/assets/perl-oz_6vUea.js +1 -0
- dw/server/ui/assets/pgsql-DTj74zXo.js +1 -0
- dw/server/ui/assets/php-nr791fC2.js +1 -0
- dw/server/ui/assets/pla-CopQ2nXW.js +1 -0
- dw/server/ui/assets/postiats-43DmfD33.js +1 -0
- dw/server/ui/assets/powerquery-D3hlyOfw.js +1 -0
- dw/server/ui/assets/powershell-DmHpPYUd.js +1 -0
- dw/server/ui/assets/protobuf-C531GsRP.js +2 -0
- dw/server/ui/assets/pug-Z5eAx3Zn.js +1 -0
- dw/server/ui/assets/python-Bcn70HdC.js +1 -0
- dw/server/ui/assets/qsharp-DkqhCAOL.js +1 -0
- dw/server/ui/assets/r-BwWrilGY.js +1 -0
- dw/server/ui/assets/razor-D1HmNnby.js +1 -0
- dw/server/ui/assets/redis-ClamHrr6.js +1 -0
- dw/server/ui/assets/redshift-DT7zqm-g.js +1 -0
- dw/server/ui/assets/restructuredtext-BYgofb2h.js +1 -0
- dw/server/ui/assets/ruby-DezsRK8O.js +1 -0
- dw/server/ui/assets/rust-DdL9SqIa.js +1 -0
- dw/server/ui/assets/sb-CcwsVR0C.js +1 -0
- dw/server/ui/assets/scala-DHpiXF5c.js +1 -0
- dw/server/ui/assets/scheme-BeGwcela.js +1 -0
- dw/server/ui/assets/scss-gp-XZpBa.js +3 -0
- dw/server/ui/assets/shell-CC2rA5mh.js +1 -0
- dw/server/ui/assets/solidity-BEEn4gHE.js +1 -0
- dw/server/ui/assets/sophia-CRfGWb83.js +1 -0
- dw/server/ui/assets/sparql-D_Lu-MrJ.js +1 -0
- dw/server/ui/assets/sql-NEE52Syq.js +1 -0
- dw/server/ui/assets/st-DbInun42.js +1 -0
- dw/server/ui/assets/swift-Bxkupp3x.js +1 -0
- dw/server/ui/assets/systemverilog-Bz4Y3fRF.js +1 -0
- dw/server/ui/assets/tcl-DISqw1ZD.js +1 -0
- dw/server/ui/assets/ts.worker-D7T1-Ig5.js +67738 -0
- dw/server/ui/assets/tsMode-D6u0XmOW.js +11 -0
- dw/server/ui/assets/twig-De2hgUGE.js +1 -0
- dw/server/ui/assets/typescript-BU6v-LMV.js +1 -0
- dw/server/ui/assets/typespec-B8J7ngcE.js +1 -0
- dw/server/ui/assets/vb-DV3o63ZY.js +1 -0
- dw/server/ui/assets/wgsl-DpFanUEy.js +298 -0
- dw/server/ui/assets/workers-Cn7cTUKr.js +1 -0
- dw/server/ui/assets/xml--0LP2Lwk.js +1 -0
- dw/server/ui/assets/yaml-mpBg9jnt.js +1 -0
- dw/server/ui/index.html +17 -0
- dw/server/updater.py +192 -0
- dw/settings.py +98 -0
- dw/shot_span_preflight.py +116 -0
- dw/shots.py +359 -0
- dw/slice_preflight.py +148 -0
- dw/step.py +187 -0
- dw/step_cache.py +442 -0
- dw/subfolders.py +107 -0
- dw/task_domains.py +307 -0
- dw/tasks/assess.py +826 -0
- dw/tasks/audio_transcription.py +88 -0
- dw/tasks/audio_utils.py +1862 -0
- dw/tasks/background_remover.py +43 -0
- dw/tasks/borders.py +113 -0
- dw/tasks/compose_text.py +74 -0
- dw/tasks/concat_videos.py +300 -0
- dw/tasks/depth_estimator.py +54 -0
- dw/tasks/diffusion_upscale.py +109 -0
- dw/tasks/dissolve_videos.py +342 -0
- dw/tasks/format_messages.py +24 -0
- dw/tasks/gather.py +173 -0
- dw/tasks/grade.py +97 -0
- dw/tasks/image_to_text.py +43 -0
- dw/tasks/image_utils.py +764 -0
- dw/tasks/interpolate_frames.py +252 -0
- dw/tasks/judge.py +68 -0
- dw/tasks/model_cache.py +55 -0
- dw/tasks/pair_audio.py +268 -0
- dw/tasks/qr_code.py +19 -0
- dw/tasks/restore_faces.py +175 -0
- dw/tasks/rife_model.py +192 -0
- dw/tasks/segment.py +121 -0
- dw/tasks/select.py +111 -0
- dw/tasks/speech_generation.py +228 -0
- dw/tasks/stabilize.py +129 -0
- dw/tasks/task.py +920 -0
- dw/tasks/tensor_image.py +57 -0
- dw/tasks/text_generation.py +169 -0
- dw/tasks/text_sections.py +80 -0
- dw/tasks/upscale.py +203 -0
- dw/tasks/video_utils.py +624 -0
- dw/tasks/zoe_depth.py +71 -0
- dw/teacache.py +381 -0
- dw/teacache_models.json +99 -0
- dw/test.py +29 -0
- dw/type_helpers.py +231 -0
- dw/validate.py +68 -0
- dw/variable_constraints.py +444 -0
- dw/variables.py +443 -0
- dw/video_extensions.py +141 -0
- dw/vram_estimate.py +116 -0
- dw/worker.py +764 -0
- dw/workflow.py +2007 -0
- dw/workflow_schema.json +1346 -0
- dw/workflow_sources.py +383 -0
- dw/workflows/h3_context_ir.json +57 -0
- dw/workflows/test.json +31 -0
- dw/workspace.py +730 -0
- dw_mcp/__init__.py +6 -0
- dw_mcp/__main__.py +133 -0
- dw_mcp/assets.py +336 -0
- dw_mcp/authoring.py +114 -0
- dw_mcp/catalog.py +360 -0
- dw_mcp/client.py +486 -0
- dw_mcp/diagnose.py +371 -0
- dw_mcp/exports.py +84 -0
- dw_mcp/guides.py +35 -0
- dw_mcp/media.py +638 -0
- dw_mcp/models.py +97 -0
- dw_mcp/prompts.py +104 -0
- dw_mcp/server.py +1343 -0
- 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
|