Compare commits

..

269 Commits

Author SHA1 Message Date
d657a40681 Add distinction in error messaging for gated models. 2026-07-02 12:41:23 +02:00
c98a212589 Add extension check on the final resolved url -> fix downloading from civitAI. 2026-07-01 15:37:30 +02:00
4c82c708a7 Add delete and clear all downloads funcitonalities. 2026-07-01 15:04:30 +02:00
fe4d0c9722 Disable newline translations on Windows, \r\n -> \n only. 2026-07-01 12:06:17 +02:00
64c5853631 Add support for ENV based HF_TOKEN. 2026-07-01 12:02:19 +02:00
8a6e7906f7 Fix an issue with windows lacking have os.pwrite 2026-07-01 11:56:41 +02:00
e4b0a72e83 Fix running CI tests. 2026-07-01 11:45:50 +02:00
28b41d4d6d Fix more AI detected issues., 2026-07-01 11:38:33 +02:00
3eb36377a8 Fix ruff. 2026-07-01 11:36:23 +02:00
27fd68a533 Update openapi.yaml. 2026-06-30 20:33:16 +02:00
61816d436b Remove sending url info over websockets for model downloads. 2026-06-30 20:33:16 +02:00
abc0b728ab Fix sweep deleting FAILED partials and fix segmented resume path trusted offsets blindly. 2026-06-30 20:33:16 +02:00
1bbd4a57db Add _positive_int in cli_args arguments. 2026-06-30 20:33:16 +02:00
b419fd8399 Redact urls in logging and fix concurrent enqueue issue that could corrupt the downloaded files. 2026-06-30 20:33:16 +02:00
e77983ca28 Simplify docstrings. 2026-06-30 20:33:16 +02:00
893ba2ad37 Normalize malformed safetensors headers into StructuralError. 2026-06-30 20:33:16 +02:00
312b282ca8 Prevent redirects to loopback/internal IPs (SSRF) 2026-06-30 20:33:16 +02:00
7690c52a34 Simplify docstrings. 2026-06-30 20:33:16 +02:00
e326ef3b16 Fix IPv4-mapped IPv6 addresses 2026-06-30 20:33:16 +02:00
115a4305ea Restrict cleartext HTTP redirects to explicit loopback/dev hosts. 2026-06-30 20:33:16 +02:00
9be31a4b7e Simplify docstrings. 2026-06-30 20:33:16 +02:00
7dba134cda Don't echo full URLs and raw exception text from probe failures. 2026-06-30 20:33:16 +02:00
e0b07014c0 Simplify docstrings. 2026-06-30 20:33:16 +02:00
3eade55077 Handle short pwrite() results. 2026-06-30 20:33:16 +02:00
c785130223 Switch to asyncio.to_thread for db calls in job.py 2026-06-30 20:33:16 +02:00
4ccaaa6f37 Improve _finalize checks for downloads. 2026-06-30 20:33:16 +02:00
58392bf7a6 Truncate file to 0 before restarting. 2026-06-30 20:33:16 +02:00
4ae294d2d5 Add max-download-size in case the server tries to send larger files than it reports. 2026-06-30 20:33:15 +02:00
95b0758a88 Clear error when the job leaves a failure state. 2026-06-30 20:33:15 +02:00
dba2bfbc02 Fix resuming of segmented download. 2026-06-30 20:33:15 +02:00
4bd7cc153e Simplify docstrings. 2026-06-30 20:33:15 +02:00
2b708d5af7 Fix potential concurrency and db integrity error. 2026-06-30 20:33:15 +02:00
9c845eeb9e Fix an issue when a credential is deleted, resuming of download fails. 2026-06-30 20:33:15 +02:00
e70f524d5f Simplify docstrings. 2026-06-30 20:33:15 +02:00
d058ffb761 Fix sercret_last4 2026-06-30 20:33:15 +02:00
53ec95b87e Improve normalize_host 2026-06-30 20:33:15 +02:00
e02c7a0890 Docstring simplification. 2026-06-30 20:33:15 +02:00
1744026eca Fix url parsing. 2026-06-30 20:33:15 +02:00
f660307489 Simplify migration docstring. 2026-06-30 20:33:15 +02:00
c7c18377a3 Add initial commit for model downloader. 2026-06-30 20:33:15 +02:00
8fe0243d97 [Partner Nodes] feat(Google): add Nano Banana 2 Lite model (#14693)
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-06-30 11:17:23 -07:00
ba3f697dbb Add ConditioningMultiply node to nodes.py as an addition to other adj… (#14686) 2026-06-30 16:27:09 +08:00
510ed5c384 Bump comfyui-frontend-package to 1.45.20 (#14684) 2026-06-30 16:25:03 +08:00
7851410511 Better and faster int8 lora applying. (#14685) 2026-06-29 21:52:08 -04:00
a58473fd9b chore: update embedded docs to v0.5.6 (#14668)
Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
2026-06-29 17:08:06 +08:00
79c555ce6b Fix int8 mm being skipped on offloaded lora weights. (#14669) 2026-06-28 23:52:36 -04:00
f19735759e ci: add team-gated Cursor review (thin caller for github-workflows) (#14527) 2026-06-27 23:34:30 -07:00
a95e461916 int8 support on turing GPUs. (#14662) 2026-06-27 15:53:11 -07:00
603d891eaf Update GLSL node to use ANGLE library (CORE-162) (#13195) 2026-06-27 08:40:31 +08:00
470ac36a0a Fix int8 loras causing lower quality requant with wrong settings. (#14650)
* Update comfy-kitchen

* Support requantizing with same settings as orig quant.
2026-06-26 16:41:29 -07:00
7cb784e0f4 Faster int8. (#14641) 2026-06-25 15:25:47 -07:00
1a510f0423 Support int8 models. (#14636) 2026-06-25 11:23:58 -07:00
639c8fa788 chore: update workflow templates to v0.10.7 (#14632) 2026-06-25 23:05:34 +08:00
e22f1500f9 [Partner Nodes] feat(ByteDance): add support for SeeDance-2.0-Mini video model (#14626)
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-06-25 17:57:04 +03:00
dac4ea3a80 feat: Bounding boxes canvas and Ideogram JSON prompt (#14537) 2026-06-25 22:34:09 +08:00
b0ec19804f chore(openapi): sync shared API contract from cloud@4118910 (#14619) 2026-06-25 13:54:53 +08:00
64e1d740b8 Add advanced krea 2 model merging node. (#14621) 2026-06-24 20:37:30 -07:00
b22d0fb9c0 feat: Add Support For Simple Seed (CORE-295) (#14616) 2026-06-25 09:39:10 +08:00
5236cd02e6 [Partner Nodes] feat(ByteDance): add 4K resolution support for SeeDance 2.0 (#14614)
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-06-24 17:57:46 +03:00
cabb7342d1 [Partner Nodes] feat(Grok): add 1080p resolution to Grok Image node (#14612)
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-06-24 16:28:56 +03:00
12218db68a Update the template to bring the HH1.1 templates back (#14613) 2026-06-24 21:01:25 +08:00
44955d783b [Partner Nodes] feat(Alibaba): add support for HappyHorse 1.1 model (#14611)
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-06-24 13:37:28 +03:00
1f275fcba6 chore(openapi): sync shared API contract from cloud@363764b (#14607) 2026-06-24 18:22:59 +08:00
f6c162ddcf ComfyUI v0.26.0 2026-06-23 13:22:28 -04:00
261bdb7cac chore: update workflow templates to v0.10.3 (#14603) 2026-06-23 13:06:26 -04:00
4a03056632 [Partner Nodes] revert last 3 PRs: #14597 #14588 #14581 (#14602) 2026-06-23 12:49:16 -04:00
0f949d0faf [Partner Nodes] feat(Grok): add 1080p resolution to Grok Image node (#14597) 2026-06-23 23:38:46 +08:00
d0b640fff7 chore: update workflow templates to v0.10.2 (#14600) 2026-06-23 23:35:21 +08:00
0ba903bd5b [Partner Nodes] feat(ByteDance): add 4K resolution support for SeeDance 2.0 (#14588)
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-06-23 16:18:35 +03:00
0a92ed161e [Partner Nodes] feat(Alibaba): add support for HappyHorse 1.1 model (#14581)
Signed-off-by: bigcat88 <bigcat88@icloud.com>
Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
2026-06-23 13:29:46 +03:00
b910f4fa2a More accurate memory usage factor for krea 2. (#14594) 2026-06-23 16:50:48 +08:00
833bfb572e Please try native formats instead of disabling dynamic vram. (#14577) 2026-06-22 21:06:19 -07:00
2a61015582 feat: Support Krea2 (#14589) 2026-06-22 14:35:00 -07:00
6978a466b8 chore: update embedded docs to v0.5.5 (#14585) 2026-06-22 18:29:02 +08:00
b0f9e326af Add output socket to save nodes (#13866) 2026-06-22 10:15:28 +08:00
0d8b7510bd Update extra model paths example. (#14570) 2026-06-20 19:28:09 -07:00
dc3f8f314a [Partner Nodes] chore(Google): remove preview versions of models that will be deprecated soon (#14555)
Signed-off-by: bigcat88 <bigcat88@icloud.com>
Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
2026-06-20 09:13:37 +03:00
d282ef7201 chore(openapi): sync shared API contract from cloud@1aea581 (#14562) 2026-06-20 12:53:10 +08:00
e00b55631a Small anima optimization. (#14557) 2026-06-20 08:05:28 +08:00
69d34f2654 Rename a bunch of nodes (#14547) 2026-06-20 08:01:28 +08:00
cd77c551d6 feat: Context Windows sampling with LTX2 models and IC-LoRa guides (CORE-3) (#13325) 2026-06-20 07:47:31 +08:00
4e716f7c57 Add jobs-namespace cancel endpoints (POST /api/jobs/{job_id}/cancel, POST /api/jobs/cancel) (#14493)
* Add jobs-namespace cancel endpoints

Add two cancel endpoints under the jobs namespace so a job can be
cancelled by id without the caller needing to know whether the job is
running or pending, or branching between /interrupt and /queue.

- POST /api/jobs/{job_id}/cancel cancels one job by id. Idempotent: an
  already-finished or unknown id returns 200 {"cancelled": false} rather
  than an error.
- POST /api/jobs/cancel takes {"job_ids": [...]} and cancels a batch.
  Fail-fast: if any id is unknown the request returns 404 listing the
  unknown ids and cancels nothing (no partial side effects).

Both are state-agnostic and map onto the existing queue mechanics: a
running job is interrupted (same path as /interrupt), a pending job is
dequeued (same path as /queue {"delete": [...]}). The cancel logic lives
in comfy_execution.jobs as pure, unit-tested helpers; the server handlers
are thin wrappers. openapi.yaml documents both routes.

* fix: resolve review feedback on cancel endpoints

- Guard cancel_job() against TOCTOU: when dequeue() returns False the
  pending job left the queue between snapshot and delete; return
  CANCEL_UNKNOWN so callers never report cancelled=True for a remove
  that did not happen.
- Validate each job_ids element in the batch cancel endpoint before
  any queue access; unhashable or non-UUID values now return 400
  instead of raising TypeError (500).
- Update batch HTTP tests to use canonical UUID ids (required now that
  the endpoint validates id format) and add tests for the new guards.

* fix: make job cancel atomic and best-effort

Addresses two cancel races/edges raised in review.

Targeted, atomic interrupt. cancel_job's interrupt callback now takes the
prompt id and returns whether it fired; the single-cancel route backs it
with the new PromptQueue.interrupt_if_running, which checks the running set
and signals the interrupt under the queue mutex. This closes the TOCTOU
where a pending job that starts executing between the snapshot and dequeue
(or a running job that finishes between the snapshot and interrupt) could be
missed or, worse, cause an unrelated prompt to be interrupted. The per-prompt
interrupt-flag reset in execute_async keeps a finished job from leaking the
interrupt onto its successor.

Best-effort batch cancel. POST /api/jobs/cancel no longer fails the whole
batch with 404 when one id is unknown/finished; such ids are treated as
no-ops, so "cancel all" still cancels the in-progress jobs even if some
finished between the client's snapshot and the request. Malformed ids are
still rejected with 400.
2026-06-19 16:39:35 -07:00
2ab3816dcf feat: add Load3DAdvanced node (#14316) 2026-06-20 07:06:55 +08:00
bc11e8a65a Bump comfyui-frontend-package to 1.45.19 (#14559) 2026-06-19 16:01:34 -07:00
bd39bbf067 [Partner Nodes] fix: respect Retry-After header (#14234)
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-06-19 11:32:56 +03:00
5955ddff52 [Partner Nodes] feat(Luma): add support for Luma Rays 3.2 (#14540)
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-06-19 08:46:07 +03:00
5ef0092af9 Move comfy sys path insert to custom node loading. (#14459) 2026-06-18 22:32:55 -04:00
94ee49b161 harden: load training-dataset shards with weights_only=True (#14543)
LoadTrainingDataset was the only torch.load call in the codebase without
weights_only=True; comfy/utils.py and comfy/sd1_clip.py already pass it.
Recent PyTorch defaults to weights_only=True, so this is defense-in-depth
for installs pinned to older PyTorch. Verified a typical shard (latents +
standard conditioning) round-trips cleanly under weights_only=True.
2026-06-18 15:30:57 -04:00
16514da2e7 chore(openapi): sync shared API contract from cloud@d10ff72 (#14518) 2026-06-18 17:27:53 +08:00
8483c215dc Update ComfyUI Desktop to Comfy Desktop for consistent product naming (#14533) 2026-06-18 17:24:05 +08:00
f2270f070a feat: add enable_telemetry CLI feature flag (#14530) 2026-06-17 19:35:05 -07:00
191a75a2cd [Partner Nodes] feat(Kling): add support for Kling V3-Turbo model (#14528) 2026-06-18 07:54:53 +08:00
52257bb435 Add negative prompt to boogu edit node and set min images to 0. (#14529) 2026-06-17 15:42:29 -07:00
e25c391888 feat: Support Boogu-Image (CORE-308) (#14523) 2026-06-17 14:22:36 -07:00
ca3dbe206c Allow using Qwen3-VL as flux2 klein text encoder (again) (#14526) 2026-06-17 08:45:06 -07:00
a590d60bb1 feat: SCAIL-2 multireference (CORE-310) (#14509)
* SCAIl-2: support multiref
2026-06-17 16:21:23 +03:00
d202707ff2 Update TripoSplat categories (#14512) 2026-06-17 21:02:45 +08:00
f026b01ba5 Update links to new comfyui desktop repo. (#14516) 2026-06-16 20:02:53 -07:00
c7b246edc4 docs: add M3 and M4 to Apple Silicon supported chips list (#14449) 2026-06-17 10:01:05 +08:00
c44d261fc2 Add new model blueprints (#14506) 2026-06-17 08:52:55 +08:00
ca1622ca24 chore: Update nodes categories (CORE-263) (#14460) 2026-06-17 08:33:09 +08:00
fc964047e7 feat: Support text generation with Qwen3-VL (CORE-276) (#14298) 2026-06-17 08:12:44 +08:00
90eeeb2139 fix: log base directory to startup messages when --base-directory is used (fixes #13363) (#13370) 2026-06-16 19:21:36 +08:00
d38ea29d62 Add the checkbox to disable head drawing in node SDPoseDrawKeypoints (#14446) 2026-06-16 16:21:04 +08:00
b732aa192f [Partner Nodes] chore(SoniloTextToMusic): reduce price by half (#14500)
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-06-16 10:12:39 +03:00
86f987ca7c chore(openapi): sync shared API contract from cloud@00ef9cc (#14423) 2026-06-16 12:24:41 +08:00
135abed8da ComfyUI v0.25.0 2026-06-15 23:45:14 -04:00
a439dcae07 Update nodes titles (#14417) 2026-06-16 11:42:00 +08:00
5db51b76b4 Fix odd-height crash and edge bleed in unaligned-width image/video decode (#14491)
a1d95f3f padded the decode width to the next multiple of 32 with the pad filter to fix libswscale's float YUV->GBR edge corruption, but kept the pad target height equal to the source height. The pad filter requires the target height to be a multiple of the input's vertical chroma subsampling factor, so a chroma-subsampled input such as yuv420p (the format the gbrpf32le float branch decodes) with an odd height makes the filter round the target below the input height and fail to configure: 'Padded dimensions cannot be smaller than input dimensions' (Errno 22). This is reachable from LoadImage, which routes static images through VideoFromFile, on a lossy WebP whose width is not a multiple of 32 and whose height is odd.

The pad filter also fills the added border with black, and chroma upsampling bleeds that black into the cropped edge of every unaligned-width subsampled decode.

Pad both axes to the next multiple of 32 (32 is a multiple of every vertical subsampling factor, including yuv410p's 4 that a plain even rounding misses) and run fillborders mode=smear to replicate the real edge into the padding so it never bleeds into the cropped output, then crop both axes back to the source size. Aligned-width and uint8 paths run the identical to_ndarray call as before and are byte-identical to master; only unaligned-width subsampled inputs change, from a crash or edge artifact to a clean, deterministic decode.
2026-06-15 20:23:09 -07:00
b13ca1ce7b main: support fallback to aimdo 0.4.9 (#14489)
The aimdo 0.4.10 protocol causing startup failure to be too early and
before the aimdo version warning can happen. This causes user
confusion. Limp on with 0.4.9 as it will work and users will see the
version warning.
2026-06-15 20:22:24 -07:00
2f4c4e983c [Partner Nodes] fix(SoniloTextToMusic): always require "duration" to be specified (#14484) 2026-06-16 00:20:01 +08:00
83a3f03218 chore: update workflow templates to v0.10.0 (#14482) 2026-06-15 08:06:15 -07:00
ec4dec93d2 Comfy Aimdo 0.4.10 + Dynamic --reserve-vram + --vram-headroom (#14480)
* main: implement --vram-headroom

Implement --vram-headroom for dynamic vram as a hybrid debug/diagnostic
option that can be used for people who still report shared VRAM spills.
They can trial and error the setting to maintain a bit more headroom to
avoid shared VRAM spills.

* main: implement --reserve-vram

Implement --reserve-vram as extra headroom on the simple method which
is semantically as close as possible to the stated functionality and
formet behaviour of non-dynamic VRAM.
2026-06-15 07:54:36 -07:00
7d4194d984 chore: update embedded docs to v0.5.4 (#14478) 2026-06-15 16:35:36 +08:00
4388eb781a This is already auto enabled by default. (#14476) 2026-06-14 18:47:22 -07:00
e1b9366898 bump manager version to 4.2.2 (#14471) 2026-06-14 14:42:03 -04:00
5897d0c3ae [Partner Nodes] feat(Tripo3d): add new "Import 3D" node (#14466)
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-06-14 17:19:20 +03:00
a1d95f3f82 Fix nondeterministic video decode at unaligned widths (CORE-299) (#14438) 2026-06-14 08:58:48 +08:00
64cc078069 Revert last commit. Last time I use this stupid GitHub app. 2026-06-13 12:50:31 -07:00
740d347279 Remove the comfy python path append. 2026-06-13 12:47:04 -07:00
b664349ae7 Expose deploy_environment in /system_stats (#14402) 2026-06-13 22:15:49 +08:00
fe54b5e955 Add 10-bit video support (#14452)
Create Video gets a bit_depth option (8-bit/10-bit); the selected depth is carried by the video and applied when it gets encoded. Save Video and Video Slice now keep the source bit depth instead of always quantizing to 8-bit, so 10-bit videos stay 10-bit. 10-bit uses h264 with the yuv420p10le pixel format,so there's no new codec or container.

Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-06-13 16:05:25 +03:00
7277d99d3a Use comfy kitchen apply rope in omnigen2 model. (#14442) 2026-06-13 09:38:39 +08:00
28a40fb2b2 [Partner Nodes] feat: add Runway Aleph2 node (#14306)
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-06-12 10:17:11 -07:00
d7a552720c add --high-ram option (#14437)
Add this option for users who know they have so much ram they want
to pin everything or have a pagefile that outruns their disk speed.

The removes the RAM pressure caps completely and pins behind the
primary model load forcing all models to be permanently comitted
to RAM.
2026-06-12 07:53:33 -07:00
02656ea0bb Fix potential dtype issue with ideogram 4. (#14436) 2026-06-12 07:51:12 -07:00
822aca1983 [Partner Nodes] feat: enable Bria Replace Background node (#14397) 2026-06-12 09:24:54 +08:00
bc5f8eca3b Add Comfy-Usage-Source pass-through for API node requests (#14404) 2026-06-12 09:20:44 +08:00
10d466b0e3 Don't crash when using flux kv cache with split batches. (#14422) 2026-06-11 16:38:06 -07:00
befc321438 Make --enable-manager-legacy-ui imply --enable-manager (#14421) 2026-06-12 06:45:22 +08:00
fb991e2c1e [Partner Nodes] fix(KlingTextToVideoNode): validation error for "kling-v2-master" model (#14418)
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-06-11 17:43:35 +03:00
ef470b61e4 [Partner Nodes] fix(GPT Image): handle mismatched image sizes returned when size="auto" (#14414)
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-06-11 11:28:17 +03:00
b97e60fc6b Fix SCAIL-2 reference mask background convention (#14415) 2026-06-11 16:17:04 +08:00
91187c58d9 Improve context window resizing for SCAIL2 (CORE-286) (#14394) 2026-06-11 13:37:43 +08:00
bda19b2604 ops: tolerate already force casted dynamic weight (#14410)
Some custom nodes .to weights completely out of load context which
can wreak havoc if its for a model that is not active. Detect this
condition and just let it fall-through to the non-dynamic loader
straight up.
2026-06-10 20:32:57 -07:00
33e6ebd0d9 I don't think this actually works anymore. (#14403) 2026-06-11 11:27:08 +08:00
74ee826790 chore(openapi): sync shared API contract from cloud@e3c52ad (#14406) 2026-06-11 11:15:53 +08:00
431a1888d3 revert(assets): drop job_ids filter from GET /api/assets (#14408)
The job_ids query filter added in #13998 has no live consumer: the
frontend Generated tab kept sourcing from GET /jobs, and the cloud side
removed its equivalent filter from the shared asset spec. Carrying it on
the local server only re-introduces Core<->Cloud drift on the shared
contract, so remove it to match.

Removed: the job_ids field + validator on ListAssetsQuery, the IN(...)
clauses in list_references_page, the service/route passthrough, and the
filter-only tests.

Kept: the canonical-UUID prompt_id enforcement at job creation (also
landed in #13998). It stands on its own -- job ids are matched verbatim
by history keys, websocket correlation, and /interrupt -- and cloud
inherits it by running core for execution, so no divergence is created.
2026-06-10 19:23:01 -07:00
ce200c0850 feat(assets): include asset id in executed WebSocket message (#13862)
* feat(assets): enrich executed WS message with asset metadata

When --enable-assets is set, each file-type output entry in the
`executed` WebSocket message now includes id, name, asset_hash, size,
and mime_type — matching the shape already returned by /upload/image.

The enrichment lives in comfy_execution/asset_enrichment.py (no torch
dependency) and is called from both send sites in execution.py: freshly
executed nodes register the file inline via register_file_in_place;
cached node re-sends look up the existing AssetReference by file path
to avoid re-hashing. Errors are caught per-entry so a failure never
blocks the WS message from sending.

* fix(assets): inject only id in executed WS message per Asset Identity RFC

Per the Asset Identity RFC, the executed WebSocket payload should carry
id alone — hash is already encoded in the filename, and name/preview_url/
size belong behind GET /api/assets/{id} rather than being pushed eagerly.

Simplifies the DB lookup path: we only need ref.id, so the asset.hash
null-check is no longer required as a fallback trigger.

* fix(assets): reject path traversal when resolving output abs_path

Subfolder/filename were joined and absolutized without containment check,
so '..' segments or an absolute filename could escape the type's base
directory and register an unrelated on-disk file as an asset.

Add commonpath-based containment check; skip enrichment (warn, leave
entry unchanged) when the resolved path escapes base. Catches ValueError
from cross-drive paths on Windows.

* docs(assets): drop Asset Identity RFC reference from docstring

* docs(assets): trim docstring to what enrichment does, not what it doesn't

* test(assets): use real platform paths so containment check works on Windows

The previous test setup patched os.path.abspath to identity and used a
POSIX-style '/output' base, which collided with Windows path separators
in os.path.commonpath. Drop the abspath/join patches and use a real
tempdir-rooted base so the containment check runs against actual
platform paths.

* refactor(assets): enrich at output-processing time, not in the WS send path

Per review: enrichment lived inside the client_id-guarded send sites, so a
headless run (no websocket client) never registered assets at all, and
ui_outputs/history stored the un-enriched entries.

Now output_ui is enriched once, right after the node produces it and before
it is stored in ui_outputs — so registration happens regardless of connected
clients, and the asset id flows into history and the execution cache for
free. _send_cached_ui re-sends the stored (already-enriched) dict verbatim,
which lets the DB-lookup-by-path fallback be deleted: every enrichment is
now a fresh output, and register_file_in_place re-hashes on upsert so an
overwritten path can never carry a stale id.
2026-06-10 17:04:52 -07:00
e5b7140dcc feat(assets): add job_ids filter to GET /api/assets (#13998)
* feat(assets): add job_ids filter to GET /api/assets

Mirrors the existing cloud `job_ids` query param on the local Python server:
clients can pass a comma-separated list (or repeated query params) of UUIDs
to filter assets by their associated job.

The `AssetReference.job_id` column already exists, so no migration is
needed — this just plumbs the filter through schema → service → query.

Marks the parameter as available in both runtimes by dropping the
`[cloud-only]` description prefix and the `x-runtime: [cloud]` tag from
the OpenAPI spec, per the OSS field-drift convention (absent runtime tag
= populated by both local and cloud).

* fix(assets): tighten job_ids — array schema, max_length, narrow except

From cursor-reviews on the parent commit:

- OpenAPI: declare job_ids as `type: array, items: string format: uuid`
  with `style: form, explode: true` so it matches the documented
  contract (and matches sibling include_tags/exclude_tags shape).
  Description now states both accepted shapes explicitly.
- Schema: cap `job_ids` at 500 entries (max_length on the Pydantic
  field) so a client can't splice an unbounded list into the IN clauses.
- Schema: drop `AttributeError` from the except — `raw` only contains
  `str` items by construction, so `uuid.UUID(<str>)` raises `ValueError`
  exclusively; the second clause was dead code.

* fix(assets): tighten job_ids validator + add schema-level tests

Aligns with the parallel hardening from draft PR #13848 (now closed as
a duplicate). The validator now:

- Raises ValueError on non-string list items (was: silently dropped).
- Raises ValueError on non-string / non-list top-level values like dict
  or int (was: silently passed through to Pydantic's downstream coercion).

Adds tests-unit/assets_test/queries/test_list_assets_query.py covering
the validator end-to-end: CSV canonicalization, dedup order, default
empty, invalid UUID, non-string list item, non-string non-list value,
and the max_length=500 boundary.

* feat(prompt): enforce canonical UUID prompt_id at job creation

POST /prompt previously accepted any client-supplied prompt_id verbatim,
str()-coercing even non-strings, and minting the literal job id "None"
for an explicit JSON null. The new GET /api/assets job_ids filter matches
stored job ids as canonical UUIDs exactly, so a non-UUID id minted a job
whose assets could never be filtered.

- validate_job_id (comfy_execution/jobs.py): requires a string in the
  canonical lowercase hyphenated UUID form; raises ValueError otherwise,
  including parseable-but-non-canonical spellings (uppercase, braced, URN,
  bare hex), which would otherwise be silently rewritten and then miss
  every exact-match lookup downstream (history keys, websocket
  correlation, /interrupt, the assets job_ids filter).
- POST /prompt: absent or null prompt_id means the server mints uuid4;
  invalid means 400 invalid_prompt_id on the standard error envelope.
- openapi.yaml: document the request-side prompt_id (format uuid,
  nullable) on PromptRequest.
- tests: unit matrix for validate_job_id; integration tests against the
  booted server covering rejection, acceptance, and null handling.

---------

Co-authored-by: guill <jacob.e.segal@gmail.com>
2026-06-10 16:55:25 -07:00
6d18f4adac main: force cudnn.benchmark to false (#14390)
Some custom nodes try to set this true globally. It messes with dynamic
VRAM with one-off spikes that can OOM but this is also very high risk
for windows where such allocations might get serviced by shared memory
fallback.

Trump it.
2026-06-10 13:54:32 -04:00
039ed38ed1 fix(assets): remove unused delete_content param from deleteAsset (#14241)
* fix(assets): remove unused delete_content param from deleteAsset

The delete_content query param on DELETE /api/assets/{id} was introduced
in #12125 and had its default flipped to false in #12621. In practice no
client sends it: the frontend issues a bare DELETE /assets/{id}, so every
real caller already gets the default soft-delete (the reference is hidden,
content preserved). The only thing that set delete_content=true was this
repo's own test teardown.

Remove the param from the route and the OpenAPI spec so the contract
matches what clients actually use (and lines up with the cloud surface).
The route now always soft-deletes. The underlying delete_asset_reference
helper keeps its delete_content_if_orphan option, so orphan reclamation
remains available internally for a future GC path — it's just no longer
exposed on the public endpoint. Tests that used delete_content=true for
hard cleanup now soft-delete; test_delete_upon_reference_count asserts
content preservation instead of orphan removal.

* test/docs: address review on deleteAsset delete_content removal

- Rename test_delete_upon_reference_count ->
  test_soft_delete_preserves_asset_identity_across_references; the old name
  implied last-ref cleanup, but it now verifies the opposite (soft delete
  preserves identity across references).
- Strengthen the re-association assertion: also check asset_hash == src_hash
  so it proves content reuse rather than relying on the now-tautological
  created_new is False.
- Document delete_asset_reference: the orphan-reclamation branch is
  intentionally internal-only; the public endpoint always soft-deletes.
- Normalize the soft-delete comment phrasing.

* test(assets): make seed content unique per test for isolation

Removing the delete_content param means delete is always a soft delete, so
content created by one test now survives into the next. The suite had been
relying on hard-delete teardown for isolation, so shared fixed-content
fixtures started colliding: seeded_asset (b"A"*4096) and
make_asset_bytes (deterministic on name) produced the same hash every test,
so the second seed deduped to the surviving asset and returned 200 instead
of 201, cascading into ~14 failures/errors.

Salt both fixtures with a per-test uuid so each test creates fresh content
(created_new True, 201), while keeping content deterministic within a test
(same name/size -> same bytes) and preserving exact byte length so size-based
list/sort assertions are unaffected.
2026-06-09 21:52:14 -07:00
84e0692a3d feat(assets): cursor-based pagination on GET /api/assets (#14014)
* spec(assets): add cursor pagination params to GET /api/assets

Add 'after' query param and 'next_cursor' response field for keyset
pagination. Matches the cloud Go implementation (BE-893) so frontend
sees a unified contract across runtimes. Offset/limit remain as a
deprecated fallback.

* feat(assets): add cursor encode/decode helpers for keyset pagination

Port of cloud common/pagination/cursor.go. Wire format is base64url of
{"s", "v", "id"} JSON; times are Unix microseconds UTC to match
PostgreSQL timestamp precision.

Includes a byte-identity fixture pinned against the cloud Go wire
format so cross-runtime FE pagination can't silently drift.

* feat(assets): thread cursor through schemas, service, and query layer

list_assets_page accepts an opaque 'after' cursor and returns
next_cursor when more pages are available. The query applies a keyset
WHERE clause and a secondary ORDER BY id for deterministic tiebreak.

Cursor sort field is validated against the request sort, and a
last_access_time sort (OSS-only) falls back to offset/limit. Offset is
ignored whenever a cursor is supplied.

* feat(assets): wire cursor pagination through GET /api/assets handler

Adds integration tests for: full cursor walk, invalid-cursor 400,
sort/cursor mismatch 400, cursor-wins-over-offset, absent next_cursor
when no more results, and pagination stability across deletes.

* fix(assets): address cursor-review verified findings

- Mint next_cursor on every cursor-supported sort, not only when 'after'
  was supplied. A first request (no 'after') previously returned
  next_cursor=None, leaving cursor mode unreachable from a clean start.
- Over-fetch limit+1 so an exactly-full terminal page doesn't mint a
  spurious cursor pointing at a phantom next page.
- Map crafted out-of-range microsecond cursors (OverflowError / OSError
  in datetime construction) to 400 INVALID_CURSOR instead of leaking 500.
- Bump MAX_CURSOR_VALUE_LENGTH 256 -> 512 to match the AssetReference
  name column max; without this, a long-named asset minted a cursor the
  same server then refused on the next request. Cross-runtime byte
  identity with cloud is unaffected because no cloud cursor ever carries
  a value > 256 (cloud schema doesn't permit it).
- Return None from _encode_next_cursor when the boundary row carries a
  NULL sort value (e.g. an Asset without size_bytes backfilled), instead
  of silently encoding 0 and mis-positioning the keyset.
- Fix schemas_in.py comment so it matches actual handler behavior
  (last_access_time + 'after' raises 400, does not fall back).
- Add AssetsApiError schema + 400 response to GET /api/assets in
  openapi.yaml so generated clients know the INVALID_CURSOR envelope.
- Extend integration coverage: first-page mint, exact-multiple terminal
  page, cursor walks for created_at/updated_at/size sorts, datetime
  overflow surfaces as 400 not 500.
- Add unit coverage for datetime overflow and 512-char round-trip.

* feat(assets): bind cursor to sort order + Go-compat JSON escaping

Address three needs-judgment items from the cursor-review judge synthesis:

1. Cursor wire format now includes an "o" key carrying the sort
   direction ("asc" / "desc") it was minted under. A request that
   replays the cursor with a flipped `order` parameter is rejected
   with 400 INVALID_CURSOR instead of silently walking the wrong
   direction. Legacy cursors without "o" still decode (the binding
   is best-effort until cloud mirrors the field — follow-up filed
   separately).

2. JSON serialization now escapes `<`, `>`, `&`, U+2028, U+2029
   to mirror Go's default `json.Marshal` behavior. Without this, an
   asset name containing those characters produced different bytes on
   Python vs cloud Go. The escaped form is what both runtimes emit.

3. Add direct query-layer tests for the keyset tiebreaker — the secondary
   ORDER BY id branch was previously unexercised. Two scenarios: all
   rows share a primary sort value, and mixed ties straddle page
   boundaries. Both assert no row is dropped or duplicated across the
   walk.

Wire-format note: Python cursors now differ from current cloud cursors
by exactly the "o" key. Cloud follow-up will bring the two back into
byte alignment.

* fix(assets): address bot review comments

- Soften offset param prose: it's not deprecated, just not preferred for
  sequential walks. Random-access UIs (jump-to-page, item count displays)
  legitimately still want offset, so dropping the 'deprecated' framing
  rather than promoting it to a machine-readable deprecated:true flag.
- Add explicit HTTP status assertions before every json() / next_cursor
  read in test_list_cursor.py so a failing request surfaces as an HTTP
  error instead of a confusing KeyError on a 4xx/5xx body.

* feat(assets): require cursor o field, drop legacy permissive path

Cursor pagination hasn't shipped on either runtime yet — this PR is
still draft and cloud's mirror is just behind it — so there are no
legacy no-o cursors in the wild. Make o mandatory from day one
rather than landing permissive and tightening later.

decode_cursor now rejects any payload without o (or with a non-string
o) as malformed. CursorPayload.order becomes a required str. Tests
that constructed CursorPayload directly now pass order="desc";
test_legacy_cursor_without_order_accepted flips to
test_cursor_without_order_rejected.

* chore(assets): drop cross-repo prose from cursor comments

Strip prose references to sibling Go implementations and external
ticket IDs from cursor.py, the cursor tests, the keyset integration
tests, asset_management's sort-field comment, and the legacy
prompt_id alias comment. Pure docstring/comment scrub — no behavior
or wire-format changes. x-runtime: [cloud] field annotations in
openapi.yaml are unchanged; those are the spec's structural
cross-runtime convention, not internal references.

* test(assets): include 'o' in microsecond-boundary cursor payload

The boundary test was building a cursor without the required `o` key, so
decode failed on the missing-order branch before reaching the µs-overflow
path the test is asserting. Both paths return 400 INVALID_CURSOR so the
assertion passed for the wrong reason. Add `o` to the payload and matching
`order=` to the request so the decode reaches the intended branch.

* fix(assets): address ultrareview findings on cursor pagination

Six fact-checked findings from the multi-model review pass:

- Encoder/decoder length asymmetry: encode_cursor now rejects empty id,
  oversized id (>128), oversized value (>512), and invalid order tokens
  symmetrically with decode_cursor. Prevents the same server from minting
  a cursor it then 400s on the next request (e.g. a filesystem-scanned
  asset name >512 chars). The bad-order path now raises InvalidCursorError
  (still subclasses ValueError) so route-layer handling stays uniform.
- Raw U+2028/U+2029 in cursor.py source: ripgrep treated those lines as
  line-terminators, confirming the bytes were the actual separators. Any
  editor save / autoformat / git tooling that normalizes invisibles would
  silently break the encoder. Replaced with explicit 
 / 

  Python escape sequences.
- set(seen) == set(names) hid ordering regressions: a cursor walk that
  dropped a row at a page boundary or returned duplicates could pass.
  Reworked the assertion to (1) reject duplicates, (2) require full
  coverage, and (3) assert strict positional order for size sort, the
  only field with a clock-independent ordering.
- Flaky time.sleep(0.05) between inserts: Windows CI clock resolution is
  ~15ms, so back-to-back inserts under load could collide and exercise
  the tiebreaker instead of the documented path. Removed the sleep and
  let the strengthened assertion above carry coverage / no-duplicates,
  with size sort carrying strict order.
- Cursor error envelope diverged from the rest of routes.py: cursor 400s
  emitted {error: {code, message}} while every other 400 in the file
  emits {error: {code, message, details}} via _build_error_response.
  Switched to _build_error_response and added the details field to the
  AssetsApiError schema in openapi.yaml.
- "Byte-identity fixtures" only checked substring containment, defeating
  the test class's stated purpose of pinning the wire format. Switched
  to exact-bytes equality against an inline expected payload string per
  fixture, so any whitespace / key-order / escape drift fails loudly.

Also dropped Go / json.Marshal references from docstrings — the byte
format is the contract, not the runtime that mints it.

* fix(assets): cap cursors by encoded wire size, not just char count

Char-count guards on value/id can still let multibyte or escape-heavy
inputs blow past MAX_ENCODED_CURSOR_LENGTH once UTF-8 + escape expansion
+ base64url runs. A 512-character name of 'é' (2 bytes UTF-8) or '<'
(serializes to the 6-byte '<' escape) passes the char check, mints
a ~1500-byte cursor, then 400s when handed back on the next request.

Compute the final encoded form and reject it before returning if it
exceeds the wire cap. Adds regression tests for both inflation paths.

* refactor(assets): extract cursor JSON escaping helper; size wire cap above per-field caps

Addresses review feedback on cursor.py:

- Extract the inline escape chain into _apply_wire_compatible_json_escapes()
  with a comment pinning it to the wire format's escape set, so the parity
  intent is explicit rather than reading as an ad-hoc transform.
- Raise MAX_ENCODED_CURSOR_LENGTH to 8192 (comfortably above the ~5.2KB
  worst-case the per-field caps can produce) and drop the mint-time length
  guard. Encoder/decoder symmetry now holds by construction: the encoder
  can't produce a cursor the decode path rejects, so there is no confusing
  user-visible 'cursor too long' failure at mint time.
- Rewrite the two over-wire-cap tests to assert worst-case multibyte and
  escape-heavy values mint and round-trip, instead of being rejected.

* refactor(assets): drop cross-runtime cursor escaping; cursors are opaque

The custom JSON escaping of <, >, &, U+2028, and U+2029 existed only to
keep the encoded cursor byte-identical with the Cloud implementation of
the same payload format. Cursors are opaque tokens, so byte-level
compatibility across implementations is not needed — plain json.dumps
output is sufficient. Remove the escaping helper and the byte-identity
test fixtures that pinned the wire format; keep round-trip coverage for
the affected characters.

---------

Co-authored-by: guill <jacob.e.segal@gmail.com>
2026-06-09 21:14:03 -07:00
a76bb4380e chore(assets): drop vestigial tags.tag_type column (#14248)
tag_type was always "user" in practice — no code path ever set it to anything
else (no system/seeded classification was wired up) and nothing queried it. The
column, its ix_tags_tag_type index, and the TagUsage.type API field were dead
weight, so they're removed. Adds alembic migration 0004 to drop the column and
index.

Verified: asset-seeder tests pass; migration applies cleanly on a fresh SQLite
(tags retains only name; tag_type column + index dropped).

Co-authored-by: guill <jacob.e.segal@gmail.com>
2026-06-09 21:07:10 -07:00
f350acdf21 [Trainer/bug] Ensure model is not inference mode (CORE-72) (#13400)
* Ensure model is not inference mode

* force clone inside training mode to avoid inference tensor

* Allow force deepcopy for model patcher
2026-06-09 23:07:47 -04:00
46d45aade1 chore(openapi): sync shared API contract from cloud@ca12913 (#14367) 2026-06-10 09:58:42 +08:00
5fcf7a4a0f Always enable cuda malloc on cu130 and higher. (#14381) 2026-06-09 21:39:24 -04:00
5ece24e73c Depth anything 3 (Core-135) (#13853)
Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
2026-06-10 09:28:24 +08:00
f8e51b674c feat: Add Bernini-R model support (Wan video) (CORE-279) (#14216) 2026-06-10 07:47:34 +08:00
ad564899d3 Ensure conditions are not trainable to avoid bugs (#14368) 2026-06-09 15:55:29 -04:00
6f01b244a2 mm: dont reset cast buffers in cleanup_models_gc() (#14372)
cleanup_models_gc can be called once per load_models_gpu via
free_memory, which in turn can de-activate an active model via
this reset_cast_buffers.

cleanup_models_gc() could also come via obscure garbage collector
paths so limit reset_cast_buffers to the post-node callsite instead.
2026-06-09 13:57:04 -04:00
9fc6f5f6dd Move bg_removal_model input socket to first position for nicer display (#14353) 2026-06-09 23:36:56 +08:00
184009c2f6 feat: Add model support for SCAIL-2 (#14373)
* initial SCAIL2 support
2026-06-09 18:24:09 +03:00
07c53f8f0f Add LoRA key mapping for LTXV/LTXAV models (#14349) 2026-06-09 09:57:58 -04:00
1639dc7a70 main/server: Add --debug-hang (#14371)
Add an option to debug a hang with ctrl-C, dumping the backtraces to
see where its stuck or slow.
2026-06-09 09:55:00 -04:00
8ed7f458d0 Allow custom templates with Ideogram4 TE (#14374) 2026-06-09 21:11:05 +08:00
f89999289a fix: Add back apply_rotary_emb for Qwen Image (#14364) 2026-06-09 11:55:49 +08:00
cb9f639416 chore(openapi): sync shared API contract from cloud@5273c30 (#14266) 2026-06-09 11:19:13 +08:00
00b633f368 Revert "Add SeedVR2 support (CORE-6) (#14110)" (#14359)
This reverts commit 7863cf0e53.
2026-06-08 18:00:20 -04:00
a0a055bc4e feat(assets): extract image dimensions at ingest and emit on asset responses (#13991)
* feat(assets): extract image dimensions at ingest and emit on asset responses

Image assets now carry width/height under the existing `metadata` field on
asset responses, shaped as `{"kind": "image", "width": W, "height": H}`.
This lets consumers get original dimensions (e.g. for clients that render
server-side thumbnails and can't recover them from naturalWidth/Height)
without an extra round-trip.

Dimensions are written to AssetReference.system_metadata across three
ingest paths:

- Direct file ingest (upload, in-place registration): Pillow reads the
  image header right after hashing, while the file is still in OS page
  cache. Non-image MIME types are skipped without touching the file.
- From-hash registration: this path never reads the file bytes, so
  dimensions are best-effort copied from any prior sibling reference of
  the same asset that already carries kind=image metadata. Missing
  siblings, non-image siblings, or absent dimension keys leave the new
  reference's metadata unchanged.
- Scanner enrichment: extends the existing system_metadata write in
  enrich_asset so scanner-registered images get the same treatment as
  uploaded ones.

Existing system_metadata keys (e.g. safetensors fields written by the
enricher, download provenance) are preserved through merge. Existing
assets ingested before this change retain their current metadata — no
automatic backfill in this PR.

Tests cover image emission, non-image no-op, merge preservation, and the
from-hash sibling back-fill (including the no-sibling and non-image-sibling
cases).

* fix(assets): validate sibling dimensions before backfilling

Per CodeRabbit review on #13991: the previous loop accepted any sibling
with `kind == "image"` and copied whichever dimension keys happened to
be present, then returned. A partial sibling (kind set but missing or
invalid width/height) could persist incomplete metadata onto the new
reference even when a later sibling had valid dimensions.

Now we validate that the sibling has both width and height as positive
integers before adopting its dimensions, and continue scanning to the
next sibling otherwise.

* fix(assets): reject booleans in sibling dimension validation (use type-is)

Per CodeRabbit follow-up on #13991: bool is a subclass of int in Python,
so isinstance(True, int) is True. The previous strict-int gate would
have accepted width=True (truthy + > 0) as a valid dimension.
Realistic occurrence is low (extract_image_dimensions returns proper
ints, JSON doesn't serialize bools as numbers), but the validation gate
exists for defense-in-depth so it should be actually strict.

---------

Co-authored-by: guill <jacob.e.segal@gmail.com>
2026-06-08 14:27:50 -07:00
a1c434eb65 Improve ResolutionSelector (#14309) 2026-06-09 01:05:10 +08:00
fc258b10e5 Add Color primitive (#14260) 2026-06-09 00:30:28 +08:00
38f750d80e chore: update embedded docs to v0.5.3 (#14350) 2026-06-08 22:58:52 +08:00
7863cf0e53 Add SeedVR2 support (CORE-6) (#14110) 2026-06-08 18:15:05 +08:00
739061dd4c Use windows line endings for windows portable readmes. (#14334) 2026-06-07 23:56:53 -04:00
2cdaaf4a25 Update line endings check to ignore .ci files. (#14319) 2026-06-06 19:33:03 -07:00
ea36cb16d6 feat(3d): reorder Preview3DAdvanced / PreviewGaussianSplat / PreviewPointCloud inputs and outputs (#14308) 2026-06-05 19:01:57 -07:00
a65a5464c7 BE-1172 fix(3d): save Preview3DAdvanced / PreviewGaussianSplat / PreviewPointCloud to temp/, rename viewport input (#14294) 2026-06-05 14:18:41 -07:00
986ce5b4f0 Update AMD portable readme. (#14303) 2026-06-05 12:41:44 -07:00
2ef2cf1a7c feat: add PreviewGaussianSplat + PreviewPointCloud nodes (#14194) 2026-06-05 12:30:58 -07:00
aeee53ff6a [Partner Nodes] feat: add temperature and top_p to NanoBanan node (#14305) 2026-06-05 11:52:15 -07:00
4a00126e9c [Partner Nodes] feat: add new Gemini text node (#14299) 2026-06-05 10:31:55 -07:00
ec6aa979a6 aimdo 049 (#14300) 2026-06-05 08:40:03 -07:00
410df27253 Fix interoperation with external source of pinned memory pressure (#14252)
* mm: split off registration helper to doer and headroom calc

* pinned_memory: implement registration comfy side

Move away from Aimdo buffer registrations which seem fraught with
danger and do it comfy side. Just start with the basic move.

* pinned_memory: do registrations as portable memory

* pinned_memory: discard async errors on registration fail

Like the good ol days.

* pinned_memory: implement abs shortfall retry

If pinned registration happens to fail despite the previous budget
ensures, consider the allocation shortfall, ensure it again, and
try again. This allows comfy pins to interoperate with other software
that might be doing substantive pinning.
2026-06-05 08:39:35 -07:00
5aa71b9bc2 Enable cfg1 optimization for DualModelGuider with CFGGuider (#14290)
* Enable cfg1 optimization for DualModelGuider

* Fix CFG Override tooltip
2026-06-05 10:04:10 +03:00
ab0d8a9203 Consolidate audio nodes into SaveAudioAdvanced node (CORE-202) (#13871) 2026-06-04 19:29:41 -07:00
514bb8ba21 Fix ideogram if model dtype gets set to fp8. (#14291) 2026-06-04 19:20:22 -07:00
4e1f7cb1db Bump comfyui-frontend-package to 1.45.15 (#14265) 2026-06-04 11:41:33 -07:00
6ecca5f468 chore: update workflow templates to v0.9.98 (#14284) 2026-06-04 09:40:44 -07:00
27b5c423a6 [Partner Nodes] feat: add seed input to Flux Erase node (#14283)
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-06-04 19:32:15 +03:00
1f9e7df52a [Partner Nodes] feat: add Krea 2 Medium Turbo model (#14280) 2026-06-04 08:24:22 -07:00
0a92dd9c09 [Partner Nodes] feat: add Bria Green Background node (#14277) 2026-06-04 07:47:20 -07:00
4d360f9c9d [Partner Nodes] fix (Seedance 2.0): prevent 1080p first/last-frame stretch jump (#14251)
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-06-04 11:23:52 +03:00
4f99ce0f8c [Partner Nodes] fix SaveWEBM node to save alpha channel; add BriaTransparentVideoBackground Partner node (#14257) 2026-06-03 16:05:48 -07:00
7758b9b321 fix: Image grid bug fix (CORE-215) (#14100) 2026-06-03 16:03:32 -07:00
bb84c75283 chore(openapi): sync shared API contract from cloud@7c470f0 (#14174) 2026-06-03 13:20:30 -07:00
f49bdb6557 ComfyUI v0.24.0 2026-06-03 12:42:13 -04:00
8e3045a90b Memory usage factor for ideogram 4 on non dynamic vram. (#14264) 2026-06-03 12:19:18 -04:00
f0619af659 chore: update workflow templates to v0.9.94 (#14263) 2026-06-03 09:10:26 -07:00
f69225df24 Mark DualModelGuider as experimental (#14262) 2026-06-03 08:55:18 -07:00
24f9a020ce Support Ideogram4 (#14259) 2026-06-03 08:41:44 -07:00
c7a22e1b4e [Partner Nodes] feat: add Ideogram V4 node (#14261)
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-06-03 18:13:20 +03:00
bd7da053ae comfy-aimdo: 0.4.8 (#14244)
Aimdo 0.4.8 fixes a crash in multi-gpu due to contention on the
singleton bounce buffer.
2026-06-02 18:57:16 -07:00
d4c7ebff9c Remove old useless no comfy kitchen fallback. (#14245)
* Remove old fallback used when no comfy kitchen.

* Remove unused logging import
2026-06-02 17:52:41 -07:00
dc10c0133e PiD: Add SDXL and QwenImage (#14240) 2026-06-02 12:40:49 -07:00
e9207aa7cc fix (MultiGPU): prevent freeze on manual abort when using MultiGPU CFG Split (#14235)
* fix (MultiGPU): prevent freeze on manual abort when using MultiGPU CFG Split

Problem:
Upon manual abort application hangs indefinitely.
`InterruptProcessingException` inherits from `BaseException` and bypasses MultiGPU's worker error handling block so thread dies silently, leaving the main thread waiting forever for `result_q.get()`

Fix:
Catch `comfy.model_management.InterruptProcessingException` instead of `Exception` so it's caught and passed back via `result_q` to unblock the main thread when manual abort signal fires.

* oops
2026-06-02 10:05:24 -07:00
c96fcddb81 Radiance: support variant with nonzero txt_ids (#14206) 2026-06-01 22:07:48 -07:00
e88a81d316 Revert partner nodes category (#14229) 2026-06-01 21:24:46 -07:00
33799c4a2e Fix uncaught OverflowError in Math Expression node for large int results (#14214) 2026-06-01 18:15:04 -07:00
4b48535a7d Do tripo dinov3 inference in fp32. (#14221) 2026-06-01 18:08:20 -07:00
06b710aa68 Fix issue with triposplat preview and old offloading mode. (#14218) 2026-06-01 14:35:52 -07:00
e785f0d212 Some cast/dtype fixes for the birefnet and dino3 models. (#14217) 2026-06-01 14:35:26 -07:00
a88e02b185 ComfyUI v0.23.0 2026-06-01 13:05:25 -04:00
0b610bd63a [Partner Nodes] fix: respect VideoSlice trim when resizing videos (#14213) 2026-06-01 09:09:57 -07:00
412d9ac33a chore: update workflow templates to v0.9.92 (#14212) 2026-06-01 07:41:00 -07:00
af58c5e674 [Partner Nodes] feat: add Flux Virtual Try-On and Erase nodes (#14207) 2026-06-01 07:12:12 -07:00
462c27fdb2 feat: Add TripoSplat support (#14210) 2026-06-01 07:01:50 -07:00
70a2e1a851 Remove old portable updater migration code. (#14202)
* Remove old portable updater migration code.

This is 2 years old so I don't think it's needed anymore.

* Delete new_updater.py
2026-05-31 20:47:00 -07:00
4f7882a7be [Partner Nodes] feat: added grok-imagine-video-1.5 model to the GrokVideo node in First Frame mode (#14198) 2026-05-31 20:40:49 -07:00
c37d2a0dac feat: Add gaussian splat nodes (#14190) 2026-05-31 11:47:29 -07:00
cd45f42a83 fix(multigpu): replace hardcoded torch.cuda.set_device with device-agnostic set_torch_device (#14191) 2026-05-30 21:18:42 -04:00
81aa5a38b2 Speed up ernie model by a bit on nvidia and use higher quality rope. (#14192) 2026-05-30 17:53:37 -07:00
ea73d3b2ea chore: update embedded docs to v0.5.2 (#14193) 2026-05-31 07:49:59 +08:00
08e93a31a3 feat: add Preview3DAdvanced node (#14175)
Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
2026-05-30 17:57:36 -04:00
f7297bc5a9 Revert deprecation of non-dynamic smart memory (CORE-152 (revert)) (#14183)
* mm: re-instantate smart memory for VRAM

* mm: restore non-dynamic smart memory

By popular demand. We aren't quite ready for the deprecation as non
dynamic enabled GPUs and some high-vram custom model loader setups
prefer the old full hands on.
2026-05-30 15:20:33 -04:00
e154da83b1 Threaded Loader performance fixes / improvements (+ Aimdo 0.4.6) (#14116)
* memory_management: Add direct to read GPU mode

Make destination optional (or make it optionally GPU) and use aimdo
to file_read direct to GPU.

* ops: Remove stream pin buffers and use aimdo reads

This consumed too much RAM and its better to just take the hit on
the CPU syncing back the stream on a short ring buffer. Aimdo
implements this so just rip the stream pin buffer from comfy.

* model_management: all active pin registration movement

Its better to just let the active model load past the pin limit as
pins and let the pins move around. The saves the HDD and SATA
people disk traffic while only costing a few GPU syncs.

* utils: use aimdo file handle

This opens on windows with more favourable flags

* mp: only count the model proper for loaded_ram and vram

Exclude live loras from the numbers to avoid the case where the reported
loaded memory exceeds the size of the model.

This causes me confusion in the Kijai visualizer when it looked fully
loaded but was hitting disk due to this accounding disrepency.

* utils: add bit reverse utility

useful for max scattering something ordered.

* pinned_memory: Implement offload balancing

Use a max scatter alogorithm to prioritize pins of the same size such
that when doing a little bit of offloading it gets scattered, allowing
the prefetcher to more evenly swollow the offload.

* comfy-aimdo 0.4.7

Aimdo 0.4.7 implement VRAM buffer exhaustion predection to avoid
early speculative load of weights that definately wont fix once the
inference gets further in.

* model-prefetch: consolidate pin ensures on the sync point

This could happen mid prefetch block, cause a sync of the entire
block and lose overlap. Get ahead of the problem with a free down
at the natural compute stream sync point.

* mm: Put a 2GB min on the pin ceiling

This is reasonably bad if it starts causing swap pressure, moreso than
during normal ram-cache proceedings. Clamp it.

* add --fast-disk
2026-05-30 15:20:04 -04:00
bb560036b9 feat(io): add File3DPLY / File3DSPLAT / File3DSPZ / File3DKSPLAT types (#14185) 2026-05-30 09:39:26 -04:00
0b04660ba3 Speed up anima a bit on nvidia. (#14181) 2026-05-29 22:47:10 -07:00
6e1ef2311b Remove useless code. (#14178) 2026-05-29 16:26:46 -07:00
ec1896aceb [Partner Nodes] feat: add new nodes for Tripo3D P1 model (#14155) 2026-05-29 09:19:53 -07:00
54d5be4a8e Fix background removal mask output shape (#14171) 2026-05-29 09:14:32 -07:00
ea5b092576 [Partner Nodes] fix: removed "beta" models versions from Grok nodes (#14170) 2026-05-29 09:08:43 -07:00
e7214d78ee feat: add model_info output to Load3D node (#14144) 2026-05-29 00:06:00 -07:00
b10a61615c chore: update workflow templates to v0.9.91 (#14163) 2026-05-28 22:42:17 -07:00
684296148e float: use CK stochastic rounding cuda kernel (#13971) 2026-05-28 19:23:42 -07:00
ade4dfd96a Update and pin comfy-kitchen version to 0.2.9 (#14161) 2026-05-28 19:23:17 -07:00
26aad73cd7 refactor: drop rotation from Load3DCamera (#14159) 2026-05-28 17:42:47 -07:00
bcf805aaea Bump pyav package to fix some image loading issues. (#14160) 2026-05-28 17:38:01 -07:00
6dd3c67427 Add unreviewed merge detector for SOC 2 compliance (#14146) 2026-05-28 15:07:22 -07:00
6ceec29bd1 feat: add overwrite/increment to SaveImageTextDataSetToFolderNode (#13215) 2026-05-28 10:12:04 -07:00
cffa2f43aa [Partner Nodes] chore: update the category of the Beeble nodes (#14156)
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-05-28 19:23:51 +03:00
4af9a47227 [Partner Nodes] fix: add runtime check for SeeDance2 image inputs (#14152)
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-05-28 01:03:28 -07:00
be06873d9b Make Load3D model_file optional by adding "none" choice (#13379) 2026-05-27 23:16:28 -07:00
8ed308bcde feat: add camera intrinsics fields to Load3DCamera info (#14143) 2026-05-27 22:34:43 -07:00
174208df6b chore: Update nodes categories (#14145)
* Move dataset/text nodes to text category

* Rename category utils into utilities

* Rename category api node into partner

* Move categories conditioning, latent, sampling, model_patches, training, etc. under model category

* Dispatch partner nodes in to 3d, audio, image, text, video categories

* Move PreviewAny node to utilities category
2026-05-27 20:43:33 -04:00
85a403d1ea Disable sage attention in stable audio dit and VAE. (#14148) 2026-05-27 20:35:03 -04:00
987a937658 Support context window for PiD and fix lq_latent rounding (#14136) 2026-05-27 12:08:06 -07:00
51ef17e8a6 [Partner Nodes] feat: Beeble SwitchX nodes (#14137)
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-05-27 11:57:55 -07:00
b1cba6f4e6 convert nodes_lt_upsampler nodes to V3 schema (#12423) 2026-05-27 11:11:43 -07:00
175e85466a [Partner Nodes] feat: add Krea2 nodes (#14130) 2026-05-27 05:39:32 -07:00
53eba227f5 chore: update workflow templates to v0.9.85 (#14134) 2026-05-27 05:32:58 -07:00
0cce76d402 [Partner Nodes] feat: improve video references uploading for SeeDance 2 (#14098)
* [Partner Nodes] feat: improve video references uploading for SeeDance 2

Signed-off-by: bigcat88 <bigcat88@icloud.com>

* [Partner Nodes] hash video via memoryview to avoid memory copy

Signed-off-by: bigcat88 <bigcat88@icloud.com>

---------

Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-05-26 23:44:27 -07:00
2072d3e46d fix: Stop LTXVCropGuides leaving stray latent frames when guides share a start position (#13882) 2026-05-26 19:59:32 -07:00
e75a92c1b6 Add memory usage factor for lens model. (#14124) 2026-05-26 18:06:51 -07:00
d8d860a588 Closer memory usage factors for PID (#14123) 2026-05-26 18:04:55 -07:00
28f4ef277c feat: Support NVIDIA PixelDiT and PiD (CORE-201) (#14103) 2026-05-26 17:50:14 -07:00
921775704c openapi: document QueueManageResponse body on POST /api/queue (#14117)
* openapi: document QueueManageResponse body on POST /api/queue

The Cloud runtime returns a JSON body from POST /api/queue describing which
prompts were deleted and whether the queue was cleared. The spec previously
declared a bare 200 with no schema, so generated clients had no type for the
response.

Adds a QueueManageResponse schema ({deleted, cleared}) and references it from
the 200 response. Tagged x-runtime: [cloud] with a [cloud-only] description:
local ComfyUI returns an empty 200 body, so both fields are nullable.

* openapi: fix GET /api/hub/labels response to the label-catalog shape (#14118)

* openapi: fix GET /api/hub/labels response to the label-catalog shape

GET /api/hub/labels returns the catalog of available labels you can filter by,
which the Cloud runtime serves as {labels: HubLabelInfo[]} (slug name,
display_name, and a type category: tag/model/custom_node).

The spec had this operation returning a bare array of HubLabel ({id, name,
color}) — that schema models the label chips attached to a published workflow
(HubWorkflow.labels), a different object. The catalog schema (HubLabelInfo)
already existed but was unreferenced.

Repoints the 200 response to a new HubLabelListResponse wrapper over the
existing HubLabelInfo. HubLabel is unchanged and still used by
HubWorkflow.labels. Endpoint remains x-runtime: [cloud].

* openapi: add Cloud-runtime fields (workflow_id, execution_error) to JobEntry (#14119)

* openapi: add Cloud-runtime fields workflow_id, execution_error to JobEntry

The Cloud runtime returns two additional fields on JobEntry that the spec
didn't declare:

- workflow_id: UUID of the Cloud workflow entity the job is associated with
- execution_error: structured ComfyUI execution error for failed jobs
  (reuses the existing ExecutionError schema)

Both tagged x-runtime: [cloud] with [cloud-only] descriptions; local ComfyUI
does not populate them.

* openapi: document Cloud-runtime request fields on POST /api/assets/export (#14120)

The Cloud runtime accepts three request fields on /api/assets/export that the
spec didn't declare:

- job_ids: include all assets associated with the given jobs
- naming_strategy: how to name files in the ZIP (enum, default group_by_job_time)
- job_asset_name_filters: optional per-job asset-name allowlist

Also drops asset_ids from required: the runtime supports exporting by job_ids
alone, so neither field is individually required.

/api/assets/export is already x-runtime: [cloud]; these are plain field
additions under that endpoint-level tag.
2026-05-26 16:25:20 -07:00
f9f54cae42 Lens: some cleanup (#14112)
* Lens: remove redundant memory optimization
2026-05-26 10:32:53 +03:00
41812fa0ac feat: Microsoft Lens support (CORE-248) (#14077) 2026-05-25 23:01:51 -07:00
57414dadfe fix: cross-attention AdaLN scale, shift, sigma parameters calculation (#14097) 2026-05-25 20:07:09 -07:00
88956e77af multigpu: use unet_manual_cast for SelectModelDevice compute dtype (#14108) 2026-05-25 20:03:37 -07:00
da49b7d0b6 Remove useless annotations imports. (#14105) 2026-05-25 19:23:29 -07:00
0a2dd86e78 MultiGPU Work Units For Accelerated Sampling (CORE-184) (#7063) 2026-05-25 18:26:40 -07:00
04879a8113 Add new open-source model and built-in tool blueprints (#13980) 2026-05-25 12:25:16 -07:00
6de7fc063b Emit hash alongside asset_hash on all Asset responses (#13739)
* Emit `hash` alongside `asset_hash` on all Asset responses

Add a `hash` field to the Asset response schema that carries the same
value as the existing `asset_hash` field. Both fields are now populated
in _build_asset_response, so every Asset-returning endpoint (GET, POST,
PUT) includes both.

No existing fields are removed. Tests updated to assert both fields.

Co-authored-by: Matt Miller <MillerMedia@users.noreply.github.com>

* Tighten hash field tests and DRY response builder

- Extract assert_hash_fields_consistent() helper that verifies presence
  parity and value equality, replacing body.get()-based assertions that
  treated missing keys and explicit nulls identically.
- Conftest seeded_asset fixture and seed-asset list assertions now check
  key absence directly, so a regression that surfaces null fields would
  be caught (validates exclude_none behavior).
- DRY duplicate hash expression in _build_asset_response.
- Add list-endpoint coverage asserting hash is present and consistent on
  populated assets.
- Add schema-level test asserting AssetCreated inherits the hash field
  from Asset, guarding against future inheritance drift.

---------

Co-authored-by: Matt Miller <MillerMedia@users.noreply.github.com>
Co-authored-by: guill <jacob.e.segal@gmail.com>
2026-05-25 11:21:35 -07:00
a4141a0f5a chore: update embedded docs to v0.5.1 (#14101) 2026-05-26 01:57:18 +08:00
0077d78cbf Save Image advanced node (CORE-32) (#13850) 2026-05-24 23:01:34 -04:00
63bcaec5d1 Add colored logs (#14036) 2026-05-25 10:00:55 +08:00
b30e980a20 cache-ram: lower thresholds (#14089)
Use the RAM right up to the wire as the community is bit accustomed too.

This trades off headroom for the case where large chunky intermediates
arrive and potenitally hits pagefile/swap, but a lot of people have
"it just fits" workflows out there, so strike a compromise with
75->90%.

Disable the incative cache for all but the very high RAM users.
2026-05-24 15:26:50 -07:00
39f963b4b0 mark loads to pins as cold immediately (#14088)
This does the posix_fadvise to kick pins out of the disk cache (to
avoid a double copy in RAM).
2026-05-24 15:25:59 -07:00
ea62dc11c9 openapi: fix invalid BillingStatus schema (object + enum hybrid) (#14071) 2026-05-24 10:58:35 +08:00
32a7092c52 fix: correct description of where compiled FE files live (#14013) 2026-05-24 10:48:31 +08:00
08d809d128 Fix --use-flash-attention ignored when xformers installed. (#14083) 2026-05-23 17:44:28 -07:00
0af123022d Bump comfyui-frontend-package to 1.44.19 (#14074) 2026-05-24 08:27:52 +08:00
d80fcafee7 Remove dead code. (#14072) 2026-05-22 19:56:36 -07:00
187442cca4 openapi: add enum values + FeedbackRequest schema for cloud cutover (PR E) (#14070)
* openapi: add enum values + FeedbackRequest schema for cloud cutover (PR E)

Adds missing cloud-runtime enum values to vendor schemas that the
cloud runtime emits but vendor declared as plain strings.

Changes:
  - JobEntry.status: enum [pending, in_progress, completed, failed, cancelled]
  - JobDetailResponse.status: same enum
  - BillingStatus: enum [awaiting_payment_method, pending_payment, paid,
      payment_failed, inactive]
  - FeedbackRequest schema added (with type enum)
  - /api/feedback POST: requestBody now $refs FeedbackRequest

All cloud-runtime-emitted; no impact on OSS-local semantics.

Identified via Comfy-Org/cloud's TestCutoverSafe gate (BE-1106) as
the remaining schema-level divergences after PRs A-D landed and got
synced.

* openapi: add type enum to Workspace schema (cutover follow-up)

Cloud's Workspace runtime shape includes a 'type' field with enum
[personal, team] that vendor's Workspace was missing. Cloud handlers
reference the generated ingest.WorkspaceType Go enum.

Same kind of surgical addition as JobEntry.status / BillingStatus /
JobDetailResponse.status in this PR — adds cloud-runtime field to
existing vendor schema.
2026-05-22 18:23:22 -07:00
c3c881f37b openapi: rename cloud-side response schemas to match runtime (PR D) (#14065)
* openapi: rename cloud-side response schemas to match runtime (PR D)

Follow-up to the BE-1106 stack (#14060/61/63). Cloud's Go handlers
reference response schemas by name (e.g., ingest.WorkflowResponse,
ingest.SubscribeResponse), but vendor's matching operations were
declaring those responses against differently-named vendor-side
schemas (CloudWorkflow, BillingSubscription, etc.). After the stack
landed, schemas like WorkflowResponse exist in vendor but weren't
referenced by any path, so codegen pruned the unreferenced types.

This PR:
  1. Updates 34 operation $refs in cloud-runtime paths to point to
     the schema names cloud's handlers expect (e.g., CloudWorkflow →
     WorkflowResponse on /api/workflows/{workflow_id}).
  2. Adds 12 cloud-only schemas that weren't in vendor yet but are
     referenced by these renames (e.g., SubscribeResponse,
     CancelSubscriptionResponse, BillingOpStatusResponse). Each
     copied verbatim from Comfy-Org/cloud's hand-written ingest spec
     and tagged x-runtime: [cloud] with a [cloud-only] description
     prefix.

Schema renames span the same domains as the operationId renames in
PR A: billing/subscriptions (7 schemas), workflows (5), userdata (3),
jobs (2), hub (2), history (2), auth/workspace (4), and misc cloud
endpoints (9).

Convergent safety check after this lands (against cloud's
TestCutoverSafe gate, BE-1106):
  Pre-PR D:   205 missing handler refs
  Post-PR D:  105 missing handler refs (-49%)
  Cumulative since the original 938-ref baseline: -89%

The remaining 105 are a Phase 3 follow-up (response headers,
text/plain responses, codegen-derived enum sub-types, and a small
set of inline-response-schema operations that vendor declares
inline where cloud has named-schema $refs).

* openapi: drop PR-label comment from new schemas block

PR-internal labels don't belong in committed code — future readers
won't know what 'PR D' means and the marker stops being useful the
moment this PR merges.
2026-05-22 16:34:52 -07:00
7984a6a38e openapi: rename 55 cloud-side operationIds to match runtime (PR A of 3) (#14060)
* openapi: rename 55 cloud-side operationIds to match runtime handlers

For the 55 operations below, vendor's operationId did not match the
name cloud's runtime handlers expect. Generated types from vendor
therefore had different names (e.g. CreateSubscription200JSONResponse)
than what cloud handlers reference (Subscribe200JSONResponse), which
blocks the post-cutover combined-spec codegen.

All 55 renames target the cloud-runtime-authoritative name. Several
of these endpoints are shared concepts (queue, settings, userdata,
object_info) that OSS local also serves — the rename aligns vendor
with the longstanding cloud handler-side convention to unblock the
shared codegen. No request/response *shape* changes in this PR; only
operationId labels.

Notable categories:
  - Billing/subscriptions: 7 renames (subscribe, getBillingPlans, ...)
  - Workspace + workflows: 13 renames (createWorkflow, ...)
  - Hub: 3 renames
  - Auth/users: 5 renames
  - Shared OSS surface (settings, queue, view, userdata): 12 renames
  - Misc cloud-only: 15 renames

Identified via Comfy-Org/cloud's TestCutoverSafe build-safety gate
(BE-1106), which compares handler type references against codegen
output from the combined spec.

* fix(openapi): resolve getHistory operationId collision

Spectral flagged: both /api/history (OSS local) and /api/history_v2
(cloud) had operationId 'getHistory' after the rename. Rename vendor's
/api/history to 'getPromptHistory' to disambiguate. Cloud's runtime
denies /api/history at the overlay level so combined codegen is
unaffected by this change.

* openapi: add 41 cloud-runtime schemas to components.schemas (PR B of 3) (#14061)

* openapi: add 41 cloud-runtime schemas to components.schemas (cutover prep)

Adds schemas that exist in Comfy-Org/cloud's hand-written ingest spec
but not yet in this vendored OSS spec. All tagged x-runtime: [cloud]
per the field-drift convention and prefixed with [cloud-only] in the
description.

These schemas are referenced by cloud's Go handlers via the generated
ingest.<Schema> Go type names. Codegen from the vendored spec didn't
produce those types because the schemas weren't declared here. Adding
them unblocks the post-cutover combined-spec codegen.

Schemas added (alphabetical):
  AssetDownloadResponse, AssetMetadataResponse, BillingBalanceResponse,
  BillingPlansResponse, BillingStatusResponse, GetUserDataResponseFull,
  HistoryDetailEntry, HistoryDetailResponse, HistoryResponse,
  HubLabelInfo, HubProfileSummary, HubWorkflowListResponse,
  HubWorkflowStatus, HubWorkflowSummary, HubWorkflowTemplateEntry,
  JobStatusResponse, JobsListResponse, LabelRef, LogsResponse, Member,
  OAuthRegisterBadRequestResponse, PendingInvite, Plan, PlanAvailability,
  PlanAvailabilityReason, PlanSeatSummary, PreviewPlanInfo,
  PreviewSubscribeResponse, PublishedWorkflowDetail, SecretResponse,
  SubscriptionDuration, SubscriptionTier, UserDataResponseFull,
  ValidationError, ValidationResult, WorkflowForkedFrom, WorkflowResponse,
  WorkflowVersionContentResponse, WorkspaceAPIKeyInfo, WorkspaceSummary,
  WorkspaceWithRole

Identified via Comfy-Org/cloud's TestCutoverSafe build-safety gate
(BE-1106). Companion to PR #14060 (operationId renames).

* fix(openapi): add BindingErrorResponse schema

OAuthRegisterBadRequestResponse references BindingErrorResponse but
that schema wasn't in the original add. Adding it now as a cloud-only
schema matching the cloud runtime's binding-error shape (single
'message' string field).

* openapi: add missing 4xx/5xx response bodies for cloud-emitting endpoints (#14063)

Vendor declares shared endpoints (e.g. /api/queue, /api/settings,
/api/assets/*, /api/billing/*) with success responses but is missing
many of the 4xx/5xx error response bodies that Comfy-Org/cloud's
runtime actually emits. Cloud's Go handlers reference the generated
ingest.Op<StatusCode>JSONResponse types for these missing statuses,
which currently fail to resolve when codegen runs against the
vendored spec.

This PR adds 237 response entries across 117 operations, restoring
the documented error responses that cloud emits. Bodies are copied
verbatim from Comfy-Org/cloud's hand-written ingest spec
(services/ingest/openapi.yaml) and reference a new ErrorResponse
schema also added in this PR (matches cloud's {code, message} runtime
shape, tagged x-runtime: [cloud]).

ErrorResponse is intentionally separate from the existing CloudError
schema. CloudError's shape ({error}) describes one runtime; cloud
emits a different shape ({code, message}). Existing CloudError refs
in vendor are untouched; new cloud-emitting error references use
ErrorResponse.

Identified via Comfy-Org/cloud's TestCutoverSafe build-safety gate
(BE-1106). Companion to PR #14060 (operationId renames) and PR #14061
(cloud-only schema additions).
2026-05-22 16:15:18 -07:00
e75b739c1d Delete the source branch after doing the backport. (#14062) 2026-05-22 15:47:03 -07:00
383 changed files with 82056 additions and 15982 deletions

View File

@ -1,5 +1,4 @@
As of the time of writing this you need this driver for best results:
https://www.amd.com/en/resources/support-articles/release-notes/RN-AMDGPU-WINDOWS-PYTORCH-7-1-1.html
As of the time of writing this you need a recent driver. Updating to the latest driver is recommended.
HOW TO RUN:
@ -7,9 +6,9 @@ If you have a AMD gpu:
run_amd_gpu.bat
If you have memory issues you can try disabling the smart memory management by running comfyui with:
If you have memory issues you can try enabling the new dynamic memory management by running comfyui with:
run_amd_gpu_disable_smart_memory.bat
run_amd_gpu_enable_dynamic_vram.bat
IF YOU GET A RED ERROR IN THE UI MAKE SURE YOU HAVE A MODEL/CHECKPOINT IN: ComfyUI\models\checkpoints

View File

@ -458,6 +458,41 @@ jobs:
echo "Released ${NEW_VERSION} on ${RELEASE_BRANCH}."
- name: Delete remote source branch
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
REPO: ${{ github.repository }}
SOURCE_BRANCH: ${{ steps.resolve.outputs.source_branch }}
SOURCE_COMMIT: ${{ inputs.commit }}
RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
# Belt-and-braces: the resolve step already refuses the default branch,
# but never delete the default or the release branch under any
# circumstances.
if [[ "${SOURCE_BRANCH}" == "${DEFAULT_BRANCH}" || "${SOURCE_BRANCH}" == "${RELEASE_BRANCH}" ]]; then
echo "::error::Refusing to delete '${SOURCE_BRANCH}' (matches default or release branch)."
exit 1
fi
# Delete the source branch on origin, but only if its tip is still the
# SHA we released from. If someone pushed new commits to it after we
# resolved it, leave it alone — those commits would be silently lost.
current_tip="$(git ls-remote origin "refs/heads/${SOURCE_BRANCH}" | awk '{print $1}')"
if [[ -z "${current_tip}" ]]; then
echo "Source branch '${SOURCE_BRANCH}' no longer exists on origin; nothing to delete."
exit 0
fi
if [[ "${current_tip}" != "${SOURCE_COMMIT}" ]]; then
echo "::warning::Source branch '${SOURCE_BRANCH}' tip (${current_tip}) no longer matches released commit (${SOURCE_COMMIT}). Leaving it in place."
exit 0
fi
git push origin --delete "refs/heads/${SOURCE_BRANCH}"
echo "Deleted remote branch '${SOURCE_BRANCH}'."
- name: Summary
if: always()
env:

View File

@ -17,7 +17,7 @@ jobs:
- name: Check for Windows line endings (CRLF)
run: |
# Get the list of changed files in the PR
CHANGED_FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }})
CHANGED_FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} -- ':!.ci')
# Flag to track if CRLF is found
CRLF_FOUND=false

38
.github/workflows/ci-cursor-review.yml vendored Normal file
View File

@ -0,0 +1,38 @@
name: CI - Cursor Review
# Thin caller for the shared reusable cursor-review workflow in
# Comfy-Org/github-workflows. The review logic (panel matrix, judge
# consolidation, prompts, extract/post/notify scripts) lives there as the
# single source of truth, so this repo only carries the repo-specific diff
# excludes.
on:
pull_request:
types: [labeled, unlabeled]
concurrency:
group: cursor-review-pr-${{ github.event.pull_request.number }}-${{ github.event.label.name }}
cancel-in-progress: true
jobs:
cursor-review:
if: github.event.label.name == 'cursor-review'
permissions:
contents: read
pull-requests: write
# SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up
# upstream changes; keep `workflows_ref` matching so prompts/scripts load
# from the same commit as the workflow definition.
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@047ca48febe3a6647608ed2e0c4331b491cb9d6a # github-workflows#9
with:
workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a
diff_excludes: >-
:!**/.claude/**
:!**/dist/**
:!**/vendor/**
:!**/*.generated.*
:!**/*.min.js
:!**/*.min.css
secrets:
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}

View File

@ -0,0 +1,24 @@
name: Detect Unreviewed Merge
# SOC 2 compliance — reusable workflow lives in Comfy-Org/github-workflows,
# tracking issues are filed in Comfy-Org/unreviewed-merges.
on:
push:
branches: [master]
concurrency:
group: detect-unreviewed-merge-${{ github.sha }}
cancel-in-progress: false
permissions:
contents: read
pull-requests: read
jobs:
detect:
uses: Comfy-Org/github-workflows/.github/workflows/detect-unreviewed-merge.yml@4d9cb6b87f953bb7cd69954280e1465fb9bd2040 # v1
with:
approval-mode: latest-per-reviewer
secrets:
UNREVIEWED_MERGES_TOKEN: ${{ secrets.UNREVIEWED_MERGES_TOKEN }}

View File

@ -140,7 +140,7 @@ ComfyUI follows a weekly release cycle targeting Monday but this regularly chang
- Commits outside of the stable release tags may be very unstable and break many custom nodes.
- Serves as the foundation for the desktop release
2. **[ComfyUI Desktop](https://github.com/Comfy-Org/desktop)**
2. **[Comfy Desktop](https://github.com/Comfy-Org/Comfy-Desktop)**
- Builds a new release using the latest stable core version
3. **[ComfyUI Frontend](https://github.com/Comfy-Org/ComfyUI_frontend)**
@ -309,7 +309,7 @@ After this you should have everything installed and can proceed to running Comfy
#### Apple Mac silicon
You can install ComfyUI in Apple Mac silicon (M1 or M2) with any recent macOS version.
You can install ComfyUI in Apple Mac silicon (M1, M2, M3 or M4) with any recent macOS version.
1. Install pytorch nightly. For instructions, read the [Accelerated PyTorch training on Mac](https://developer.apple.com/metal/pytorch/) Apple Developer guide (make sure to install the latest pytorch nightly).
1. Follow the [ComfyUI manual installation](#manual-install-windows-linux) instructions for Windows and Linux.
@ -364,7 +364,7 @@ For models compatible with Iluvatar Extension for PyTorch. Here's a step-by-step
| Flag | Description |
|------|-------------|
| `--enable-manager` | Enable ComfyUI-Manager |
| `--enable-manager-legacy-ui` | Use the legacy manager UI instead of the new UI (requires `--enable-manager`) |
| `--enable-manager-legacy-ui` | Use the legacy manager UI instead of the new UI (implies `--enable-manager`) |
| `--disable-manager-ui` | Disable the manager UI and endpoints while keeping background features like security checks and scheduled installation completion (requires `--enable-manager`) |
@ -382,11 +382,7 @@ For AMD 7600 and maybe other RDNA3 cards: ```HSA_OVERRIDE_GFX_VERSION=11.0.0 pyt
### AMD ROCm Tips
You can enable experimental memory efficient attention on recent pytorch in ComfyUI on some AMD GPUs using this command, it should already be enabled by default on RDNA3. If this improves speed for you on latest pytorch on your GPU please report it so that I can enable it by default.
```TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL=1 python main.py --use-pytorch-cross-attention```
You can also try setting this env variable `PYTORCH_TUNABLEOP_ENABLED=1` which might speed things up at the cost of a very slow initial run.
You can try setting this env variable `PYTORCH_TUNABLEOP_ENABLED=1` which might speed things up at the cost of a very slow initial run.
# Notes
@ -433,7 +429,7 @@ See also: [https://www.comfy.org/](https://www.comfy.org/)
## Frontend Development
As of August 15, 2024, we have transitioned to a new frontend, which is now hosted in a separate repository: [ComfyUI Frontend](https://github.com/Comfy-Org/ComfyUI_frontend). This repository now hosts the compiled JS (from TS/Vue) under the `web/` directory.
As of August 15, 2024, we have transitioned to a new frontend, which is now hosted in a separate repository: [ComfyUI Frontend](https://github.com/Comfy-Org/ComfyUI_frontend). The compiled JS files (from TS/Vue) are published to [pypi](https://pypi.org/project/comfyui-frontend-package) and installed as a dependency in ComfyUI.
### Reporting Issues and Requesting Features
@ -462,16 +458,6 @@ To use the most up-to-date frontend version:
This approach allows you to easily switch between the stable fortnightly release and the cutting-edge daily updates, or even specific versions for testing purposes.
### Accessing the Legacy Frontend
If you need to use the legacy frontend for any reason, you can access it using the following command line argument:
```
--front-end-version Comfy-Org/ComfyUI_legacy_frontend@latest
```
This will use a snapshot of the legacy frontend preserved in the [ComfyUI Legacy Frontend repository](https://github.com/Comfy-Org/ComfyUI_legacy_frontend).
# QA
### Which GPU should I buy for this?

View File

@ -0,0 +1,39 @@
"""
Drop the vestigial tags.tag_type column.
tag_type was always "user" in practice — no code path ever set it to anything
else (no system/seeded classification was ever wired up) and nothing queried it.
The column, its index (ix_tags_tag_type), and the corresponding API field were
dead weight, so they are removed.
Revision ID: 0004_drop_tag_type
Revises: 0003_add_metadata_job_id
Create Date: 2026-06-03
"""
from alembic import op
import sqlalchemy as sa
revision = "0004_drop_tag_type"
down_revision = "0003_add_metadata_job_id"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("tags") as batch_op:
batch_op.drop_index("ix_tags_tag_type")
batch_op.drop_column("tag_type")
def downgrade() -> None:
with op.batch_alter_table("tags") as batch_op:
batch_op.add_column(
sa.Column(
"tag_type",
sa.String(length=32),
nullable=False,
server_default="user",
)
)
batch_op.create_index("ix_tags_tag_type", ["tag_type"])

View File

@ -0,0 +1,115 @@
"""
Download manager schema.
Adds the three tables that back the server-side model download manager
: transient job/queue state (``downloads`` + per-segment
``download_segments``) and one-API-key-per-host auth (``host_credentials``).
Revision ID: 0005_download_manager
Revises: 0004_drop_tag_type
Create Date: 2026-06-27
"""
from alembic import op
import sqlalchemy as sa
revision = "0005_download_manager"
down_revision = "0004_drop_tag_type"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"downloads",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("url", sa.Text(), nullable=False),
sa.Column("final_url", sa.Text(), nullable=True),
sa.Column("model_id", sa.String(length=1024), nullable=False),
sa.Column("dest_path", sa.Text(), nullable=False),
sa.Column("temp_path", sa.Text(), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("priority", sa.Integer(), nullable=False, server_default="0"),
sa.Column("total_bytes", sa.BigInteger(), nullable=True),
sa.Column("bytes_done", sa.BigInteger(), nullable=False, server_default="0"),
sa.Column("etag", sa.String(length=512), nullable=True),
sa.Column("last_modified", sa.String(length=128), nullable=True),
sa.Column(
"accept_ranges", sa.Boolean(), nullable=False, server_default=sa.text("false")
),
sa.Column("expected_sha256", sa.String(length=64), nullable=True),
sa.Column("credential_id", sa.String(length=36), nullable=True),
sa.Column(
"allow_any_extension",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
sa.CheckConstraint("bytes_done >= 0", name="ck_downloads_bytes_done_nonneg"),
sa.CheckConstraint(
"total_bytes IS NULL OR total_bytes >= 0",
name="ck_downloads_total_bytes_nonneg",
),
)
op.create_index("ix_downloads_status", "downloads", ["status"])
op.create_index("ix_downloads_priority", "downloads", ["priority"])
op.create_index("ix_downloads_model_id", "downloads", ["model_id"])
op.create_table(
"download_segments",
sa.Column(
"download_id",
sa.String(length=36),
sa.ForeignKey("downloads.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("idx", sa.Integer(), nullable=False),
sa.Column("start_offset", sa.BigInteger(), nullable=False),
sa.Column("end_offset", sa.BigInteger(), nullable=False),
sa.Column("bytes_done", sa.BigInteger(), nullable=False, server_default="0"),
sa.PrimaryKeyConstraint("download_id", "idx", name="pk_download_segments"),
sa.CheckConstraint("bytes_done >= 0", name="ck_segments_bytes_done_nonneg"),
sa.CheckConstraint("end_offset >= start_offset", name="ck_segments_range"),
)
op.create_table(
"host_credentials",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("host", sa.String(length=255), nullable=False),
sa.Column(
"match_subdomains",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
sa.Column("label", sa.String(length=255), nullable=True),
sa.Column(
"auth_scheme", sa.String(length=16), nullable=False, server_default="bearer"
),
sa.Column("header_name", sa.String(length=255), nullable=True),
sa.Column("query_param", sa.String(length=255), nullable=True),
sa.Column("secret", sa.Text(), nullable=False),
sa.Column("secret_last4", sa.String(length=4), nullable=True),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
)
op.create_index(
"uq_host_credentials_host", "host_credentials", ["host"], unique=True
)
def downgrade() -> None:
op.drop_index("uq_host_credentials_host", table_name="host_credentials")
op.drop_table("host_credentials")
op.drop_table("download_segments")
op.drop_index("ix_downloads_model_id", table_name="downloads")
op.drop_index("ix_downloads_priority", table_name="downloads")
op.drop_index("ix_downloads_status", table_name="downloads")
op.drop_table("downloads")

View File

@ -39,6 +39,7 @@ from app.assets.services import (
update_asset_metadata,
upload_from_temp_path,
)
from app.assets.services.cursor import InvalidCursorError
from app.assets.services.tagging import list_tag_histogram
ROUTES = web.RouteTableDef()
@ -160,10 +161,12 @@ def _build_asset_response(result: schemas.AssetDetailResult | schemas.UploadResu
preview_url = None
else:
preview_url = _build_preview_url_from_view(result.tags, result.ref.user_metadata)
asset_content_hash = result.asset.hash if result.asset else None
return schemas_out.Asset(
id=result.ref.id,
name=result.ref.name,
asset_hash=result.asset.hash if result.asset else None,
hash=asset_content_hash,
asset_hash=asset_content_hash,
size=int(result.asset.size_bytes) if result.asset else None,
mime_type=result.asset.mime_type if result.asset else None,
tags=result.tags,
@ -172,7 +175,7 @@ def _build_asset_response(result: schemas.AssetDetailResult | schemas.UploadResu
user_metadata=result.ref.user_metadata or {},
metadata=result.ref.system_metadata,
job_id=result.ref.job_id,
prompt_id=result.ref.job_id, # deprecated: mirrors job_id for cloud compat
prompt_id=result.ref.job_id, # deprecated alias of job_id, kept for compatibility
created_at=result.ref.created_at,
updated_at=result.ref.updated_at,
last_access_time=result.ref.last_access_time,
@ -209,24 +212,37 @@ async def list_assets_route(request: web.Request) -> web.Response:
order_candidate = (q.order or "desc").lower()
order = order_candidate if order_candidate in {"asc", "desc"} else "desc"
result = list_assets_page(
owner_id=USER_MANAGER.get_request_user_id(request),
include_tags=q.include_tags,
exclude_tags=q.exclude_tags,
name_contains=q.name_contains,
metadata_filter=q.metadata_filter,
limit=q.limit,
offset=q.offset,
sort=sort,
order=order,
)
try:
result = list_assets_page(
owner_id=USER_MANAGER.get_request_user_id(request),
include_tags=q.include_tags,
exclude_tags=q.exclude_tags,
name_contains=q.name_contains,
metadata_filter=q.metadata_filter,
limit=q.limit,
offset=q.offset,
sort=sort,
order=order,
after=q.after,
)
except InvalidCursorError as e:
return _build_error_response(400, "INVALID_CURSOR", str(e))
summaries = [_build_asset_response(item) for item in result.items]
# has_more semantics differ by mode:
# - cursor mode: a non-empty next_cursor means there are more results.
# - offset mode: derived from total - (offset + page size).
if q.after is not None:
has_more = result.next_cursor is not None
else:
has_more = (q.offset + len(summaries)) < result.total
payload = schemas_out.AssetsList(
assets=summaries,
total=result.total,
has_more=(q.offset + len(summaries)) < result.total,
has_more=has_more,
next_cursor=result.next_cursor,
)
return web.json_response(payload.model_dump(mode="json", exclude_none=True))
@ -517,18 +533,14 @@ async def update_asset_route(request: web.Request) -> web.Response:
@_require_assets_feature_enabled
async def delete_asset_route(request: web.Request) -> web.Response:
reference_id = str(uuid.UUID(request.match_info["id"]))
delete_content_param = request.query.get("delete_content")
delete_content = (
False
if delete_content_param is None
else delete_content_param.lower() not in {"0", "false", "no"}
)
try:
# Deleting an asset is a soft delete of the reference; the underlying
# content is preserved (it may be shared with other references).
deleted = delete_asset_reference(
reference_id=reference_id,
owner_id=USER_MANAGER.get_request_user_id(request),
delete_content_if_orphan=delete_content,
delete_content_if_orphan=False,
)
except Exception:
logging.exception(
@ -573,8 +585,8 @@ async def get_tags(request: web.Request) -> web.Response:
)
tags = [
schemas_out.TagUsage(name=name, count=count, type=tag_type)
for (name, tag_type, count) in rows
schemas_out.TagUsage(name=name, count=count)
for (name, count) in rows
]
payload = schemas_out.TagsList(
tags=tags, total=total, has_more=(query.offset + len(tags)) < total

View File

@ -59,6 +59,11 @@ class ListAssetsQuery(BaseModel):
limit: conint(ge=1, le=500) = 20
offset: conint(ge=0) = 0
# Opaque keyset cursor. When supplied, `offset` is ignored. Cursor pagination
# is supported for sort values `created_at`, `updated_at`, `name`, `size`.
# Supplying `after` together with `sort=last_access_time` returns
# 400 INVALID_CURSOR; that sort only supports offset/limit.
after: str | None = None
sort: Literal["name", "created_at", "updated_at", "size", "last_access_time"] = (
"created_at"

View File

@ -10,6 +10,7 @@ class Asset(BaseModel):
id: str
name: str
hash: str | None = None
asset_hash: str | None = None
size: int | None = None
mime_type: str | None = None
@ -40,12 +41,13 @@ class AssetsList(BaseModel):
assets: list[Asset]
total: int
has_more: bool
# Opaque cursor for the next page. Omitted when there are no more results.
next_cursor: str | None = None
class TagUsage(BaseModel):
name: str
count: int
type: str
class TagsList(BaseModel):

View File

@ -227,7 +227,6 @@ class Tag(Base):
__tablename__ = "tags"
name: Mapped[str] = mapped_column(String(512), primary_key=True)
tag_type: Mapped[str] = mapped_column(String(32), nullable=False, default="user")
asset_reference_links: Mapped[list[AssetReferenceTag]] = relationship(
back_populates="tag",
@ -240,7 +239,5 @@ class Tag(Base):
overlaps="asset_reference_links,tag_links,tags,asset_reference",
)
__table_args__ = (Index("ix_tags_tag_type", "tag_type"),)
def __repr__(self) -> str:
return f"<Tag {self.name}>"

View File

@ -266,9 +266,18 @@ def list_references_page(
metadata_filter: dict | None = None,
sort: str | None = None,
order: str | None = None,
after_cursor_value: object | None = None,
after_cursor_id: str | None = None,
) -> tuple[list[AssetReference], dict[str, list[str]], int]:
"""List references with pagination, filtering, and sorting.
When ``after_cursor_value``/``after_cursor_id`` are supplied the query uses
keyset pagination — ``offset`` is ignored and a WHERE clause selects rows
strictly after the given ``(sort_col, id)`` position in the active sort
direction. The cursor value must already be typed for the column
(datetime for time sorts, int for size, str for name); the caller decodes
the opaque cursor string and resolves to the typed value.
Returns (references, tag_map, total_count).
"""
base = (
@ -297,9 +306,31 @@ def list_references_page(
"size": Asset.size_bytes,
}
sort_col = sort_map.get(sort, AssetReference.created_at)
sort_exp = sort_col.desc() if order == "desc" else sort_col.asc()
descending = order == "desc"
base = base.order_by(sort_exp).limit(limit).offset(offset)
# Keyset WHERE: (sort_col, id) strictly less-than / greater-than the cursor.
# Equivalent to: sort_col <op> v OR (sort_col = v AND id <op> cursor_id).
if after_cursor_value is not None and after_cursor_id is not None:
if descending:
keyset = sa.or_(
sort_col < after_cursor_value,
sa.and_(sort_col == after_cursor_value, AssetReference.id < after_cursor_id),
)
else:
keyset = sa.or_(
sort_col > after_cursor_value,
sa.and_(sort_col == after_cursor_value, AssetReference.id > after_cursor_id),
)
base = base.where(keyset)
# Secondary ORDER BY id (matching the primary direction) gives the keyset
# comparison a deterministic tiebreaker on duplicate sort_col values.
id_exp = AssetReference.id.desc() if descending else AssetReference.id.asc()
sort_exp = sort_col.desc() if descending else sort_col.asc()
base = base.order_by(sort_exp, id_exp).limit(limit)
if after_cursor_id is None:
base = base.offset(offset)
count_stmt = (
select(sa.func.count())

View File

@ -55,13 +55,11 @@ def validate_tags_exist(session: Session, tags: list[str]) -> None:
raise ValueError(f"Unknown tags: {missing}")
def ensure_tags_exist(
session: Session, names: Iterable[str], tag_type: str = "user"
) -> None:
def ensure_tags_exist(session: Session, names: Iterable[str]) -> None:
wanted = normalize_tags(list(names))
if not wanted:
return
rows = [{"name": n, "tag_type": tag_type} for n in list(dict.fromkeys(wanted))]
rows = [{"name": n} for n in list(dict.fromkeys(wanted))]
ins = (
sqlite.insert(Tag)
.values(rows)
@ -97,7 +95,7 @@ def set_reference_tags(
to_remove = [t for t in current if t not in desired]
if to_add:
ensure_tags_exist(session, to_add, tag_type="user")
ensure_tags_exist(session, to_add)
session.add_all(
[
AssetReferenceTag(
@ -142,7 +140,7 @@ def add_tags_to_reference(
return AddTagsResult(added=[], already_present=[], total_tags=total)
if create_if_missing:
ensure_tags_exist(session, norm, tag_type="user")
ensure_tags_exist(session, norm)
current = set(get_reference_tags(session, reference_id))
@ -289,7 +287,6 @@ def list_tags_with_usage(
q = (
select(
Tag.name,
Tag.tag_type,
func.coalesce(counts_sq.c.cnt, 0).label("count"),
)
.select_from(Tag)
@ -331,7 +328,7 @@ def list_tags_with_usage(
rows = (session.execute(q.limit(limit).offset(offset))).all()
total = (session.execute(total_q)).scalar_one()
rows_norm = [(name, ttype, int(count or 0)) for (name, ttype, count) in rows]
rows_norm = [(name, int(count or 0)) for (name, count) in rows]
return rows_norm, int(total or 0)

View File

@ -33,6 +33,7 @@ from app.assets.services.file_utils import (
verify_file_unchanged,
)
from app.assets.services.hashing import HashCheckpoint, compute_blake3_hash
from app.assets.services.image_dimensions import extract_image_dimensions
from app.assets.services.metadata_extract import extract_file_metadata
from app.assets.services.path_utils import (
compute_relative_filename,
@ -354,7 +355,7 @@ def insert_asset_specs(specs: list[SeedAssetSpec], tag_pool: set[str]) -> int:
return 0
with create_session() as sess:
if tag_pool:
ensure_tags_exist(sess, tag_pool, tag_type="user")
ensure_tags_exist(sess, tag_pool)
result = batch_insert_seed_assets(sess, specs=specs, owner_id="")
sess.commit()
return result.inserted_refs
@ -506,6 +507,10 @@ def enrich_asset(
if extract_metadata and metadata:
system_metadata = metadata.to_user_metadata()
if mime_type and mime_type.startswith("image/"):
dims = extract_image_dimensions(file_path, mime_type=mime_type)
if dims:
system_metadata.update(dims)
set_reference_system_metadata(session, reference_id, system_metadata)
if full_hash:

View File

@ -1,8 +1,19 @@
import contextlib
import mimetypes
import os
from datetime import timezone
from typing import Sequence
from app.assets.services.cursor import (
CursorPayload,
InvalidCursorError,
decode_cursor,
decode_cursor_int,
decode_cursor_time,
encode_cursor,
encode_cursor_from_time,
)
from app.assets.database.models import Asset
from app.assets.database.queries import (
@ -149,6 +160,16 @@ def delete_asset_reference(
owner_id: str,
delete_content_if_orphan: bool = True,
) -> bool:
"""Delete an asset reference.
With ``delete_content_if_orphan=False`` (a soft delete), the reference is
hidden and the underlying content is preserved. With ``True``, the content
is also removed once it becomes orphaned.
Note: the public DELETE /api/assets/{id} endpoint always soft-deletes
(passes ``False``); the orphan-reclamation path is intentionally
internal-only, retained for a future GC/admin caller.
"""
with create_session() as session:
if not delete_content_if_orphan:
# Soft delete: mark the reference as deleted but keep everything
@ -242,6 +263,11 @@ def get_asset_by_hash(asset_hash: str) -> AssetData | None:
return extract_asset_data(asset)
# Sort fields that support cursor pagination. `last_access_time` is not
# in this list — it falls back to offset/limit.
_CURSOR_SORT_FIELDS = ("created_at", "updated_at", "name", "size")
def list_assets_page(
owner_id: str = "",
include_tags: Sequence[str] | None = None,
@ -252,7 +278,39 @@ def list_assets_page(
offset: int = 0,
sort: str = "created_at",
order: str = "desc",
after: str | None = None,
) -> ListAssetsResult:
"""List assets with optional cursor pagination.
When ``after`` is supplied it overrides ``offset``. The cursor's sort field
must match ``sort`` and be in the cursor-supported allowlist; mismatches
raise InvalidCursorError so the handler can map to 400 INVALID_CURSOR.
"""
cursor_value: object | None = None
cursor_id: str | None = None
# Mint next_cursor on every page where the sort is cursor-supported, not
# only when the request itself arrived with a cursor. Otherwise a first
# request (no `after`) returns next_cursor=None and the client can never
# enter cursor mode.
mint_cursor = sort in _CURSOR_SORT_FIELDS
if after is not None:
if sort not in _CURSOR_SORT_FIELDS:
raise InvalidCursorError(
f"cursor pagination is not supported for sort={sort!r}"
)
payload = decode_cursor(after, _CURSOR_SORT_FIELDS, expected_order=order)
if payload.sort_field != sort:
raise InvalidCursorError(
f"cursor sort field {payload.sort_field!r} does not match request sort {sort!r}"
)
cursor_value, cursor_id = _resolve_cursor_value(payload), payload.id
# Over-fetch by one row so we can distinguish "exactly `limit` rows total
# remaining" from "more rows past this page" without a second query. Drop
# the sentinel before returning.
fetch_limit = limit + 1 if mint_cursor else limit
with create_session() as session:
refs, tag_map, total = list_references_page(
session,
@ -261,12 +319,22 @@ def list_assets_page(
exclude_tags=exclude_tags,
name_contains=name_contains,
metadata_filter=metadata_filter,
limit=limit,
limit=fetch_limit,
offset=offset,
sort=sort,
order=order,
after_cursor_value=cursor_value,
after_cursor_id=cursor_id,
)
next_cursor: str | None = None
if mint_cursor and len(refs) > limit:
# There's at least one more row past this page — mint a cursor from
# the last row of the page (i.e. index `limit - 1`, since we
# over-fetched), and drop the sentinel.
next_cursor = _encode_next_cursor(refs[limit - 1], sort, order)
refs = refs[:limit]
items: list[AssetSummaryData] = []
for ref in refs:
items.append(
@ -277,7 +345,39 @@ def list_assets_page(
)
)
return ListAssetsResult(items=items, total=total)
return ListAssetsResult(items=items, total=total, next_cursor=next_cursor)
def _resolve_cursor_value(payload: CursorPayload) -> object:
"""Map a decoded cursor payload to a column-typed Python value."""
if payload.sort_field in ("created_at", "updated_at"):
# DB stores naive UTC; strip tzinfo so the comparison binds against a
# `TIMESTAMP WITHOUT TIME ZONE` column without an offset shift.
return decode_cursor_time(payload).replace(tzinfo=None)
if payload.sort_field == "size":
return decode_cursor_int(payload)
return payload.value # name, str-typed
def _encode_next_cursor(ref, sort: str, order: str) -> str | None:
"""Mint a cursor pointing at *ref* for the given sort dimension.
Returns None when the boundary row carries a NULL sort value (e.g. an asset
record whose size_bytes hasn't been backfilled). Continuing pagination
across a NULL boundary is undefined under keyset ordering — better to
truncate cleanly here than to mint a cursor that mis-positions.
"""
if sort == "name":
return encode_cursor("name", ref.name, ref.id, order=order)
if sort == "size":
if ref.asset is None or ref.asset.size_bytes is None:
return None
return encode_cursor("size", str(ref.asset.size_bytes), ref.id, order=order)
# created_at / updated_at — DB datetimes are naive UTC; attach tz before encoding.
value = ref.created_at if sort == "created_at" else ref.updated_at
if value is None:
return None
return encode_cursor_from_time(sort, value.replace(tzinfo=timezone.utc), ref.id, order=order)
def resolve_hash_to_path(

View File

@ -0,0 +1,213 @@
"""Opaque keyset-pagination cursor for /api/assets.
Payload JSON uses short keys to keep the encoded length small:
{"s": <sort_field>, "v": <value>, "id": <id>, "o": <order>}
The `o` key binds the cursor to the sort direction it was minted under,
so replaying a `desc` cursor against an `asc` request fails with
``INVALID_CURSOR`` rather than silently walking the wrong direction.
`o` is mandatory on every payload — a cursor without it is rejected as
malformed.
Encoding is base64url with no padding. Cursors are opaque tokens: the
payload format is internal to this server, and clients must treat a
cursor as a black box handed back via `next_cursor`. No byte-level
compatibility with any other implementation is required.
Time values are serialized as Unix microseconds (UTC) — microsecond
precision is sufficient to round-trip the timestamps stored by the
database without rounding rows in the same millisecond bucket.
"""
from __future__ import annotations
import base64
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Iterable, Optional
class InvalidCursorError(ValueError):
"""Raised on a malformed, oversized, or unsupported-sort-field cursor.
Map to a 400 response with code ``INVALID_CURSOR`` at the handler.
"""
# Wire-format length caps. Cursors are user-controlled, so caps protect the
# decode path from oversized allocations and downstream SQL predicates from
# unbounded strings.
#
# MAX_CURSOR_VALUE_LENGTH is 512 to fit the `AssetReference.name` column max
# (`String(512)`) — otherwise a long-named asset would mint a cursor the same
# server then refuses on the next request.
#
# MAX_ENCODED_CURSOR_LENGTH is the decode-path guard, sized comfortably above
# the largest cursor the per-field caps can produce. Worst case is value + id
# at their caps with every character JSON-escaping to the six-byte `\uXXXX`
# form (control characters), which is ~5.2 KB once base64url-encoded. At 8192
# the encoder can never mint a cursor that exceeds it, so a freshly minted
# cursor always decodes on the next request and there is no user-visible
# "cursor too long" failure.
MAX_ENCODED_CURSOR_LENGTH = 8192
MAX_CURSOR_VALUE_LENGTH = 512
MAX_CURSOR_ID_LENGTH = 128
@dataclass(frozen=True)
class CursorPayload:
sort_field: str
value: str
id: str
order: str
_VALID_ORDERS = ("asc", "desc")
def encode_cursor(sort_field: str, value: str, id: str, order: str = "desc") -> str:
"""Encode a cursor payload as a base64url (no-padding) string.
`order` binds the cursor to the sort direction it was minted under so a
later request with a flipped `order` query parameter is rejected with
``INVALID_CURSOR`` rather than silently walking the wrong direction.
"""
if order not in _VALID_ORDERS:
raise InvalidCursorError(f"order must be one of {_VALID_ORDERS}, got {order!r}")
# Symmetric input validation: the encoder must reject anything the
# decoder rejects, or the same server will mint cursors it then 400s on
# the next request.
if not id:
raise InvalidCursorError("id must be non-empty")
if len(id) > MAX_CURSOR_ID_LENGTH:
raise InvalidCursorError("id exceeds maximum length")
if len(value) > MAX_CURSOR_VALUE_LENGTH:
raise InvalidCursorError("value exceeds maximum length")
payload = {"s": sort_field, "v": value, "id": id, "o": order}
raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
# No mint-time length guard is needed: the per-field caps above bound the
# encoded length well below MAX_ENCODED_CURSOR_LENGTH (see its definition),
# so the encoder can never produce a cursor the decode path would reject.
return base64.urlsafe_b64encode(raw.encode("utf-8")).rstrip(b"=").decode("ascii")
def encode_cursor_from_time(sort_field: str, t: datetime, id: str, order: str = "desc") -> str:
"""Encode a time-typed cursor at Unix microsecond precision.
Accepts an aware datetime (any timezone) and normalizes to UTC. Naive
datetimes are rejected so callers can't accidentally encode the local
wall-clock value of a UTC-stored timestamp.
"""
if t.tzinfo is None:
raise ValueError("encode_cursor_from_time requires an aware datetime")
micros = _datetime_to_unix_micros(t.astimezone(timezone.utc))
return encode_cursor(sort_field, str(micros), id, order=order)
def decode_cursor(
cursor: str,
allowed_sort_fields: Iterable[str],
expected_order: str | None = None,
) -> CursorPayload:
"""Parse an opaque cursor.
``allowed_sort_fields`` is the endpoint's accepted sort-field list — a
cursor carrying a field outside this set is rejected so a cursor minted
for one column can't be replayed against another (e.g. a ``created_at``
timestamp string compared against a ``name`` column).
``expected_order`` (``"asc"``/``"desc"``), when supplied, must match the
payload's ``o`` field. ``o`` is required on every payload; a cursor
missing it is rejected as malformed.
Passing no allowed fields rejects every cursor.
"""
if len(cursor) > MAX_ENCODED_CURSOR_LENGTH:
raise InvalidCursorError("cursor exceeds maximum length")
try:
# urlsafe_b64decode requires correct padding; we strip on encode, so
# restore the trailing '=' pad here.
padding = "=" * (-len(cursor) % 4)
raw = base64.urlsafe_b64decode(cursor + padding)
except (ValueError, base64.binascii.Error) as e:
raise InvalidCursorError(f"encoding: {e}") from e
try:
decoded = json.loads(raw)
except (json.JSONDecodeError, UnicodeDecodeError) as e:
raise InvalidCursorError(f"payload: {e}") from e
if not isinstance(decoded, dict):
raise InvalidCursorError("payload: expected object")
sort_field = decoded.get("s")
value = decoded.get("v")
id = decoded.get("id")
order = decoded.get("o")
if not isinstance(sort_field, str) or not isinstance(value, str) or not isinstance(id, str):
raise InvalidCursorError("payload: missing or non-string s/v/id")
if id == "":
raise InvalidCursorError("missing id")
if len(id) > MAX_CURSOR_ID_LENGTH:
raise InvalidCursorError("id exceeds maximum length")
if len(value) > MAX_CURSOR_VALUE_LENGTH:
raise InvalidCursorError("value exceeds maximum length")
if sort_field not in allowed_sort_fields:
raise InvalidCursorError(f"unsupported sort field {sort_field!r}")
if not isinstance(order, str):
raise InvalidCursorError("missing or non-string o")
if order not in _VALID_ORDERS:
raise InvalidCursorError(f"unsupported order {order!r}")
if expected_order is not None and order != expected_order:
raise InvalidCursorError(
f"cursor order {order!r} does not match request order {expected_order!r}"
)
return CursorPayload(sort_field=sort_field, value=value, id=id, order=order)
def decode_cursor_time(payload: Optional[CursorPayload]) -> datetime:
"""Parse a time-typed cursor value as Unix microseconds, returning UTC."""
if payload is None:
raise InvalidCursorError("nil cursor payload")
try:
micros = int(payload.value)
except ValueError as e:
raise InvalidCursorError(f"value is not a valid timestamp: {e}") from e
try:
return _unix_micros_to_datetime(micros)
except (OverflowError, OSError, ValueError) as e:
# Crafted out-of-range microseconds (e.g. > datetime.MAX_YEAR) blow up
# in fromtimestamp / datetime construction. Map to 400, not 500.
raise InvalidCursorError(f"value is out of representable range: {e}") from e
def decode_cursor_int(payload: Optional[CursorPayload]) -> int:
"""Parse a cursor value as a base-10 integer."""
if payload is None:
raise InvalidCursorError("nil cursor payload")
try:
return int(payload.value)
except ValueError as e:
raise InvalidCursorError(f"value is not a valid integer: {e}") from e
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
def _datetime_to_unix_micros(t: datetime) -> int:
"""Convert an aware UTC datetime to Unix microseconds (integer math)."""
delta = t - _EPOCH
return (delta.days * 86_400 + delta.seconds) * 1_000_000 + delta.microseconds
def _unix_micros_to_datetime(micros: int) -> datetime:
"""Convert Unix microseconds to a UTC datetime, preserving precision."""
seconds, micro_remainder = divmod(micros, 1_000_000)
return datetime.fromtimestamp(seconds, tz=timezone.utc).replace(microsecond=micro_remainder)

View File

@ -0,0 +1,63 @@
"""Image dimension extraction for asset ingest.
Reads only the image header via Pillow to capture width/height cheaply,
without a full pixel decode. Returns a metadata dict suitable for merging
into ``AssetReference.system_metadata``.
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
def extract_image_dimensions(
file_path: str, mime_type: str | None = None
) -> dict[str, Any] | None:
"""Extract image dimensions for the file at ``file_path``.
Args:
file_path: Absolute path to a file on disk.
mime_type: Optional MIME type hint. When provided and not prefixed
with ``image/``, extraction is skipped without touching the file.
Returns:
``{"kind": "image", "width": W, "height": H}`` when the file is a
recognizable image with positive dimensions, otherwise ``None``.
The dict shape is intended to be merged into ``system_metadata`` so the
asset response surfaces ``metadata.kind`` plus dimension fields for image
assets. Forward-compatible: future media kinds (e.g. ``"video"`` with
duration/fps) can extend this shape without schema changes.
"""
if mime_type is not None and not mime_type.startswith("image/"):
return None
try:
from PIL import Image, UnidentifiedImageError
except ImportError:
logger.debug(
"Pillow not available; skipping image dimension extraction for %s",
file_path,
)
return None
try:
with Image.open(file_path) as img:
width, height = img.size
except (OSError, UnidentifiedImageError, ValueError) as exc:
logger.debug(
"Failed to read image dimensions from %s: %s", file_path, exc
)
return None
if (
not isinstance(width, int)
or not isinstance(height, int)
or width <= 0
or height <= 0
):
return None
return {"kind": "image", "width": width, "height": height}

View File

@ -17,9 +17,11 @@ from app.assets.database.queries import (
get_reference_by_file_path,
get_reference_tags,
get_or_create_reference,
list_references_by_asset_id,
reference_exists,
remove_missing_tag_for_asset_id,
set_reference_metadata,
set_reference_system_metadata,
set_reference_tags,
update_asset_hash_and_mime,
upsert_asset,
@ -29,6 +31,7 @@ from app.assets.database.queries import (
from app.assets.helpers import get_utc_now, normalize_tags
from app.assets.services.bulk_ingest import batch_insert_seed_assets
from app.assets.services.file_utils import get_size_and_mtime_ns
from app.assets.services.image_dimensions import extract_image_dimensions
from app.assets.services.path_utils import (
compute_relative_filename,
get_name_and_tags_from_asset_path,
@ -118,6 +121,14 @@ def _ingest_file_from_path(
user_metadata=user_metadata,
)
_maybe_store_image_dimensions(
session,
reference_id=reference_id,
file_path=locator,
mime_type=mime_type,
current_system_metadata=ref.system_metadata,
)
try:
remove_missing_tag_for_asset_id(session, asset_id=asset.id)
except Exception:
@ -288,6 +299,13 @@ def _register_existing_asset(
user_metadata=new_meta,
)
_backfill_image_dimensions_from_siblings(
session,
asset_id=asset.id,
new_reference_id=ref.id,
current_system_metadata=ref.system_metadata,
)
if tags is not None:
set_reference_tags(
session,
@ -334,6 +352,87 @@ def _update_metadata_with_filename(
)
_IMAGE_DIMENSION_KEYS = ("kind", "width", "height")
def _maybe_store_image_dimensions(
session: Session,
reference_id: str,
file_path: str,
mime_type: str | None,
current_system_metadata: dict | None,
) -> None:
"""Populate ``kind``/``width``/``height`` on system_metadata for image refs.
Non-image MIME types are a no-op. Pre-existing keys (e.g. enricher-written
safetensors metadata, download provenance) are preserved by merge.
"""
if not mime_type or not mime_type.startswith("image/"):
return
dims = extract_image_dimensions(file_path, mime_type=mime_type)
if not dims:
return
current = current_system_metadata or {}
merged = dict(current)
merged.update(dims)
if merged != current:
set_reference_system_metadata(
session,
reference_id=reference_id,
system_metadata=merged,
)
def _backfill_image_dimensions_from_siblings(
session: Session,
asset_id: str,
new_reference_id: str,
current_system_metadata: dict | None,
) -> None:
"""Copy image dimension keys from any sibling reference of the same asset.
The from-hash path doesn't read the file bytes, so dimensions can't be
extracted there directly. When another reference of the same asset already
carries image dimensions, copy them onto the new reference so consumers
see consistent metadata regardless of how the asset was registered.
Best-effort: missing siblings, non-image siblings, or absent dimension
keys leave the target reference unchanged.
"""
current = current_system_metadata or {}
if current.get("kind") == "image" and "width" in current and "height" in current:
return
for sibling in list_references_by_asset_id(session, asset_id):
if sibling.id == new_reference_id:
continue
meta = sibling.system_metadata or {}
if meta.get("kind") != "image":
continue
width = meta.get("width")
height = meta.get("height")
if (
type(width) is not int
or type(height) is not int
or width <= 0
or height <= 0
):
continue
merged = dict(current)
merged["kind"] = "image"
merged["width"] = width
merged["height"] = height
if merged != current:
set_reference_system_metadata(
session,
reference_id=new_reference_id,
system_metadata=merged,
)
return
def _sanitize_filename(name: str | None, fallback: str) -> str:
n = os.path.basename((name or "").strip() or fallback)
return n if n else fallback

View File

@ -4,7 +4,6 @@ Tier 1: Filesystem metadata (zero parsing)
Tier 2: Safetensors header metadata (fast JSON read only)
"""
from __future__ import annotations
import json
import logging

View File

@ -56,7 +56,6 @@ class IngestResult:
class TagUsage(NamedTuple):
name: str
tag_type: str
count: int
@ -71,6 +70,7 @@ class AssetSummaryData:
class ListAssetsResult:
items: list[AssetSummaryData]
total: int
next_cursor: str | None = None
@dataclass(frozen=True)

View File

@ -75,7 +75,7 @@ def list_tags(
owner_id=owner_id,
)
return [TagUsage(name, tag_type, count) for name, tag_type, count in rows], total
return [TagUsage(name, count) for name, count in rows], total
def list_tag_histogram(

View File

@ -1,5 +1,3 @@
from __future__ import annotations
import os
import folder_paths
import glob

View File

@ -4,7 +4,11 @@ import shutil
from app.logger import log_startup_warning
from utils.install_util import get_missing_requirements_message
from filelock import FileLock, Timeout
from comfy.cli_args import args
# NOTE: import the module (not `from ... import args`) so we always read the
# live `args` object. Tests reload `comfy.cli_args`, which replaces the module
# global; a bound `args` reference would go stale and point at the default
# database URL instead of the one configured for the test.
import comfy.cli_args
_DB_AVAILABLE = False
Session = None
@ -21,6 +25,7 @@ try:
from app.database.models import Base
import app.assets.database.models # noqa: F401 — register models with Base.metadata
import app.model_downloader.database.models # noqa: F401 — register models with Base.metadata
_DB_AVAILABLE = True
except ImportError as e:
@ -57,13 +62,13 @@ def get_alembic_config():
config = Config(config_path)
config.set_main_option("script_location", scripts_path)
config.set_main_option("sqlalchemy.url", args.database_url)
config.set_main_option("sqlalchemy.url", comfy.cli_args.args.database_url)
return config
def get_db_path():
url = args.database_url
url = comfy.cli_args.args.database_url
if url.startswith("sqlite:///"):
return url.split("///")[1]
else:
@ -97,7 +102,7 @@ def _is_memory_db(db_url):
def init_db():
db_url = args.database_url
db_url = comfy.cli_args.args.database_url
logging.debug(f"Database URL: {db_url}")
if _is_memory_db(db_url):

View File

@ -1,4 +1,3 @@
from __future__ import annotations
import argparse
import logging
import os

View File

@ -5,6 +5,40 @@ import logging
import sys
import threading
ANSI_NAMED_COLORS = {
'black': '\033[30m',
'red': '\033[31m',
'green': '\033[32m',
'yellow': '\033[33m',
'blue': '\033[34m',
'magenta': '\033[35m',
'cyan': '\033[36m',
'white': '\033[37m',
}
ANSI_LEVEL_COLORS = {
'DEBUG': ANSI_NAMED_COLORS['cyan'],
'INFO': ANSI_NAMED_COLORS['green'],
'WARNING': ANSI_NAMED_COLORS['yellow'],
'ERROR': ANSI_NAMED_COLORS['red'],
'CRITICAL': ANSI_NAMED_COLORS['magenta'],
}
ANSI_RESET = '\033[0m'
ANSI_BOLD = '\033[1m'
class ColoredFormatter(logging.Formatter):
def format(self, record):
color = ANSI_LEVEL_COLORS.get(record.levelname, '')
bold = ANSI_BOLD if record.levelno >= logging.WARNING else ''
level_tag = f"{bold}{color}[{record.levelname}]{ANSI_RESET} "
message = super().format(record)
line_color = ANSI_NAMED_COLORS.get(getattr(record, 'color', ''), '')
if line_color:
return f"{level_tag}{line_color}{message}{ANSI_RESET}"
return level_tag + message
logs = None
stdout_interceptor = None
stderr_interceptor = None
@ -68,8 +102,10 @@ def setup_logger(log_level: str = 'INFO', capacity: int = 300, use_stdout: bool
logger = logging.getLogger()
logger.setLevel(log_level)
formatter = ColoredFormatter("%(message)s")
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(logging.Formatter("%(message)s"))
stream_handler.setFormatter(formatter)
if use_stdout:
# Only errors and critical to stderr
@ -77,7 +113,7 @@ def setup_logger(log_level: str = 'INFO', capacity: int = 300, use_stdout: bool
# Lesser to stdout
stdout_handler = logging.StreamHandler(sys.stdout)
stdout_handler.setFormatter(logging.Formatter("%(message)s"))
stdout_handler.setFormatter(formatter)
stdout_handler.addFilter(lambda record: record.levelno < logging.ERROR)
logger.addHandler(stdout_handler)

View File

@ -0,0 +1,220 @@
"""aiohttp routes for the download manager.
Endpoint surface (all under ``/api/download``), mirroring the response
envelope used by ``app/assets/api/routes.py``:
POST /api/download/enqueue
GET /api/download
POST /api/download/availability
POST /api/download/clear
POST /api/download/credentials
GET /api/download/credentials
GET /api/download/credentials/{id}
DELETE /api/download/credentials/{id}
GET /api/download/{id}
DELETE /api/download/{id}
POST /api/download/{id}/pause
POST /api/download/{id}/resume
POST /api/download/{id}/cancel
POST /api/download/{id}/priority
Note on ordering: the static ``credentials`` routes are registered before the
dynamic ``/api/download/{id}`` route so a request to ``.../credentials`` is not
captured as ``id == "credentials"``.
"""
from __future__ import annotations
import json
from aiohttp import web
from pydantic import BaseModel, ValidationError
from app.model_downloader.api import schemas_in, schemas_out
from app.model_downloader.credentials.store import (
CREDENTIAL_STORE,
CredentialValidationError,
)
from app.model_downloader.manager import DOWNLOAD_MANAGER, DownloadError
ROUTES = web.RouteTableDef()
def register_routes(app: web.Application) -> None:
"""Wire the download-manager routes into the running aiohttp app."""
app.add_routes(ROUTES)
# ----- envelope helpers (same shape as app/assets/api/routes.py) -----
def _error(status: int, code: str, message: str, details: dict | None = None) -> web.Response:
return web.json_response(
{"error": {"code": code, "message": message, "details": details or {}}},
status=status,
)
def _ok(payload, status: int = 200) -> web.Response:
return web.json_response(payload, status=status)
async def _parse(request: web.Request, model: type[BaseModel]):
try:
raw = await request.json()
except json.JSONDecodeError:
return _error(400, "INVALID_JSON", "Request body must be valid JSON.")
try:
return model.model_validate(raw)
except ValidationError as ve:
return _error(400, "INVALID_BODY", "Validation failed.", {"errors": json.loads(ve.json())})
def _from_download_error(e: DownloadError) -> web.Response:
return _error(e.http_status, e.code, e.message)
# ----- downloads: collection + enqueue + availability -----
@ROUTES.post("/api/download/enqueue")
async def enqueue(request: web.Request) -> web.Response:
parsed = await _parse(request, schemas_in.EnqueueRequest)
if isinstance(parsed, web.Response):
return parsed
try:
download_id = await DOWNLOAD_MANAGER.enqueue(
parsed.url,
parsed.model_id,
priority=parsed.priority,
expected_sha256=parsed.expected_sha256,
allow_any_extension=parsed.allow_any_extension,
credential_id=parsed.credential_id,
)
except DownloadError as e:
return _from_download_error(e)
return _ok({"download_id": download_id, "accepted": True}, status=202)
@ROUTES.get("/api/download")
async def list_downloads(request: web.Request) -> web.Response:
return _ok({"downloads": await DOWNLOAD_MANAGER.list()})
@ROUTES.post("/api/download/availability")
async def availability(request: web.Request) -> web.Response:
parsed = await _parse(request, schemas_in.AvailabilityRequest)
if isinstance(parsed, web.Response):
return parsed
return _ok({"models": await DOWNLOAD_MANAGER.availability(parsed.models)})
@ROUTES.post("/api/download/clear")
async def clear(request: web.Request) -> web.Response:
deleted = await DOWNLOAD_MANAGER.clear()
return _ok({"deleted": deleted})
# ----- credentials (secrets are write-only) — must precede /{id} -----
@ROUTES.post("/api/download/credentials")
async def upsert_credential(request: web.Request) -> web.Response:
parsed = await _parse(request, schemas_in.CredentialUpsertRequest)
if isinstance(parsed, web.Response):
return parsed
try:
view = await CREDENTIAL_STORE.upsert(
parsed.host,
parsed.secret,
auth_scheme=parsed.auth_scheme,
header_name=parsed.header_name,
query_param=parsed.query_param,
label=parsed.label,
match_subdomains=parsed.match_subdomains,
enabled=parsed.enabled,
)
except CredentialValidationError as e:
return _error(400, "INVALID_CREDENTIAL", str(e))
return _ok(schemas_out.credential_to_dict(view), status=201)
@ROUTES.get("/api/download/credentials")
async def list_credentials(request: web.Request) -> web.Response:
views = await CREDENTIAL_STORE.list()
return _ok({"credentials": [schemas_out.credential_to_dict(v) for v in views]})
@ROUTES.get("/api/download/credentials/{id}")
async def get_credential(request: web.Request) -> web.Response:
view = await CREDENTIAL_STORE.get(request.match_info["id"])
if view is None:
return _error(404, "NOT_FOUND", "No such credential.")
return _ok(schemas_out.credential_to_dict(view))
@ROUTES.delete("/api/download/credentials/{id}")
async def delete_credential(request: web.Request) -> web.Response:
deleted = await CREDENTIAL_STORE.delete(request.match_info["id"])
if not deleted:
return _error(404, "NOT_FOUND", "No such credential.")
return _ok({"deleted": True})
# ----- single download by id (dynamic; registered last) -----
@ROUTES.get("/api/download/{id}")
async def get_download(request: web.Request) -> web.Response:
view = await DOWNLOAD_MANAGER.status(request.match_info["id"])
if view is None:
return _error(404, "NOT_FOUND", "No such download.")
return _ok(view)
@ROUTES.delete("/api/download/{id}")
async def delete_download(request: web.Request) -> web.Response:
try:
await DOWNLOAD_MANAGER.delete(request.match_info["id"])
except DownloadError as e:
return _from_download_error(e)
return _ok({"deleted": True})
@ROUTES.post("/api/download/{id}/pause")
async def pause(request: web.Request) -> web.Response:
try:
await DOWNLOAD_MANAGER.pause(request.match_info["id"])
except DownloadError as e:
return _from_download_error(e)
return _ok({"ok": True})
@ROUTES.post("/api/download/{id}/resume")
async def resume(request: web.Request) -> web.Response:
try:
await DOWNLOAD_MANAGER.resume(request.match_info["id"])
except DownloadError as e:
return _from_download_error(e)
return _ok({"ok": True})
@ROUTES.post("/api/download/{id}/cancel")
async def cancel(request: web.Request) -> web.Response:
try:
await DOWNLOAD_MANAGER.cancel(request.match_info["id"])
except DownloadError as e:
return _from_download_error(e)
return _ok({"ok": True})
@ROUTES.post("/api/download/{id}/priority")
async def set_priority(request: web.Request) -> web.Response:
parsed = await _parse(request, schemas_in.PriorityRequest)
if isinstance(parsed, web.Response):
return parsed
try:
await DOWNLOAD_MANAGER.set_priority(request.match_info["id"], parsed.priority)
except DownloadError as e:
return _from_download_error(e)
return _ok({"ok": True})

View File

@ -0,0 +1,51 @@
"""Request schemas for the download manager API.
Pydantic enforces shape at the boundary; handlers operate only on validated
values past that point.
"""
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, Field
from app.model_downloader.constants import AUTH_SCHEME_BEARER
class EnqueueRequest(BaseModel):
url: str
model_id: str
priority: int = 0
expected_sha256: Optional[str] = None
allow_any_extension: bool = False
credential_id: Optional[str] = None
class PriorityRequest(BaseModel):
priority: int
class AvailabilityRequest(BaseModel):
"""``{model_id: url}`` — the URLs declared in the workflow JSON."""
models: dict[str, str] = Field(default_factory=dict)
class CredentialUpsertRequest(BaseModel):
host: str
secret: str
auth_scheme: str = AUTH_SCHEME_BEARER
header_name: Optional[str] = None
query_param: Optional[str] = None
label: Optional[str] = None
match_subdomains: bool = False
enabled: bool = True
__all__ = [
"EnqueueRequest",
"PriorityRequest",
"AvailabilityRequest",
"CredentialUpsertRequest",
]

View File

@ -0,0 +1,26 @@
"""Response helpers for the download manager API.
The download/status read models are plain dicts produced by the manager. This
module only needs to mask credentials for output (the secret is never returned).
"""
from __future__ import annotations
from app.model_downloader.credentials.store import CredentialView
def credential_to_dict(view: CredentialView) -> dict:
"""API-safe credential representation — never includes the secret."""
return {
"id": view.id,
"host": view.host,
"auth_scheme": view.auth_scheme,
"header_name": view.header_name,
"query_param": view.query_param,
"label": view.label,
"match_subdomains": view.match_subdomains,
"enabled": view.enabled,
"secret_last4": view.secret_last4,
"created_at": view.created_at,
"updated_at": view.updated_at,
}

View File

@ -0,0 +1,47 @@
"""Shared constants for the download manager.
Status values are persisted as TEXT in the ``downloads`` table; keep them
stable. The lifecycle is:
queued -> active -> verifying -> completed
| |-> paused -> (resume) -> active
| |-> failed (network, retryable) -> queued (backoff)
|-> cancelled
"""
from __future__ import annotations
# Auth schemes for HostCredential
AUTH_SCHEME_BEARER = "bearer"
AUTH_SCHEME_HEADER = "header"
AUTH_SCHEME_QUERY = "query"
AUTH_SCHEMES = (AUTH_SCHEME_BEARER, AUTH_SCHEME_HEADER, AUTH_SCHEME_QUERY)
# Hosts for which a bearer token can be sourced from the environment when no
# stored credential matches. Values are the env var names to try, in order.
# Only consulted during auto-resolve for an exact host match over https, so the
# same per-hop boundary rules apply (e.g. the token is dropped on a redirect to
# a CDN host). Kept here so the host->env-var mapping lives in one place.
ENV_TOKEN_HOSTS = {
"huggingface.co": ("HF_TOKEN", "HUGGING_FACE_HUB_TOKEN"),
}
class DownloadStatus:
QUEUED = "queued"
ACTIVE = "active"
PAUSED = "paused"
VERIFYING = "verifying"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
#: States from which a worker is doing (or about to do) network I/O.
LIVE = (QUEUED, ACTIVE, VERIFYING)
#: Terminal states — the job will not transition again on its own.
TERMINAL = (COMPLETED, FAILED, CANCELLED)
# Default temp-file suffix. Distinctive so the startup orphan sweep only
# removes files THIS subsystem created, never unrelated *.tmp files.
TMP_SUFFIX = ".comfy-download.part"

View File

@ -0,0 +1,111 @@
"""Turn a stored credential into a per-hop request modifier (PRD section 9.4.2).
The critical rule: a credential is only ever attached when *the current hop's
host* matches a stored credential, and only over https. This is recomputed
from scratch on every redirect hop, so a token bound to ``huggingface.co`` is
silently dropped when the request is redirected to a presigned CDN host —
which is exactly what these hubs expect.
"""
from __future__ import annotations
import asyncio
import os
from dataclasses import dataclass, field
from typing import Optional
from urllib.parse import urlencode, urlsplit, urlunsplit
from app.model_downloader.constants import (
AUTH_SCHEME_BEARER,
AUTH_SCHEME_HEADER,
AUTH_SCHEME_QUERY,
ENV_TOKEN_HOSTS,
)
from app.model_downloader.credentials.store import normalize_host
from app.model_downloader.database import queries
from app.model_downloader.database.models import HostCredential
@dataclass
class RequestAuth:
"""How to modify a single request to carry a credential."""
headers: dict[str, str] = field(default_factory=dict)
query: dict[str, str] = field(default_factory=dict)
def apply_to_url(self, url: str) -> str:
if not self.query:
return url
parts = urlsplit(url)
# Append only the credential params, leaving the original query string
# (including any repeated keys and existing encoding) untouched.
creds = urlencode(self.query)
query = f"{parts.query}&{creds}" if parts.query else creds
return urlunsplit(parts._replace(query=query))
def _matches(cred: HostCredential, hop_host: str) -> bool:
cred_host = cred.host
if hop_host == cred_host:
return True
if cred.match_subdomains:
# Label-boundary suffix: api.example.com matches example.com, but
# evil-example.com does NOT.
return hop_host.endswith("." + cred_host)
return False
def _build_auth(cred: HostCredential) -> RequestAuth:
if cred.auth_scheme == AUTH_SCHEME_BEARER:
return RequestAuth(headers={"Authorization": f"Bearer {cred.secret}"})
if cred.auth_scheme == AUTH_SCHEME_HEADER:
name = cred.header_name or "Authorization"
return RequestAuth(headers={name: cred.secret})
if cred.auth_scheme == AUTH_SCHEME_QUERY and cred.query_param:
return RequestAuth(query={cred.query_param: cred.secret})
return RequestAuth()
def _resolve_sync(
host: str, scheme: str, explicit_credential_id: Optional[str]
) -> Optional[RequestAuth]:
# Never attach a secret over a non-https hop (PRD section 9.4.2).
if scheme.lower() != "https":
return None
hop_host = normalize_host(host)
if not hop_host:
return None
if explicit_credential_id is not None:
cred = queries.get_credential(explicit_credential_id)
# An explicit credential is still subject to the per-hop host check —
# it is not forced onto a non-matching host.
if cred is None or not cred.enabled or not _matches(cred, hop_host):
return None
return _build_auth(cred)
# Auto-resolve: exact host first, then any subdomain-matching credential.
cred = queries.get_credential_by_host(hop_host)
if cred is not None and cred.enabled:
return _build_auth(cred)
for sub in queries.list_subdomain_credentials():
if sub.enabled and _matches(sub, hop_host):
return _build_auth(sub)
# Env fallback: only for an exact host match, and only after the DB lookups
# miss, so a user-set credential always takes precedence. The token is never
# persisted; it is read fresh from the environment on each hop.
for var in ENV_TOKEN_HOSTS.get(hop_host, ()):
token = os.environ.get(var)
if token:
return RequestAuth(headers={"Authorization": f"Bearer {token}"})
return None
async def resolve_auth_for_hop(
host: str, scheme: str, *, explicit_credential_id: Optional[str] = None
) -> Optional[RequestAuth]:
"""Resolve the credential (if any) to attach for one request hop."""
return await asyncio.to_thread(
_resolve_sync, host, scheme, explicit_credential_id
)

View File

@ -0,0 +1,141 @@
"""The credential store: one API key per host.
Secrets are write-only over the API — :class:`CredentialView` carries only
masked metadata (``secret_last4`` + scheme + label), never the secret itself.
At-rest protection for v1 is filesystem permissions on the shared DB (the DB
is the trust boundary); encryption-at-rest is a noted future seam.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import Optional
from urllib.parse import urlsplit
from app.model_downloader.constants import (
AUTH_SCHEME_BEARER,
AUTH_SCHEME_HEADER,
AUTH_SCHEME_QUERY,
AUTH_SCHEMES,
)
from app.model_downloader.database import queries
from app.model_downloader.database.models import HostCredential
def normalize_host(host: str) -> str:
"""Lowercase, strip port, IDNA-encode."""
if not host:
return ""
host = host.strip()
if "://" in host: # a full URL was pasted — extract just the host
host = urlsplit(host).hostname or ""
host = host.lower()
if host.startswith("[") and "]" in host: # bracketed IPv6 literal
host = host[1 : host.index("]")]
elif host.count(":") == 1: # host:port (not IPv6)
host = host.split(":", 1)[0]
try:
host = host.encode("idna").decode("ascii")
except (UnicodeError, ValueError):
pass
return host
@dataclass(frozen=True)
class CredentialView:
"""Masked, API-safe view of a credential — never includes the secret."""
id: str
host: str
auth_scheme: str
header_name: Optional[str]
query_param: Optional[str]
label: Optional[str]
match_subdomains: bool
enabled: bool
secret_last4: Optional[str]
created_at: int
updated_at: int
def _to_view(row: HostCredential) -> CredentialView:
return CredentialView(
id=row.id,
host=row.host,
auth_scheme=row.auth_scheme,
header_name=row.header_name,
query_param=row.query_param,
label=row.label,
match_subdomains=row.match_subdomains,
enabled=row.enabled,
secret_last4=row.secret_last4,
created_at=row.created_at,
updated_at=row.updated_at,
)
class CredentialValidationError(ValueError):
"""A credential upsert had inconsistent fields."""
class CredentialStore:
"""Async facade over the ``host_credentials`` table.
DB access is synchronous (SQLite) and offloaded via ``asyncio.to_thread``.
"""
async def upsert(
self,
host: str,
secret: str,
*,
auth_scheme: str = AUTH_SCHEME_BEARER,
header_name: Optional[str] = None,
query_param: Optional[str] = None,
label: Optional[str] = None,
match_subdomains: bool = False,
enabled: bool = True,
) -> CredentialView:
host = normalize_host(host)
if not host:
raise CredentialValidationError("host is required")
if not secret:
raise CredentialValidationError("secret is required")
if auth_scheme not in AUTH_SCHEMES:
raise CredentialValidationError(
f"auth_scheme must be one of {AUTH_SCHEMES}, got {auth_scheme!r}"
)
if auth_scheme == AUTH_SCHEME_HEADER and not header_name:
header_name = "Authorization"
if auth_scheme == AUTH_SCHEME_QUERY and not query_param:
raise CredentialValidationError(
"query_param is required when auth_scheme='query'"
)
values = {
"host": host,
"secret": secret,
"secret_last4": secret[-4:] if len(secret) > 4 else None,
"auth_scheme": auth_scheme,
"header_name": header_name,
"query_param": query_param,
"label": label,
"match_subdomains": match_subdomains,
"enabled": enabled,
}
row = await asyncio.to_thread(queries.upsert_credential, values)
return _to_view(row)
async def list(self) -> list[CredentialView]:
rows = await asyncio.to_thread(queries.list_credentials)
return [_to_view(r) for r in rows]
async def get(self, credential_id: str) -> Optional[CredentialView]:
row = await asyncio.to_thread(queries.get_credential, credential_id)
return _to_view(row) if row is not None else None
async def delete(self, credential_id: str) -> bool:
return await asyncio.to_thread(queries.delete_credential, credential_id)
CREDENTIAL_STORE = CredentialStore()

View File

@ -0,0 +1,173 @@
"""SQLAlchemy models for the download manager.
Three tables:
- ``downloads`` one row per requested file (job + queue state).
- ``download_segments`` per-segment byte progress, for segmented resume.
- ``host_credentials`` one API key per host, reused across downloads.
On completion a finished file is registered into the assets catalog;
``downloads`` is kept only as job history.
"""
from __future__ import annotations
import time
import uuid
from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
ForeignKey,
Index,
Integer,
String,
Text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.models import Base
def _uuid() -> str:
return str(uuid.uuid4())
def _now() -> int:
return int(time.time())
class Download(Base):
__tablename__ = "downloads"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
# Original requested URL and the final URL after validated redirects.
url: Mapped[str] = mapped_column(Text, nullable=False)
final_url: Mapped[str | None] = mapped_column(Text, nullable=True)
# Canonical "<directory>/<filename>" identifier (resolved via folder_paths).
model_id: Mapped[str] = mapped_column(String(1024), nullable=False)
# Final on-disk location and the .part write target.
dest_path: Mapped[str] = mapped_column(Text, nullable=False)
temp_path: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False)
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
total_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
bytes_done: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
etag: Mapped[str | None] = mapped_column(String(512), nullable=True)
last_modified: Mapped[str | None] = mapped_column(String(128), nullable=True)
accept_ranges: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Optional hub-provided checksum to verify against (NOT the dedup key).
expected_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
# Explicit credential override; otherwise auto-resolved by host.
# RESTRICT keeps a credential from being deleted while a download references it.
credential_id: Mapped[str | None] = mapped_column(
String(36),
ForeignKey("host_credentials.id", ondelete="RESTRICT"),
nullable=True,
)
allow_any_extension: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
# How many retryable failures we have seen (for backoff capping).
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[int] = mapped_column(BigInteger, nullable=False, default=_now)
updated_at: Mapped[int] = mapped_column(
BigInteger, nullable=False, default=_now, onupdate=_now
)
segments: Mapped[list[DownloadSegment]] = relationship(
"DownloadSegment",
back_populates="download",
cascade="all,delete-orphan",
passive_deletes=True,
order_by="DownloadSegment.idx",
)
credential: Mapped[HostCredential | None] = relationship(
"HostCredential", back_populates="downloads"
)
__table_args__ = (
Index("ix_downloads_status", "status"),
Index("ix_downloads_priority", "priority"),
Index("ix_downloads_model_id", "model_id"),
CheckConstraint("bytes_done >= 0", name="ck_downloads_bytes_done_nonneg"),
CheckConstraint(
"total_bytes IS NULL OR total_bytes >= 0",
name="ck_downloads_total_bytes_nonneg",
),
)
def __repr__(self) -> str:
return f"<Download id={self.id} model_id={self.model_id!r} status={self.status}>"
class DownloadSegment(Base):
__tablename__ = "download_segments"
download_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("downloads.id", ondelete="CASCADE"),
primary_key=True,
)
idx: Mapped[int] = mapped_column(Integer, primary_key=True)
start_offset: Mapped[int] = mapped_column(BigInteger, nullable=False)
end_offset: Mapped[int] = mapped_column(BigInteger, nullable=False)
bytes_done: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
download: Mapped[Download] = relationship("Download", back_populates="segments")
__table_args__ = (
CheckConstraint("bytes_done >= 0", name="ck_segments_bytes_done_nonneg"),
CheckConstraint("end_offset >= start_offset", name="ck_segments_range"),
)
def __repr__(self) -> str:
return (
f"<DownloadSegment {self.download_id}#{self.idx} "
f"{self.start_offset}-{self.end_offset} done={self.bytes_done}>"
)
class HostCredential(Base):
__tablename__ = "host_credentials"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
# Normalized lowercase hostname, e.g. "civitai.com".
host: Mapped[str] = mapped_column(String(255), nullable=False)
match_subdomains: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
label: Mapped[str | None] = mapped_column(String(255), nullable=True)
auth_scheme: Mapped[str] = mapped_column(
String(16), nullable=False, default="bearer"
)
header_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
query_param: Mapped[str | None] = mapped_column(String(255), nullable=True)
# The API key itself. Write-only over the API; never returned. See PRD 9.4.4.
secret: Mapped[str] = mapped_column(Text, nullable=False)
secret_last4: Mapped[str | None] = mapped_column(String(4), nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[int] = mapped_column(BigInteger, nullable=False, default=_now)
updated_at: Mapped[int] = mapped_column(
BigInteger, nullable=False, default=_now, onupdate=_now
)
downloads: Mapped[list[Download]] = relationship(
"Download", back_populates="credential"
)
__table_args__ = (
Index("uq_host_credentials_host", "host", unique=True),
)
def __repr__(self) -> str:
return f"<HostCredential id={self.id} host={self.host!r} scheme={self.auth_scheme}>"

View File

@ -0,0 +1,272 @@
"""Synchronous DB access for the download manager.
All functions open their own short-lived session via ``create_session`` and
commit before returning, mirroring ``app/assets`` usage. They are blocking
(SQLite) and should be called from async code through ``asyncio.to_thread``.
"""
from __future__ import annotations
import time
from typing import Optional
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from app.database.db import create_session
from app.model_downloader.constants import DownloadStatus
from app.model_downloader.database.models import (
Download,
DownloadSegment,
HostCredential,
)
# ----- downloads -----
def insert_download(values: dict) -> None:
with create_session() as session:
session.add(Download(**values))
session.commit()
def get_download(download_id: str) -> Optional[Download]:
with create_session() as session:
row = session.get(Download, download_id)
if row is not None:
session.expunge_all()
return row
def list_downloads() -> list[Download]:
with create_session() as session:
rows = list(
session.execute(
select(Download).order_by(Download.created_at.desc())
).scalars()
)
session.expunge_all()
return rows
def list_segments(download_id: str) -> list[DownloadSegment]:
with create_session() as session:
rows = list(
session.execute(
select(DownloadSegment)
.where(DownloadSegment.download_id == download_id)
.order_by(DownloadSegment.idx)
).scalars()
)
session.expunge_all()
return rows
def update_download(download_id: str, **fields) -> None:
if not fields:
return
fields.setdefault("updated_at", int(time.time()))
with create_session() as session:
row = session.get(Download, download_id)
if row is None:
return
for key, value in fields.items():
setattr(row, key, value)
session.commit()
def delete_download(download_id: str) -> None:
with create_session() as session:
row = session.get(Download, download_id)
if row is not None:
session.delete(row)
session.commit()
def delete_downloads(download_ids: list[str]) -> int:
"""Delete many downloads in one transaction; returns the number removed.
Uses a bulk ``DELETE ... WHERE id IN (...)``. Segment rows are removed by
the ``ON DELETE CASCADE`` foreign key (SQLite ``PRAGMA foreign_keys=ON`` is
set in ``app/database/db.py``), so this stays consistent without loading the
ORM relationship.
"""
if not download_ids:
return 0
with create_session() as session:
result = session.execute(
delete(Download).where(Download.id.in_(download_ids))
)
session.commit()
return result.rowcount or 0
def replace_segments(download_id: str, segments: list[dict]) -> None:
"""Atomically replace the segment plan for a download."""
with create_session() as session:
session.query(DownloadSegment).filter(
DownloadSegment.download_id == download_id
).delete()
for seg in segments:
session.add(DownloadSegment(download_id=download_id, **seg))
session.commit()
def update_segment_progress(download_id: str, idx: int, bytes_done: int) -> None:
with create_session() as session:
row = session.get(DownloadSegment, {"download_id": download_id, "idx": idx})
if row is None:
return
row.bytes_done = bytes_done
session.commit()
def list_queued_downloads() -> list[Download]:
"""Queued rows ordered for admission (priority desc, then FIFO)."""
with create_session() as session:
rows = list(
session.execute(
select(Download)
.where(Download.status == DownloadStatus.QUEUED)
.order_by(Download.priority.desc(), Download.created_at.asc())
).scalars()
)
session.expunge_all()
return rows
def reconcile_live_downloads() -> list[Download]:
"""Reset any ``active``/``verifying`` rows left by a previous run.
On a clean restart there can be no live worker, so anything still marked
live is stale. Move it back to ``queued`` (offsets are preserved on the
segment rows) so the scheduler re-admits it. Returns the rows that should
be re-queued by the scheduler (queued + paused).
"""
with create_session() as session:
stale = list(
session.execute(
select(Download).where(
Download.status.in_([DownloadStatus.ACTIVE, DownloadStatus.VERIFYING])
)
).scalars()
)
now = int(time.time())
for row in stale:
row.status = DownloadStatus.QUEUED
row.updated_at = now
session.commit()
resumable = list(
session.execute(
select(Download)
.where(Download.status == DownloadStatus.QUEUED)
.order_by(Download.priority.desc(), Download.created_at.asc())
).scalars()
)
session.expunge_all()
return resumable
# ----- host credentials -----
def get_credential(credential_id: str) -> Optional[HostCredential]:
with create_session() as session:
row = session.get(HostCredential, credential_id)
if row is not None:
session.expunge_all()
return row
def get_credential_by_host(host: str) -> Optional[HostCredential]:
with create_session() as session:
row = (
session.execute(
select(HostCredential).where(HostCredential.host == host).limit(1)
)
.scalars()
.first()
)
if row is not None:
session.expunge_all()
return row
def list_credentials() -> list[HostCredential]:
with create_session() as session:
rows = list(
session.execute(
select(HostCredential).order_by(HostCredential.host)
).scalars()
)
session.expunge_all()
return rows
def list_subdomain_credentials() -> list[HostCredential]:
"""Credentials that opted into subdomain matching, for suffix checks."""
with create_session() as session:
rows = list(
session.execute(
select(HostCredential).where(HostCredential.match_subdomains.is_(True))
).scalars()
)
session.expunge_all()
return rows
def upsert_credential(values: dict) -> HostCredential:
"""Insert or update a credential keyed by ``host``.
Callers can target the same host concurrently (each runs in its own
short-lived session on a separate connection), so the read-then-write here
can race: two callers both see no existing row and both attempt an insert.
The ``host`` column is uniquely indexed, so the loser's insert raises
``IntegrityError``. We recover by rolling back and retrying, at which point
the now-committed row is found and updated in place, letting concurrent
calls converge instead of failing or creating duplicates.
"""
host = values["host"]
now = int(time.time())
last_error: IntegrityError | None = None
for _ in range(2):
with create_session() as session:
row = (
session.execute(
select(HostCredential).where(HostCredential.host == host).limit(1)
)
.scalars()
.first()
)
if row is None:
row = HostCredential(**values)
row.created_at = now
row.updated_at = now
session.add(row)
else:
for key, value in values.items():
setattr(row, key, value)
row.updated_at = now
try:
session.commit()
except IntegrityError as exc:
session.rollback()
last_error = exc
continue
session.refresh(row)
session.expunge(row)
return row
assert last_error is not None
raise last_error
def delete_credential(credential_id: str) -> bool:
with create_session() as session:
row = session.get(HostCredential, credential_id)
if row is None:
return False
session.delete(row)
session.commit()
return True

View File

@ -0,0 +1,612 @@
"""The per-download worker.
One :class:`DownloadJob` drives a single file from probe to verified, cataloged
completion. It supports cooperative pause / resume / cancel, segmented
multi-connection transfer with positioned writes, and a verification gate
(size + structural + optional sha256) before the atomic rename into place.
Control is cooperative: external callers flip ``_control`` via
:meth:`request_pause` / :meth:`request_cancel`; segment loops observe it between
chunks and raise, which unwinds cleanly and persists resume offsets.
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
from dataclasses import dataclass, field
from typing import Callable, Optional
from comfy.cli_args import args
from app.model_downloader.constants import DownloadStatus
from app.model_downloader.database import queries
from app.model_downloader.engine.planner import (
effective_segment_count,
plan_segments,
)
from app.model_downloader.engine.writer import FileWriter
from app.model_downloader.net.http import open_validated, redact_url
from app.model_downloader.net.probe import gated_error_message, probe
from app.model_downloader.verify import checksum, dedup, structural
_RETRYABLE_STATUSES = {408, 429, 500, 502, 503, 504}
_PERSIST_INTERVAL = 2.0 # seconds between throttled progress persists
class Paused(Exception):
pass
class Cancelled(Exception):
pass
class RemoteChanged(Exception):
"""The remote file changed under a resume (got 200 where 206 expected)."""
class RetryableError(Exception):
pass
class FatalError(Exception):
"""Non-retryable: 4xx, checksum mismatch, structural failure, gated, etc."""
@dataclass
class SegmentRuntime:
idx: int
start: int
end: int # inclusive; may be -1 for unknown-size single stream
bytes_done: int = 0
@property
def length(self) -> int:
return self.end - self.start + 1
@dataclass
class RuntimeState:
download_id: str
model_id: str
url: str
priority: int
status: str
total_bytes: Optional[int] = None
bytes_done: int = 0
error: Optional[str] = None
segments: list[SegmentRuntime] = field(default_factory=list)
started_at: float = field(default_factory=time.monotonic)
_last_bytes: int = 0
_last_time: float = field(default_factory=time.monotonic)
speed_bps: float = 0.0
@property
def progress(self) -> Optional[float]:
if not self.total_bytes:
return None
return min(1.0, self.bytes_done / self.total_bytes)
@property
def eta_seconds(self) -> Optional[float]:
if not self.total_bytes or self.speed_bps <= 0:
return None
remaining = max(0, self.total_bytes - self.bytes_done)
return remaining / self.speed_bps
@dataclass
class JobSpec:
download_id: str
url: str
model_id: str
dest_path: str
temp_path: str
priority: int = 0
credential_id: Optional[str] = None
expected_sha256: Optional[str] = None
allow_any_extension: bool = False
etag: Optional[str] = None
attempts: int = 0
class DownloadJob:
def __init__(
self, spec: JobSpec, notify_cb: Optional[Callable[[str], None]] = None
) -> None:
self.spec = spec
self._notify = notify_cb
self._control = "run" # run | pause | cancel
self.state = RuntimeState(
download_id=spec.download_id,
model_id=spec.model_id,
url=spec.url,
priority=spec.priority,
status=DownloadStatus.QUEUED,
)
self._writer: Optional[FileWriter] = None
self._etag: Optional[str] = spec.etag
self._last_persist = 0.0
# ----- external control -----
def request_pause(self) -> None:
if self._control == "run":
self._control = "pause"
def request_cancel(self) -> None:
self._control = "cancel"
def _check_control(self) -> None:
if self._control == "cancel":
raise Cancelled()
if self._control == "pause":
raise Paused()
# ----- lifecycle -----
async def run(self) -> str:
"""Run to a terminal/paused state; returns the final status string."""
await self._set_status(DownloadStatus.ACTIVE, error=None)
try:
pr = await self._probe_and_plan()
await self._transfer(pr)
await self._finalize()
await self._set_status(DownloadStatus.COMPLETED)
except Paused:
await self._persist_progress(force=True)
await self._set_status(DownloadStatus.PAUSED)
except Cancelled:
await self._close_writer()
self._remove_temp()
await self._set_status(DownloadStatus.CANCELLED)
except RemoteChanged:
await self._reset_for_restart()
await self._set_status(
DownloadStatus.QUEUED, error="remote file changed; restarting"
)
except RetryableError as e:
await self._persist_progress(force=True)
await self._set_status(DownloadStatus.QUEUED, error=str(e))
except FatalError as e:
await self._close_writer()
self._remove_temp()
await self._set_status(DownloadStatus.FAILED, error=str(e))
except Exception as e: # unexpected -> treat as retryable
logging.warning(
"[model_downloader] %s unexpected error: %s",
self.spec.model_id, e, exc_info=True,
)
await self._persist_progress(force=True)
await self._set_status(DownloadStatus.QUEUED, error=f"{type(e).__name__}: {e}")
finally:
await self._close_writer()
return self.state.status
# ----- probe + plan -----
async def _probe_and_plan(self):
pr = await probe(self.spec.url, credential_id=self.spec.credential_id)
if not pr.ok:
if pr.gated:
raise FatalError(gated_error_message(self.spec.url, pr))
if pr.status == 0 or pr.status in _RETRYABLE_STATUSES:
raise RetryableError(pr.error or "probe failed")
raise FatalError(pr.error or f"probe returned HTTP {pr.status}")
max_bytes = self._max_download_bytes()
if max_bytes is not None and pr.total_bytes is not None and pr.total_bytes > max_bytes:
raise FatalError(
f"file size {pr.total_bytes} exceeds the maximum allowed "
f"download size {max_bytes} (--download-max-bytes)"
)
self._etag = pr.etag or self._etag
self.state.total_bytes = pr.total_bytes
await asyncio.to_thread(
queries.update_download,
self.spec.download_id,
final_url=pr.final_url,
total_bytes=pr.total_bytes,
accept_ranges=pr.accept_ranges,
etag=pr.etag,
last_modified=pr.last_modified,
)
seg_count = effective_segment_count(
pr.total_bytes, pr.accept_ranges, max(1, args.download_segments)
)
existing = await asyncio.to_thread(queries.list_segments, self.spec.download_id)
can_resume_segmented = (
seg_count > 1
and existing
and pr.total_bytes is not None
and existing[-1].end_offset == pr.total_bytes - 1
)
if can_resume_segmented and not self._segmented_part_valid(pr.total_bytes):
# The persisted per-segment offsets describe bytes in a preallocated
# .part that is now gone or the wrong size (e.g. the partial of a
# failed download was swept on restart, or removed by a fatal
# error). Trusting them would skip already-"complete" segments and
# leave zero-filled holes. Discard the offsets and re-plan fresh.
logging.info(
"[model_downloader] %s discarding segmented resume offsets "
"(preallocated .part missing or wrong size); restarting",
self.spec.model_id,
)
self._remove_temp()
await asyncio.to_thread(
queries.replace_segments, self.spec.download_id, []
)
await asyncio.to_thread(
queries.update_download, self.spec.download_id, bytes_done=0
)
existing = []
can_resume_segmented = False
if can_resume_segmented:
# Resume an existing segmented plan.
self.state.segments = [
SegmentRuntime(s.idx, s.start_offset, s.end_offset, s.bytes_done)
for s in existing
]
elif seg_count > 1 and pr.total_bytes is not None:
plans = plan_segments(pr.total_bytes, seg_count)
await asyncio.to_thread(
queries.replace_segments,
self.spec.download_id,
[
{"idx": p.idx, "start_offset": p.start, "end_offset": p.end, "bytes_done": 0}
for p in plans
],
)
self.state.segments = [SegmentRuntime(p.idx, p.start, p.end, 0) for p in plans]
else:
# Single-stream: one logical segment; bytes_done tracked on the row.
row = await asyncio.to_thread(queries.get_download, self.spec.download_id)
resume_from = row.bytes_done if row else 0
end = (pr.total_bytes - 1) if pr.total_bytes else -1
# ``row.bytes_done`` may be the SUM of per-segment offsets from a
# prior segmented run (a preallocated, non-contiguous .part). A
# single-stream resume writes a contiguous prefix, so the offset is
# only trustworthy when the on-disk file is exactly that many
# contiguous bytes. This guards the case where a download that ran
# segmented now resolves to one segment (server dropped
# Accept-Ranges, or --download-segments was lowered between runs):
# resuming over non-contiguous data would corrupt the output.
if resume_from > 0 and not self._contiguous_prefix_valid(resume_from):
logging.info(
"[model_downloader] %s discarding untrusted resume offset "
"%d (on-disk .part not a contiguous prefix); restarting",
self.spec.model_id, resume_from,
)
resume_from = 0
self._remove_temp()
if await asyncio.to_thread(queries.list_segments, self.spec.download_id):
await asyncio.to_thread(
queries.replace_segments, self.spec.download_id, []
)
await asyncio.to_thread(
queries.update_download, self.spec.download_id, bytes_done=0
)
self.state.segments = [SegmentRuntime(0, 0, end, resume_from)]
self._recompute_bytes_done()
return pr
# ----- transfer -----
async def _transfer(self, pr) -> None:
self._writer = FileWriter(self.spec.temp_path)
await self._writer.open()
segmented = len(self.state.segments) > 1
if segmented and self.state.total_bytes:
await self._writer.preallocate(self.state.total_bytes)
await self._run_segmented()
else:
await self._run_single()
await self._writer.flush()
async def _run_segmented(self) -> None:
pending = [
asyncio.ensure_future(self._run_segment(seg))
for seg in self.state.segments
if seg.bytes_done < seg.length
]
if not pending:
return
done, not_done = await asyncio.wait(
pending, return_when=asyncio.FIRST_EXCEPTION
)
first_exc: Optional[BaseException] = None
for task in done:
exc = task.exception()
if exc is not None and first_exc is None:
first_exc = exc
if first_exc is not None:
for task in not_done:
task.cancel()
await asyncio.gather(*not_done, return_exceptions=True)
raise first_exc
async def _run_segment(self, seg: SegmentRuntime) -> None:
offset = seg.start + seg.bytes_done
headers = {
"Range": f"bytes={offset}-{seg.end}",
"Accept-Encoding": "identity",
}
if self._etag:
headers["If-Range"] = self._etag
async with open_validated(
"GET", self.spec.url, credential_id=self.spec.credential_id, headers=headers
) as (resp, _final):
if resp.status == 200:
# Server ignored the range -> remote changed / no resume support.
raise RemoteChanged()
if resp.status not in (206,):
self._raise_for_status(resp.status)
async for chunk in resp.content.iter_chunked(args.download_chunk_size):
self._check_control()
# Never write past this segment's planned range: a
# non-conforming 206 that returns more than the requested
# bytes would otherwise overrun adjacent segments and the
# preallocated file. Cap the write and abort on overflow.
remaining = seg.length - seg.bytes_done
if remaining <= 0:
raise FatalError(
f"segment {seg.idx}: server returned more than the "
f"requested {seg.length} bytes"
)
overflow = len(chunk) > remaining
if overflow:
chunk = chunk[:remaining]
await self._writer.write_at(offset, chunk)
offset += len(chunk)
seg.bytes_done += len(chunk)
self._recompute_bytes_done()
await self._persist_progress()
if overflow:
raise FatalError(
f"segment {seg.idx}: server returned more than the "
f"requested {seg.length} bytes"
)
async def _run_single(self) -> None:
seg = self.state.segments[0]
offset = seg.bytes_done # resume from here for single-stream
headers = {"Accept-Encoding": "identity"}
if offset > 0:
headers["Range"] = f"bytes={offset}-"
if self._etag:
headers["If-Range"] = self._etag
async with open_validated(
"GET", self.spec.url, credential_id=self.spec.credential_id, headers=headers
) as (resp, _final):
if offset > 0 and resp.status == 200:
# Resume not honoured -> start over from the beginning. Truncate
# the existing partial so stale trailing bytes from the prior
# attempt cannot survive past the new (possibly shorter) end.
offset = 0
seg.bytes_done = 0
self.state.bytes_done = 0
await self._writer.truncate(0)
elif offset > 0 and resp.status != 206:
self._raise_for_status(resp.status)
elif offset == 0 and resp.status != 200:
self._raise_for_status(resp.status)
# Byte ceiling for this stream: the known total when the server
# reported a size, otherwise the configured maximum download size.
# Without a bound, a non-conforming response or an unknown-length
# stream (end == -1) that never closes could fill the disk (DoS).
limit = (seg.end + 1) if seg.end >= 0 else self._max_download_bytes()
async for chunk in resp.content.iter_chunked(args.download_chunk_size):
self._check_control()
overflow = False
if limit is not None:
remaining = limit - offset
if remaining <= 0:
raise FatalError(
f"download exceeded the maximum size {limit} bytes"
)
if len(chunk) > remaining:
chunk = chunk[:remaining]
overflow = True
await self._writer.write_at(offset, chunk)
offset += len(chunk)
seg.bytes_done = offset
self.state.bytes_done = offset
await self._persist_progress()
if overflow:
raise FatalError(
f"download exceeded the maximum size {limit} bytes"
)
def _max_download_bytes(self) -> Optional[int]:
"""Configured maximum download size in bytes, or ``None`` if disabled."""
cap = getattr(args, "download_max_bytes", 0)
return cap if cap and cap > 0 else None
def _raise_for_status(self, status: int) -> None:
if status in (401, 403):
raise FatalError(
f"{redact_url(self.spec.url)} returned {status}; add/update an API key for "
f"this host at /api/download/credentials."
)
if status in _RETRYABLE_STATUSES:
raise RetryableError(f"HTTP {status}")
raise FatalError(f"unexpected HTTP {status}")
# ----- finalize / verify (PRD section 8.4) -----
async def _finalize(self) -> None:
self._check_control()
await self._close_writer()
await self._set_status(DownloadStatus.VERIFYING)
total = self.state.total_bytes
segmented = len(self.state.segments) > 1
if segmented:
# The .part was preallocated to total_bytes, so its on-disk size is
# not evidence of completeness: a segment that ends short (truncated
# 206 / server closes mid-range) leaves a zero-filled hole while the
# file size still equals total. Verify each segment wrote its full
# planned range, and trust the byte counter (== sum of segments)
# rather than os.path.getsize for the total check.
for seg in self.state.segments:
if seg.bytes_done != seg.length:
raise FatalError(
f"segment {seg.idx} incomplete: wrote {seg.bytes_done} "
f"of {seg.length} bytes"
)
observed = self.state.bytes_done
else:
# Single-stream writes a contiguous prefix, so the on-disk size is
# an independent witness of how much actually landed.
observed = os.path.getsize(self.spec.temp_path)
if total is not None and observed != total:
raise FatalError(
f"size mismatch: wrote {observed} of {total} bytes"
)
# Structural gate (cheap, no full read) then optional sha256 (full read).
# Both failures are non-retryable (a truncated/corrupt or mismatched file
# will not heal on retry), so surface them as FatalError rather than
# letting the plain Exceptions fall through to the retryable handler.
# ``temp_path`` carries the ``.part`` suffix; pass ``dest_path`` so the
# structural check detects the real file format instead of skipping it.
try:
await asyncio.to_thread(
structural.validate, self.spec.temp_path, self.spec.dest_path
)
if self.spec.expected_sha256:
await asyncio.to_thread(
checksum.verify_sha256,
self.spec.temp_path,
self.spec.expected_sha256,
)
except (structural.StructuralError, checksum.ChecksumError) as e:
raise FatalError(str(e)) from e
os.makedirs(os.path.dirname(self.spec.dest_path), exist_ok=True)
os.replace(self.spec.temp_path, self.spec.dest_path)
logging.info(
"[model_downloader] completed %s (%d bytes)",
self.spec.model_id, observed,
)
# Catalog into the assets system (blake3 dedup identity). Best-effort.
await dedup.register_completed(self.spec.dest_path)
# ----- helpers -----
def _recompute_bytes_done(self) -> None:
self.state.bytes_done = sum(s.bytes_done for s in self.state.segments)
now = time.monotonic()
dt = now - self.state._last_time
if dt >= 0.5:
self.state.speed_bps = (self.state.bytes_done - self.state._last_bytes) / dt
self.state._last_bytes = self.state.bytes_done
self.state._last_time = now
async def _persist_progress(self, force: bool = False) -> None:
# Both the DB write and the websocket notify are gated by the same
# throttle: persisting hits SQLite, and notifying broadcasts to every
# client, so doing either per-chunk (small --download-chunk-size or
# many concurrent segments) would overwhelm both. Skip entirely inside
# the window; the next persist (or a forced one) ships the latest bytes.
now = time.monotonic()
if not force and now - self._last_persist < _PERSIST_INTERVAL:
return
self._last_persist = now
# SQLite is blocking; run it off the event loop per the queries module
# contract so progress persists don't stall the web server.
await asyncio.to_thread(self._write_progress)
if self._notify:
self._notify(self.spec.download_id)
def _write_progress(self) -> None:
queries.update_download(self.spec.download_id, bytes_done=self.state.bytes_done)
for seg in self.state.segments:
if seg.end >= seg.start: # skip unknown-size sentinel
queries.update_segment_progress(
self.spec.download_id, seg.idx, seg.bytes_done
)
async def _reset_for_restart(self) -> None:
await self._close_writer()
self._remove_temp()
for seg in self.state.segments:
seg.bytes_done = 0
self.state.bytes_done = 0
await asyncio.to_thread(
queries.update_download, self.spec.download_id, bytes_done=0
)
if await asyncio.to_thread(queries.list_segments, self.spec.download_id):
await asyncio.to_thread(
queries.replace_segments, self.spec.download_id, []
)
async def _close_writer(self) -> None:
if self._writer is not None:
try:
await self._writer.close()
except Exception:
logging.debug("[model_downloader] writer close error", exc_info=True)
self._writer = None
def _segmented_part_valid(self, total_bytes: int) -> bool:
"""True when the temp file is the preallocated segmented ``.part``.
A segmented transfer preallocates the .part to ``total_bytes`` up front
and tracks how much of each range landed via per-segment offsets. Those
offsets are only trustworthy when the file they describe is still on
disk at its full preallocated size. A missing file (swept after a
failure, removed on a fatal error, deleted by hand) or a wrong-sized one
means the persisted offsets no longer correspond to real bytes and must
not be resumed over. Doing so would skip "complete" segments and leave
zero-filled holes that pass the size-only verification gate.
"""
try:
return os.path.getsize(self.spec.temp_path) == total_bytes
except OSError:
return False
def _contiguous_prefix_valid(self, prefix_len: int) -> bool:
"""True when the temp file is exactly ``prefix_len`` contiguous bytes.
Single-stream resume appends sequentially, so a valid resume point
implies the .part size equals the persisted offset. A larger file (e.g.
one preallocated to ``total_bytes`` by a previous segmented run) or a
missing/short file means the persisted offset is not a trustworthy
contiguous prefix and must not be resumed over.
"""
try:
return os.path.getsize(self.spec.temp_path) == prefix_len
except OSError:
return False
def _remove_temp(self) -> None:
try:
os.remove(self.spec.temp_path)
except FileNotFoundError:
pass
except OSError as e:
logging.warning(
"[model_downloader] could not remove %s: %s", self.spec.temp_path, e
)
async def _set_status(self, status: str, error: Optional[str] = None) -> None:
# ``error`` is authoritative: passing None clears any prior failure
# text so transitions out of a failure state (retry/success) don't
# leave stale messages on RuntimeState or in the persisted row.
self.state.status = status
self.state.error = error
fields = {"status": status, "bytes_done": self.state.bytes_done, "error": error}
if status == DownloadStatus.QUEUED:
fields["attempts"] = self.spec.attempts + 1
self.spec.attempts += 1
await asyncio.to_thread(queries.update_download, self.spec.download_id, **fields)
if self._notify:
self._notify(self.spec.download_id)

View File

@ -0,0 +1,51 @@
"""Segment planning.
Split a known byte range into S roughly-equal segments, each fetched by its
own coroutine with ``Range: bytes=start-end``. Falls back to a single segment
when the server doesn't support ranges or the size is unknown/too small for
segmentation to be worthwhile.
"""
from __future__ import annotations
from dataclasses import dataclass
# Below this size, the per-connection setup cost outweighs any parallelism.
_MIN_SEGMENT_BYTES = 1 * 1024 * 1024
@dataclass(frozen=True)
class SegmentPlan:
idx: int
start: int
end: int # inclusive
@property
def length(self) -> int:
return self.end - self.start + 1
def effective_segment_count(
total_bytes: int | None, accept_ranges: bool, configured: int
) -> int:
"""How many segments to actually use for this file."""
if not accept_ranges or total_bytes is None or total_bytes <= 0:
return 1
by_size = max(1, total_bytes // _MIN_SEGMENT_BYTES)
return max(1, min(configured, by_size))
def plan_segments(total_bytes: int, num_segments: int) -> list[SegmentPlan]:
"""Return ``num_segments`` contiguous, inclusive byte ranges covering [0, total)."""
if total_bytes <= 0 or num_segments <= 1:
return [SegmentPlan(idx=0, start=0, end=max(0, total_bytes - 1))]
base = total_bytes // num_segments
plans: list[SegmentPlan] = []
start = 0
for i in range(num_segments):
# Last segment soaks up the remainder.
length = base if i < num_segments - 1 else total_bytes - start
end = start + length - 1
plans.append(SegmentPlan(idx=i, start=start, end=end))
start = end + 1
return plans

View File

@ -0,0 +1,110 @@
"""Positioned, off-loop file writes.
Network I/O stays on the event loop; every blocking disk op (preallocate,
positioned write, fsync) is run in a bounded thread pool via
``run_in_executor`` so downloads never stall inference or the web server.
A single file descriptor is opened for the whole download. Segments write to
their own offsets with ``os.pwrite`` — which is offset-addressed and atomic
per call, so concurrent segment writers need no extra locking. Per-chunk
fsync is avoided; we fsync once at completion.
``os.pwrite`` is unavailable on Windows, so there we fall back to
``os.lseek`` + ``os.write`` guarded by a per-writer lock (the seek/write pair
is not atomic, so concurrent segment writers must be serialized).
"""
from __future__ import annotations
import asyncio
import os
import threading
from concurrent.futures import ThreadPoolExecutor
from typing import Optional
# One shared, bounded pool for all download disk I/O.
_EXECUTOR = ThreadPoolExecutor(max_workers=8, thread_name_prefix="dl-writer")
_HAS_PWRITE = hasattr(os, "pwrite")
# On Windows ``os.open`` defaults to text mode, which translates every ``\n``
# byte into ``\r\n`` on write and corrupts binary payloads (the file grows by
# one byte per 0x0A). ``O_BINARY`` disables that translation; it does not exist
# on POSIX, where the default is already binary.
_O_BINARY = getattr(os, "O_BINARY", 0)
class FileWriter:
"""Owns the ``.part`` file descriptor for one download."""
def __init__(self, path: str) -> None:
self.path = path
self._fd: Optional[int] = None
# Serializes lseek+write on platforms without os.pwrite (Windows).
self._seek_lock = threading.Lock()
def _open(self) -> None:
os.makedirs(os.path.dirname(self.path), exist_ok=True)
self._fd = os.open(self.path, os.O_RDWR | os.O_CREAT | _O_BINARY, 0o644)
async def open(self) -> None:
await asyncio.get_running_loop().run_in_executor(_EXECUTOR, self._open)
async def preallocate(self, size: int) -> None:
"""Grow the file to ``size`` so segments write to their offsets."""
if self._fd is None or size <= 0:
return
await asyncio.get_running_loop().run_in_executor(
_EXECUTOR, os.ftruncate, self._fd, size
)
async def truncate(self, size: int = 0) -> None:
"""Truncate the file to ``size`` bytes (default: empty it)."""
if self._fd is None:
return
await asyncio.get_running_loop().run_in_executor(
_EXECUTOR, os.ftruncate, self._fd, size
)
def _pwrite_all(self, data: bytes, offset: int) -> None:
"""A positioned write may write fewer bytes than requested (signal
interruption, near-ENOSPC); loop until every byte lands so we never
leave a gap while the caller advances by the full chunk length.
Uses ``os.pwrite`` where available (offset-addressed, atomic per call).
On Windows it falls back to ``os.lseek`` + ``os.write`` under a lock,
since that pair is not atomic across concurrent segment writers."""
assert self._fd is not None, "writer not opened"
view = memoryview(data)
written = 0
total = len(view)
while written < total:
if _HAS_PWRITE:
n = os.pwrite(self._fd, view[written:], offset + written)
else:
with self._seek_lock:
os.lseek(self._fd, offset + written, os.SEEK_SET)
n = os.write(self._fd, view[written:])
if n == 0:
raise OSError(
f"positioned write wrote 0 bytes at offset {offset + written} "
f"({written}/{total} bytes written)"
)
written += n
async def write_at(self, offset: int, data: bytes) -> None:
assert self._fd is not None, "writer not opened"
await asyncio.get_running_loop().run_in_executor(
_EXECUTOR, self._pwrite_all, data, offset
)
async def flush(self) -> None:
if self._fd is None:
return
await asyncio.get_running_loop().run_in_executor(_EXECUTOR, os.fsync, self._fd)
async def close(self) -> None:
if self._fd is None:
return
fd, self._fd = self._fd, None
await asyncio.get_running_loop().run_in_executor(_EXECUTOR, os.close, fd)

View File

@ -0,0 +1,454 @@
"""Public facade for the download manager.
This is the only object the server imports. It validates requests, owns the
:class:`Scheduler`, and exposes a small async API plus read models for status.
"""
from __future__ import annotations
import asyncio
import logging
import os
import uuid
from typing import Callable, Optional
from app.model_downloader.constants import DownloadStatus
from app.model_downloader.database import queries
from app.model_downloader.net.probe import gated_error_message, probe
from app.model_downloader.scheduler import SCHEDULER
from app.model_downloader.security import paths
from app.model_downloader.net.http import redact_url
from app.model_downloader.security.allowlist import (
ALLOWED_MODEL_EXTENSIONS,
filename_extension,
is_host_allowed_url,
is_url_downloadable,
url_path_extension,
)
from app.model_downloader.security.paths import InvalidModelId
# Non-terminal statuses: an existing row in one of these blocks a re-enqueue.
_LIVE_STATUSES = (
DownloadStatus.QUEUED,
DownloadStatus.ACTIVE,
DownloadStatus.PAUSED,
DownloadStatus.VERIFYING,
)
class DownloadError(Exception):
"""A user-facing error with a stable machine-readable code."""
def __init__(self, code: str, message: str, status: int = 400) -> None:
super().__init__(message)
self.code = code
self.message = message
self.http_status = status
class DownloadManager:
def __init__(self) -> None:
self._scheduler = SCHEDULER
self._notify_cb: Optional[Callable[[str], None]] = None
# Serializes the "check for a live download, then write" critical section
# per model_id. ``downloads`` has no uniqueness constraint on model_id
# (history rows are kept), so without this two concurrent enqueue/resume
# calls could both pass the live check and admit two jobs sharing one
# temp/dest path. The manager is a process singleton over a local SQLite
# DB, so an in-process lock is sufficient (and avoids a migration).
self._model_locks: dict[str, asyncio.Lock] = {}
def set_notify(self, cb: Optional[Callable[[str], None]]) -> None:
self._notify_cb = cb
self._scheduler.set_notify(cb)
async def start(self) -> None:
await self._scheduler.start()
# ----- enqueue -----
async def enqueue(
self,
url: str,
model_id: str,
*,
priority: int = 0,
expected_sha256: Optional[str] = None,
allow_any_extension: bool = False,
credential_id: Optional[str] = None,
) -> str:
# Coarse gate first: host/scheme must be allowlisted, and any extension
# present in the URL path must be a known model type. A URL whose path
# carries NO extension (e.g. Civitai's ``/api/download/models/<id>``) is
# admitted here and its real extension is resolved from the network
# below before the download is finally accepted.
if allow_any_extension:
if not is_host_allowed_url(url):
raise DownloadError(
"URL_NOT_ALLOWED",
"URL is not on the download allowlist (host/scheme).",
)
elif not is_url_downloadable(url):
raise DownloadError(
"URL_NOT_ALLOWED",
"URL is not on the download allowlist (host/scheme/extension).",
)
# When the URL path has no extension, follow it to where it resolves and
# adopt the real extension from the response, forcing the stored
# filename to match. Skipped when the caller opted into any extension.
if not allow_any_extension and url_path_extension(url) == "":
resolved_ext = await self._resolve_extension(url, credential_id)
model_id = paths.apply_extension(model_id, resolved_ext)
try:
paths.parse_model_id(model_id, allow_any_extension)
dest_path, temp_path = paths.resolve_destination(model_id, allow_any_extension)
except InvalidModelId as e:
raise DownloadError("INVALID_MODEL_ID", str(e))
if await asyncio.to_thread(
paths.resolve_existing, model_id, allow_any_extension
):
raise DownloadError(
"ALREADY_AVAILABLE",
f"Model already exists on disk: {model_id}",
status=409,
)
download_id = str(uuid.uuid4())
# Hold the per-model lock across the live check and the insert so a
# concurrent enqueue/resume for the same model_id cannot interleave
# between them and create a second job against the same temp/dest path.
async with self._model_lock(model_id):
if await self._has_live_download(model_id):
raise DownloadError(
"ALREADY_DOWNLOADING",
f"A download for {model_id} is already in progress.",
status=409,
)
await asyncio.to_thread(
queries.insert_download,
{
"id": download_id,
"url": url,
"model_id": model_id,
"dest_path": dest_path,
"temp_path": temp_path,
"status": DownloadStatus.QUEUED,
"priority": priority,
"expected_sha256": expected_sha256,
"credential_id": credential_id,
"allow_any_extension": allow_any_extension,
},
)
logging.info("[model_downloader] enqueued %s -> %s", redact_url(url), model_id)
await self._scheduler.pump()
return download_id
async def _resolve_extension(
self, url: str, credential_id: Optional[str]
) -> str:
"""Follow ``url`` to its final response and return the real extension.
Used for allowlisted URLs whose path has no extension (e.g. Civitai
download endpoints): the filename lives in the ``Content-Disposition``
header or the post-redirect URL. Raises :class:`DownloadError` when the
URL can't be resolved, needs credentials, or resolves to something that
is not a known model file — so we never persist a bogus destination.
"""
pr = await probe(url, credential_id=credential_id)
if not pr.ok:
if pr.gated:
raise DownloadError(
"GATED_REPO" if pr.is_gated_repo else "CREDENTIALS_REQUIRED",
gated_error_message(url, pr),
status=401,
)
raise DownloadError(
"URL_RESOLVE_FAILED",
f"Could not resolve {redact_url(url)}: {pr.error or 'unknown error'}",
status=502,
)
ext = filename_extension(pr.filename) if pr.filename else ""
if ext not in ALLOWED_MODEL_EXTENSIONS:
raise DownloadError(
"URL_NOT_ALLOWED",
f"URL resolves to {pr.filename or '<unknown>'!r}, which is not a "
f"known model file type {ALLOWED_MODEL_EXTENSIONS}.",
)
return ext
def _model_lock(self, model_id: str) -> asyncio.Lock:
# Lazily create one lock per model_id. There is no ``await`` between the
# lookup and the insert, so under the single asyncio thread this is
# atomic and cannot hand out two different locks for the same model_id.
lock = self._model_locks.get(model_id)
if lock is None:
lock = asyncio.Lock()
self._model_locks[model_id] = lock
return lock
async def _has_live_download(
self, model_id: str, *, exclude_id: Optional[str] = None
) -> bool:
rows = await asyncio.to_thread(queries.list_downloads)
return any(
r.model_id == model_id
and r.id != exclude_id
and r.status in _LIVE_STATUSES
for r in rows
)
# ----- control -----
async def pause(self, download_id: str) -> None:
job = self._scheduler.get_job(download_id)
if job is not None:
job.request_pause()
return
row = await asyncio.to_thread(queries.get_download, download_id)
if row is None:
raise DownloadError("NOT_FOUND", "No such download.", status=404)
if row.status == DownloadStatus.QUEUED:
await asyncio.to_thread(
queries.update_download, download_id, status=DownloadStatus.PAUSED
)
async def resume(self, download_id: str) -> None:
row = await asyncio.to_thread(queries.get_download, download_id)
if row is None:
raise DownloadError("NOT_FOUND", "No such download.", status=404)
if row.status not in (DownloadStatus.PAUSED, DownloadStatus.FAILED):
return
# Re-queueing a paused/failed row must respect the single-live-per-model
# invariant: another download (e.g. a newer enqueue) may already be live
# for this model_id and would share this row's temp/dest path. Hold the
# per-model lock across the check and the status flip, and exclude this
# row itself (a paused row is already a "live" status).
async with self._model_lock(row.model_id):
if await self._has_live_download(row.model_id, exclude_id=download_id):
raise DownloadError(
"ALREADY_DOWNLOADING",
f"A download for {row.model_id} is already in progress.",
status=409,
)
await asyncio.to_thread(
queries.update_download,
download_id,
status=DownloadStatus.QUEUED,
error=None,
)
await self._scheduler.pump()
async def cancel(self, download_id: str) -> None:
job = self._scheduler.get_job(download_id)
if job is not None:
job.request_cancel()
return
row = await asyncio.to_thread(queries.get_download, download_id)
if row is None:
raise DownloadError("NOT_FOUND", "No such download.", status=404)
if row.status in _LIVE_STATUSES:
import os
try:
os.remove(row.temp_path)
except OSError:
pass
await asyncio.to_thread(
queries.update_download, download_id, status=DownloadStatus.CANCELLED
)
async def set_priority(self, download_id: str, priority: int) -> None:
row = await asyncio.to_thread(queries.get_download, download_id)
if row is None:
raise DownloadError("NOT_FOUND", "No such download.", status=404)
await asyncio.to_thread(
queries.update_download, download_id, priority=priority
)
# Admission-order only; a higher priority is
# picked up the next time a slot frees. Pump in case a slot is free now.
await self._scheduler.pump()
async def delete(self, download_id: str) -> None:
"""Delete a terminal download so it stays gone from history.
Refuses to delete a live download so a record is never removed out from
under a running worker; cancel it first. Any leftover ``.part`` temp
file (e.g. from a failed transfer) is removed, but the finished model
file on disk is never touched.
"""
if self._scheduler.get_job(download_id) is not None:
raise DownloadError(
"DOWNLOAD_ACTIVE",
"Cannot delete a download that is still in progress.",
status=409,
)
row = await asyncio.to_thread(queries.get_download, download_id)
if row is None:
raise DownloadError("NOT_FOUND", "No such download.", status=404)
if row.status in _LIVE_STATUSES:
raise DownloadError(
"DOWNLOAD_ACTIVE",
"Cannot delete a download that is still in progress.",
status=409,
)
try:
os.remove(row.temp_path)
except OSError:
pass
await asyncio.to_thread(queries.delete_download, download_id)
async def clear(self) -> int:
"""Delete all terminal downloads from history in one transaction.
Skips anything still live (queued/active/paused/verifying, or a running
job) so an in-flight download is never removed out from under a worker.
Finished model files on disk are never touched; only leftover ``.part``
temp files from failed/cancelled transfers are removed. Returns the
number of history rows deleted.
"""
rows = await asyncio.to_thread(queries.list_downloads)
deletable = [
r
for r in rows
if r.status not in _LIVE_STATUSES
and self._scheduler.get_job(r.id) is None
]
if not deletable:
return 0
for r in deletable:
try:
os.remove(r.temp_path)
except OSError:
pass
return await asyncio.to_thread(
queries.delete_downloads, [r.id for r in deletable]
)
# ----- read models -----
def _view(self, row) -> dict:
"""Combine the persisted row with live in-memory progress, if running."""
job = self._scheduler.get_job(row.id)
bytes_done = row.bytes_done
total = row.total_bytes
speed = None
eta = None
segments = None
if job is not None:
st = job.state
bytes_done = st.bytes_done
total = st.total_bytes if st.total_bytes is not None else total
speed = st.speed_bps
eta = st.eta_seconds
segments = [
{"idx": s.idx, "bytes_done": s.bytes_done, "length": s.length}
for s in st.segments
if s.end >= s.start
]
progress = (bytes_done / total) if total else None
return {
"download_id": row.id,
"model_id": row.model_id,
"url": redact_url(row.url),
"status": row.status,
"priority": row.priority,
"total_bytes": total,
"bytes_done": bytes_done,
"progress": progress,
"speed_bps": speed,
"eta_seconds": eta,
"segments": segments,
"error": row.error,
"created_at": row.created_at,
"updated_at": row.updated_at,
}
def _view_from_state(self, job) -> dict:
"""Build a view purely from the live in-memory job state (no DB)."""
st = job.state
return {
"download_id": st.download_id,
"model_id": st.model_id,
"url": redact_url(st.url),
"status": st.status,
"priority": st.priority,
"total_bytes": st.total_bytes,
"bytes_done": st.bytes_done,
"progress": st.progress,
"speed_bps": st.speed_bps,
"eta_seconds": st.eta_seconds,
"segments": [
{"idx": s.idx, "bytes_done": s.bytes_done, "length": s.length}
for s in st.segments
if s.end >= s.start
],
"error": st.error,
}
def status_sync(self, download_id: str) -> Optional[dict]:
"""Synchronous status read for the websocket notify path.
Uses live in-memory state when the job is running (no DB round-trip on
the hot path); falls back to a quick DB read otherwise.
"""
job = self._scheduler.get_job(download_id)
if job is not None:
return self._view_from_state(job)
row = queries.get_download(download_id)
return self._view(row) if row is not None else None
async def status(self, download_id: str) -> Optional[dict]:
row = await asyncio.to_thread(queries.get_download, download_id)
return self._view(row) if row is not None else None
async def list(self) -> list[dict]:
rows = await asyncio.to_thread(queries.list_downloads)
return [self._view(r) for r in rows]
async def availability(self, models: dict[str, str]) -> dict[str, dict]:
"""Bulk per-id ``{state, progress, ...}`` for the frontend poll.
``state`` is ``available`` (on disk), ``downloading`` (live row), or
``missing``. Cheap: a path lookup plus an in-memory/DB status check.
"""
rows = await asyncio.to_thread(queries.list_downloads)
by_model: dict[str, object] = {}
for r in rows:
if r.status in _LIVE_STATUSES or r.model_id not in by_model:
by_model[r.model_id] = r
# ``url_allowed`` mirrors the coarse enqueue gate (host/scheme + a
# non-disallowed extension); URLs whose extension is only known after a
# network resolve — e.g. Civitai download endpoints — report allowed.
out: dict[str, dict] = {}
for model_id, url in models.items():
try:
exists = await asyncio.to_thread(paths.resolve_existing, model_id)
except InvalidModelId:
out[model_id] = {"state": "missing", "url_allowed": is_url_downloadable(url)}
continue
if exists:
out[model_id] = {"state": "available", "url_allowed": is_url_downloadable(url)}
continue
row = by_model.get(model_id)
if row is not None and row.status in _LIVE_STATUSES:
view = self._view(row)
out[model_id] = {
"state": "downloading",
"url_allowed": is_url_downloadable(url),
"download_id": view["download_id"],
"progress": view["progress"],
"bytes_done": view["bytes_done"],
"total_bytes": view["total_bytes"],
"speed_bps": view["speed_bps"],
}
else:
out[model_id] = {"state": "missing", "url_allowed": is_url_downloadable(url)}
return out
DOWNLOAD_MANAGER = DownloadManager()

View File

@ -0,0 +1,148 @@
"""Manual, validated redirect-following request opener.
Automatic redirects are disabled. We follow hops ourselves
so that on *every* hop we (a) re-validate scheme + reject credentials-in-URL,
(b) recompute which stored credential — if any — applies to that hop's host,
and (c) let the connector's resolver screen the IP. This is the single place
that attaches credentials, so a token can never ride a redirect to a CDN host.
"""
from __future__ import annotations
import logging
import re
from contextlib import asynccontextmanager
from typing import AsyncIterator, Optional
from urllib.parse import unquote, urljoin, urlsplit, urlunsplit
import aiohttp
from app.model_downloader.credentials.resolver import resolve_auth_for_hop
from app.model_downloader.net.session import get_session
from app.model_downloader.security.ssrf import (
MAX_REDIRECTS,
SSRFError,
check_redirect_hop,
)
_REDIRECT_CODES = {301, 302, 303, 307, 308}
DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=None, sock_connect=30, sock_read=120)
def redact_url(url: str) -> str:
"""Drop the query string so a query-scheme secret is never logged/stored."""
try:
parts = urlsplit(url)
except ValueError:
return "<unparseable-url>"
return urlunsplit(parts._replace(query=""))
_CD_FILENAME_STAR = re.compile(
r"filename\*\s*=\s*[^']*'[^']*'([^;]+)", re.IGNORECASE
)
_CD_FILENAME_QUOTED = re.compile(r'filename\s*=\s*"([^"]+)"', re.IGNORECASE)
_CD_FILENAME_BARE = re.compile(r"filename\s*=\s*([^;]+)", re.IGNORECASE)
def filename_from_content_disposition(value: Optional[str]) -> Optional[str]:
"""Extract the download filename from a ``Content-Disposition`` header.
Prefers the RFC 5987 ``filename*=`` form (percent-decoded) over the plain
``filename=`` form. Any directory components in the value are stripped so a
hostile header can only influence the *name*, never the target directory.
Returns ``None`` when no filename is present.
"""
if not value:
return None
for pat, decode in (
(_CD_FILENAME_STAR, True),
(_CD_FILENAME_QUOTED, False),
(_CD_FILENAME_BARE, False),
):
m = pat.search(value)
if not m:
continue
raw = m.group(1).strip().strip('"')
if decode:
try:
raw = unquote(raw)
except Exception:
pass
name = raw.replace("\\", "/").rsplit("/", 1)[-1].strip()
if name:
return name
return None
async def _resolve_final_response(
method: str,
url: str,
credential_id: Optional[str],
base_headers: dict[str, str],
timeout: aiohttp.ClientTimeout,
) -> tuple[aiohttp.ClientResponse, str]:
"""Follow redirects manually until a non-redirect response.
Each intermediate redirect response is released before the next hop.
Returns the final ``(response, final_url)``; the caller owns releasing it.
"""
session = await get_session()
current = url
hops = 0
while True:
check_redirect_hop(current, is_initial_url=(hops == 0))
parts = urlsplit(current)
auth = await resolve_auth_for_hop(
parts.hostname or "", parts.scheme, explicit_credential_id=credential_id
)
req_headers = dict(base_headers)
req_url = current
if auth is not None:
req_headers.update(auth.headers)
req_url = auth.apply_to_url(current)
resp = await session.request(
method,
req_url,
allow_redirects=False,
headers=req_headers,
timeout=timeout,
)
if resp.status in _REDIRECT_CODES and resp.headers.get("Location"):
next_url = urljoin(str(resp.url), resp.headers["Location"])
await resp.release()
hops += 1
if hops > MAX_REDIRECTS:
raise SSRFError(
f"too many redirects (> {MAX_REDIRECTS}) for {redact_url(url)}"
)
current = next_url
continue
return resp, redact_url(str(resp.url))
@asynccontextmanager
async def open_validated(
method: str,
url: str,
*,
credential_id: Optional[str] = None,
headers: Optional[dict[str, str]] = None,
timeout: aiohttp.ClientTimeout = DEFAULT_TIMEOUT,
) -> AsyncIterator[tuple[aiohttp.ClientResponse, str]]:
"""Open ``method url`` following redirects manually and validated.
Yields ``(response, final_url)`` where ``final_url`` is redacted of any
query string. The response is released automatically on exit.
"""
resp, final_url = await _resolve_final_response(
method, url, credential_id, dict(headers or {}), timeout
)
try:
yield resp, final_url
finally:
try:
await resp.release()
except Exception: # pragma: no cover - best-effort cleanup
logging.debug("[model_downloader] response release error", exc_info=True)

View File

@ -0,0 +1,157 @@
"""Pre-download probe.
Issues a tiny ranged GET (``Range: bytes=0-0``) — which doubles as a
range-support test — to discover ``Content-Length``, ``Accept-Ranges``,
``ETag``/``Last-Modified``, and the final post-redirect URL. For HuggingFace
LFS files the true size also appears in the non-standard ``X-Linked-Size``
header, which we read as a fallback.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Optional
from urllib.parse import urlparse, urlsplit
import aiohttp
from app.model_downloader.net.http import (
filename_from_content_disposition,
open_validated,
redact_url,
)
from app.model_downloader.net.session import parse_int_header
_PROBE_TIMEOUT = aiohttp.ClientTimeout(total=60, sock_connect=30, sock_read=30)
@dataclass
class ProbeResult:
ok: bool
status: int
final_url: Optional[str] = None
total_bytes: Optional[int] = None
accept_ranges: bool = False
etag: Optional[str] = None
last_modified: Optional[str] = None
gated: bool = False # 401/403 — needs (or has wrong) credentials
error: Optional[str] = None
# HuggingFace's ``X-Error-Code`` header (e.g. ``GatedRepo``,
# ``RepoNotFound``) when the host reports one. Lets us tell "this repo is
# gated — request access" apart from "you just need a token".
error_code: Optional[str] = None
# Filename the server intends this response to be saved as: the
# ``Content-Disposition`` name if present, else the post-redirect URL's
# basename. Used to resolve the real extension for URLs (e.g. Civitai's
# ``/api/download`` endpoints) that carry no extension in their path.
filename: Optional[str] = None
@property
def is_gated_repo(self) -> bool:
"""True when the host says the repo is gated (access must be granted).
Distinct from a plain missing/invalid token: even a valid credential
won't help until the user accepts the model's terms on its page.
"""
return (self.error_code or "").lower() == "gatedrepo"
def _total_from_content_range(value: Optional[str]) -> Optional[int]:
# "bytes 0-0/12345" -> 12345 ; "bytes 0-0/*" -> None
if not value or "/" not in value:
return None
total = value.rsplit("/", 1)[1].strip()
return parse_int_header(total)
def _filename_from_response(
content_disposition: Optional[str], final_url: Optional[str]
) -> Optional[str]:
name = filename_from_content_disposition(content_disposition)
if name:
return name
if final_url:
base = urlsplit(final_url).path.rsplit("/", 1)[-1]
if base:
return base
return None
async def probe(url: str, *, credential_id: Optional[str] = None) -> ProbeResult:
"""Probe ``url`` and return discovered metadata, failing soft."""
try:
async with open_validated(
"GET",
url,
credential_id=credential_id,
headers={"Range": "bytes=0-0", "Accept-Encoding": "identity"},
timeout=_PROBE_TIMEOUT,
) as (resp, final_url):
if resp.status in (401, 403):
error_code = resp.headers.get("X-Error-Code")
error_message = resp.headers.get("X-Error-Message")
return ProbeResult(
ok=False, status=resp.status, final_url=final_url, gated=True,
error_code=error_code,
error=(
error_message
or f"host returned {resp.status} (authentication required)"
),
)
if resp.status not in (200, 206):
return ProbeResult(
ok=False, status=resp.status, final_url=final_url,
error=f"probe returned HTTP {resp.status}",
)
headers = resp.headers
accept_ranges = False
total: Optional[int] = None
if resp.status == 206:
accept_ranges = True
total = _total_from_content_range(headers.get("Content-Range"))
else: # 200: server ignored the range
accept_ranges = headers.get("Accept-Ranges", "").lower() == "bytes"
total = parse_int_header(headers.get("Content-Length"))
if total is None:
total = parse_int_header(headers.get("X-Linked-Size"))
return ProbeResult(
ok=True,
status=resp.status,
final_url=final_url,
total_bytes=total,
accept_ranges=accept_ranges,
etag=headers.get("ETag"),
last_modified=headers.get("Last-Modified"),
filename=_filename_from_response(
headers.get("Content-Disposition"), final_url
),
)
except Exception as e: # network / SSRF / timeout
host = urlparse(url).netloc or "<unknown>"
logging.debug("[model_downloader] probe failed for %s: %s", host, type(e).__name__)
return ProbeResult(ok=False, status=0, error="probe failed: network error")
def gated_error_message(url: str, pr: ProbeResult) -> str:
"""Build a user-facing message for a gated/auth-required probe result.
Distinguishes a *gated* repo (access must be requested/granted on the model
page — a token alone is not enough) from a plain missing/invalid credential.
"""
redacted = redact_url(url)
if pr.is_gated_repo:
detail = (pr.error or "access is restricted").rstrip()
if detail and not detail.endswith((".", "!", "?")):
detail += "."
return (
f"{redacted} is a gated model — {detail} Request access on the model's "
f"page, add an API key for this host at /api/download/credentials, and retry."
)
return (
f"{redacted} requires authentication. Add an API key for this host at "
f"/api/download/credentials and retry."
)

View File

@ -0,0 +1,72 @@
"""Lazily-created shared :class:`aiohttp.ClientSession`.
A single session reuses TLS handshakes and TCP connections across the probe
and the many segment GETs to the same host (HuggingFace is the dominant
case), which is a large speedup on cold connections and exactly the
connection-reuse strategy that lets us match aria2c.
The connector uses :class:`ValidatingResolver` so every connection — initial
or post-redirect — is screened for private/special-use IPs at connect time.
TLS is pinned to certifi's CA bundle because the OS trust store is not wired
up on some Python installs (python.org macOS, slim containers).
"""
from __future__ import annotations
import asyncio
import ssl
from typing import Optional
import aiohttp
try:
import certifi
_CA_FILE = certifi.where()
except Exception: # pragma: no cover - certifi is a transitive dep of aiohttp
_CA_FILE = None
from comfy.cli_args import args
from app.model_downloader.security.ssrf import ValidatingResolver
_session: Optional[aiohttp.ClientSession] = None
_lock = asyncio.Lock()
def ssl_context() -> ssl.SSLContext:
if _CA_FILE is not None:
return ssl.create_default_context(cafile=_CA_FILE)
return ssl.create_default_context()
async def get_session() -> aiohttp.ClientSession:
"""Return the shared session, creating it on first use."""
global _session
if _session is not None and not _session.closed:
return _session
async with _lock:
if _session is None or _session.closed:
connector = aiohttp.TCPConnector(
limit_per_host=max(1, getattr(args, "download_max_connections_per_host", 16)),
ssl=ssl_context(),
resolver=ValidatingResolver(),
)
_session = aiohttp.ClientSession(connector=connector)
return _session
async def close_session() -> None:
global _session
if _session is not None and not _session.closed:
await _session.close()
_session = None
def parse_int_header(value: Optional[str]) -> Optional[int]:
"""Parse a non-negative integer header value, or None if bad/absent."""
if not value:
return None
try:
n = int(value)
except (TypeError, ValueError):
return None
return n if n >= 0 else None

View File

@ -0,0 +1,177 @@
"""Priority scheduler + lifecycle.
Owns the set of running jobs and admits queued downloads up to a global
concurrency limit (K), highest priority first, FIFO within a priority. Runs
entirely on the existing ComfyUI asyncio loop; blocking work (disk, hashing,
DB) is offloaded by the job/writer layers.
On startup it reconciles DB vs. disk: ``active``/``verifying`` rows left by a
previous run are reset to ``queued`` and resumed from persisted offsets, and
orphaned ``.part`` files with no live download row are swept.
"""
from __future__ import annotations
import asyncio
import logging
import os
import random
import time
from typing import Callable, Optional
from comfy.cli_args import args
from app.model_downloader.constants import DownloadStatus
from app.model_downloader.database import queries
from app.model_downloader.engine.job import DownloadJob, JobSpec
from app.model_downloader.security import paths
# Backoff for retryable failures
_BACKOFF_BASE = 2.0
_BACKOFF_CAP = 300.0
_MAX_ATTEMPTS = 6
class Scheduler:
def __init__(self) -> None:
self._jobs: dict[str, DownloadJob] = {}
self._tasks: dict[str, asyncio.Task] = {}
self._backoff_until: dict[str, float] = {}
self._pump_lock = asyncio.Lock()
self._notify_cb: Optional[Callable[[str], None]] = None
self._started = False
@property
def max_active(self) -> int:
return max(1, getattr(args, "download_max_active", 3))
def set_notify(self, cb: Optional[Callable[[str], None]]) -> None:
self._notify_cb = cb
def get_job(self, download_id: str) -> Optional[DownloadJob]:
return self._jobs.get(download_id)
def is_active(self, download_id: str) -> bool:
return download_id in self._tasks
# ----- startup -----
async def start(self) -> None:
if self._started:
return
self._started = True
try:
await asyncio.to_thread(queries.reconcile_live_downloads)
await asyncio.to_thread(self._sweep_orphan_temp_files)
except Exception as e:
logging.warning("[model_downloader] startup reconcile failed: %s", e)
await self.pump()
@staticmethod
def _sweep_orphan_temp_files() -> None:
"""Remove ``.part`` files not referenced by a resumable download row.
Resumable partials are preserved; only truly orphaned temp files from
crashed runs are deleted. ``FAILED`` is included because
:meth:`DownloadManager.resume` explicitly permits resuming a
retry-exhausted failed row: deleting its partial here while the
per-segment offsets survive in the DB would make the next resume
preallocate a fresh sparse file, skip every "complete" segment, and
leave zero-filled holes that pass the size-only verification gate.
"""
live = {
row.temp_path
for row in queries.list_downloads()
if row.status
in (
DownloadStatus.QUEUED,
DownloadStatus.PAUSED,
DownloadStatus.FAILED,
)
}
for path in paths.iter_all_tmp_paths():
if path in live:
continue
try:
os.remove(path)
logging.info("[model_downloader] removed orphan temp file: %s", path)
except OSError as e:
logging.warning("[model_downloader] could not remove %s: %s", path, e)
# ----- admission -----
async def pump(self) -> None:
async with self._pump_lock:
slots = self.max_active - len(self._tasks)
if slots <= 0:
return
now = time.monotonic()
candidates = await asyncio.to_thread(queries.list_queued_downloads)
for row in candidates:
if slots <= 0:
break
if row.id in self._tasks:
continue
if self._backoff_until.get(row.id, 0.0) > now:
continue
self._admit(row)
slots -= 1
def _admit(self, row) -> None:
spec = JobSpec(
download_id=row.id,
url=row.url,
model_id=row.model_id,
dest_path=row.dest_path,
temp_path=row.temp_path,
priority=row.priority,
credential_id=row.credential_id,
expected_sha256=row.expected_sha256,
allow_any_extension=row.allow_any_extension,
etag=row.etag,
attempts=row.attempts,
)
job = DownloadJob(spec, notify_cb=self._notify_cb)
self._jobs[row.id] = job
self._tasks[row.id] = asyncio.ensure_future(self._run_job(job))
async def _run_job(self, job: DownloadJob) -> None:
download_id = job.spec.download_id
status = DownloadStatus.FAILED
try:
status = await job.run()
except Exception as e: # run() is defensive, but never let a task die silently
logging.error("[model_downloader] job %s crashed: %s", download_id, e)
queries.update_download(
download_id,
status=DownloadStatus.FAILED,
error=f"internal error: {e}",
)
if self._notify_cb:
self._notify_cb(download_id)
finally:
self._tasks.pop(download_id, None)
self._jobs.pop(download_id, None)
if status == DownloadStatus.QUEUED:
if job.spec.attempts >= _MAX_ATTEMPTS:
queries.update_download(
download_id,
status=DownloadStatus.FAILED,
error=f"giving up after {job.spec.attempts} attempts",
)
if self._notify_cb:
self._notify_cb(download_id)
else:
delay = min(
_BACKOFF_CAP, _BACKOFF_BASE ** job.spec.attempts
) + random.uniform(0, 1.0)
self._backoff_until[download_id] = time.monotonic() + delay
asyncio.ensure_future(self._delayed_pump(delay))
await self.pump()
async def _delayed_pump(self, delay: float) -> None:
await asyncio.sleep(delay)
await self.pump()
SCHEDULER = Scheduler()

View File

@ -0,0 +1,140 @@
"""URL allowlist for server-side model fetches.
Default-deny. A URL is downloadable only when its parsed host + scheme are
allowlisted AND (unless explicitly relaxed) its final filename ends in a
known model extension.
The built-in host defaults mirror the frontend's ``isModelDownloadable``
allowlist so the two flows agree on what is eligible; ``--download-allowed-hosts``
extends it for self-hosted mirrors. Matching is done on ``urlparse().hostname``
(never a raw string prefix) so userinfo tricks like
``http://127.0.0.1@169.254.169.254/x.safetensors`` — whose real host is the
metadata IP — cannot slip past.
"""
from __future__ import annotations
from urllib.parse import urlparse
from comfy.cli_args import args
# host -> set of allowed schemes. Frontend parity (HuggingFace / Civitai /
# localhost). Extra hosts from --download-allowed-hosts are https-only.
_DEFAULT_ALLOWED_HOSTS: dict[str, set[str]] = {
"huggingface.co": {"https"},
"civitai.com": {"https"},
"localhost": {"http", "https"},
"127.0.0.1": {"http", "https"},
}
# Hosts for which loopback addresses are intentionally permitted (the localhost
# "download a local model" feature). Every other host's loopback resolution is
# rejected by the SSRF resolver.
LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
# Known model file extensions (frontend parity). Checked on the final filename.
ALLOWED_MODEL_EXTENSIONS = (
".safetensors",
".sft",
".ckpt",
".pth",
".pt",
".gguf",
".bin",
)
def _allowed_hosts() -> dict[str, set[str]]:
hosts = {h: set(s) for h, s in _DEFAULT_ALLOWED_HOSTS.items()}
for extra in getattr(args, "download_allowed_hosts", []) or []:
host = extra.strip().lower()
if host:
hosts.setdefault(host, set()).add("https")
return hosts
def is_host_allowed(host: str | None, scheme: str | None) -> bool:
"""True iff ``host`` is allowlisted for ``scheme``.
Used both for the initial URL and re-checked on every redirect hop,
so a whitelisted URL cannot 30x into an off-list host.
"""
if not host or not scheme:
return False
allowed = _allowed_hosts().get(host.lower())
return allowed is not None and scheme.lower() in allowed
def has_allowed_extension(path: str, allow_any_extension: bool = False) -> bool:
if allow_any_extension:
return True
return path.lower().endswith(ALLOWED_MODEL_EXTENSIONS)
def filename_extension(name: str) -> str:
"""Lowercased extension (including the leading dot) of a bare filename.
Returns ``""`` when there is no extension. A leading-dot name
(``.safetensors``) is treated as having no extension (all stem), matching
``os.path.splitext`` semantics so dotfiles aren't mistaken for typed files.
"""
base = name.replace("\\", "/").rsplit("/", 1)[-1]
dot = base.rfind(".")
if dot <= 0:
return ""
return base[dot:].lower()
def is_allowed_extension_name(name: str) -> bool:
"""True iff ``name`` ends in one of the known model extensions."""
return name.lower().endswith(ALLOWED_MODEL_EXTENSIONS)
def is_host_allowed_url(url: str) -> bool:
"""True iff ``url`` parses and its host+scheme are allowlisted."""
if not isinstance(url, str) or not url:
return False
try:
parsed = urlparse(url)
except ValueError:
return False
return is_host_allowed(parsed.hostname, parsed.scheme)
def url_path_extension(url: str) -> str:
"""Extension of the URL *path* basename (query ignored), or ``""``."""
try:
parsed = urlparse(url)
except ValueError:
return ""
return filename_extension(parsed.path)
def is_url_downloadable(url: str) -> bool:
"""Coarse enqueue gate: host/scheme allowed and extension not disallowed.
Unlike :func:`is_url_allowed` (which demands a known extension *in the URL*),
this also admits URLs whose path carries no extension at all — e.g. a Civitai
``/api/download/models/<id>`` endpoint whose real filename only shows up in
the redirect target / ``Content-Disposition``. The true extension is then
resolved from the network and re-validated before the download is admitted.
A path bearing an explicit *non-model* extension (``.zip``, ``.html``, ...)
is still rejected here.
"""
if not is_host_allowed_url(url):
return False
ext = url_path_extension(url)
return ext == "" or ext in ALLOWED_MODEL_EXTENSIONS
def is_url_allowed(url: str, allow_any_extension: bool = False) -> bool:
"""Check whether ``url`` is permitted as a server-side download source."""
if not isinstance(url, str) or not url:
return False
try:
parsed = urlparse(url)
except ValueError:
return False
if not is_host_allowed(parsed.hostname, parsed.scheme):
return False
return has_allowed_extension(parsed.path, allow_any_extension)

View File

@ -0,0 +1,132 @@
"""Path resolution + traversal safety for downloads.
A ``model_id`` is a *relative destination path* of the form
``<directory>/<filename>`` (e.g. ``loras/my_lora.safetensors``). This module
turns one into an absolute on-disk path under one of ComfyUI's registered
model folders, rejecting unknown folders, path traversal, and symlink escape.
This is the only thing that composes destination paths, so the engine never
touches user-supplied path strings directly.
"""
from __future__ import annotations
import os
import re
from typing import Iterator, Optional
import folder_paths
from app.model_downloader.constants import TMP_SUFFIX
from app.model_downloader.security.allowlist import ALLOWED_MODEL_EXTENSIONS
# A model_id component is a single path segment of safe characters — no slashes,
# no "..", no leading dots that could escape the target directory.
_SEGMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
class InvalidModelId(ValueError):
"""Raised when a model_id is malformed or names an unknown model folder."""
def parse_model_id(model_id: str, allow_any_extension: bool = False) -> tuple[str, str]:
"""Split ``<directory>/<filename>`` and validate both components.
Returns ``(directory, filename)``. Does not touch the filesystem.
"""
if not isinstance(model_id, str) or "/" not in model_id:
raise InvalidModelId(
f"model_id must be '<directory>/<filename>', got {model_id!r}"
)
directory, _, filename = model_id.partition("/")
if "/" in filename or not directory or not filename:
raise InvalidModelId(
f"model_id must have exactly one '/' separator, got {model_id!r}"
)
if not _SEGMENT_RE.match(directory):
raise InvalidModelId(f"invalid directory segment {directory!r}")
if not _SEGMENT_RE.match(filename):
raise InvalidModelId(f"invalid filename segment {filename!r}")
if not allow_any_extension and not filename.lower().endswith(
ALLOWED_MODEL_EXTENSIONS
):
raise InvalidModelId(
f"filename must end with a known model extension "
f"{ALLOWED_MODEL_EXTENSIONS}, got {filename!r}"
)
if directory not in folder_paths.folder_names_and_paths:
raise InvalidModelId(f"unknown model folder {directory!r}")
return directory, filename
def apply_extension(model_id: str, ext: str) -> str:
"""Return ``model_id`` with its filename forced to end in ``ext``.
``ext`` includes the leading dot (e.g. ``".safetensors"``). If the filename
already ends in a *known model extension* it is replaced; otherwise ``ext``
is appended (so ``loras/mymodel`` -> ``loras/mymodel.safetensors`` and
``loras/mymodel.ckpt`` -> ``loras/mymodel.safetensors``). A filename with a
non-model suffix (``my.model.v2``) is treated as an extensionless stem and
``ext`` is appended. The directory part is left untouched; validation is
still the caller's job via :func:`parse_model_id`.
"""
directory, sep, filename = model_id.partition("/")
if not sep:
return model_id # malformed; parse_model_id will reject it
low = filename.lower()
for known in ALLOWED_MODEL_EXTENSIONS:
if low.endswith(known):
filename = filename[: -len(known)]
break
return f"{directory}{sep}{filename}{ext}"
def resolve_existing(model_id: str, allow_any_extension: bool = False) -> Optional[str]:
"""Return the absolute path of an installed model, or None if missing.
Honours ``extra_model_paths.yaml`` transparently via ``get_full_path``.
"""
directory, filename = parse_model_id(model_id, allow_any_extension)
return folder_paths.get_full_path(directory, filename)
def resolve_destination(
model_id: str, allow_any_extension: bool = False
) -> tuple[str, str]:
"""Return ``(final_path, temp_path)`` for a download.
Downloads land at the first registered path for the model's directory
(the "primary" location). ``temp_path`` is a sibling ``.part`` file that
is atomically renamed onto ``final_path`` on success. The result is
asserted to stay within the registered root (defence in depth on top of
the segment regex).
"""
directory, filename = parse_model_id(model_id, allow_any_extension)
roots = folder_paths.get_folder_paths(directory)
if not roots:
raise InvalidModelId(f"no on-disk path registered for folder {directory!r}")
root = os.path.realpath(roots[0])
final_path = os.path.realpath(os.path.join(root, filename))
if final_path != root and not final_path.startswith(root + os.sep):
raise InvalidModelId(f"resolved path escapes model root: {model_id!r}")
temp_path = f"{final_path}{TMP_SUFFIX}"
return final_path, temp_path
def iter_all_tmp_paths() -> Iterator[str]:
"""Yield this subsystem's temp files under every registered model folder.
Matches only the distinctive ``TMP_SUFFIX`` so the startup orphan sweep
can never delete temp files created by other tools.
"""
seen_roots: set[str] = set()
for directory in list(folder_paths.folder_names_and_paths.keys()):
for root in folder_paths.get_folder_paths(directory):
if root in seen_roots or not os.path.isdir(root):
continue
seen_roots.add(root)
try:
for entry in os.scandir(root):
if entry.is_file() and entry.name.endswith(TMP_SUFFIX):
yield entry.path
except OSError:
continue

View File

@ -0,0 +1,163 @@
"""SSRF / exfiltration defenses.
Two cooperating layers:
1. :class:`ValidatingResolver` is installed on the shared connector. Every
connection — the initial probe and every segment GET, including ones made
after a redirect — resolves its host through this resolver, which rejects
any address that lands on a private / special-use IP range. Because the
resolve and the connect happen together inside the connector, there is no
check-then-connect window for DNS rebinding to exploit.
2. :func:`check_redirect_hop` re-validates every hop. The host allowlist gates
only the *initial* user-supplied URL (anti-SSRF for arbitrary input);
legitimate downloads from allowlisted origins redirect to presigned CDN
hosts that are deliberately NOT on the allowlist (HF ->
``cdn-lfs*.huggingface.co``, Civitai -> signed Cloudflare/S3), so hops are
instead screened for scheme, embedded credentials, and — via the resolver
above — private IPs. Credentials are only ever attached when a hop's host
exactly matches a stored credential, so they are dropped on the CDN hop.
Loopback (the "download a local model" feature) is exempt from IP filtering
only for the initial URL: a *redirect* may never target a loopback host or
a blocked IP-literal, which the resolver alone can't enforce (it exempts
loopback literals and never sees IP literals through DNS).
"""
from __future__ import annotations
import ipaddress
import socket
from urllib.parse import urlparse
from aiohttp.abc import AbstractResolver
from aiohttp.resolver import DefaultResolver
from app.model_downloader.security.allowlist import LOOPBACK_HOSTS
# Cap the redirect chain length a hop may use.
MAX_REDIRECTS = 5
class SSRFError(Exception):
"""A hop failed an SSRF / allowlist check."""
def is_scheme_allowed(scheme: str | None, host: str | None) -> bool:
"""True iff ``scheme`` is permitted for ``host`` on a download hop.
https is always allowed; plain http only for loopback/approved dev hosts.
"""
if not scheme:
return False
scheme = scheme.lower()
if scheme == "https":
return True
if scheme == "http":
return bool(host) and host.lower() in LOOPBACK_HOSTS
return False
def is_blocked_ip(ip_str: str) -> bool:
"""True for any address we refuse to connect to.
Covers loopback, link-local (incl. 169.254.169.254 cloud metadata),
RFC1918 private ranges, unique-local (ULA), unspecified (0.0.0.0/::),
multicast and other reserved ranges.
"""
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
return True # unparseable -> refuse
# On CPython before the gh-113171 fix (backported to 3.12.4/3.11.9/
# 3.10.14/3.9.19) the is_* properties don't see through IPv4-mapped IPv6
# (e.g. ::ffff:169.254.169.254), so resolve and re-check the embedded IPv4
# to keep mapped metadata/private addresses from slipping past the filter.
mapped = getattr(ip, "ipv4_mapped", None)
if mapped is not None:
ip = mapped
return (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_multicast
or ip.is_reserved
or ip.is_unspecified
)
class ValidatingResolver(AbstractResolver):
"""Delegating resolver that drops blocked IPs from every resolution.
If a hostname resolves only to blocked addresses, the connection fails
closed with an :class:`OSError`, which aiohttp surfaces as a connection
error to the caller.
"""
def __init__(self) -> None:
self._inner = DefaultResolver()
async def resolve(self, host, port=0, family=socket.AF_INET):
infos = await self._inner.resolve(host, port, family)
# localhost/127.0.0.1 are an explicit, opt-in allowlist feature.
if isinstance(host, str) and host.lower() in LOOPBACK_HOSTS:
return infos
safe = [info for info in infos if not is_blocked_ip(info["host"])]
if not safe:
raise OSError(
f"refusing to connect to {host!r}: resolves only to "
f"private/special-use addresses"
)
return safe
async def close(self) -> None:
await self._inner.close()
def check_redirect_hop(url: str, *, is_initial_url: bool = False) -> str:
"""Validate one hop's URL.
Returns the URL unchanged on success; raises :class:`SSRFError` otherwise.
Requires https for external hosts (http only for loopback/approved dev
hosts) and forbids credentials-in-URL. The host is NOT re-checked against
the allowlist (CDN redirect targets are off-list by design); credential
leakage is prevented by exact host matching at attach time, and the landing
filename's extension is gated separately by the caller.
Loopback/blocked-IP screening: the connector's resolver filters resolvable
hostnames but exempts literal loopback hosts (``localhost``/``127.0.0.1``/
``::1``) and never sees IP literals through DNS. That loopback exemption is
legitimate only for the *initial* user-supplied URL (``is_initial_url``);
on a redirect hop we reject loopback hosts and any blocked IP-literal here,
so a 30x can't steer a server-side GET at loopback/internal services.
"""
try:
parsed = urlparse(url)
except ValueError as e:
raise SSRFError(f"unparseable redirect URL {url!r}: {e}") from e
host = parsed.hostname
if not host:
raise SSRFError(f"redirect URL has no host: {url!r}")
if not is_scheme_allowed(parsed.scheme, host):
raise SSRFError(
f"redirect to disallowed scheme {parsed.scheme!r} for host "
f"{host!r} (https required for external hosts)"
)
if parsed.username or parsed.password:
raise SSRFError("credentials-in-URL are not allowed")
host_is_loopback = host.lower() in LOOPBACK_HOSTS
if not is_initial_url and host_is_loopback:
raise SSRFError(f"redirect to loopback host {host!r} is not allowed")
# IP-literal targets never go through DNS, so the connector's resolver can't
# screen them — check them directly. The only blocked IP allowed through is
# a loopback literal on the initial URL (handled by the exemption above).
try:
ipaddress.ip_address(host)
except ValueError:
is_ip_literal = False
else:
is_ip_literal = True
if is_ip_literal and is_blocked_ip(host) and not (
is_initial_url and host_is_loopback
):
raise SSRFError(f"redirect to blocked internal address {host!r}")
return url

View File

@ -0,0 +1,49 @@
"""Hub-checksum verification = SHA256.
Only used to confirm a download matches a *provided* ``expected_sha256``. It
is NOT the dedup key (that is blake3, owned by the assets system). The full
sequential read happens at most once, here, only when a checksum was supplied.
"""
from __future__ import annotations
import hashlib
from typing import Callable, Optional
_CHUNK = 8 * 1024 * 1024
InterruptCheck = Callable[[], bool]
class ChecksumError(Exception):
"""The computed SHA256 did not match the expected value."""
def sha256_file(path: str, interrupt_check: Optional[InterruptCheck] = None) -> Optional[str]:
"""Stream the file and return its lowercase hex SHA256.
Returns ``None`` if interrupted via ``interrupt_check``.
"""
h = hashlib.sha256()
with open(path, "rb") as f:
while True:
if interrupt_check is not None and interrupt_check():
return None
chunk = f.read(_CHUNK)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
def verify_sha256(
path: str, expected: str, interrupt_check: Optional[InterruptCheck] = None
) -> None:
"""Raise :class:`ChecksumError` unless the file's SHA256 matches ``expected``."""
actual = sha256_file(path, interrupt_check)
if actual is None:
return # interrupted; caller will re-verify on resume
if actual.lower() != expected.lower():
raise ChecksumError(
f"sha256 mismatch: expected {expected.lower()}, got {actual.lower()}"
)

View File

@ -0,0 +1,53 @@
"""Dedup + catalog handoff — reuse the assets system.
We do NOT build a parallel indexer. "Do I already have it?" is answered by
``resolve_existing`` (path) at enqueue time and, where a hash is known, by the
assets blake3 catalog. After a completed download we register the file
through the assets ingest path so it is cataloged and (eventually) hashed by
the existing enrichment worker.
"""
from __future__ import annotations
import asyncio
import logging
import os
from typing import Optional
def _register_sync(abs_path: str) -> Optional[str]:
"""Register a finished file into the assets catalog. Returns asset hash."""
try:
from app.assets.services.ingest import register_file_in_place
except Exception as e: # assets package import failure — non-fatal
logging.debug("[model_downloader] assets ingest unavailable: %s", e)
return None
try:
result = register_file_in_place(abs_path, name=os.path.basename(abs_path), tags=[])
return result.asset.hash if result and result.asset else None
except Exception as e:
# The file is already safely on disk; cataloging is best-effort.
logging.warning(
"[model_downloader] could not register %s into assets catalog: %s",
abs_path, e,
)
return None
async def register_completed(abs_path: str) -> Optional[str]:
"""Catalog a completed download via the assets system (off the event loop)."""
return await asyncio.to_thread(_register_sync, abs_path)
def _find_by_hash_sync(blake3_hex: str) -> Optional[str]:
try:
from app.assets.services.asset_management import get_asset_by_hash
except Exception:
return None
asset = get_asset_by_hash("blake3:" + blake3_hex)
return asset.hash if asset is not None else None
async def find_existing_by_hash(blake3_hex: str) -> Optional[str]:
"""Pure DB lookup — never triggers hashing on the hot path."""
return await asyncio.to_thread(_find_by_hash_sync, blake3_hex)

View File

@ -0,0 +1,86 @@
"""Cheap structural validation, no full read.
For ``.safetensors``/``.sft`` we parse the header (first few KB): it carries
the tensor table and the byte length of the data region. We assert
``file_size == 8 + header_len + data_region_len``. This detects truncation
and most corruption for free, before any crypto hashing. Other extensions
have no cheap structural check and pass through.
"""
from __future__ import annotations
import json
import os
import struct
from typing import Optional
_SAFETENSORS_EXTS = (".safetensors", ".sft")
# A sane upper bound so a corrupt header length can't make us read gigabytes.
_MAX_HEADER_BYTES = 100 * 1024 * 1024
class StructuralError(Exception):
"""The file failed its structural integrity check."""
def validate(path: str, name_hint: Optional[str] = None) -> None:
"""Validate the file at ``path``. Raises :class:`StructuralError` on failure.
The file format is detected from ``name_hint`` when provided, otherwise from
``path``. Callers that download into a temp file with an opaque suffix (e.g.
``*.comfy-download.part``) must pass the final destination name as
``name_hint`` so the format check is not silently skipped.
"""
lower = (name_hint or path).lower()
if lower.endswith(_SAFETENSORS_EXTS):
_validate_safetensors(path)
# No structural check for other formats; the size + (optional) checksum
# gates in the engine cover those.
def _validate_safetensors(path: str) -> None:
file_size = os.path.getsize(path)
if file_size < 8:
raise StructuralError(f"file too small to be safetensors ({file_size} bytes)")
with open(path, "rb") as f:
header_len = struct.unpack("<Q", f.read(8))[0]
if header_len <= 0 or header_len > _MAX_HEADER_BYTES:
raise StructuralError(f"implausible safetensors header length {header_len}")
if 8 + header_len > file_size:
raise StructuralError("safetensors header extends past end of file")
try:
header = json.loads(f.read(header_len).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as e:
raise StructuralError(f"safetensors header is not valid JSON: {e}") from e
if not isinstance(header, dict):
raise StructuralError("safetensors header is not a JSON object")
data_len = 0
for name, entry in header.items():
if name == "__metadata__":
continue
if not isinstance(entry, dict) or "data_offsets" not in entry:
raise StructuralError(f"tensor {name!r} missing data_offsets")
offsets = entry["data_offsets"]
if not (isinstance(offsets, list) and len(offsets) == 2):
raise StructuralError(f"tensor {name!r} has malformed data_offsets")
begin, end = offsets
# bool is an int subclass; reject it explicitly to avoid True/False offsets.
if (
not isinstance(begin, int)
or not isinstance(end, int)
or isinstance(begin, bool)
or isinstance(end, bool)
or begin < 0
or end < begin
):
raise StructuralError(f"tensor {name!r} has malformed data_offsets")
data_len = max(data_len, end)
expected = 8 + header_len + data_len
if file_size != expected:
raise StructuralError(
f"size mismatch: file is {file_size} bytes, header implies {expected} "
f"(8 + {header_len} header + {data_len} data)"
)

View File

@ -1,5 +1,3 @@
from __future__ import annotations
import os
import base64
import json

View File

@ -1,4 +1,3 @@
from __future__ import annotations
import json
import os
import re

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1553,7 +1553,7 @@
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"category": "Image generation and editing/Canny to image",
"category": "Image generation and editing/Conditioned",
"description": "Generates an image from a Canny edge map using Z-Image-Turbo, with text conditioning."
}
]

View File

@ -3600,7 +3600,7 @@
"extra": {
"workflowRendererVersion": "LG"
},
"category": "Video generation and editing/Canny to video",
"category": "Video generation and editing/Conditioned",
"description": "Generates video from Canny edge maps using LTX-2, with optional synchronized audio."
}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1401,7 +1401,7 @@
"extra": {
"workflowRendererVersion": "LG"
},
"category": "Image generation and editing/ControlNet",
"category": "Image generation and editing/Conditioned",
"description": "Generates images from a text prompt and ControlNet conditioning (e.g. depth, canny) using Z-Image-Turbo."
}
]

View File

@ -1579,7 +1579,7 @@
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"category": "Image generation and editing/Depth to image",
"category": "Image generation and editing/Conditioned",
"description": "Generates an image from a depth map using Z-Image-Turbo with text conditioning."
},
{

View File

@ -4233,7 +4233,7 @@
"extra": {
"workflowRendererVersion": "LG"
},
"category": "Video generation and editing/Depth to video",
"category": "Video generation and editing/Conditioned",
"description": "Generates depth-controlled video with LTX-2: motion and structure follow a depth-reference video alongside text prompting, optional first-frame image conditioning, with optional synchronized audio."
},
{

View File

@ -3350,7 +3350,7 @@
}
],
"extra": {},
"category": "Video generation and editing/First-Last-Frame to Video",
"category": "Video generation and editing/Conditioned",
"description": "Generates a video interpolating between first and last keyframes using LTX-2.3."
}
]

View File

@ -3350,7 +3350,7 @@
}
],
"extra": {},
"category": "Video generation and editing/First-Last-Frame to Video",
"category": "Video generation and editing/FLF2V",
"description": "Generates a video that interpolates between the first and last keyframes using LTX-2.3, including optional audio."
}
]

File diff suppressed because it is too large Load Diff

View File

@ -310,9 +310,9 @@
"extra": {
"workflowRendererVersion": "LG"
},
"category": "Text generation/Image Captioning",
"category": "Image Tools",
"description": "Generates descriptive captions for images using Google's Gemini multimodal LLM."
}
]
}
}
}

View File

@ -0,0 +1,569 @@
{
"revision": 0,
"last_node_id": 89,
"last_link_id": 0,
"nodes": [
{
"id": 89,
"type": "85e595bd-af9e-40ee-85c5-b98bb15da47a",
"pos": [
320,
520
],
"size": [
400,
360
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": null
},
{
"name": "resolution",
"type": "INT",
"widget": {
"name": "resolution"
},
"link": null
},
{
"name": "resize_method",
"type": "COMBO",
"widget": {
"name": "resize_method"
},
"link": null
},
{
"label": "output_type",
"name": "output",
"type": "COMFY_DYNAMICCOMBO_V3",
"widget": {
"name": "output"
},
"link": null
},
{
"label": "output_normalization",
"name": "output.normalization",
"type": "COMBO",
"widget": {
"name": "output.normalization"
},
"link": null
},
{
"label": "apply_sky_clip",
"name": "output.apply_sky_clip",
"type": "BOOLEAN",
"widget": {
"name": "output.apply_sky_clip"
},
"link": null
},
{
"name": "model_name",
"type": "COMBO",
"widget": {
"name": "model_name"
},
"link": null
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": []
}
],
"properties": {
"proxyWidgets": [
[
"87",
"resolution"
],
[
"87",
"resize_method"
],
[
"86",
"output"
],
[
"86",
"output.normalization"
],
[
"86",
"output.apply_sky_clip"
],
[
"88",
"model_name"
]
],
"cnr_id": "comfy-core",
"ver": "0.24.0"
},
"widgets_values": [],
"title": "Image Depth Estimation (Depth Anything 3)"
}
],
"links": [],
"version": 0.4,
"definitions": {
"subgraphs": [
{
"id": "85e595bd-af9e-40ee-85c5-b98bb15da47a",
"version": 1,
"state": {
"lastGroupId": 4,
"lastNodeId": 89,
"lastLinkId": 109,
"lastRerouteId": 0
},
"revision": 2,
"config": {},
"name": "Image Depth Estimation (Depth Anything 3)",
"inputNode": {
"id": -10,
"bounding": [
400,
90,
166.998046875,
188
]
},
"outputNode": {
"id": -20,
"bounding": [
1250,
146,
128,
68
]
},
"inputs": [
{
"id": "43cf3118-495a-487d-8eb3-a17c7e92f64f",
"name": "image",
"type": "IMAGE",
"linkIds": [
19
],
"localized_name": "image",
"pos": [
542.998046875,
114
]
},
{
"id": "1089a0a1-6db1-45a8-84b0-0bfdc2ed920a",
"name": "resolution",
"type": "INT",
"linkIds": [
22
],
"pos": [
542.998046875,
134
]
},
{
"id": "25fb64ac-26d5-466d-995b-6d51b9afa2c4",
"name": "resize_method",
"type": "COMBO",
"linkIds": [
23
],
"pos": [
542.998046875,
154
]
},
{
"id": "8acafb7c-6c8b-46b3-9d74-c563498a3af1",
"name": "output",
"type": "COMFY_DYNAMICCOMBO_V3",
"linkIds": [
24
],
"label": "output_type",
"pos": [
542.998046875,
174
]
},
{
"id": "1da5009b-4648-43e8-a257-16426630cf22",
"name": "output.normalization",
"type": "COMBO",
"linkIds": [
25
],
"label": "output_normalization",
"pos": [
542.998046875,
194
]
},
{
"id": "fd7edb33-5fb1-4538-a411-26e5039a9321",
"name": "output.apply_sky_clip",
"type": "BOOLEAN",
"linkIds": [
26
],
"label": "apply_sky_clip",
"pos": [
542.998046875,
214
]
},
{
"id": "b5be4c8a-b833-4f1e-8c94-3ed1dd722190",
"name": "model_name",
"type": "COMBO",
"linkIds": [
106
],
"pos": [
542.998046875,
234
]
}
],
"outputs": [
{
"id": "478ab537-63bc-4d74-a9f0-c975f550880f",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [
7
],
"localized_name": "IMAGE",
"pos": [
1274,
170
]
}
],
"widgets": [],
"nodes": [
{
"id": 86,
"type": "DA3Render",
"pos": [
800,
310
],
"size": [
380,
130
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "da3_geometry",
"name": "da3_geometry",
"type": "DA3_GEOMETRY",
"link": 12
},
{
"localized_name": "output",
"name": "output",
"type": "COMFY_DYNAMICCOMBO_V3",
"widget": {
"name": "output"
},
"link": 24
},
{
"localized_name": "output.normalization",
"name": "output.normalization",
"type": "COMBO",
"widget": {
"name": "output.normalization"
},
"link": 25
},
{
"localized_name": "output.apply_sky_clip",
"name": "output.apply_sky_clip",
"type": "BOOLEAN",
"widget": {
"name": "output.apply_sky_clip"
},
"link": 26
},
{
"name": "geometry",
"type": "DA3_GEOMETRY",
"link": null
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
7
]
}
],
"properties": {
"Node name for S&R": "DA3Render",
"cnr_id": "comfy-core",
"ver": "0.19.0"
},
"widgets_values": [
"depth",
"v2_style",
false
]
},
{
"id": 87,
"type": "DA3Inference",
"pos": [
800,
50
],
"size": [
390,
130
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "da3_model",
"name": "da3_model",
"type": "DA3_MODEL",
"link": 107
},
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": 19
},
{
"localized_name": "resolution",
"name": "resolution",
"type": "INT",
"widget": {
"name": "resolution"
},
"link": 22
},
{
"localized_name": "resize_method",
"name": "resize_method",
"type": "COMBO",
"widget": {
"name": "resize_method"
},
"link": 23
},
{
"localized_name": "mode",
"name": "mode",
"type": "COMFY_DYNAMICCOMBO_V3",
"widget": {
"name": "mode"
},
"link": null
}
],
"outputs": [
{
"localized_name": "da3_geometry",
"name": "da3_geometry",
"type": "DA3_GEOMETRY",
"slot_index": 0,
"links": [
12
]
}
],
"properties": {
"Node name for S&R": "DA3Inference",
"cnr_id": "comfy-core",
"ver": "0.19.0"
},
"widgets_values": [
504,
"upper_bound_resize",
"mono"
]
},
{
"id": 88,
"type": "LoadDA3Model",
"pos": [
810,
-160
],
"size": [
400,
140
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "model_name",
"name": "model_name",
"type": "COMBO",
"widget": {
"name": "model_name"
},
"link": 106
},
{
"localized_name": "weight_dtype",
"name": "weight_dtype",
"type": "COMBO",
"widget": {
"name": "weight_dtype"
},
"link": null
}
],
"outputs": [
{
"localized_name": "DA3_MODEL",
"name": "DA3_MODEL",
"type": "DA3_MODEL",
"links": [
107
]
}
],
"properties": {
"Node name for S&R": "LoadDA3Model",
"cnr_id": "comfy-core",
"ver": "0.24.0",
"models": [
{
"name": "depth_anything_3_mono_large.safetensors",
"url": "https://huggingface.co/Comfy-Org/Depth-Anything-3/resolve/main/geometry_estimation/depth_anything_3_mono_large.safetensors",
"directory": "geometry_estimation"
}
]
},
"widgets_values": [
"depth_anything_3_mono_large.safetensors",
"default"
]
}
],
"groups": [],
"links": [
{
"id": 12,
"origin_id": 87,
"origin_slot": 0,
"target_id": 86,
"target_slot": 0,
"type": "DA3_GEOMETRY"
},
{
"id": 19,
"origin_id": -10,
"origin_slot": 0,
"target_id": 87,
"target_slot": 1,
"type": "IMAGE"
},
{
"id": 7,
"origin_id": 86,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 22,
"origin_id": -10,
"origin_slot": 1,
"target_id": 87,
"target_slot": 2,
"type": "INT"
},
{
"id": 23,
"origin_id": -10,
"origin_slot": 2,
"target_id": 87,
"target_slot": 3,
"type": "COMBO"
},
{
"id": 24,
"origin_id": -10,
"origin_slot": 3,
"target_id": 86,
"target_slot": 1,
"type": "COMFY_DYNAMICCOMBO_V3"
},
{
"id": 25,
"origin_id": -10,
"origin_slot": 4,
"target_id": 86,
"target_slot": 2,
"type": "COMBO"
},
{
"id": 26,
"origin_id": -10,
"origin_slot": 5,
"target_id": 86,
"target_slot": 3,
"type": "BOOLEAN"
},
{
"id": 106,
"origin_id": -10,
"origin_slot": 6,
"target_id": 88,
"target_slot": 0,
"type": "COMBO"
},
{
"id": 107,
"origin_id": 88,
"origin_slot": 0,
"target_id": 87,
"target_slot": 0,
"type": "DA3_MODEL"
}
],
"extra": {},
"category": "Conditioning & Preprocessors/Depth",
"description": "This subgraph takes an input image and produces a depth map using the Depth Anything 3 model, which recovers spatially consistent geometry from any number of views. It is ideal for single or multi-view images, videos, and 3D scenes where accurate depth estimation is needed for tasks like SLAM, novel view synthesis, or spatial perception. The model uses a plain transformer backbone and supports both monocular and multi-view inputs without."
}
]
},
"extra": {
"BlueprintDescription": "This subgraph takes an input image and produces a depth map using the Depth Anything 3 model, which recovers spatially consistent geometry from any number of views. It is ideal for single or multi-view images, videos, and 3D scenes where accurate depth estimation is needed for tasks like SLAM, novel view synthesis, or spatial perception. The model uses a plain transformer backbone and supports both monocular and multi-view inputs without."
}
}

View File

@ -1,19 +1,18 @@
{
"id": "6af0a6c1-0161-4528-8685-65776e838d44",
"revision": 0,
"last_node_id": 75,
"last_link_id": 245,
"last_node_id": 76,
"last_link_id": 0,
"nodes": [
{
"id": 75,
"type": "488652fd-6edf-4d06-8f9f-4d84d3a34eaf",
"id": 76,
"type": "96338968-1242-4f02-b6a1-d496af4bcffe",
"pos": [
600,
830
670,
1280
],
"size": [
400,
110
201.3125
],
"flags": {},
"order": 0,
@ -59,47 +58,44 @@
"links": []
}
],
"title": "Image Depth Estimation (Lotus Depth)",
"properties": {
"proxyWidgets": [
[
"-1",
"28",
"sigma"
],
[
"-1",
"10",
"unet_name"
],
[
"-1",
"14",
"vae_name"
]
],
"cnr_id": "comfy-core",
"ver": "0.14.1"
},
"widgets_values": [
999.0000000000002,
"lotus-depth-d-v1-1.safetensors",
"vae-ft-mse-840000-ema-pruned.safetensors"
]
"widgets_values": []
}
],
"links": [],
"groups": [],
"version": 0.4,
"definitions": {
"subgraphs": [
{
"id": "488652fd-6edf-4d06-8f9f-4d84d3a34eaf",
"id": "96338968-1242-4f02-b6a1-d496af4bcffe",
"version": 1,
"state": {
"lastGroupId": 1,
"lastNodeId": 75,
"lastNodeId": 76,
"lastLinkId": 245,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Image to Depth Map (Lotus)",
"name": "Image Depth Estimation (Lotus Depth)",
"inputNode": {
"id": -10,
"bounding": [
@ -191,12 +187,12 @@
"id": 10,
"type": "UNETLoader",
"pos": [
108.05555555555557,
-253.05555555555557
110,
-250
],
"size": [
254.93706597222226,
82
260,
90
],
"flags": {},
"order": 4,
@ -234,9 +230,9 @@
}
],
"properties": {
"Node name for S&R": "UNETLoader",
"cnr_id": "comfy-core",
"ver": "0.3.34",
"Node name for S&R": "UNETLoader",
"models": [
{
"name": "lotus-depth-d-v1-1.safetensors",
@ -255,12 +251,12 @@
"id": 18,
"type": "DisableNoise",
"pos": [
607.0641494069639,
-268.33337840371513
610,
-270
],
"size": [
175,
33.333333333333336
180,
40
],
"flags": {},
"order": 0,
@ -278,26 +274,25 @@
}
],
"properties": {
"Node name for S&R": "DisableNoise",
"cnr_id": "comfy-core",
"ver": "0.3.34",
"Node name for S&R": "DisableNoise",
"widget_ue_connectable": {}
},
"widgets_values": []
}
},
{
"id": 23,
"id": 74,
"type": "VAEEncode",
"pos": [
620,
160
],
"size": [
175,
180,
50
],
"flags": {},
"order": 10,
"order": 11,
"mode": 0,
"inputs": [
{
@ -325,12 +320,11 @@
}
],
"properties": {
"Node name for S&R": "VAEEncode",
"cnr_id": "comfy-core",
"ver": "0.3.34",
"Node name for S&R": "VAEEncode",
"widget_ue_connectable": {}
},
"widgets_values": []
}
},
{
"id": 21,
@ -341,7 +335,7 @@
],
"size": [
210,
58
60
],
"flags": {},
"order": 1,
@ -369,9 +363,9 @@
}
],
"properties": {
"Node name for S&R": "KSamplerSelect",
"cnr_id": "comfy-core",
"ver": "0.3.34",
"Node name for S&R": "KSamplerSelect",
"widget_ue_connectable": {}
},
"widgets_values": [
@ -386,7 +380,7 @@
-170
],
"size": [
175,
180,
50
],
"flags": {},
@ -418,12 +412,11 @@
}
],
"properties": {
"Node name for S&R": "BasicGuider",
"cnr_id": "comfy-core",
"ver": "0.3.34",
"Node name for S&R": "BasicGuider",
"widget_ue_connectable": {}
},
"widgets_values": []
}
},
{
"id": 16,
@ -433,8 +426,8 @@
-130
],
"size": [
295.99609375,
271.65798611111114
300,
280
],
"flags": {},
"order": 6,
@ -490,12 +483,11 @@
}
],
"properties": {
"Node name for S&R": "SamplerCustomAdvanced",
"cnr_id": "comfy-core",
"ver": "0.3.34",
"Node name for S&R": "SamplerCustomAdvanced",
"widget_ue_connectable": {}
},
"widgets_values": []
}
},
{
"id": 28,
@ -506,10 +498,10 @@
],
"size": [
210,
58
60
],
"flags": {},
"order": 11,
"order": 10,
"mode": 0,
"inputs": [
{
@ -540,9 +532,9 @@
}
],
"properties": {
"Node name for S&R": "SetFirstSigma",
"cnr_id": "comfy-core",
"ver": "0.3.34",
"Node name for S&R": "SetFirstSigma",
"widget_ue_connectable": {}
},
"widgets_values": [
@ -557,7 +549,7 @@
-120
],
"size": [
175,
180,
50
],
"flags": {},
@ -589,12 +581,11 @@
}
],
"properties": {
"Node name for S&R": "VAEDecode",
"cnr_id": "comfy-core",
"ver": "0.3.34",
"Node name for S&R": "VAEDecode",
"widget_ue_connectable": {}
},
"widgets_values": []
}
},
{
"id": 22,
@ -604,8 +595,8 @@
-220
],
"size": [
175,
33.333333333333336
180,
40
],
"flags": {},
"order": 9,
@ -630,12 +621,11 @@
}
],
"properties": {
"Node name for S&R": "ImageInvert",
"cnr_id": "comfy-core",
"ver": "0.3.34",
"Node name for S&R": "ImageInvert",
"widget_ue_connectable": {}
},
"widgets_values": []
}
},
{
"id": 14,
@ -645,8 +635,8 @@
-90
],
"size": [
254.93706597222226,
58
260,
60
],
"flags": {},
"order": 5,
@ -675,9 +665,9 @@
}
],
"properties": {
"Node name for S&R": "VAELoader",
"cnr_id": "comfy-core",
"ver": "0.3.34",
"Node name for S&R": "VAELoader",
"models": [
{
"name": "vae-ft-mse-840000-ema-pruned.safetensors",
@ -692,15 +682,15 @@
]
},
{
"id": 68,
"id": 75,
"type": "LotusConditioning",
"pos": [
400,
-150
],
"size": [
175,
33.333333333333336
180,
40
],
"flags": {},
"order": 2,
@ -718,12 +708,11 @@
}
],
"properties": {
"Node name for S&R": "LotusConditioning",
"cnr_id": "comfy-core",
"ver": "0.3.34",
"Node name for S&R": "LotusConditioning",
"widget_ue_connectable": {}
},
"widgets_values": []
}
},
{
"id": 20,
@ -734,7 +723,7 @@
],
"size": [
210,
106
110
],
"flags": {},
"order": 8,
@ -786,9 +775,9 @@
}
],
"properties": {
"Node name for S&R": "BasicScheduler",
"cnr_id": "comfy-core",
"ver": "0.3.34",
"Node name for S&R": "BasicScheduler",
"widget_ue_connectable": {}
},
"widgets_values": [
@ -850,7 +839,7 @@
},
{
"id": 201,
"origin_id": 23,
"origin_id": 74,
"origin_slot": 0,
"target_id": 16,
"target_slot": 4,
@ -866,7 +855,7 @@
},
{
"id": 238,
"origin_id": 68,
"origin_id": 75,
"origin_slot": 0,
"target_id": 19,
"target_slot": 1,
@ -892,7 +881,7 @@
"id": 38,
"origin_id": 14,
"origin_slot": 0,
"target_id": 23,
"target_id": 74,
"target_slot": 1,
"type": "VAE"
},
@ -908,7 +897,7 @@
"id": 37,
"origin_id": -10,
"origin_slot": 0,
"target_id": 23,
"target_id": 74,
"target_slot": 0,
"type": "IMAGE"
},
@ -948,12 +937,11 @@
"extra": {
"workflowRendererVersion": "LG"
},
"category": "Image generation and editing/Depth to image",
"category": "Conditioning & Preprocessors/Depth",
"description": "Estimates a monocular depth map from an input image using the Lotus depth estimation model."
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1.3589709866044692,
@ -961,8 +949,6 @@
-138.53613935617864,
-786.0629126022195
]
},
"workflowRendererVersion": "LG"
},
"version": 0.4
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,779 @@
{
"revision": 0,
"last_node_id": 33,
"last_link_id": 0,
"nodes": [
{
"id": 33,
"type": "6062babb-b649-4a71-be9e-20ebce567744",
"pos": [
-450,
4240
],
"size": [
420,
400
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": null
},
{
"name": "face_landmarker",
"type": "FACE_LANDMARKER",
"link": null
},
{
"name": "detector_variant",
"type": "COMBO",
"widget": {
"name": "detector_variant"
},
"link": null
},
{
"name": "num_faces",
"type": "INT",
"widget": {
"name": "num_faces"
},
"link": null
},
{
"label": "custom_face_oval",
"name": "regions.face_oval",
"type": "BOOLEAN",
"widget": {
"name": "regions.face_oval"
},
"link": null
},
{
"label": "custom_lips",
"name": "regions.lips",
"type": "BOOLEAN",
"widget": {
"name": "regions.lips"
},
"link": null
},
{
"label": "custom_left_eye",
"name": "regions.left_eye",
"type": "BOOLEAN",
"widget": {
"name": "regions.left_eye"
},
"link": null
},
{
"label": "custom_right_eye",
"name": "regions.right_eye",
"type": "BOOLEAN",
"widget": {
"name": "regions.right_eye"
},
"link": null
},
{
"label": "custom_irises",
"name": "regions.irises",
"type": "BOOLEAN",
"widget": {
"name": "regions.irises"
},
"link": null
},
{
"name": "model_name",
"type": "COMBO",
"widget": {
"name": "model_name"
},
"link": null
}
],
"outputs": [
{
"localized_name": "face_landmarks",
"name": "face_landmarks",
"type": "FACE_LANDMARKS",
"links": []
},
{
"localized_name": "bboxes",
"name": "bboxes",
"type": "BOUNDING_BOX",
"links": []
},
{
"label": "mask",
"name": "MASK_1",
"type": "MASK",
"links": []
}
],
"title": "Image Face Detection (Mediapipe)",
"properties": {
"proxyWidgets": [
[
"11",
"detector_variant"
],
[
"11",
"num_faces"
],
[
"20",
"regions.face_oval"
],
[
"20",
"regions.lips"
],
[
"20",
"regions.left_eye"
],
[
"20",
"regions.right_eye"
],
[
"20",
"regions.irises"
],
[
"2",
"model_name"
]
],
"cnr_id": "comfy-core",
"ver": "0.22.0",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": []
}
],
"links": [],
"version": 0.4,
"definitions": {
"subgraphs": [
{
"id": "6062babb-b649-4a71-be9e-20ebce567744",
"version": 1,
"state": {
"lastGroupId": 2,
"lastNodeId": 158,
"lastLinkId": 140,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Image Face Detection (Mediapipe)",
"description": "Detects facial landmarks from an image using MediaPipe, outputting landmark data, face bounding boxes, and an optional face-region mask.",
"inputNode": {
"id": -10,
"bounding": [
-710,
4300,
148.880859375,
248
]
},
"outputNode": {
"id": -20,
"bounding": [
140,
4480,
137.677734375,
108
]
},
"inputs": [
{
"id": "705dc1ae-6dc9-4155-92df-52f816ad451e",
"name": "image",
"type": "IMAGE",
"linkIds": [
60
],
"localized_name": "image",
"pos": [
-585.119140625,
4324
]
},
{
"id": "d6277190-732c-4604-b7cd-d3a9588bf761",
"name": "face_landmarker",
"type": "FACE_LANDMARKER",
"linkIds": [
74
],
"pos": [
-585.119140625,
4344
]
},
{
"id": "ac473a08-6a86-42a7-b460-e70c6c5e1e2b",
"name": "detector_variant",
"type": "COMBO",
"linkIds": [
75
],
"pos": [
-585.119140625,
4364
]
},
{
"id": "1bec2252-ca2d-496e-8a33-33a61d21f897",
"name": "num_faces",
"type": "INT",
"linkIds": [
76
],
"pos": [
-585.119140625,
4384
]
},
{
"id": "17994fa2-0ea0-4c9b-a70a-19789c459c80",
"name": "regions.face_oval",
"type": "BOOLEAN",
"linkIds": [
77
],
"label": "custom_face_oval",
"pos": [
-585.119140625,
4404
]
},
{
"id": "1c6c5893-2aee-4c37-b702-15ef2e20d863",
"name": "regions.lips",
"type": "BOOLEAN",
"linkIds": [
78
],
"label": "custom_lips",
"pos": [
-585.119140625,
4424
]
},
{
"id": "f353fcea-4b6f-42a1-8fdd-32b3aa1e1f09",
"name": "regions.left_eye",
"type": "BOOLEAN",
"linkIds": [
79
],
"label": "custom_left_eye",
"pos": [
-585.119140625,
4444
]
},
{
"id": "1387e121-c1fb-4522-8f0d-43459e11dd86",
"name": "regions.right_eye",
"type": "BOOLEAN",
"linkIds": [
80
],
"label": "custom_right_eye",
"pos": [
-585.119140625,
4464
]
},
{
"id": "14acb0a0-d1f4-48f3-ba31-811b26236ef9",
"name": "regions.irises",
"type": "BOOLEAN",
"linkIds": [
81
],
"label": "custom_irises",
"pos": [
-585.119140625,
4484
]
},
{
"id": "25a82859-87de-42c8-8431-09948665546e",
"name": "model_name",
"type": "COMBO",
"linkIds": [
86
],
"pos": [
-585.119140625,
4504
]
}
],
"outputs": [
{
"id": "d2ba3f92-e8b1-49c3-9590-cfad56c54cf4",
"name": "face_landmarks",
"type": "FACE_LANDMARKS",
"linkIds": [
44
],
"localized_name": "face_landmarks",
"pos": [
164,
4504
]
},
{
"id": "4f356bb0-d4c4-4f93-b4cf-0845a65c4e6d",
"name": "bboxes",
"type": "BOUNDING_BOX",
"linkIds": [
25
],
"localized_name": "bboxes",
"pos": [
164,
4524
]
},
{
"id": "f6309e1d-6397-4363-b38f-778a122abc51",
"name": "MASK_1",
"type": "MASK",
"linkIds": [
83
],
"label": "mask",
"pos": [
164,
4544
]
}
],
"widgets": [],
"nodes": [
{
"id": 11,
"type": "MediaPipeFaceLandmarker",
"pos": [
-280,
4280
],
"size": [
350,
220
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "face_detection_model",
"name": "face_detection_model",
"type": "FACE_DETECTION_MODEL",
"link": 66
},
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": 60
},
{
"localized_name": "detector_variant",
"name": "detector_variant",
"type": "COMBO",
"widget": {
"name": "detector_variant"
},
"link": 75
},
{
"localized_name": "num_faces",
"name": "num_faces",
"type": "INT",
"widget": {
"name": "num_faces"
},
"link": 76
},
{
"localized_name": "min_confidence",
"name": "min_confidence",
"type": "FLOAT",
"widget": {
"name": "min_confidence"
},
"link": null
},
{
"localized_name": "missing_frame_fallback",
"name": "missing_frame_fallback",
"type": "COMBO",
"widget": {
"name": "missing_frame_fallback"
},
"link": null
},
{
"name": "face_landmarker",
"type": "FACE_LANDMARKER",
"link": 74
}
],
"outputs": [
{
"localized_name": "face_landmarks",
"name": "face_landmarks",
"type": "FACE_LANDMARKS",
"links": [
44,
46
]
},
{
"localized_name": "bboxes",
"name": "bboxes",
"type": "BOUNDING_BOX",
"links": [
25
]
}
],
"properties": {
"Node name for S&R": "MediaPipeFaceLandmarker",
"cnr_id": "comfy-core",
"ver": "0.22.0",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
"full",
0,
0.5,
"empty"
]
},
{
"id": 2,
"type": "LoadMediaPipeFaceLandmarker",
"pos": [
-290,
4060
],
"size": [
350,
140
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model_name",
"name": "model_name",
"type": "COMBO",
"widget": {
"name": "model_name"
},
"link": 86
}
],
"outputs": [
{
"localized_name": "FACE_DETECTION_MODEL",
"name": "FACE_DETECTION_MODEL",
"type": "FACE_DETECTION_MODEL",
"links": [
66
]
}
],
"properties": {
"Node name for S&R": "LoadMediaPipeFaceLandmarker",
"cnr_id": "comfy-core",
"ver": "0.22.0",
"models": [
{
"name": "mediapipe_face_fp32.safetensors",
"url": "https://huggingface.co/Comfy-Org/mediapipe/resolve/main/detection/mediapipe_face_fp32.safetensors",
"directory": "detection"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
"mediapipe_face_fp32.safetensors"
]
},
{
"id": 20,
"type": "MediaPipeFaceMask",
"pos": [
-290,
4560
],
"size": [
360,
180
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "face_landmarks",
"name": "face_landmarks",
"type": "FACE_LANDMARKS",
"link": 46
},
{
"localized_name": "regions",
"name": "regions",
"type": "COMFY_DYNAMICCOMBO_V3",
"widget": {
"name": "regions"
},
"link": null
},
{
"localized_name": "regions.face_oval",
"name": "regions.face_oval",
"type": "BOOLEAN",
"widget": {
"name": "regions.face_oval"
},
"link": 77
},
{
"localized_name": "regions.lips",
"name": "regions.lips",
"type": "BOOLEAN",
"widget": {
"name": "regions.lips"
},
"link": 78
},
{
"localized_name": "regions.left_eye",
"name": "regions.left_eye",
"type": "BOOLEAN",
"widget": {
"name": "regions.left_eye"
},
"link": 79
},
{
"localized_name": "regions.right_eye",
"name": "regions.right_eye",
"type": "BOOLEAN",
"widget": {
"name": "regions.right_eye"
},
"link": 80
},
{
"localized_name": "regions.irises",
"name": "regions.irises",
"type": "BOOLEAN",
"widget": {
"name": "regions.irises"
},
"link": 81
}
],
"outputs": [
{
"localized_name": "MASK",
"name": "MASK",
"type": "MASK",
"links": [
83
]
}
],
"properties": {
"Node name for S&R": "MediaPipeFaceMask",
"cnr_id": "comfy-core",
"ver": "0.22.0",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
"custom",
true,
false,
false,
false,
false
]
}
],
"groups": [],
"links": [
{
"id": 66,
"origin_id": 2,
"origin_slot": 0,
"target_id": 11,
"target_slot": 0,
"type": "FACE_DETECTION_MODEL"
},
{
"id": 46,
"origin_id": 11,
"origin_slot": 0,
"target_id": 20,
"target_slot": 0,
"type": "FACE_LANDMARKS"
},
{
"id": 60,
"origin_id": -10,
"origin_slot": 0,
"target_id": 11,
"target_slot": 1,
"type": "IMAGE"
},
{
"id": 44,
"origin_id": 11,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "FACE_LANDMARKS"
},
{
"id": 25,
"origin_id": 11,
"origin_slot": 1,
"target_id": -20,
"target_slot": 1,
"type": "BOUNDING_BOX"
},
{
"id": 74,
"origin_id": -10,
"origin_slot": 1,
"target_id": 11,
"target_slot": 6,
"type": "FACE_LANDMARKER"
},
{
"id": 75,
"origin_id": -10,
"origin_slot": 2,
"target_id": 11,
"target_slot": 2,
"type": "COMBO"
},
{
"id": 76,
"origin_id": -10,
"origin_slot": 3,
"target_id": 11,
"target_slot": 3,
"type": "INT"
},
{
"id": 77,
"origin_id": -10,
"origin_slot": 4,
"target_id": 20,
"target_slot": 2,
"type": "BOOLEAN"
},
{
"id": 78,
"origin_id": -10,
"origin_slot": 5,
"target_id": 20,
"target_slot": 3,
"type": "BOOLEAN"
},
{
"id": 79,
"origin_id": -10,
"origin_slot": 6,
"target_id": 20,
"target_slot": 4,
"type": "BOOLEAN"
},
{
"id": 80,
"origin_id": -10,
"origin_slot": 7,
"target_id": 20,
"target_slot": 5,
"type": "BOOLEAN"
},
{
"id": 81,
"origin_id": -10,
"origin_slot": 8,
"target_id": 20,
"target_slot": 6,
"type": "BOOLEAN"
},
{
"id": 83,
"origin_id": 20,
"origin_slot": 0,
"target_id": -20,
"target_slot": 2,
"type": "MASK"
},
{
"id": 86,
"origin_id": -10,
"origin_slot": 9,
"target_id": 2,
"target_slot": 0,
"type": "COMBO"
}
],
"extra": {},
"category": "Conditioning & Preprocessors/Face Detection"
}
]
},
"extra": {}
}

View File

@ -703,7 +703,7 @@
}
],
"extra": {},
"category": "Image Tools/Image Segmentation",
"category": "Conditioning & Preprocessors/Segmentation & Mask",
"description": "Segments images into masks using Meta SAM3 from text prompts, points, or boxes."
}
]

View File

@ -1302,7 +1302,7 @@
"extra": {
"workflowRendererVersion": "LG"
},
"category": "Image generation and editing/Enhance",
"category": "Image generation and editing/Upscale",
"description": "Upscales images to higher resolution using Z-Image-Turbo."
}
]
@ -1312,4 +1312,4 @@
"workflowRendererVersion": "LG"
},
"version": 0.4
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,888 @@
{
"revision": 0,
"last_node_id": 675,
"last_link_id": 0,
"nodes": [
{
"id": 675,
"type": "01b6a731-fb78-4070-9a38-c87146da9604",
"pos": [
-2480,
3400
],
"size": [
360,
433.3125
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "input",
"name": "input",
"type": "IMAGE,MASK",
"link": null
},
{
"label": "resize_target_longer_size",
"name": "resize_type.longer_size",
"type": "INT",
"widget": {
"name": "resize_type.longer_size"
},
"link": null
},
{
"name": "scale_method",
"type": "COMBO",
"widget": {
"name": "scale_method"
},
"link": null
},
{
"name": "draw_body",
"type": "BOOLEAN",
"widget": {
"name": "draw_body"
},
"link": null
},
{
"name": "draw_hands",
"type": "BOOLEAN",
"widget": {
"name": "draw_hands"
},
"link": null
},
{
"name": "draw_face",
"type": "BOOLEAN",
"widget": {
"name": "draw_face"
},
"link": null
},
{
"name": "draw_feet",
"type": "BOOLEAN",
"widget": {
"name": "draw_feet"
},
"link": null
},
{
"name": "stick_width",
"type": "INT",
"widget": {
"name": "stick_width"
},
"link": null
},
{
"name": "face_point_size",
"type": "INT",
"widget": {
"name": "face_point_size"
},
"link": null
},
{
"name": "score_threshold",
"type": "FLOAT",
"widget": {
"name": "score_threshold"
},
"link": null
},
{
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": null
},
{
"name": "bboxes",
"shape": 7,
"type": "BOUNDING_BOX",
"link": null
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": []
},
{
"name": "keypoints",
"type": "POSE_KEYPOINT",
"links": null
}
],
"properties": {
"proxyWidgets": [
[
"674",
"resize_type.longer_size"
],
[
"674",
"scale_method"
],
[
"672",
"draw_body"
],
[
"672",
"draw_hands"
],
[
"672",
"draw_face"
],
[
"672",
"draw_feet"
],
[
"672",
"stick_width"
],
[
"672",
"face_point_size"
],
[
"672",
"score_threshold"
],
[
"673",
"ckpt_name"
]
],
"cnr_id": "comfy-core",
"ver": "0.15.1",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.7",
"input_ue_unconnectable": {}
}
},
"widgets_values": [],
"title": "Image to Pose Map (SDPose-OOD)"
}
],
"links": [],
"version": 0.4,
"definitions": {
"subgraphs": [
{
"id": "01b6a731-fb78-4070-9a38-c87146da9604",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 676,
"lastLinkId": 1715,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Image to Pose Map (SDPose-OOD)",
"inputNode": {
"id": -10,
"bounding": [
-3290,
3590,
190.8984375,
288
]
},
"outputNode": {
"id": -20,
"bounding": [
-1756.2451602089645,
3366,
128,
88
]
},
"inputs": [
{
"id": "e24699c3-1356-4634-9eb4-19bb58e5c0b0",
"name": "input",
"type": "IMAGE,MASK",
"linkIds": [
1700
],
"localized_name": "input",
"pos": [
-3123.1015625,
3614
]
},
{
"id": "088eefc1-cd8a-4573-993f-9e4da008a12d",
"name": "resize_type.longer_size",
"type": "INT",
"linkIds": [
1704
],
"label": "resize_target_longer_size",
"pos": [
-3123.1015625,
3634
]
},
{
"id": "b6449bd3-73d4-41c8-b81f-cf8d33f76a2e",
"name": "scale_method",
"type": "COMBO",
"linkIds": [
1705
],
"pos": [
-3123.1015625,
3654
]
},
{
"id": "4cff52ad-ed07-4c97-8803-fcbd89554fd0",
"name": "draw_body",
"type": "BOOLEAN",
"linkIds": [
1706
],
"pos": [
-3123.1015625,
3674
]
},
{
"id": "7af63dce-f7df-4d7e-8215-d7c7f60bf81c",
"name": "draw_hands",
"type": "BOOLEAN",
"linkIds": [
1707
],
"pos": [
-3123.1015625,
3694
]
},
{
"id": "af3a9bce-61f9-4aca-b530-9f65e028b35e",
"name": "draw_face",
"type": "BOOLEAN",
"linkIds": [
1708
],
"pos": [
-3123.1015625,
3714
]
},
{
"id": "4620f6a3-2c85-4b79-ad8f-35d0326b568f",
"name": "draw_feet",
"type": "BOOLEAN",
"linkIds": [
1709
],
"pos": [
-3123.1015625,
3734
]
},
{
"id": "fee5d0c9-8d4b-4934-81d8-ba2206dc56cb",
"name": "stick_width",
"type": "INT",
"linkIds": [
1710
],
"pos": [
-3123.1015625,
3754
]
},
{
"id": "aafdd060-ba81-4324-a9cc-b656e1ebc133",
"name": "face_point_size",
"type": "INT",
"linkIds": [
1711
],
"pos": [
-3123.1015625,
3774
]
},
{
"id": "514c5503-f9e6-4d23-b1ae-1d3291acb2a3",
"name": "score_threshold",
"type": "FLOAT",
"linkIds": [
1712
],
"pos": [
-3123.1015625,
3794
]
},
{
"id": "ae46de61-2cc6-483e-8ee9-87e4144a2ffa",
"name": "ckpt_name",
"type": "COMBO",
"linkIds": [
1713
],
"pos": [
-3123.1015625,
3814
]
},
{
"id": "41bec0c6-dffa-4c78-9289-ee678715ae54",
"name": "bboxes",
"type": "BOUNDING_BOX",
"linkIds": [
1714
],
"pos": [
-3123.1015625,
3834
]
}
],
"outputs": [
{
"id": "f05ed8cc-9403-4f14-8085-4364b06f8a48",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [
1701
],
"localized_name": "IMAGE",
"pos": [
-1732.2451602089645,
3390
]
},
{
"id": "29a6584e-4685-4986-8ffd-e6d8539953fd",
"name": "keypoints",
"type": "POSE_KEYPOINT",
"linkIds": [
1715
],
"pos": [
-1732.2451602089645,
3410
]
}
],
"widgets": [],
"nodes": [
{
"id": 671,
"type": "SDPoseKeypointExtractor",
"pos": [
-2470,
3250
],
"size": [
270,
180
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 1696
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 1697
},
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": 1698
},
{
"localized_name": "bboxes",
"name": "bboxes",
"shape": 7,
"type": "BOUNDING_BOX",
"link": 1714
},
{
"localized_name": "batch_size",
"name": "batch_size",
"type": "INT",
"widget": {
"name": "batch_size"
},
"link": null
}
],
"outputs": [
{
"localized_name": "keypoints",
"name": "keypoints",
"type": "POSE_KEYPOINT",
"links": [
1699,
1715
]
}
],
"properties": {
"Node name for S&R": "SDPoseKeypointExtractor",
"cnr_id": "comfy-core",
"ver": "0.15.0",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.7",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
16
]
},
{
"id": 674,
"type": "ResizeImageMaskNode",
"pos": [
-2960,
3490
],
"size": [
270,
110
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "input",
"name": "input",
"type": "IMAGE,MASK",
"link": 1700
},
{
"localized_name": "resize_type",
"name": "resize_type",
"type": "COMFY_DYNAMICCOMBO_V3",
"widget": {
"name": "resize_type"
},
"link": null
},
{
"localized_name": "resize_type.longer_size",
"name": "resize_type.longer_size",
"type": "INT",
"widget": {
"name": "resize_type.longer_size"
},
"link": 1704
},
{
"localized_name": "scale_method",
"name": "scale_method",
"type": "COMBO",
"widget": {
"name": "scale_method"
},
"link": 1705
}
],
"outputs": [
{
"localized_name": "resized",
"name": "resized",
"type": "*",
"links": [
1698
]
}
],
"properties": {
"Node name for S&R": "ResizeImageMaskNode",
"cnr_id": "comfy-core",
"ver": "0.15.0",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.7",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"scale longer dimension",
1024,
"area"
]
},
{
"id": 672,
"type": "SDPoseDrawKeypoints",
"pos": [
-2120,
3260
],
"size": [
270,
280
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "keypoints",
"name": "keypoints",
"type": "POSE_KEYPOINT",
"link": 1699
},
{
"localized_name": "draw_body",
"name": "draw_body",
"type": "BOOLEAN",
"widget": {
"name": "draw_body"
},
"link": 1706
},
{
"localized_name": "draw_hands",
"name": "draw_hands",
"type": "BOOLEAN",
"widget": {
"name": "draw_hands"
},
"link": 1707
},
{
"localized_name": "draw_face",
"name": "draw_face",
"type": "BOOLEAN",
"widget": {
"name": "draw_face"
},
"link": 1708
},
{
"localized_name": "draw_feet",
"name": "draw_feet",
"type": "BOOLEAN",
"widget": {
"name": "draw_feet"
},
"link": 1709
},
{
"localized_name": "stick_width",
"name": "stick_width",
"type": "INT",
"widget": {
"name": "stick_width"
},
"link": 1710
},
{
"localized_name": "face_point_size",
"name": "face_point_size",
"type": "INT",
"widget": {
"name": "face_point_size"
},
"link": 1711
},
{
"localized_name": "score_threshold",
"name": "score_threshold",
"type": "FLOAT",
"widget": {
"name": "score_threshold"
},
"link": 1712
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [
1701
]
}
],
"properties": {
"Node name for S&R": "SDPoseDrawKeypoints",
"cnr_id": "comfy-core",
"ver": "0.15.0",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.7",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
true,
true,
true,
true,
4,
2,
0.5
]
},
{
"id": 673,
"type": "CheckpointLoaderSimple",
"pos": [
-2960,
3250
],
"size": [
390,
190
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "ckpt_name",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": 1713
}
],
"outputs": [
{
"localized_name": "MODEL",
"name": "MODEL",
"type": "MODEL",
"links": [
1696
]
},
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"links": []
},
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"links": [
1697
]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple",
"cnr_id": "comfy-core",
"ver": "0.15.0",
"models": [
{
"name": "sdpose_wholebody_fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/SDPose/resolve/main/checkpoints/sdpose_wholebody_fp16.safetensors",
"directory": "checkpoints"
}
],
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.7",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"sdpose_wholebody_fp16.safetensors"
]
}
],
"groups": [],
"links": [
{
"id": 1696,
"origin_id": 673,
"origin_slot": 0,
"target_id": 671,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 1697,
"origin_id": 673,
"origin_slot": 2,
"target_id": 671,
"target_slot": 1,
"type": "VAE"
},
{
"id": 1698,
"origin_id": 674,
"origin_slot": 0,
"target_id": 671,
"target_slot": 2,
"type": "IMAGE"
},
{
"id": 1699,
"origin_id": 671,
"origin_slot": 0,
"target_id": 672,
"target_slot": 0,
"type": "POSE_KEYPOINT"
},
{
"id": 1700,
"origin_id": -10,
"origin_slot": 0,
"target_id": 674,
"target_slot": 0,
"type": "IMAGE,MASK"
},
{
"id": 1701,
"origin_id": 672,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 1704,
"origin_id": -10,
"origin_slot": 1,
"target_id": 674,
"target_slot": 2,
"type": "INT"
},
{
"id": 1705,
"origin_id": -10,
"origin_slot": 2,
"target_id": 674,
"target_slot": 3,
"type": "COMBO"
},
{
"id": 1706,
"origin_id": -10,
"origin_slot": 3,
"target_id": 672,
"target_slot": 1,
"type": "BOOLEAN"
},
{
"id": 1707,
"origin_id": -10,
"origin_slot": 4,
"target_id": 672,
"target_slot": 2,
"type": "BOOLEAN"
},
{
"id": 1708,
"origin_id": -10,
"origin_slot": 5,
"target_id": 672,
"target_slot": 3,
"type": "BOOLEAN"
},
{
"id": 1709,
"origin_id": -10,
"origin_slot": 6,
"target_id": 672,
"target_slot": 4,
"type": "BOOLEAN"
},
{
"id": 1710,
"origin_id": -10,
"origin_slot": 7,
"target_id": 672,
"target_slot": 5,
"type": "INT"
},
{
"id": 1711,
"origin_id": -10,
"origin_slot": 8,
"target_id": 672,
"target_slot": 6,
"type": "INT"
},
{
"id": 1712,
"origin_id": -10,
"origin_slot": 9,
"target_id": 672,
"target_slot": 7,
"type": "FLOAT"
},
{
"id": 1713,
"origin_id": -10,
"origin_slot": 10,
"target_id": 673,
"target_slot": 0,
"type": "COMBO"
},
{
"id": 1714,
"origin_id": -10,
"origin_slot": 11,
"target_id": 671,
"target_slot": 3,
"type": "BOUNDING_BOX"
},
{
"id": 1715,
"origin_id": 671,
"origin_slot": 0,
"target_id": -20,
"target_slot": 1,
"type": "POSE_KEYPOINT"
}
],
"extra": {
"workflowRendererVersion": "LG"
},
"category": "Conditioning & Preprocessors/Pose",
"description": "Extracts human pose keypoints and stick-figure visuals from an image using SDPose-OOD, with optional bounding-box input per subject."
}
]
},
"extra": {
"ue_links": []
}
}

1219
blueprints/Merge Videos.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1298,7 +1298,7 @@
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"category": "Image generation and editing/Pose to image",
"category": "Image generation and editing/Conditioned",
"description": "Generates an image from pose keypoints using Z-Image-Turbo with text conditioning."
}
]

View File

@ -3870,7 +3870,7 @@
"extra": {
"workflowRendererVersion": "LG"
},
"category": "Video generation and editing/Pose to video",
"category": "Video generation and editing/Conditioned",
"description": "Generates video from pose reference frames using LTX-2, with optional synchronized audio."
}
]

View File

@ -270,7 +270,7 @@
"extra": {
"workflowRendererVersion": "LG"
},
"category": "Text generation/Prompt enhance",
"category": "Text Tools",
"description": "Expands short text prompts into detailed descriptions using a text generation model for better generation quality."
}
]

View File

@ -389,7 +389,7 @@
}
],
"extra": {},
"category": "Image generation and editing/Background Removal"
"category": "Image Tools/Background Removal"
}
]
},

View File

@ -0,0 +1,485 @@
{
"revision": 0,
"last_node_id": 10,
"last_link_id": 0,
"nodes": [
{
"id": 10,
"type": "3fb7557a-470d-4983-9d8c-6d5caa9788f0",
"pos": [
-250,
8590
],
"size": [
280,
360
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "text_per_line",
"name": "text_per_line",
"type": "STRING",
"widget": {
"name": "text_per_line"
},
"link": null
},
{
"localized_name": "index",
"name": "index",
"type": "INT",
"widget": {
"name": "index"
},
"link": null
}
],
"outputs": [
{
"localized_name": "selected_line",
"name": "selected_line",
"type": "STRING",
"links": []
}
],
"properties": {
"proxyWidgets": [
[
"2",
"string"
],
[
"3",
"value"
]
],
"cnr_id": "comfy-core",
"ver": "0.19.0",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
}
},
"widgets_values": [],
"title": "Select Per-Line Text by Index"
}
],
"links": [],
"version": 0.4,
"definitions": {
"subgraphs": [
{
"id": "3fb7557a-470d-4983-9d8c-6d5caa9788f0",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 10,
"lastLinkId": 14,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Select Per-Line Text by Index",
"inputNode": {
"id": -10,
"bounding": [
-990,
8595,
128,
88
]
},
"outputNode": {
"id": -20,
"bounding": [
710,
8585,
128,
68
]
},
"inputs": [
{
"id": "75417d82-a934-4ac9-b667-d8dcd5a3bfb3",
"name": "text_per_line",
"type": "STRING",
"linkIds": [
13
],
"localized_name": "text_per_line",
"pos": [
-886,
8619
]
},
{
"id": "46e69a73-1804-4ca6-9175-31445bf0be96",
"name": "index",
"type": "INT",
"linkIds": [
14
],
"localized_name": "index",
"pos": [
-886,
8639
]
}
],
"outputs": [
{
"id": "e34e8ad1-84d2-4bd2-a460-eb7de6067c10",
"name": "selected_line",
"type": "STRING",
"linkIds": [
10
],
"localized_name": "selected_line",
"pos": [
734,
8609
]
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "PreviewAny",
"pos": [
-500,
8400
],
"size": [
230,
180
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "source",
"name": "source",
"type": "*",
"link": 1
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [
6
]
}
],
"properties": {
"Node name for S&R": "PreviewAny",
"cnr_id": "comfy-core",
"ver": "0.19.0",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
}
},
"widgets_values": [
null,
null,
null
]
},
{
"id": 2,
"type": "RegexExtract",
"pos": [
-240,
8740
],
"size": [
470,
460
],
"flags": {},
"order": 1,
"mode": 0,
"showAdvanced": false,
"inputs": [
{
"localized_name": "string",
"name": "string",
"type": "STRING",
"widget": {
"name": "string"
},
"link": 13
},
{
"localized_name": "regex_pattern",
"name": "regex_pattern",
"type": "STRING",
"widget": {
"name": "regex_pattern"
},
"link": 9
},
{
"localized_name": "mode",
"name": "mode",
"type": "COMBO",
"widget": {
"name": "mode"
},
"link": null
},
{
"localized_name": "case_insensitive",
"name": "case_insensitive",
"type": "BOOLEAN",
"widget": {
"name": "case_insensitive"
},
"link": null
},
{
"localized_name": "multiline",
"name": "multiline",
"type": "BOOLEAN",
"widget": {
"name": "multiline"
},
"link": null
},
{
"localized_name": "dotall",
"name": "dotall",
"type": "BOOLEAN",
"widget": {
"name": "dotall"
},
"link": null
},
{
"localized_name": "group_index",
"name": "group_index",
"type": "INT",
"widget": {
"name": "group_index"
},
"link": null
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [
10
]
}
],
"properties": {
"Node name for S&R": "RegexExtract",
"cnr_id": "comfy-core",
"ver": "0.19.0",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"",
"",
"First Group",
false,
false,
false,
1
]
},
{
"id": 3,
"type": "PrimitiveInt",
"pos": [
-810,
8400
],
"size": [
270,
110
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "INT",
"widget": {
"name": "value"
},
"link": 14
}
],
"outputs": [
{
"localized_name": "INT",
"name": "INT",
"type": "INT",
"links": [
1
]
}
],
"title": "Int (line index)",
"properties": {
"Node name for S&R": "Int (line index)",
"cnr_id": "comfy-core",
"ver": "0.19.0",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
}
},
"widgets_values": [
0,
"fixed"
]
},
{
"id": 8,
"type": "StringReplace",
"pos": [
-240,
8400
],
"size": [
400,
280
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "string",
"name": "string",
"type": "STRING",
"widget": {
"name": "string"
},
"link": null
},
{
"localized_name": "find",
"name": "find",
"type": "STRING",
"widget": {
"name": "find"
},
"link": null
},
{
"localized_name": "replace",
"name": "replace",
"type": "STRING",
"widget": {
"name": "replace"
},
"link": 6
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [
9
]
}
],
"properties": {
"Node name for S&R": "StringReplace",
"cnr_id": "comfy-core",
"ver": "0.19.0",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"^(?:[^\\n]*\\n){index}([^\\n]*)(?:\\n|$)",
"index",
""
]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": 3,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "INT"
},
{
"id": 9,
"origin_id": 8,
"origin_slot": 0,
"target_id": 2,
"target_slot": 1,
"type": "STRING"
},
{
"id": 6,
"origin_id": 1,
"origin_slot": 0,
"target_id": 8,
"target_slot": 2,
"type": "STRING"
},
{
"id": 10,
"origin_id": 2,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 13,
"origin_id": -10,
"origin_slot": 0,
"target_id": 2,
"target_slot": 0,
"type": "STRING"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 1,
"target_id": 3,
"target_slot": 0,
"type": "INT"
}
],
"extra": {},
"category": "Text Tools",
"description": "Selects one line from multiline text by zero-based index for batch or list-driven prompt workflows."
}
]
},
"extra": {
"ue_links": [],
"links_added_by_ue": []
}
}

View File

@ -0,0 +1,714 @@
{
"revision": 0,
"last_node_id": 251,
"last_link_id": 0,
"nodes": [
{
"id": 251,
"type": "609e1fd1-b731-4b78-89ac-d19b1156b025",
"pos": [
-1490,
130
],
"size": [
230,
164
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "source_image",
"name": "source_image",
"type": "IMAGE",
"link": null
},
{
"localized_name": "columns",
"name": "columns",
"type": "INT",
"widget": {
"name": "columns"
},
"link": null
},
{
"localized_name": "rows",
"name": "rows",
"type": "INT",
"widget": {
"name": "rows"
},
"link": null
}
],
"outputs": [
{
"localized_name": "tiles",
"name": "tiles",
"type": "IMAGE",
"links": []
}
],
"properties": {
"proxyWidgets": [
[
"228",
"value"
],
[
"252",
"value"
]
],
"cnr_id": "comfy-core",
"ver": "0.20.1",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [],
"title": "Split Image Grid to Tiles"
}
],
"links": [],
"version": 0.4,
"definitions": {
"subgraphs": [
{
"id": "609e1fd1-b731-4b78-89ac-d19b1156b025",
"version": 1,
"state": {
"lastGroupId": 9,
"lastNodeId": 252,
"lastLinkId": 429,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Split Image Grid to Tiles",
"inputNode": {
"id": -10,
"bounding": [
-1690,
260,
128,
108
]
},
"outputNode": {
"id": -20,
"bounding": [
-510,
590,
128,
68
]
},
"inputs": [
{
"id": "866ac798-cfbc-450a-b755-e704f86404d9",
"name": "source_image",
"type": "IMAGE",
"linkIds": [
386,
389
],
"localized_name": "source_image",
"pos": [
-1586,
284
]
},
{
"id": "bc37b1f8-8ab2-4f19-bd00-75d4fbc4feb3",
"name": "columns",
"type": "INT",
"linkIds": [
427
],
"localized_name": "columns",
"pos": [
-1586,
304
]
},
{
"id": "d45915da-e848-43dd-9ccc-e3161e9c99d9",
"name": "rows",
"type": "INT",
"linkIds": [
428
],
"localized_name": "rows",
"pos": [
-1586,
324
]
}
],
"outputs": [
{
"id": "18bc780f-064b-4038-87c6-67dba71deb08",
"name": "tiles",
"type": "IMAGE",
"linkIds": [
394
],
"localized_name": "tiles",
"shape": 6,
"pos": [
-486,
614
]
}
],
"widgets": [],
"nodes": [
{
"id": 225,
"type": "SplitImageToTileList",
"pos": [
-1010,
620
],
"size": [
290,
170
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": 386
},
{
"localized_name": "tile_width",
"name": "tile_width",
"type": "INT",
"widget": {
"name": "tile_width"
},
"link": 403
},
{
"localized_name": "tile_height",
"name": "tile_height",
"type": "INT",
"widget": {
"name": "tile_height"
},
"link": 404
},
{
"localized_name": "overlap",
"name": "overlap",
"type": "INT",
"widget": {
"name": "overlap"
},
"link": null
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"shape": 6,
"type": "IMAGE",
"links": [
394
]
}
],
"properties": {
"Node name for S&R": "SplitImageToTileList",
"cnr_id": "comfy-core",
"ver": "0.20.1",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
1024,
1024,
0
]
},
{
"id": 231,
"type": "ComfyMathExpression",
"pos": [
-1080,
330
],
"size": [
370,
190
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"label": "a",
"localized_name": "values.a",
"name": "values.a",
"type": "FLOAT,INT,BOOLEAN",
"link": 390
},
{
"label": "b",
"localized_name": "values.b",
"name": "values.b",
"shape": 7,
"type": "FLOAT,INT,BOOLEAN",
"link": 429
},
{
"label": "c",
"localized_name": "values.c",
"name": "values.c",
"shape": 7,
"type": "FLOAT,INT,BOOLEAN",
"link": null
},
{
"localized_name": "expression",
"name": "expression",
"type": "STRING",
"widget": {
"name": "expression"
},
"link": null
}
],
"outputs": [
{
"localized_name": "FLOAT",
"name": "FLOAT",
"type": "FLOAT",
"links": null
},
{
"localized_name": "INT",
"name": "INT",
"type": "INT",
"links": [
404
]
},
{
"localized_name": "BOOL",
"name": "BOOL",
"type": "BOOLEAN",
"links": null
}
],
"title": "Math Expression Height",
"properties": {
"Node name for S&R": "ComfyMathExpression",
"cnr_id": "comfy-core",
"ver": "0.18.1",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65,
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.7",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"max(1, (int(a) + int(b) - 1) // int(b))"
]
},
{
"id": 229,
"type": "ComfyMathExpression",
"pos": [
-1090,
-30
],
"size": [
370,
190
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"label": "a",
"localized_name": "values.a",
"name": "values.a",
"type": "FLOAT,INT,BOOLEAN",
"link": 387
},
{
"label": "b",
"localized_name": "values.b",
"name": "values.b",
"shape": 7,
"type": "FLOAT,INT,BOOLEAN",
"link": 388
},
{
"label": "c",
"localized_name": "values.c",
"name": "values.c",
"shape": 7,
"type": "FLOAT,INT,BOOLEAN",
"link": null
},
{
"localized_name": "expression",
"name": "expression",
"type": "STRING",
"widget": {
"name": "expression"
},
"link": null
}
],
"outputs": [
{
"localized_name": "FLOAT",
"name": "FLOAT",
"type": "FLOAT",
"links": null
},
{
"localized_name": "INT",
"name": "INT",
"type": "INT",
"links": [
403
]
},
{
"localized_name": "BOOL",
"name": "BOOL",
"type": "BOOLEAN",
"links": null
}
],
"title": "Math Expression Width",
"properties": {
"Node name for S&R": "ComfyMathExpression",
"cnr_id": "comfy-core",
"ver": "0.18.1",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65,
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.7",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"max(1, (int(a) + int(b) - 1) // int(b))"
]
},
{
"id": 228,
"type": "PrimitiveInt",
"pos": [
-1380,
90
],
"size": [
230,
110
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "INT",
"widget": {
"name": "value"
},
"link": 427
}
],
"outputs": [
{
"localized_name": "INT",
"name": "INT",
"type": "INT",
"links": [
388
]
}
],
"title": "Int (grid columns)",
"properties": {
"Node name for S&R": "Int (grid columns)",
"cnr_id": "comfy-core",
"ver": "0.18.1",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65,
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.7",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
2,
"fixed"
]
},
{
"id": 230,
"type": "GetImageSize",
"pos": [
-1380,
290
],
"size": [
230,
100
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": 389
}
],
"outputs": [
{
"localized_name": "width",
"name": "width",
"type": "INT",
"links": [
387
]
},
{
"localized_name": "height",
"name": "height",
"type": "INT",
"links": [
390
]
},
{
"localized_name": "batch_size",
"name": "batch_size",
"type": "INT",
"links": null
}
],
"properties": {
"Node name for S&R": "GetImageSize",
"cnr_id": "comfy-core",
"ver": "0.18.1",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65,
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.7",
"input_ue_unconnectable": {}
}
}
},
{
"id": 252,
"type": "PrimitiveInt",
"pos": [
-1380,
470
],
"size": [
230,
110
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "INT",
"widget": {
"name": "value"
},
"link": 428
}
],
"outputs": [
{
"localized_name": "INT",
"name": "INT",
"type": "INT",
"links": [
429
]
}
],
"title": "Int (grid rows)",
"properties": {
"Node name for S&R": "Int (grid rows)",
"cnr_id": "comfy-core",
"ver": "0.18.1",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65,
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.7",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
3,
"fixed"
]
}
],
"groups": [],
"links": [
{
"id": 403,
"origin_id": 229,
"origin_slot": 1,
"target_id": 225,
"target_slot": 1,
"type": "INT"
},
{
"id": 404,
"origin_id": 231,
"origin_slot": 1,
"target_id": 225,
"target_slot": 2,
"type": "INT"
},
{
"id": 390,
"origin_id": 230,
"origin_slot": 1,
"target_id": 231,
"target_slot": 0,
"type": "INT"
},
{
"id": 387,
"origin_id": 230,
"origin_slot": 0,
"target_id": 229,
"target_slot": 0,
"type": "INT"
},
{
"id": 388,
"origin_id": 228,
"origin_slot": 0,
"target_id": 229,
"target_slot": 1,
"type": "INT"
},
{
"id": 386,
"origin_id": -10,
"origin_slot": 0,
"target_id": 225,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 389,
"origin_id": -10,
"origin_slot": 0,
"target_id": 230,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 394,
"origin_id": 225,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 427,
"origin_id": -10,
"origin_slot": 1,
"target_id": 228,
"target_slot": 0,
"type": "INT"
},
{
"id": 428,
"origin_id": -10,
"origin_slot": 2,
"target_id": 252,
"target_slot": 0,
"type": "INT"
},
{
"id": 429,
"origin_id": 252,
"origin_slot": 0,
"target_id": 231,
"target_slot": 1,
"type": "INT"
}
],
"extra": {},
"category": "Image Tools/Crop",
"description": "Splits an image into a configurable columns×rows grid of equal tiles for tiled generation or processing."
}
]
},
"extra": {}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -307,9 +307,9 @@
"extra": {
"workflowRendererVersion": "LG"
},
"category": "Text generation/Video Captioning",
"category": "Video Tools",
"description": "Generates descriptive captions for video input using Google's Gemini multimodal LLM."
}
]
}
}
}

View File

@ -0,0 +1,825 @@
{
"revision": 0,
"last_node_id": 97,
"last_link_id": 0,
"nodes": [
{
"id": 97,
"type": "253ec5ca-8333-4ddf-a036-9fc0923651b9",
"pos": [
410,
500
],
"size": [
400,
400
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "video",
"type": "VIDEO",
"link": null
},
{
"name": "start_time",
"type": "FLOAT",
"widget": {
"name": "start_time"
},
"link": null
},
{
"name": "duration",
"type": "FLOAT",
"widget": {
"name": "duration"
},
"link": null
},
{
"name": "resolution",
"type": "INT",
"widget": {
"name": "resolution"
},
"link": null
},
{
"name": "resize_method",
"type": "COMBO",
"widget": {
"name": "resize_method"
},
"link": null
},
{
"label": "output_type",
"name": "output",
"type": "COMFY_DYNAMICCOMBO_V3",
"widget": {
"name": "output"
},
"link": null
},
{
"label": "normalization",
"name": "output.normalization",
"type": "COMBO",
"widget": {
"name": "output.normalization"
},
"link": null
},
{
"name": "output.apply_sky_clip",
"type": "BOOLEAN",
"widget": {
"name": "output.apply_sky_clip"
},
"link": null
},
{
"name": "model_name",
"type": "COMBO",
"widget": {
"name": "model_name"
},
"link": null
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": []
},
{
"name": "audio",
"type": "AUDIO",
"links": []
},
{
"name": "fps",
"type": "FLOAT",
"links": []
}
],
"properties": {
"proxyWidgets": [
[
"96",
"start_time"
],
[
"96",
"duration"
],
[
"93",
"resolution"
],
[
"93",
"resize_method"
],
[
"92",
"output"
],
[
"92",
"output.normalization"
],
[
"92",
"output.apply_sky_clip"
],
[
"94",
"model_name"
]
],
"cnr_id": "comfy-core",
"ver": "0.24.0"
},
"widgets_values": [],
"title": "Video Depth Estimation (Depth Anything 3)"
}
],
"links": [],
"version": 0.4,
"definitions": {
"subgraphs": [
{
"id": "253ec5ca-8333-4ddf-a036-9fc0923651b9",
"version": 1,
"state": {
"lastGroupId": 4,
"lastNodeId": 97,
"lastLinkId": 129,
"lastRerouteId": 0
},
"revision": 2,
"config": {},
"name": "Video Depth Estimation (Depth Anything 3)",
"inputNode": {
"id": -10,
"bounding": [
-230,
130,
167.912109375,
228
]
},
"outputNode": {
"id": -20,
"bounding": [
1520,
140,
128,
108
]
},
"inputs": [
{
"id": "698c28c6-cf92-4039-8b39-f3062868ea7c",
"name": "video",
"type": "VIDEO",
"linkIds": [
119
],
"pos": [
-86.087890625,
154
]
},
{
"id": "97a1f63e-1585-4a40-9dec-e2700120d84a",
"name": "start_time",
"type": "FLOAT",
"linkIds": [
121
],
"pos": [
-86.087890625,
174
]
},
{
"id": "4dbbd3b3-c5ee-4a56-a0d3-3268d3b2fd64",
"name": "duration",
"type": "FLOAT",
"linkIds": [
122
],
"pos": [
-86.087890625,
194
]
},
{
"id": "16f55101-f99d-4c0c-bebf-c3b31c54f13e",
"name": "resolution",
"type": "INT",
"linkIds": [
124
],
"pos": [
-86.087890625,
214
]
},
{
"id": "d9cd7693-4bb3-4ed7-9a75-276b997abcd9",
"name": "resize_method",
"type": "COMBO",
"linkIds": [
125
],
"pos": [
-86.087890625,
234
]
},
{
"id": "a6e90532-323b-462e-ba9c-1672384d5b31",
"name": "output",
"type": "COMFY_DYNAMICCOMBO_V3",
"linkIds": [
126
],
"label": "output_type",
"pos": [
-86.087890625,
254
]
},
{
"id": "69e6aeef-437d-4fde-b2fc-d5ab9369238d",
"name": "output.normalization",
"type": "COMBO",
"linkIds": [
127
],
"label": "normalization",
"pos": [
-86.087890625,
274
]
},
{
"id": "73206f72-f89a-4698-885e-5d9277df2998",
"name": "output.apply_sky_clip",
"type": "BOOLEAN",
"linkIds": [
128
],
"pos": [
-86.087890625,
294
]
},
{
"id": "dddbc7fc-9431-448a-9ed3-9aa62404288b",
"name": "model_name",
"type": "COMBO",
"linkIds": [
129
],
"pos": [
-86.087890625,
314
]
}
],
"outputs": [
{
"id": "478ab537-63bc-4d74-a9f0-c975f550880f",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [
7
],
"localized_name": "IMAGE",
"pos": [
1544,
164
]
},
{
"id": "cdaf037e-79bc-4a94-b06c-0fd32e76f615",
"name": "audio",
"type": "AUDIO",
"linkIds": [
112
],
"pos": [
1544,
184
]
},
{
"id": "4c0e5484-d193-49c7-b107-92619628880a",
"name": "fps",
"type": "FLOAT",
"linkIds": [
113
],
"pos": [
1544,
204
]
}
],
"widgets": [],
"nodes": [
{
"id": 92,
"type": "DA3Render",
"pos": [
740,
230
],
"size": [
380,
130
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "da3_geometry",
"name": "da3_geometry",
"type": "DA3_GEOMETRY",
"link": 12
},
{
"localized_name": "output",
"name": "output",
"type": "COMFY_DYNAMICCOMBO_V3",
"widget": {
"name": "output"
},
"link": 126
},
{
"localized_name": "output.normalization",
"name": "output.normalization",
"type": "COMBO",
"widget": {
"name": "output.normalization"
},
"link": 127
},
{
"localized_name": "output.apply_sky_clip",
"name": "output.apply_sky_clip",
"type": "BOOLEAN",
"widget": {
"name": "output.apply_sky_clip"
},
"link": 128
},
{
"name": "geometry",
"type": "DA3_GEOMETRY",
"link": null
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
7
]
}
],
"properties": {
"Node name for S&R": "DA3Render",
"cnr_id": "comfy-core",
"ver": "0.19.0"
},
"widgets_values": [
"depth",
"v2_style",
false
]
},
{
"id": 93,
"type": "DA3Inference",
"pos": [
740,
-30
],
"size": [
390,
130
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "da3_model",
"name": "da3_model",
"type": "DA3_MODEL",
"link": 107
},
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": 111
},
{
"localized_name": "resolution",
"name": "resolution",
"type": "INT",
"widget": {
"name": "resolution"
},
"link": 124
},
{
"localized_name": "resize_method",
"name": "resize_method",
"type": "COMBO",
"widget": {
"name": "resize_method"
},
"link": 125
},
{
"localized_name": "mode",
"name": "mode",
"type": "COMFY_DYNAMICCOMBO_V3",
"widget": {
"name": "mode"
},
"link": null
}
],
"outputs": [
{
"localized_name": "da3_geometry",
"name": "da3_geometry",
"type": "DA3_GEOMETRY",
"slot_index": 0,
"links": [
12
]
}
],
"properties": {
"Node name for S&R": "DA3Inference",
"cnr_id": "comfy-core",
"ver": "0.19.0"
},
"widgets_values": [
504,
"lower_bound_resize",
"mono"
]
},
{
"id": 94,
"type": "LoadDA3Model",
"pos": [
50,
410
],
"size": [
400,
140
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "model_name",
"name": "model_name",
"type": "COMBO",
"widget": {
"name": "model_name"
},
"link": 129
},
{
"localized_name": "weight_dtype",
"name": "weight_dtype",
"type": "COMBO",
"widget": {
"name": "weight_dtype"
},
"link": null
}
],
"outputs": [
{
"localized_name": "DA3_MODEL",
"name": "DA3_MODEL",
"type": "DA3_MODEL",
"links": [
107
]
}
],
"properties": {
"Node name for S&R": "LoadDA3Model",
"cnr_id": "comfy-core",
"ver": "0.24.0",
"models": [
{
"name": "depth_anything_3_mono_large.safetensors",
"url": "https://huggingface.co/Comfy-Org/Depth-Anything-3/resolve/main/geometry_estimation/depth_anything_3_mono_large.safetensors",
"directory": "geometry_estimation"
}
]
},
"widgets_values": [
"depth_anything_3_mono_large.safetensors",
"default"
]
},
{
"id": 95,
"type": "GetVideoComponents",
"pos": [
70,
-140
],
"size": [
260,
120
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "video",
"name": "video",
"type": "VIDEO",
"link": 120
}
],
"outputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"links": [
111
]
},
{
"localized_name": "audio",
"name": "audio",
"type": "AUDIO",
"links": [
112
]
},
{
"localized_name": "fps",
"name": "fps",
"type": "FLOAT",
"links": [
113
]
},
{
"localized_name": "bit_depth",
"name": "bit_depth",
"type": "INT",
"links": null
}
],
"properties": {
"Node name for S&R": "GetVideoComponents",
"cnr_id": "comfy-core",
"ver": "0.24.0"
}
},
{
"id": 96,
"type": "Video Slice",
"pos": [
70,
-360
],
"size": [
270,
170
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "video",
"name": "video",
"type": "VIDEO",
"link": 119
},
{
"localized_name": "start_time",
"name": "start_time",
"type": "FLOAT",
"widget": {
"name": "start_time"
},
"link": 121
},
{
"localized_name": "duration",
"name": "duration",
"type": "FLOAT",
"widget": {
"name": "duration"
},
"link": 122
},
{
"localized_name": "strict_duration",
"name": "strict_duration",
"type": "BOOLEAN",
"widget": {
"name": "strict_duration"
},
"link": null
}
],
"outputs": [
{
"localized_name": "VIDEO",
"name": "VIDEO",
"type": "VIDEO",
"links": [
120
]
}
],
"properties": {
"Node name for S&R": "Video Slice",
"cnr_id": "comfy-core",
"ver": "0.24.0"
},
"widgets_values": [
0,
5,
false
]
}
],
"groups": [],
"links": [
{
"id": 12,
"origin_id": 93,
"origin_slot": 0,
"target_id": 92,
"target_slot": 0,
"type": "DA3_GEOMETRY"
},
{
"id": 7,
"origin_id": 92,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 107,
"origin_id": 94,
"origin_slot": 0,
"target_id": 93,
"target_slot": 0,
"type": "DA3_MODEL"
},
{
"id": 111,
"origin_id": 95,
"origin_slot": 0,
"target_id": 93,
"target_slot": 1,
"type": "IMAGE"
},
{
"id": 112,
"origin_id": 95,
"origin_slot": 1,
"target_id": -20,
"target_slot": 1,
"type": "AUDIO"
},
{
"id": 113,
"origin_id": 95,
"origin_slot": 2,
"target_id": -20,
"target_slot": 2,
"type": "FLOAT"
},
{
"id": 119,
"origin_id": -10,
"origin_slot": 0,
"target_id": 96,
"target_slot": 0,
"type": "VIDEO"
},
{
"id": 120,
"origin_id": 96,
"origin_slot": 0,
"target_id": 95,
"target_slot": 0,
"type": "VIDEO"
},
{
"id": 121,
"origin_id": -10,
"origin_slot": 1,
"target_id": 96,
"target_slot": 1,
"type": "FLOAT"
},
{
"id": 122,
"origin_id": -10,
"origin_slot": 2,
"target_id": 96,
"target_slot": 2,
"type": "FLOAT"
},
{
"id": 124,
"origin_id": -10,
"origin_slot": 3,
"target_id": 93,
"target_slot": 2,
"type": "INT"
},
{
"id": 125,
"origin_id": -10,
"origin_slot": 4,
"target_id": 93,
"target_slot": 3,
"type": "COMBO"
},
{
"id": 126,
"origin_id": -10,
"origin_slot": 5,
"target_id": 92,
"target_slot": 1,
"type": "COMFY_DYNAMICCOMBO_V3"
},
{
"id": 127,
"origin_id": -10,
"origin_slot": 6,
"target_id": 92,
"target_slot": 2,
"type": "COMBO"
},
{
"id": 128,
"origin_id": -10,
"origin_slot": 7,
"target_id": 92,
"target_slot": 3,
"type": "BOOLEAN"
},
{
"id": 129,
"origin_id": -10,
"origin_slot": 8,
"target_id": 94,
"target_slot": 0,
"type": "COMBO"
}
],
"extra": {},
"category": "Conditioning & Preprocessors/Depth",
"description": "This subgraph processes a video input through Depth Anything 3 to produce temporally consistent depth maps for each frame, outputting a depth video. It is ideal for video content requiring spatial geometry estimation, such as 3D reconstruction, SLAM, or novel view synthesis from moving cameras. The model uses a plain transformer backbone trained with a depth-ray representation, supporting any number of views without requiring known camera poses."
}
]
},
"extra": {
"BlueprintDescription": "This subgraph processes a video input through Depth Anything 3 to produce temporally consistent depth maps for each frame, outputting a depth video. It is ideal for video content requiring spatial geometry estimation, such as 3D reconstruction, SLAM, or novel view synthesis from moving cameras. The model uses a plain transformer backbone trained with a depth-ray representation, supporting any number of views without requiring known camera poses."
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -818,7 +818,7 @@
}
],
"extra": {},
"category": "Video Tools",
"category": "Conditioning & Preprocessors/Segmentation & Mask",
"description": "Segments video into temporally consistent masks using Meta SAM3 from text or interactive prompts."
}
]

View File

@ -412,7 +412,7 @@
"extra": {
"workflowRendererVersion": "LG"
},
"category": "Video generation and editing/Enhance video",
"category": "Video generation and editing/Upscale",
"description": "Upscales video to 4× resolution using a GAN-based upscaling model."
}
]

File diff suppressed because it is too large Load Diff

View File

@ -105,7 +105,7 @@ class WindowAttention(nn.Module):
relative_position_bias = self.relative_position_bias_table[self.relative_position_index.long().view(-1)].view(
self.window_size[0] * self.window_size[1], self.window_size[0] * self.window_size[1], -1) # Wh*Ww,Wh*Ww,nH
relative_position_bias = relative_position_bias.permute(2, 0, 1).contiguous() # nH, Wh*Ww, Wh*Ww
relative_position_bias = comfy.ops.cast_to_input(relative_position_bias.permute(2, 0, 1).contiguous(), attn) # nH, Wh*Ww, Wh*Ww
attn = attn + relative_position_bias.unsqueeze(0)
if mask is not None:

View File

@ -55,12 +55,7 @@ class BackgroundRemovalModel():
out = torch.nn.functional.interpolate(out, size=(H, W), mode="bicubic", antialias=False)
mask = out.sigmoid().to(device=comfy.model_management.intermediate_device(), dtype=comfy.model_management.intermediate_dtype())
if mask.ndim == 3:
mask = mask.unsqueeze(0)
if mask.shape[1] != 1:
mask = mask.movedim(-1, 1)
return mask
return mask.squeeze(1) # (B, 1, H, W) -> (B, H, W)
def load_background_removal_model(sd):

View File

@ -33,6 +33,28 @@ class EnumAction(argparse.Action):
setattr(namespace, self.dest, value)
def _positive_int(value: str) -> int:
"""argparse type that rejects zero and negative integers."""
try:
ivalue = int(value)
except ValueError:
raise argparse.ArgumentTypeError(f"{value!r} is not an integer")
if ivalue <= 0:
raise argparse.ArgumentTypeError(f"{value!r} must be a positive integer (> 0)")
return ivalue
def _non_negative_int(value: str) -> int:
"""argparse type that rejects negatives but allows zero (a disable sentinel)."""
try:
ivalue = int(value)
except ValueError:
raise argparse.ArgumentTypeError(f"{value!r} is not an integer")
if ivalue < 0:
raise argparse.ArgumentTypeError(f"{value!r} must be a non-negative integer (>= 0)")
return ivalue
parser = argparse.ArgumentParser()
parser.add_argument("--listen", type=str, default="127.0.0.1", metavar="IP", nargs="?", const="0.0.0.0,::", help="Specify the IP address to listen on (default: 127.0.0.1). You can give a list of ip addresses by separating them with a comma like: 127.2.2.2,127.3.3.3 If --listen is provided without an argument, it defaults to 0.0.0.0,:: (listens on all ipv4 and ipv6)")
@ -49,7 +71,7 @@ parser.add_argument("--temp-directory", type=str, default=None, help="Set the Co
parser.add_argument("--input-directory", type=str, default=None, help="Set the ComfyUI input directory. Overrides --base-directory.")
parser.add_argument("--auto-launch", action="store_true", help="Automatically launch ComfyUI in the default browser.")
parser.add_argument("--disable-auto-launch", action="store_true", help="Disable auto launching the browser.")
parser.add_argument("--cuda-device", type=int, default=None, metavar="DEVICE_ID", help="Set the id of the cuda device this instance will use. All other devices will not be visible.")
parser.add_argument("--cuda-device", type=str, default=None, metavar="DEVICE_ID", help="Set the ids of cuda devices this instance will use, as a comma-separated list (e.g. '0' or '0,1'). All other devices will not be visible.")
parser.add_argument("--default-device", type=int, default=None, metavar="DEFAULT_DEVICE_ID", help="Set the id of the default device, all other devices will stay visible.")
cm_group = parser.add_mutually_exclusive_group()
cm_group.add_argument("--cuda-malloc", action="store_true", help="Enable cudaMallocAsync (enabled by default for torch 2.0 and up).")
@ -111,10 +133,11 @@ parser.add_argument("--preview-method", type=LatentPreviewMethod, default=Latent
parser.add_argument("--preview-size", type=int, default=512, help="Sets the maximum preview size for sampler nodes.")
cache_group = parser.add_mutually_exclusive_group()
cache_group.add_argument("--cache-ram", nargs='*', type=float, default=[], metavar="GB", help="Use RAM pressure caching with the specified headroom thresholds. This is the default caching mode. The first value sets the active-cache threshold; the optional second value sets the inactive-cache/pin threshold. Defaults when no values are provided: active 25%% of system RAM (min 4GB, max 32GB), inactive 75%% of system RAM (min 12GB, max 96GB).")
cache_group.add_argument("--cache-ram", nargs='*', type=float, default=[], metavar="GB", help="Use RAM pressure caching with the specified headroom thresholds. This is the default caching mode. The first value sets the active-cache threshold; the optional second value sets the inactive-cache/pin threshold. Defaults when no values are provided: active 10%% of system RAM (min 2GB, max 10GB), inactive 100%% of system RAM (max 96GB).")
cache_group.add_argument("--cache-classic", action="store_true", help="Use the old style (aggressive) caching.")
cache_group.add_argument("--cache-lru", type=int, default=0, help="Use LRU caching with a maximum of N node results cached. May use more RAM/VRAM.")
cache_group.add_argument("--cache-none", action="store_true", help="Reduced RAM/VRAM usage at the expense of executing every node for each run.")
cache_group.add_argument("--high-ram", action="store_true", help="Can improve performance slightly on high RAM or on systems where pagefile use is preferred over model loading.")
attn_group = parser.add_mutually_exclusive_group()
attn_group.add_argument("--use-split-cross-attention", action="store_true", help="Use the split cross attention optimization. Ignored when xformers is used.")
@ -133,7 +156,7 @@ upcast.add_argument("--dont-upcast-attention", action="store_true", help="Disabl
parser.add_argument("--enable-manager", action="store_true", help="Enable the ComfyUI-Manager feature.")
manager_group = parser.add_mutually_exclusive_group()
manager_group.add_argument("--disable-manager-ui", action="store_true", help="Disables only the ComfyUI-Manager UI and endpoints. Scheduled installations and similar background tasks will still operate.")
manager_group.add_argument("--enable-manager-legacy-ui", action="store_true", help="Enables the legacy UI of ComfyUI-Manager")
manager_group.add_argument("--enable-manager-legacy-ui", action="store_true", help="Enables the legacy UI of ComfyUI-Manager. Implies --enable-manager.")
vram_group = parser.add_mutually_exclusive_group()
@ -144,11 +167,13 @@ vram_group.add_argument("--novram", action="store_true", help="When lowvram isn'
vram_group.add_argument("--cpu", action="store_true", help="To use the CPU for everything (slow).")
parser.add_argument("--reserve-vram", type=float, default=None, help="Set the amount of vram in GB you want to reserve for use by your OS/other software. By default some amount is reserved depending on your OS.")
parser.add_argument("--vram-headroom", type=float, default=0, help="Set the amount of vram in GB for DynamicVRAM to maintain as extra headroom above default. ComfyUI will try and keep this much VRAM completely free and unused, even counting VRAM from other apps.")
parser.add_argument("--async-offload", nargs='?', const=2, type=int, default=None, metavar="NUM_STREAMS", help="Use async weight offloading. An optional argument controls the amount of offload streams. Default is 2. Enabled by default on Nvidia.")
parser.add_argument("--disable-async-offload", action="store_true", help="Disable async weight offloading.")
parser.add_argument("--disable-dynamic-vram", action="store_true", help="Disable dynamic VRAM and use estimate based model loading.")
parser.add_argument("--enable-dynamic-vram", action="store_true", help="Enable dynamic VRAM on systems where it's not enabled by default.")
parser.add_argument("--fast-disk", action="store_true", help="Prefer disk-backed dynamic loading and offload over unpinned RAM. Can be faster for users with fast NVME disks.")
parser.add_argument("--force-non-blocking", action="store_true", help="Force ComfyUI to use non-blocking operations for all applicable tensors. This may improve performance on some non-Nvidia systems but can cause issues with some workflows.")
@ -165,6 +190,8 @@ class PerformanceFeature(enum.Enum):
parser.add_argument("--fast", nargs="*", type=PerformanceFeature, help="Enable some untested and potentially quality deteriorating optimizations. This is used to test new features so using it might crash your comfyui. --fast with no arguments enables everything. You can pass a list specific optimizations if you only want to enable specific ones. Current valid optimizations: {}".format(" ".join(map(lambda c: c.value, PerformanceFeature))))
parser.add_argument("--debug-hang", action="store_true", help="Enable stack trace dumps on Ctrl-C for debugging hangs.")
parser.add_argument("--disable-pinned-memory", action="store_true", help="Disable pinned memory use.")
parser.add_argument("--mmap-torch-files", action="store_true", help="Use mmap when loading ckpt/pt files.")
@ -238,6 +265,15 @@ parser.add_argument("--enable-assets", action="store_true", help="Enable the ass
parser.add_argument("--feature-flag", type=str, action='append', default=[], metavar="KEY[=VALUE]", help="Set a server feature flag. Use KEY=VALUE to set an explicit value, or bare KEY to set it to true. Can be specified multiple times. Boolean values (true/false) and numbers are auto-converted. Examples: --feature-flag show_signin_button=true or --feature-flag show_signin_button")
parser.add_argument("--list-feature-flags", action="store_true", help="Print the registry of known CLI-settable feature flags as JSON and exit.")
# ----- Model download manager (PRD: docs/prd-download-manager.md) -----
parser.add_argument("--download-segments", type=_positive_int, default=8, metavar="N", help="Number of parallel HTTP range segments per file for the model download manager (default: 8).")
parser.add_argument("--download-max-active", type=_positive_int, default=3, metavar="N", help="Maximum number of model downloads running concurrently (default: 3).")
parser.add_argument("--download-max-connections-per-host", type=_positive_int, default=16, metavar="N", help="Maximum simultaneous connections to a single host for the download manager (default: 16).")
parser.add_argument("--download-chunk-size", type=_positive_int, default=4 * 1024 * 1024, metavar="BYTES", help="Read chunk size in bytes for the download manager (default: 4 MiB).")
parser.add_argument("--download-max-bytes", type=_non_negative_int, default=1024 * 1024 * 1024 * 1024, metavar="BYTES", help="Maximum size in bytes of a single download; aborts transfers that exceed it (guards against malicious/non-conforming hosts filling the disk). Set to 0 to disable (default: 1 TiB).")
parser.add_argument("--download-allowed-hosts", type=str, nargs="*", default=[], metavar="HOST", help="Additional hostnames to add to the download manager allowlist (https only). The built-in defaults always include huggingface.co and civitai.com.")
parser.add_argument("--download-allow-any-extension", action="store_true", help="Allow the download manager to fetch files with any extension (default: only known model extensions like .safetensors).")
if comfy.options.args_parsing:
args = parser.parse_args()
else:
@ -246,6 +282,9 @@ else:
if args.cache_ram is not None and len(args.cache_ram) > 2:
parser.error("--cache-ram accepts at most two values: active GB and inactive GB")
if args.high_ram:
args.cache_classic = True
if args.windows_standalone_build:
args.auto_launch = True
@ -255,6 +294,10 @@ if args.disable_auto_launch:
if args.force_fp16:
args.fp16_unet = True
# '--enable-manager-legacy-ui' is meaningless unless the manager is enabled, so imply '--enable-manager'.
if args.enable_manager_legacy_ui:
args.enable_manager = True
# '--fast' is not provided, use an empty set
if args.fast is None:

View File

@ -9,6 +9,7 @@ import comfy.model_management
import comfy.utils
import comfy.clip_model
import comfy.image_encoders.dino2
import comfy.image_encoders.dino3
class Output:
def __getitem__(self, key):
@ -23,12 +24,16 @@ IMAGE_ENCODERS = {
"siglip_vision_model": comfy.clip_model.CLIPVisionModelProjection,
"siglip2_vision_model": comfy.clip_model.CLIPVisionModelProjection,
"dinov2": comfy.image_encoders.dino2.Dinov2Model,
"dinov3": comfy.image_encoders.dino3.DINOv3ViTModel,
}
class ClipVisionModel():
def __init__(self, json_config):
with open(json_config) as f:
config = json.load(f)
if isinstance(json_config, dict):
config = json_config
else:
with open(json_config) as f:
config = json.load(f)
self.image_size = config.get("image_size", 224)
self.image_mean = config.get("image_mean", [0.48145466, 0.4578275, 0.40821073])
@ -134,6 +139,8 @@ def load_clipvision_from_sd(sd, prefix="", convert_keys=False):
json_config = os.path.join(os.path.join(os.path.dirname(os.path.realpath(__file__)), "image_encoders"), "dino2_giant.json")
elif 'encoder.layer.23.layer_scale2.lambda1' in sd:
json_config = os.path.join(os.path.join(os.path.dirname(os.path.realpath(__file__)), "image_encoders"), "dino2_large.json")
elif 'layer.0.mlp.gate_proj.weight' in sd and 'layer.31.norm1.weight' in sd: # Dinov3 ViT-H/16+ (SwiGLU gated MLP, 32 layers)
json_config = comfy.image_encoders.dino3.DINOV3_VITH_CONFIG
else:
return None

View File

@ -1,6 +1,5 @@
"""Comfy-specific type hinting"""
from __future__ import annotations
from typing import Literal, TypedDict, Optional
from typing_extensions import NotRequired
from abc import ABC, abstractmethod

View File

@ -8,6 +8,8 @@ from abc import ABC, abstractmethod
import logging
import comfy.model_management
import comfy.patcher_extension
import comfy.utils
import comfy.conds
if TYPE_CHECKING:
from comfy.model_base import BaseModel
from comfy.model_patcher import ModelPatcher
@ -51,12 +53,18 @@ class ContextHandlerABC(ABC):
class IndexListContextWindow(ContextWindowABC):
def __init__(self, index_list: list[int], dim: int=0, total_frames: int=0):
def __init__(self, index_list: list[int], dim: int=0, total_frames: int=0, modality_windows: dict=None, context_overlap: int=0):
self.index_list = index_list
self.context_length = len(index_list)
self.context_overlap = context_overlap
self.dim = dim
self.total_frames = total_frames
self.center_ratio = (min(index_list) + max(index_list)) / (2 * total_frames)
self.modality_windows = modality_windows # dict of {mod_idx: IndexListContextWindow}
self.guide_frames_indices: list[int] = []
self.guide_overlap_info: list[tuple[int, int]] = []
self.guide_kf_local_positions: list[int] = []
self.guide_downscale_factors: list[int] = []
def get_tensor(self, full: torch.Tensor, device=None, dim=None, retain_index_list=[]) -> torch.Tensor:
if dim is None:
@ -85,6 +93,11 @@ class IndexListContextWindow(ContextWindowABC):
region_idx = int(self.center_ratio * num_regions)
return min(max(region_idx, 0), num_regions - 1)
def get_window_for_modality(self, modality_idx: int) -> 'IndexListContextWindow':
if modality_idx == 0:
return self
return self.modality_windows[modality_idx]
class IndexListCallbacks:
EVALUATE_CONTEXT_WINDOWS = "evaluate_context_windows"
@ -148,6 +161,172 @@ def slice_cond(cond_value, window: IndexListContextWindow, x_in: torch.Tensor, d
return cond_value._copy_with(sliced)
def compute_guide_overlap(guide_entries: list[dict], keyframe_idxs: torch.Tensor, temporal_downscale_ratio: int, window_index_list: list[int]):
"""Compute which concatenated guide frames overlap with a context window.
Each guide's latent-space start is derived from its first token's pixel-t-start
in keyframe_idxs (shape (B, [t,h,w], num_tokens, [start, end])), divided by the
model's temporal_downscale_ratio.
Args:
guide_entries: list of guide_attention_entry dicts
keyframe_idxs: per-token pixel coords cond tensor for the modality
temporal_downscale_ratio: model's pixel-to-latent temporal compression ratio
window_index_list: the window's frame indices into the video portion
Returns:
suffix_indices: indices into the guide_frames tensor for frame selection
overlap_info: list of (entry_idx, overlap_count) for guide_attention_entries adjustment
kf_local_positions: window-local frame positions for keyframe_idxs regeneration
total_overlap: total number of overlapping guide frames
"""
window_set = set(window_index_list)
window_list = list(window_index_list)
suffix_indices = []
overlap_info = []
kf_local_positions = []
suffix_base = 0
token_offset = 0
for entry_idx, entry in enumerate(guide_entries):
first_t_pixel = int(keyframe_idxs[0, 0, token_offset, 0].item())
latent_start = (first_t_pixel + temporal_downscale_ratio - 1) // temporal_downscale_ratio
guide_len = entry["latent_shape"][0]
entry_overlap = 0
for local_offset in range(guide_len):
video_pos = latent_start + local_offset
if video_pos in window_set:
suffix_indices.append(suffix_base + local_offset)
kf_local_positions.append(window_list.index(video_pos))
entry_overlap += 1
if entry_overlap > 0:
overlap_info.append((entry_idx, entry_overlap))
suffix_base += guide_len
token_offset += entry["pre_filter_count"]
return suffix_indices, overlap_info, kf_local_positions, len(suffix_indices)
@dataclass
class WindowingState:
"""Per-modality context windowing state for each step,
built using IndexListContextHandler._build_window_state().
For non-multimodal models the lists are length 1
"""
latents: list[torch.Tensor] # per-modality working latents (guide frames stripped)
guide_latents: list[torch.Tensor | None] # per-modality guide frames stripped from latents
guide_entries: list[list[dict] | None] # per-modality guide_attention_entry metadata
keyframe_idxs: list[torch.Tensor | None] # per-modality keyframe_idxs tensor for guide latent_start derivation
latent_shapes: list | None # original packed shapes for unpack/pack (None if not multimodal)
dim: int = 0 # primary modality temporal dim for context windowing
is_multimodal: bool = False
temporal_downscale_ratio: int = 1 # model's pixel-to-latent temporal compression ratio
def prepare_window(self, window: IndexListContextWindow, model) -> IndexListContextWindow:
"""Reformat window for multimodal contexts by deriving per-modality index lists.
Non-multimodal contexts return the input window unchanged.
"""
if not self.is_multimodal:
return window
x = self.latents[0]
primary_total = self.latent_shapes[0][self.dim]
primary_overlap = window.context_overlap
map_shapes = self.latent_shapes
if x.size(self.dim) != primary_total:
map_shapes = list(self.latent_shapes)
video_shape = list(self.latent_shapes[0])
video_shape[self.dim] = x.size(self.dim)
map_shapes[0] = torch.Size(video_shape)
try:
per_modality_indices = model.map_context_window_to_modalities(
window.index_list, map_shapes, self.dim)
except AttributeError:
raise NotImplementedError(
f"{type(model).__name__} must implement map_context_window_to_modalities for multimodal context windows.")
modality_windows = {}
for mod_idx in range(1, len(self.latents)):
modality_total_frames = self.latents[mod_idx].shape[self.dim]
ratio = modality_total_frames / primary_total if primary_total > 0 else 1
modality_overlap = max(round(primary_overlap * ratio), 0)
modality_windows[mod_idx] = IndexListContextWindow(
per_modality_indices[mod_idx], dim=self.dim,
total_frames=modality_total_frames,
context_overlap=modality_overlap)
return IndexListContextWindow(
window.index_list, dim=self.dim, total_frames=x.shape[self.dim],
modality_windows=modality_windows, context_overlap=primary_overlap)
def slice_for_window(self, window: IndexListContextWindow, retain_index_list: list[int], device=None) -> tuple[list[torch.Tensor], list[int]]:
"""Slice latents for a context window, injecting guide frames where applicable.
For multimodal contexts, uses the modality-specific windows derived in prepare_window().
"""
sliced = []
guide_frame_counts = []
for idx in range(len(self.latents)):
modality_window = window.get_window_for_modality(idx)
retain = retain_index_list if idx == 0 else []
s = modality_window.get_tensor(self.latents[idx], device, retain_index_list=retain)
if self.guide_entries[idx] is not None:
s, ng = self._inject_guide_frames(s, modality_window, modality_idx=idx)
else:
ng = 0
sliced.append(s)
guide_frame_counts.append(ng)
return sliced, guide_frame_counts
def strip_guide_frames(self, out_per_modality: list[list[torch.Tensor]], guide_frame_counts: list[int], window: IndexListContextWindow):
"""Strip injected guide frames from per-cond, per-modality outputs in place."""
for idx in range(len(self.latents)):
if guide_frame_counts[idx] > 0:
window_len = len(window.get_window_for_modality(idx).index_list)
for ci in range(len(out_per_modality)):
out_per_modality[ci][idx] = out_per_modality[ci][idx].narrow(self.dim, 0, window_len)
def _inject_guide_frames(self, latent_slice: torch.Tensor, window: IndexListContextWindow, modality_idx: int = 0) -> tuple[torch.Tensor, int]:
guide_entries = self.guide_entries[modality_idx]
guide_frames = self.guide_latents[modality_idx]
keyframe_idxs = self.keyframe_idxs[modality_idx]
suffix_idx, overlap_info, kf_local_pos, guide_frame_count = compute_guide_overlap(
guide_entries, keyframe_idxs, self.temporal_downscale_ratio, window.index_list)
# Shift keyframe positions to account for causal_window_fix anchor occupying sub-pos 0.
anchor_idx = getattr(window, 'causal_anchor_index', None)
if anchor_idx is not None and anchor_idx >= 0:
kf_local_pos = [p + 1 for p in kf_local_pos]
window.guide_frames_indices = suffix_idx
window.guide_overlap_info = overlap_info
window.guide_kf_local_positions = kf_local_pos
# Derive per-overlap-entry latent_downscale_factor from guide entry latent_shape vs guide frame spatial dims.
# guide_frames has full (post-dilation) spatial dims; entry["latent_shape"] has pre-dilation dims.
guide_downscale_factors = []
if guide_frame_count > 0:
full_H = guide_frames.shape[3]
for entry_idx, _ in overlap_info:
entry_H = guide_entries[entry_idx]["latent_shape"][1]
guide_downscale_factors.append(full_H // entry_H)
window.guide_downscale_factors = guide_downscale_factors
if guide_frame_count > 0:
idx = tuple([slice(None)] * self.dim + [suffix_idx])
return torch.cat([latent_slice, guide_frames[idx]], dim=self.dim), guide_frame_count
return latent_slice, 0
def patch_latent_shapes(self, sub_conds, new_shapes):
if not self.is_multimodal:
return
for cond_list in sub_conds:
if cond_list is None:
continue
for cond_dict in cond_list:
model_conds = cond_dict.get('model_conds', {})
if 'latent_shapes' in model_conds:
model_conds['latent_shapes'] = comfy.conds.CONDConstant(new_shapes)
@dataclass
class ContextSchedule:
name: str
@ -162,7 +341,7 @@ ContextResults = collections.namedtuple("ContextResults", ['window_idx', 'sub_co
class IndexListContextHandler(ContextHandlerABC):
def __init__(self, context_schedule: ContextSchedule, fuse_method: ContextFuseMethod, context_length: int=1, context_overlap: int=0, context_stride: int=1,
closed_loop: bool=False, dim:int=0, freenoise: bool=False, cond_retain_index_list: list[int]=[], split_conds_to_windows: bool=False,
causal_window_fix: bool=True):
latent_retain_index_list: list[int]=[], causal_window_fix: bool=True):
self.context_schedule = context_schedule
self.fuse_method = fuse_method
self.context_length = context_length
@ -174,17 +353,118 @@ class IndexListContextHandler(ContextHandlerABC):
self.freenoise = freenoise
self.cond_retain_index_list = [int(x.strip()) for x in cond_retain_index_list.split(",")] if cond_retain_index_list else []
self.split_conds_to_windows = split_conds_to_windows
self.latent_retain_index_list = [int(x.strip()) for x in latent_retain_index_list.split(",")] if latent_retain_index_list else []
self.causal_window_fix = causal_window_fix
self.callbacks = {}
@staticmethod
def _get_latent_shapes(conds):
for cond_list in conds:
if cond_list is None:
continue
for cond_dict in cond_list:
model_conds = cond_dict.get('model_conds', {})
if 'latent_shapes' in model_conds:
return model_conds['latent_shapes'].cond
return None
@staticmethod
def _get_guide_entries(conds):
for cond_list in conds:
if cond_list is None:
continue
for cond_dict in cond_list:
model_conds = cond_dict.get('model_conds', {})
entries = model_conds.get('guide_attention_entries')
if entries is not None and hasattr(entries, 'cond') and entries.cond:
return entries.cond
return None
@staticmethod
def _get_keyframe_idxs(conds):
for cond_list in conds:
if cond_list is None:
continue
for cond_dict in cond_list:
model_conds = cond_dict.get('model_conds', {})
kf = model_conds.get('keyframe_idxs')
if kf is not None and hasattr(kf, 'cond') and kf.cond is not None:
return kf.cond
return None
def _apply_freenoise(self, noise: torch.Tensor, conds: list[list[dict]], seed: int) -> torch.Tensor:
"""Apply FreeNoise shuffling, scaling context length/overlap per-modality by frame ratio.
If guide frames are present on the primary modality, only the video portion is shuffled.
"""
guide_entries = self._get_guide_entries(conds)
guide_count = sum(e["latent_shape"][0] for e in guide_entries) if guide_entries else 0
latent_shapes = self._get_latent_shapes(conds)
if latent_shapes is not None and len(latent_shapes) > 1:
modalities = comfy.utils.unpack_latents(noise, latent_shapes)
primary_total = latent_shapes[0][self.dim]
primary_video_count = modalities[0].size(self.dim) - guide_count
apply_freenoise(modalities[0].narrow(self.dim, 0, primary_video_count), self.dim, self.context_length, self.context_overlap, seed)
for i in range(1, len(modalities)):
mod_total = latent_shapes[i][self.dim]
ratio = mod_total / primary_total if primary_total > 0 else 1
mod_ctx_len = max(round(self.context_length * ratio), 1)
mod_ctx_overlap = max(round(self.context_overlap * ratio), 0)
modalities[i] = apply_freenoise(modalities[i], self.dim, mod_ctx_len, mod_ctx_overlap, seed)
noise, _ = comfy.utils.pack_latents(modalities)
return noise
video_count = noise.size(self.dim) - guide_count
apply_freenoise(noise.narrow(self.dim, 0, video_count), self.dim, self.context_length, self.context_overlap, seed)
return noise
def _build_window_state(self, x_in: torch.Tensor, conds: list[list[dict]], model: BaseModel) -> WindowingState:
"""Build windowing state for the current step, including unpacking latents and extracting guide frame info from conds."""
latent_shapes = self._get_latent_shapes(conds)
is_multimodal = latent_shapes is not None and len(latent_shapes) > 1
unpacked_latents = comfy.utils.unpack_latents(x_in, latent_shapes) if is_multimodal else [x_in]
unpacked_latents_list = list(unpacked_latents)
guide_latents_list = [None] * len(unpacked_latents)
guide_entries_list = [None] * len(unpacked_latents)
keyframe_idxs_list = [None] * len(unpacked_latents)
extracted_guide_entries = self._get_guide_entries(conds)
extracted_keyframe_idxs = self._get_keyframe_idxs(conds)
# Strip guide frames (only from first modality for now)
if extracted_guide_entries is not None:
guide_count = sum(e["latent_shape"][0] for e in extracted_guide_entries)
if guide_count > 0:
x = unpacked_latents[0]
latent_count = x.size(self.dim) - guide_count
unpacked_latents_list[0] = x.narrow(self.dim, 0, latent_count)
guide_latents_list[0] = x.narrow(self.dim, latent_count, guide_count)
guide_entries_list[0] = extracted_guide_entries
keyframe_idxs_list[0] = extracted_keyframe_idxs
return WindowingState(
latents=unpacked_latents_list,
guide_latents=guide_latents_list,
guide_entries=guide_entries_list,
keyframe_idxs=keyframe_idxs_list,
latent_shapes=latent_shapes,
dim=self.dim,
is_multimodal=is_multimodal,
temporal_downscale_ratio=model.latent_format.temporal_downscale_ratio)
def should_use_context(self, model: BaseModel, conds: list[list[dict]], x_in: torch.Tensor, timestep: torch.Tensor, model_options: dict[str]) -> bool:
# for now, assume first dim is batch - should have stored on BaseModel in actual implementation
if x_in.size(self.dim) > self.context_length:
logging.info(f"Using context windows {self.context_length} with overlap {self.context_overlap} for {x_in.size(self.dim)} frames.")
window_state = self._build_window_state(x_in, conds, model) # build window_state to check frame counts, will be built again in execute
total_frame_count = window_state.latents[0].size(self.dim)
if total_frame_count > self.context_length:
logging.info(f"\nUsing context windows: Context length {self.context_length} with overlap {self.context_overlap} for {total_frame_count} frames.")
if self.cond_retain_index_list:
logging.info(f"Retaining original cond for indexes: {self.cond_retain_index_list}")
if self.latent_retain_index_list:
logging.info(f"Retaining original latent for indexes: {self.latent_retain_index_list}")
return True
logging.info(f"\nNot using context windows since context length ({self.context_length}) exceeds input frames ({total_frame_count}).")
return False
def prepare_control_objects(self, control: ControlBase, device=None) -> ControlBase:
@ -275,7 +555,9 @@ class IndexListContextHandler(ContextHandlerABC):
return resized_cond
def set_step(self, timestep: torch.Tensor, model_options: dict[str]):
mask = torch.isclose(model_options["transformer_options"]["sample_sigmas"], timestep[0], rtol=0.0001)
sample_sigmas = model_options["transformer_options"]["sample_sigmas"]
current_timestep = timestep[0].to(sample_sigmas.dtype)
mask = torch.isclose(sample_sigmas, current_timestep, rtol=0.0001)
matches = torch.nonzero(mask)
if torch.numel(matches) == 0:
return # substep from multi-step sampler: keep self._step from the last full step
@ -284,54 +566,98 @@ class IndexListContextHandler(ContextHandlerABC):
def get_context_windows(self, model: BaseModel, x_in: torch.Tensor, model_options: dict[str]) -> list[IndexListContextWindow]:
full_length = x_in.size(self.dim) # TODO: choose dim based on model
context_windows = self.context_schedule.func(full_length, self, model_options)
context_windows = [IndexListContextWindow(window, dim=self.dim, total_frames=full_length) for window in context_windows]
context_windows = [IndexListContextWindow(window, dim=self.dim, total_frames=full_length, context_overlap=self.context_overlap) for window in context_windows]
return context_windows
def execute(self, calc_cond_batch: Callable, model: BaseModel, conds: list[list[dict]], x_in: torch.Tensor, timestep: torch.Tensor, model_options: dict[str]):
self._model = model
self.set_step(timestep, model_options)
context_windows = self.get_context_windows(model, x_in, model_options)
enumerated_context_windows = list(enumerate(context_windows))
conds_final = [torch.zeros_like(x_in) for _ in conds]
window_state = self._build_window_state(x_in, conds, model)
num_modalities = len(window_state.latents)
context_windows = self.get_context_windows(model, window_state.latents[0], model_options)
enumerated_context_windows = list(enumerate(context_windows))
total_windows = len(enumerated_context_windows)
# Initialize per-modality accumulators (length 1 for single-modality)
accum = [[torch.zeros_like(m) for _ in conds] for m in window_state.latents]
if self.fuse_method.name == ContextFuseMethods.RELATIVE:
counts_final = [torch.ones(get_shape_for_dim(x_in, self.dim), device=x_in.device) for _ in conds]
counts = [[torch.ones(get_shape_for_dim(m, self.dim), device=m.device) for _ in conds] for m in window_state.latents]
else:
counts_final = [torch.zeros(get_shape_for_dim(x_in, self.dim), device=x_in.device) for _ in conds]
biases_final = [([0.0] * x_in.shape[self.dim]) for _ in conds]
counts = [[torch.zeros(get_shape_for_dim(m, self.dim), device=m.device) for _ in conds] for m in window_state.latents]
biases = [[([0.0] * m.shape[self.dim]) for _ in conds] for m in window_state.latents]
for callback in comfy.patcher_extension.get_all_callbacks(IndexListCallbacks.EXECUTE_START, self.callbacks):
callback(self, model, x_in, conds, timestep, model_options)
# accumulate results from each context window
for enum_window in enumerated_context_windows:
results = self.evaluate_context_windows(calc_cond_batch, model, x_in, conds, timestep, [enum_window], model_options)
results = self.evaluate_context_windows(
calc_cond_batch, model, x_in, conds, timestep, [enum_window],
model_options, window_state=window_state, total_windows=total_windows)
for result in results:
self.combine_context_window_results(x_in, result.sub_conds_out, result.sub_conds, result.window, result.window_idx, len(enumerated_context_windows), timestep,
conds_final, counts_final, biases_final)
# result.sub_conds_out is per-cond, per-modality: list[list[Tensor]]
for mod_idx in range(num_modalities):
mod_out = [result.sub_conds_out[ci][mod_idx] for ci in range(len(conds))]
modality_window = result.window.get_window_for_modality(mod_idx)
self.combine_context_window_results(
window_state.latents[mod_idx], mod_out, result.sub_conds, modality_window,
result.window_idx, total_windows, timestep,
accum[mod_idx], counts[mod_idx], biases[mod_idx])
# fuse accumulated results into final conds
try:
# finalize conds
if self.fuse_method.name == ContextFuseMethods.RELATIVE:
# relative is already normalized, so return as is
del counts_final
return conds_final
else:
# normalize conds via division by context usage counts
for i in range(len(conds_final)):
conds_final[i] /= counts_final[i]
del counts_final
return conds_final
result_out = []
for ci in range(len(conds)):
finalized = []
for mod_idx in range(num_modalities):
if self.fuse_method.name != ContextFuseMethods.RELATIVE:
accum[mod_idx][ci] /= counts[mod_idx][ci]
f = accum[mod_idx][ci]
# if guide frames were injected, append them to the end of the fused latents for the next step
if window_state.guide_latents[mod_idx] is not None:
f = torch.cat([f, window_state.guide_latents[mod_idx]], dim=self.dim)
finalized.append(f)
# pack modalities together if needed
if window_state.is_multimodal and len(finalized) > 1:
packed, _ = comfy.utils.pack_latents(finalized)
else:
packed = finalized[0]
result_out.append(packed)
return result_out
finally:
for callback in comfy.patcher_extension.get_all_callbacks(IndexListCallbacks.EXECUTE_CLEANUP, self.callbacks):
callback(self, model, x_in, conds, timestep, model_options)
def evaluate_context_windows(self, calc_cond_batch: Callable, model: BaseModel, x_in: torch.Tensor, conds, timestep: torch.Tensor, enumerated_context_windows: list[tuple[int, IndexListContextWindow]],
model_options, device=None, first_device=None):
def evaluate_context_windows(self, calc_cond_batch: Callable, model: BaseModel, x_in: torch.Tensor, conds,
timestep: torch.Tensor, enumerated_context_windows: list[tuple[int, IndexListContextWindow]],
model_options, window_state: WindowingState, total_windows: int = None,
device=None, first_device=None):
"""Evaluate context windows and return per-cond, per-modality outputs in ContextResults.sub_conds_out
For each window:
1. Builds windows (for each modality if multimodal)
2. Slices window for each modality
3. Injects concatenated latent guide frames where present
4. Packs together if needed and calls model
5. Unpacks and strips any guides from outputs
"""
x = window_state.latents[0]
results: list[ContextResults] = []
for window_idx, window in enumerated_context_windows:
# allow processing to end between context window executions for faster Cancel
comfy.model_management.throw_exception_if_processing_interrupted()
# causal_window_fix: prepend a pre-window frame that will be stripped post-forward
# prepare the window accounting for multimodal windows
window = window_state.prepare_window(window, model)
# causal_window_fix: prepend a pre-window frame that will be stripped post-forward.
# Set anchor before slice_for_window so the latent slice and downstream cond slices both pick it up.
anchor_applied = False
if self.causal_window_fix:
anchor_idx = window.index_list[0] - 1
@ -339,27 +665,46 @@ class IndexListContextHandler(ContextHandlerABC):
window.causal_anchor_index = anchor_idx
anchor_applied = True
# slice the window for each modality, injecting guide frames where applicable
sliced, guide_frame_counts_per_modality = window_state.slice_for_window(window, self.latent_retain_index_list, device)
for callback in comfy.patcher_extension.get_all_callbacks(IndexListCallbacks.EVALUATE_CONTEXT_WINDOWS, self.callbacks):
callback(self, model, x_in, conds, timestep, model_options, window_idx, window, model_options, device, first_device)
# update exposed params
logging.info(f"Context window {window_idx + 1}/{total_windows or len(enumerated_context_windows)}: frames {window.index_list[0]}-{window.index_list[-1]} of {x.shape[self.dim]}"
+ (f" (+{guide_frame_counts_per_modality[0]} guide frames)" if guide_frame_counts_per_modality[0] > 0 else "")
)
# if multimodal, pack modalities together
if window_state.is_multimodal and len(sliced) > 1:
sub_x, sub_shapes = comfy.utils.pack_latents(sliced)
else:
sub_x, sub_shapes = sliced[0], [sliced[0].shape]
# get resized conds for window
model_options["transformer_options"]["context_window"] = window
# get subsections of x, timestep, conds
sub_x = window.get_tensor(x_in, device)
sub_timestep = window.get_tensor(timestep, device, dim=0)
sub_conds = [self.get_resized_cond(cond, x_in, window, device) for cond in conds]
sub_timestep = window.get_tensor(timestep, dim=0)
sub_conds = [self.get_resized_cond(cond, x, window) for cond in conds]
# if multimodal, patch latent_shapes in conds for correct unpacking in model
window_state.patch_latent_shapes(sub_conds, sub_shapes)
# call model on window
sub_conds_out = calc_cond_batch(model, sub_conds, sub_x, sub_timestep, model_options)
if device is not None:
for i in range(len(sub_conds_out)):
sub_conds_out[i] = sub_conds_out[i].to(x_in.device)
# strip causal_window_fix anchor if applied
# unpack outputs
out_per_modality = [comfy.utils.unpack_latents(sub_conds_out[i], sub_shapes) for i in range(len(sub_conds_out))]
# strip causal_window_fix anchor from primary modality before guide strip so window_len math stays correct
if anchor_applied:
for i in range(len(sub_conds_out)):
sub_conds_out[i] = sub_conds_out[i].narrow(self.dim, 1, sub_conds_out[i].shape[self.dim] - 1)
for ci in range(len(out_per_modality)):
t = out_per_modality[ci][0]
out_per_modality[ci][0] = t.narrow(self.dim, 1, t.shape[self.dim] - 1)
results.append(ContextResults(window_idx, sub_conds_out, sub_conds, window))
# strip injected guide frames
window_state.strip_guide_frames(out_per_modality, guide_frame_counts_per_modality, window)
results.append(ContextResults(window_idx, out_per_modality, sub_conds, window))
return results
@ -383,7 +728,7 @@ class IndexListContextHandler(ContextHandlerABC):
biases_final[i][idx] = bias_total + bias
else:
# add conds and counts based on weights of fuse method
weights = get_context_weights(window.context_length, x_in.shape[self.dim], window.index_list, self, sigma=timestep)
weights = get_context_weights(window.context_length, x_in.shape[self.dim], window.index_list, self, sigma=timestep, context_overlap=window.context_overlap)
weights_tensor = match_weights_to_dim(weights, x_in, self.dim, device=x_in.device)
for i in range(len(sub_conds_out)):
window.add_window(conds_final[i], sub_conds_out[i] * weights_tensor)
@ -393,16 +738,22 @@ class IndexListContextHandler(ContextHandlerABC):
callback(self, x_in, sub_conds_out, sub_conds, window, window_idx, total_windows, timestep, conds_final, counts_final, biases_final)
def _prepare_sampling_wrapper(executor, model, noise_shape: torch.Tensor, *args, **kwargs):
# limit noise_shape length to context_length for more accurate vram use estimation
def _prepare_sampling_wrapper(executor, model, noise_shape: torch.Tensor, conds, *args, **kwargs):
# Scale noise_shape to a single context window so VRAM estimation budgets per-window.
model_options = kwargs.get("model_options", None)
if model_options is None:
raise Exception("model_options not found in prepare_sampling_wrapper; this should never happen, something went wrong.")
handler: IndexListContextHandler = model_options.get("context_handler", None)
if handler is not None:
noise_shape = list(noise_shape)
noise_shape[handler.dim] = min(noise_shape[handler.dim], handler.context_length)
return executor(model, noise_shape, *args, **kwargs)
is_packed = len(noise_shape) == 3 and noise_shape[1] == 1
if is_packed:
# TODO: latent_shapes cond isn't attached yet at this point, so we can't compute a
# per-window flat latent here. Skipping the clamp over-estimates but prevents immediate OOM.
pass
elif handler.dim < len(noise_shape) and noise_shape[handler.dim] > handler.context_length:
noise_shape[handler.dim] = min(noise_shape[handler.dim], handler.context_length)
return executor(model, noise_shape, conds, *args, **kwargs)
def create_prepare_sampling_wrapper(model: ModelPatcher):
@ -422,11 +773,12 @@ def _sampler_sample_wrapper(executor, guider, sigmas, extra_args, callback, nois
raise Exception("context_handler not found in sampler_sample_wrapper; this should never happen, something went wrong.")
if not handler.freenoise:
return executor(guider, sigmas, extra_args, callback, noise, *args, **kwargs)
noise = apply_freenoise(noise, handler.dim, handler.context_length, handler.context_overlap, extra_args["seed"])
conds = [guider.conds.get('positive', guider.conds.get('negative', []))]
noise = handler._apply_freenoise(noise, conds, extra_args["seed"])
return executor(guider, sigmas, extra_args, callback, noise, *args, **kwargs)
def create_sampler_sample_wrapper(model: ModelPatcher):
model.add_wrapper_with_key(
comfy.patcher_extension.WrappersMP.SAMPLER_SAMPLE,
@ -434,7 +786,6 @@ def create_sampler_sample_wrapper(model: ModelPatcher):
_sampler_sample_wrapper
)
def match_weights_to_dim(weights: list[float], x_in: torch.Tensor, dim: int, device=None) -> torch.Tensor:
total_dims = len(x_in.shape)
weights_tensor = torch.Tensor(weights).to(device=device)
@ -580,8 +931,9 @@ def get_matching_context_schedule(context_schedule: str) -> ContextSchedule:
return ContextSchedule(context_schedule, func)
def get_context_weights(length: int, full_length: int, idxs: list[int], handler: IndexListContextHandler, sigma: torch.Tensor=None):
return handler.fuse_method.func(length, sigma=sigma, handler=handler, full_length=full_length, idxs=idxs)
def get_context_weights(length: int, full_length: int, idxs: list[int], handler: IndexListContextHandler, sigma: torch.Tensor=None, context_overlap: int=None):
context_overlap = handler.context_overlap if context_overlap is None else context_overlap
return handler.fuse_method.func(length, sigma=sigma, handler=handler, full_length=full_length, idxs=idxs, context_overlap=context_overlap)
def create_weights_flat(length: int, **kwargs) -> list[float]:
@ -599,18 +951,18 @@ def create_weights_pyramid(length: int, **kwargs) -> list[float]:
weight_sequence = list(range(1, max_weight, 1)) + [max_weight] + list(range(max_weight - 1, 0, -1))
return weight_sequence
def create_weights_overlap_linear(length: int, full_length: int, idxs: list[int], handler: IndexListContextHandler, **kwargs):
def create_weights_overlap_linear(length: int, full_length: int, idxs: list[int], context_overlap: int, **kwargs):
# based on code in Kijai's WanVideoWrapper: https://github.com/kijai/ComfyUI-WanVideoWrapper/blob/dbb2523b37e4ccdf45127e5ae33e31362f755c8e/nodes.py#L1302
# only expected overlap is given different weights
weights_torch = torch.ones((length))
# blend left-side on all except first window
if min(idxs) > 0:
ramp_up = torch.linspace(1e-37, 1, handler.context_overlap)
weights_torch[:handler.context_overlap] = ramp_up
ramp_up = torch.linspace(1e-37, 1, context_overlap)
weights_torch[:context_overlap] = ramp_up
# blend right-side on all except last window
if max(idxs) < full_length-1:
ramp_down = torch.linspace(1, 1e-37, handler.context_overlap)
weights_torch[-handler.context_overlap:] = ramp_down
ramp_down = torch.linspace(1, 1e-37, context_overlap)
weights_torch[-context_overlap:] = ramp_down
return weights_torch
class ContextFuseMethods:

View File

@ -15,13 +15,14 @@
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
import torch
from enum import Enum
import math
import os
import logging
import copy
import comfy.utils
import comfy.model_management
import comfy.model_detection
@ -38,7 +39,7 @@ import comfy.ldm.hydit.controlnet
import comfy.ldm.flux.controlnet
import comfy.ldm.qwen_image.controlnet
import comfy.cldm.dit_embedder
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Union
if TYPE_CHECKING:
from comfy.hooks import HookGroup
@ -64,6 +65,18 @@ class StrengthType(Enum):
CONSTANT = 1
LINEAR_UP = 2
class ControlIsolation:
'''Temporarily set a ControlBase object's previous_controlnet to None to prevent cascading calls.'''
def __init__(self, control: ControlBase):
self.control = control
self.orig_previous_controlnet = control.previous_controlnet
def __enter__(self):
self.control.previous_controlnet = None
def __exit__(self, *args):
self.control.previous_controlnet = self.orig_previous_controlnet
class ControlBase:
def __init__(self):
self.cond_hint_original = None
@ -77,7 +90,7 @@ class ControlBase:
self.compression_ratio = 8
self.upscale_algorithm = 'nearest-exact'
self.extra_args = {}
self.previous_controlnet = None
self.previous_controlnet: Union[ControlBase, None] = None
self.extra_conds = []
self.strength_type = StrengthType.CONSTANT
self.concat_mask = False
@ -85,6 +98,7 @@ class ControlBase:
self.extra_concat = None
self.extra_hooks: HookGroup = None
self.preprocess_image = lambda a: a
self.multigpu_clones: dict[torch.device, ControlBase] = {}
def set_cond_hint(self, cond_hint, strength=1.0, timestep_percent_range=(0.0, 1.0), vae=None, extra_concat=[]):
self.cond_hint_original = cond_hint
@ -111,17 +125,38 @@ class ControlBase:
def cleanup(self):
if self.previous_controlnet is not None:
self.previous_controlnet.cleanup()
for device_cnet in self.multigpu_clones.values():
with ControlIsolation(device_cnet):
device_cnet.cleanup()
self.cond_hint = None
self.extra_concat = None
self.timestep_range = None
def get_models(self):
out = []
for device_cnet in self.multigpu_clones.values():
out += device_cnet.get_models_only_self()
if self.previous_controlnet is not None:
out += self.previous_controlnet.get_models()
return out
def get_models_only_self(self):
'Calls get_models, but temporarily sets previous_controlnet to None.'
with ControlIsolation(self):
return self.get_models()
def get_instance_for_device(self, device):
'Returns instance of this Control object intended for selected device.'
return self.multigpu_clones.get(device, self)
def deepclone_multigpu(self, load_device, autoregister=False):
'''
Create deep clone of Control object where model(s) is set to other devices.
When autoregister is set to True, the deep clone is also added to multigpu_clones dict.
'''
raise NotImplementedError("Classes inheriting from ControlBase should define their own deepclone_multigpu funtion.")
def get_extra_hooks(self):
out = []
if self.extra_hooks is not None:
@ -130,7 +165,7 @@ class ControlBase:
out += self.previous_controlnet.get_extra_hooks()
return out
def copy_to(self, c):
def copy_to(self, c: ControlBase):
c.cond_hint_original = self.cond_hint_original
c.strength = self.strength
c.timestep_percent_range = self.timestep_percent_range
@ -284,6 +319,14 @@ class ControlNet(ControlBase):
self.copy_to(c)
return c
def deepclone_multigpu(self, load_device, autoregister=False):
c = self.copy()
c.control_model = copy.deepcopy(c.control_model)
c.control_model_wrapped = comfy.model_patcher.ModelPatcher(c.control_model, load_device=load_device, offload_device=comfy.model_management.unet_offload_device())
if autoregister:
self.multigpu_clones[load_device] = c
return c
def get_models(self):
out = super().get_models()
out.append(self.control_model_wrapped)
@ -314,6 +357,10 @@ class QwenFunControlNet(ControlNet):
super().pre_run(model, percent_to_timestep_function)
self.set_extra_arg("base_model", model.diffusion_model)
def cleanup(self):
self.extra_args.pop("base_model", None)
super().cleanup()
def copy(self):
c = QwenFunControlNet(None, global_average_pooling=self.global_average_pooling, load_device=self.load_device, manual_cast_dtype=self.manual_cast_dtype)
c.control_model = self.control_model
@ -906,6 +953,14 @@ class T2IAdapter(ControlBase):
self.copy_to(c)
return c
def deepclone_multigpu(self, load_device, autoregister=False):
c = self.copy()
c.t2i_model = copy.deepcopy(c.t2i_model)
c.device = load_device
if autoregister:
self.multigpu_clones[load_device] = c
return c
def load_t2i_adapter(t2i_data, model_options={}): #TODO: model_options
compression_ratio = 8
upscale_algorithm = 'nearest-exact'

Some files were not shown because too many files have changed in this diff Show More