Skip to content

Commit c148b26

Browse files
committed
Merge master into qa/video-edit-combined
# Conflicts: # comfy_api/latest/_input_impl/video_types.py
2 parents ea441aa + dcbcf8c commit c148b26

126 files changed

Lines changed: 10607 additions & 1056 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: Notify on Merge
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
8+
jobs:
9+
notify:
10+
runs-on: ubuntu-latest
11+
if: github.repository == 'Comfy-Org/ComfyUI'
12+
steps:
13+
- name: Notify downstream
14+
env:
15+
DISPATCH_TOKEN: ${{ secrets.SYNC_DISPATCH_TOKEN }}
16+
TARGET_REPO: ${{ secrets.SYNC_TARGET_REPO }}
17+
COMMIT_SHA: ${{ github.sha }}
18+
run: |
19+
set -euo pipefail
20+
if [ -z "${DISPATCH_TOKEN:-}" ] || [ -z "${TARGET_REPO:-}" ]; then
21+
echo "::notice::SYNC_DISPATCH_TOKEN/SYNC_TARGET_REPO not set; skipping downstream notify."
22+
exit 0
23+
fi
24+
PAYLOAD="$(jq -n --arg sha "$COMMIT_SHA" \
25+
'{ event_type: "upstream-push", client_payload: { sha: $sha } }')"
26+
curl -fsSL --connect-timeout 10 --max-time 60 -X POST \
27+
-H "Accept: application/vnd.github+json" \
28+
-H "Authorization: Bearer ${DISPATCH_TOKEN}" \
29+
"https://api.github.com/repos/${TARGET_REPO}/dispatches" \
30+
-d "$PAYLOAD"

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737

3838
ComfyUI is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output. Its powerful and modular node graph interface empowers creatives to generate images, videos, 3D models, audio, and more...
3939
- ComfyUI natively supports the latest open-source state of the art models.
40-
- API nodes provide access to the best closed source models such as Nano Banana, Seedance, Hunyuan3D, etc.
40+
- [Partner nodes](https://docs.comfy.org/tutorials/partner-nodes/overview#partner-nodes) provide access to the best closed source models such as Nano Banana, Seedance, Hunyuan3D, etc.
4141
- It is available on Windows, Linux, and macOS, locally with our [desktop application](https://www.comfy.org/download), our [portable install](#installing) or on our [cloud](https://www.comfy.org/cloud).
4242
- The most sophisticated workflows can be exposed through a simple UI thanks to App Mode.
4343
- It integrates seamlessly into production pipelines with our API endpoints.
@@ -74,7 +74,7 @@ See what ComfyUI can do with the [newer template workflows](https://comfy.org/wo
7474
- [Image editing](https://comfy.org/workflows/tag/image-edit/): Flux Kontext, Flux.2 Klein, Qwen Image Edit, HiDream E1.1 and O1, OmniGen2, Boogu, JoyImage Edit, MageFlow Edit, and LongCat Image Edit.
7575
- [Video generation](https://comfy.org/workflows/tag/video-generation/): Wan 2.1 and 2.2, LTX-Video 2 and 2.3, HunyuanVideo 1.5, Kandinsky 5 Video, CogVideoX, Cosmos Predict2, Bernini-R, SCAIL 2, and Mochi.
7676
- [Audio and video generation](https://comfy.org/workflows/): MiniMax H3 and LTX-AV.
77-
- [Audio generation](https://comfy.org/workflows/tag/text-to-audio/): ACE-Step 1.5 and Stable Audio 3.
77+
- [Audio generation](https://comfy.org/workflows/tag/text-to-audio/): ACE-Step 1.5, Stable Audio 3 and MiniMax Music 3
7878
- [3D and vision](https://comfy.org/workflows/): Hunyuan3D 2.1, TripoSplat, SeedVR2, SUPIR, Depth Anything 3, MoGe, SAM 3 and 3.1, RT-DETRv4, and BiRefNet.
7979
- [Text generation](https://comfy.org/workflows/tag/text-generation/): Gemma 3 and 4, Qwen3, Qwen3.5, and Qwen3-VL, including multimodal inputs.
8080
- Load complete checkpoints or separate diffusion models, VAEs, text encoders, LoRAs, ControlNets, adapters, and upscalers from supported model formats.
@@ -194,7 +194,7 @@ Python 3.14 works but some custom nodes may have issues. The free threaded varia
194194

195195
Python 3.13 is very well supported. If you have trouble with some custom node dependencies on 3.13 you can try 3.12
196196

197-
torch 2.5 is minimally supported but using a newer version is extremely recommended. Some features and optimizations might only work on newer versions. We generally recommend using the latest major version of pytorch with the latest cuda version unless it is less than 2 weeks old. If your pytorch is more than 6 months old, please update it.
197+
torch 2.7 is minimally supported but using a newer version is extremely recommended. Using a cu130 or above version of pytorch is required on Nvidia 20 series and above. Some features and optimizations might only work on newer versions. We generally recommend using the latest major version of pytorch with the latest cuda version unless it is less than 2 weeks old. If your pytorch is more than 6 months old, please update it.
198198

199199
### Instructions:
200200

app/assets/api/routes.py

Lines changed: 151 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import functools
33
import json
44
import logging
5+
import mimetypes
56
import os
67
import urllib.parse
78
import uuid
@@ -18,7 +19,7 @@
1819
AssetValidationError,
1920
UploadError,
2021
)
21-
from app.assets.helpers import validate_blake3_hash
22+
from app.assets.helpers import normalize_tags, validate_blake3_hash
2223
from app.assets.api.upload import (
2324
delete_temp_file_if_exists,
2425
parse_multipart_upload,
@@ -32,6 +33,7 @@
3233
create_from_hash,
3334
delete_asset_reference,
3435
get_asset_detail,
36+
get_preview_file_paths,
3537
list_assets_page,
3638
list_tags,
3739
remove_tags,
@@ -40,7 +42,7 @@
4042
upload_from_temp_path,
4143
)
4244
from app.assets.services.cursor import InvalidCursorError
43-
from app.assets.services.path_utils import compute_display_name
45+
from app.assets.services.path_utils import compute_asset_response_paths
4446
from app.assets.services.tagging import list_tag_histogram
4547

4648
ROUTES = web.RouteTableDef()
@@ -117,6 +119,87 @@ def _build_validation_error_response(code: str, ve: ValidationError) -> web.Resp
117119
return _build_error_response(400, code, "Validation failed.", {"errors": errors})
118120

119121

122+
class InvalidTagFilterError(Exception):
123+
"""Invalid combination of tag-filter query parameters."""
124+
125+
def __init__(self, message: str, details: dict):
126+
super().__init__(message)
127+
self.details = details
128+
129+
130+
# Caps the per-tag EXISTS fan-out; deliberately covers the legacy spellings too.
131+
MAX_TAG_FILTER_TAGS = 100
132+
133+
134+
def _resolve_tag_filters(
135+
q: schemas_in.ListAssetsQuery | schemas_in.TagsRefineQuery,
136+
) -> tuple[list[str], list[str], list[str]]:
137+
"""Resolve legacy (include/exclude) and new (all/any/none) tag-filter
138+
spellings into effective (all, any, none) lists.
139+
140+
Combination validation applies only when the request uses at least one
141+
new-name parameter (non-empty after normalisation); requests using only
142+
the legacy names keep their historical behaviour, including degenerate
143+
combinations like include_tags=a&exclude_tags=a.
144+
"""
145+
# model_dump, not attribute access: deprecated fields warn on every attribute read.
146+
legacy = q.model_dump(include={"include_tags", "exclude_tags"})
147+
include_tags = normalize_tags(legacy["include_tags"])
148+
exclude_tags = normalize_tags(legacy["exclude_tags"])
149+
tags_all = normalize_tags(q.tags_all)
150+
tags_any = normalize_tags(q.tags_any)
151+
tags_none = normalize_tags(q.tags_none)
152+
153+
for param_name, values in (
154+
("include_tags", include_tags),
155+
("exclude_tags", exclude_tags),
156+
("tags_all", tags_all),
157+
("tags_any", tags_any),
158+
("tags_none", tags_none),
159+
):
160+
if len(values) > MAX_TAG_FILTER_TAGS:
161+
raise InvalidTagFilterError(
162+
f"'{param_name}' lists {len(values)} tags; the maximum is "
163+
f"{MAX_TAG_FILTER_TAGS}.",
164+
{
165+
"parameter": param_name,
166+
"count": len(values),
167+
"max": MAX_TAG_FILTER_TAGS,
168+
},
169+
)
170+
171+
if not (tags_all or tags_any or tags_none):
172+
return include_tags, [], exclude_tags
173+
174+
if include_tags and tags_all:
175+
raise InvalidTagFilterError(
176+
"Cannot combine 'include_tags' with 'tags_all'; use 'tags_all'.",
177+
{"parameters": ["include_tags", "tags_all"]},
178+
)
179+
if exclude_tags and tags_none:
180+
raise InvalidTagFilterError(
181+
"Cannot combine 'exclude_tags' with 'tags_none'; use 'tags_none'.",
182+
{"parameters": ["exclude_tags", "tags_none"]},
183+
)
184+
185+
all_param, all_list = (
186+
("tags_all", tags_all) if tags_all else ("include_tags", include_tags)
187+
)
188+
none_param, none_list = (
189+
("tags_none", tags_none) if tags_none else ("exclude_tags", exclude_tags)
190+
)
191+
192+
conflicting = sorted(set(all_list) & set(none_list))
193+
if conflicting:
194+
raise InvalidTagFilterError(
195+
f"Query can never match: {', '.join(repr(t) for t in conflicting)} "
196+
f"required by '{all_param}' but rejected by '{none_param}'.",
197+
{"conflicting_tags": conflicting, "parameters": [all_param, none_param]},
198+
)
199+
200+
return all_list, tags_any, none_list
201+
202+
120203
def _validate_sort_field(requested: str | None) -> str:
121204
if not requested:
122205
return "created_at"
@@ -126,44 +209,62 @@ def _validate_sort_field(requested: str | None) -> str:
126209
return "created_at"
127210

128211

129-
def _build_preview_url_from_view(tags: list[str], user_metadata: dict[str, Any] | None) -> str | None:
130-
"""Build a /api/view preview URL from asset tags and user_metadata filename."""
131-
if not user_metadata:
212+
# What a client can render from the bytes themselves; anything else needs a nominated preview.
213+
PREVIEWABLE_MIME_PREFIXES = ("image/", "video/", "audio/", "text/")
214+
215+
# models is deliberately absent: /api/view has no directory type for it.
216+
VIEWABLE_NAMESPACES = frozenset({"input", "output", "temp"})
217+
218+
219+
def _has_previewable_content(asset: schemas.AssetData | None, file_path: str | None) -> bool:
220+
if asset is None:
221+
return False
222+
# Resolved from the path, not the caller-editable name, so a rename cannot change what previews.
223+
raw = asset.mime_type or mimetypes.guess_type(file_path or "")[0] or ""
224+
return raw.split(";", 1)[0].strip().lower().startswith(PREVIEWABLE_MIME_PREFIXES)
225+
226+
227+
def _build_view_url(file_path: str | None) -> str | None:
228+
# /api/view is a FileResponse: byte-range seeking, no user header, no access write.
229+
if not file_path:
132230
return None
133-
filename = user_metadata.get("filename")
134-
if not filename:
231+
paths = compute_asset_response_paths(file_path)
232+
if not paths:
135233
return None
136-
137-
if "input" in tags:
138-
view_type = "input"
139-
elif "output" in tags:
140-
view_type = "output"
141-
else:
234+
logical_path, relative_path = paths
235+
namespace = logical_path.split("/", 1)[0]
236+
if namespace not in VIEWABLE_NAMESPACES or not relative_path:
142237
return None
143238

144-
subfolder = ""
145-
if "/" in filename:
146-
subfolder, filename = filename.rsplit("/", 1)
147-
148-
encoded_filename = urllib.parse.quote(filename, safe="")
149-
url = f"/api/view?type={view_type}&filename={encoded_filename}"
239+
subfolder, _, filename = relative_path.rpartition("/")
240+
url = f"/api/view?type={namespace}&filename={urllib.parse.quote(filename, safe='')}"
150241
if subfolder:
151242
url += f"&subfolder={urllib.parse.quote(subfolder, safe='')}"
152243
return url
153244

154245

155-
def _build_asset_response(result: schemas.AssetDetailResult | schemas.UploadResult) -> schemas_out.Asset:
156-
"""Build an Asset response from a service result."""
246+
def _resolve_preview_paths(
247+
results: "list[schemas.AssetDetailResult] | list[schemas.AssetSummaryData]",
248+
) -> dict[str, str]:
249+
# A miss means no live preview - that is what keeps a soft-deleted one quiet.
250+
preview_ids = {r.ref.preview_id for r in results if r.ref.preview_id}
251+
return get_preview_file_paths(sorted(preview_ids))
252+
253+
254+
def _build_asset_response(
255+
result: schemas.AssetDetailResult | schemas.UploadResult,
256+
preview_paths: dict[str, str],
257+
) -> schemas_out.Asset:
157258
if result.ref.preview_id:
158-
preview_detail = get_asset_detail(result.ref.preview_id)
159-
if preview_detail:
160-
preview_url = _build_preview_url_from_view(preview_detail.tags, preview_detail.ref.user_metadata)
161-
else:
162-
preview_url = None
259+
# A nominated preview is one whatever it holds, so no media check here.
260+
preview_url = _build_view_url(preview_paths.get(result.ref.preview_id))
261+
elif _has_previewable_content(result.asset, result.ref.file_path):
262+
preview_url = _build_view_url(result.ref.file_path)
163263
else:
164-
preview_url = _build_preview_url_from_view(result.tags, result.ref.user_metadata)
264+
preview_url = None
165265
if result.ref.file_path:
166-
display_name = compute_display_name(result.ref.file_path)
266+
paths = compute_asset_response_paths(result.ref.file_path)
267+
display_name = paths[1] if paths else None
167268
# In-root loader path (model category dropped): what model loaders consume.
168269
loader_path = result.ref.loader_path
169270
else:
@@ -217,15 +318,21 @@ async def list_assets_route(request: web.Request) -> web.Response:
217318
except ValidationError as ve:
218319
return _build_validation_error_response("INVALID_QUERY", ve)
219320

321+
try:
322+
tags_all, tags_any, tags_none = _resolve_tag_filters(q)
323+
except InvalidTagFilterError as e:
324+
return _build_error_response(400, "INVALID_TAG_FILTER", str(e), e.details)
325+
220326
sort = _validate_sort_field(q.sort)
221327
order_candidate = (q.order or "desc").lower()
222328
order = order_candidate if order_candidate in {"asc", "desc"} else "desc"
223329

224330
try:
225331
result = list_assets_page(
226332
owner_id=USER_MANAGER.get_request_user_id(request),
227-
include_tags=q.include_tags,
228-
exclude_tags=q.exclude_tags,
333+
include_tags=tags_all,
334+
exclude_tags=tags_none,
335+
any_tags=tags_any,
229336
name_contains=q.name_contains,
230337
metadata_filter=q.metadata_filter,
231338
limit=q.limit,
@@ -237,7 +344,8 @@ async def list_assets_route(request: web.Request) -> web.Response:
237344
except InvalidCursorError as e:
238345
return _build_error_response(400, "INVALID_CURSOR", str(e))
239346

240-
summaries = [_build_asset_response(item) for item in result.items]
347+
preview_paths = _resolve_preview_paths(result.items)
348+
summaries = [_build_asset_response(item, preview_paths) for item in result.items]
241349

242350
# has_more semantics differ by mode:
243351
# - cursor mode: a non-empty next_cursor means there are more results.
@@ -276,7 +384,7 @@ async def get_asset_route(request: web.Request) -> web.Response:
276384
{"id": reference_id},
277385
)
278386

279-
payload = _build_asset_response(result)
387+
payload = _build_asset_response(result, _resolve_preview_paths([result]))
280388
except ValueError as e:
281389
return _build_error_response(
282390
404, "ASSET_NOT_FOUND", str(e), {"id": reference_id}
@@ -407,7 +515,7 @@ async def create_asset_from_hash_route(request: web.Request) -> web.Response:
407515
404, "ASSET_NOT_FOUND", f"Asset content {body.hash} does not exist"
408516
)
409517

410-
asset = _build_asset_response(result)
518+
asset = _build_asset_response(result, _resolve_preview_paths([result]))
411519
payload_out = schemas_out.AssetCreated(
412520
**asset.model_dump(),
413521
created_new=result.created_new,
@@ -498,7 +606,7 @@ async def upload_asset(request: web.Request) -> web.Response:
498606
logging.exception("upload_asset failed for owner_id=%s", owner_id)
499607
return _build_error_response(500, "INTERNAL", "Unexpected server error.")
500608

501-
asset = _build_asset_response(result)
609+
asset = _build_asset_response(result, _resolve_preview_paths([result]))
502610
payload_out = schemas_out.AssetCreated(
503611
**asset.model_dump(),
504612
created_new=result.created_new,
@@ -528,7 +636,7 @@ async def update_asset_route(request: web.Request) -> web.Response:
528636
owner_id=USER_MANAGER.get_request_user_id(request),
529637
preview_id=body.preview_id,
530638
)
531-
payload = _build_asset_response(result)
639+
payload = _build_asset_response(result, _resolve_preview_paths([result]))
532640
except PermissionError as pe:
533641
return _build_error_response(403, "FORBIDDEN", str(pe), {"id": reference_id})
534642
except ValueError as ve:
@@ -715,10 +823,16 @@ async def get_tags_refine(request: web.Request) -> web.Response:
715823
except ValidationError as ve:
716824
return _build_validation_error_response("INVALID_QUERY", ve)
717825

826+
try:
827+
tags_all, tags_any, tags_none = _resolve_tag_filters(q)
828+
except InvalidTagFilterError as e:
829+
return _build_error_response(400, "INVALID_TAG_FILTER", str(e), e.details)
830+
718831
tag_counts = list_tag_histogram(
719832
owner_id=USER_MANAGER.get_request_user_id(request),
720-
include_tags=q.include_tags,
721-
exclude_tags=q.exclude_tags,
833+
include_tags=tags_all,
834+
exclude_tags=tags_none,
835+
any_tags=tags_any,
722836
name_contains=q.name_contains,
723837
metadata_filter=q.metadata_filter,
724838
limit=q.limit,

0 commit comments

Comments
 (0)