Skip to content

Commit 6885904

Browse files
authored
Merge branch 'master' into ci/notify-on-merge
2 parents 7652bbe + 8583b0c commit 6885904

79 files changed

Lines changed: 5928 additions & 553 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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.

app/assets/api/routes.py

Lines changed: 53 additions & 32 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
@@ -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()
@@ -207,44 +209,62 @@ def _validate_sort_field(requested: str | None) -> str:
207209
return "created_at"
208210

209211

210-
def _build_preview_url_from_view(tags: list[str], user_metadata: dict[str, Any] | None) -> str | None:
211-
"""Build a /api/view preview URL from asset tags and user_metadata filename."""
212-
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:
213230
return None
214-
filename = user_metadata.get("filename")
215-
if not filename:
231+
paths = compute_asset_response_paths(file_path)
232+
if not paths:
216233
return None
217-
218-
if "input" in tags:
219-
view_type = "input"
220-
elif "output" in tags:
221-
view_type = "output"
222-
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:
223237
return None
224238

225-
subfolder = ""
226-
if "/" in filename:
227-
subfolder, filename = filename.rsplit("/", 1)
228-
229-
encoded_filename = urllib.parse.quote(filename, safe="")
230-
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='')}"
231241
if subfolder:
232242
url += f"&subfolder={urllib.parse.quote(subfolder, safe='')}"
233243
return url
234244

235245

236-
def _build_asset_response(result: schemas.AssetDetailResult | schemas.UploadResult) -> schemas_out.Asset:
237-
"""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:
238258
if result.ref.preview_id:
239-
preview_detail = get_asset_detail(result.ref.preview_id)
240-
if preview_detail:
241-
preview_url = _build_preview_url_from_view(preview_detail.tags, preview_detail.ref.user_metadata)
242-
else:
243-
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)
244263
else:
245-
preview_url = _build_preview_url_from_view(result.tags, result.ref.user_metadata)
264+
preview_url = None
246265
if result.ref.file_path:
247-
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
248268
# In-root loader path (model category dropped): what model loaders consume.
249269
loader_path = result.ref.loader_path
250270
else:
@@ -324,7 +344,8 @@ async def list_assets_route(request: web.Request) -> web.Response:
324344
except InvalidCursorError as e:
325345
return _build_error_response(400, "INVALID_CURSOR", str(e))
326346

327-
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]
328349

329350
# has_more semantics differ by mode:
330351
# - cursor mode: a non-empty next_cursor means there are more results.
@@ -363,7 +384,7 @@ async def get_asset_route(request: web.Request) -> web.Response:
363384
{"id": reference_id},
364385
)
365386

366-
payload = _build_asset_response(result)
387+
payload = _build_asset_response(result, _resolve_preview_paths([result]))
367388
except ValueError as e:
368389
return _build_error_response(
369390
404, "ASSET_NOT_FOUND", str(e), {"id": reference_id}
@@ -494,7 +515,7 @@ async def create_asset_from_hash_route(request: web.Request) -> web.Response:
494515
404, "ASSET_NOT_FOUND", f"Asset content {body.hash} does not exist"
495516
)
496517

497-
asset = _build_asset_response(result)
518+
asset = _build_asset_response(result, _resolve_preview_paths([result]))
498519
payload_out = schemas_out.AssetCreated(
499520
**asset.model_dump(),
500521
created_new=result.created_new,
@@ -585,7 +606,7 @@ async def upload_asset(request: web.Request) -> web.Response:
585606
logging.exception("upload_asset failed for owner_id=%s", owner_id)
586607
return _build_error_response(500, "INTERNAL", "Unexpected server error.")
587608

588-
asset = _build_asset_response(result)
609+
asset = _build_asset_response(result, _resolve_preview_paths([result]))
589610
payload_out = schemas_out.AssetCreated(
590611
**asset.model_dump(),
591612
created_new=result.created_new,
@@ -615,7 +636,7 @@ async def update_asset_route(request: web.Request) -> web.Response:
615636
owner_id=USER_MANAGER.get_request_user_id(request),
616637
preview_id=body.preview_id,
617638
)
618-
payload = _build_asset_response(result)
639+
payload = _build_asset_response(result, _resolve_preview_paths([result]))
619640
except PermissionError as pe:
620641
return _build_error_response(403, "FORBIDDEN", str(pe), {"id": reference_id})
621642
except ValueError as ve:

app/assets/database/queries/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
get_reference_by_id,
2929
get_reference_with_owner_check,
3030
get_reference_ids_by_ids,
31+
get_reference_paths_by_ids,
3132
get_references_by_paths_and_asset_ids,
3233
get_references_for_prefixes,
3334
get_unenriched_references,
@@ -101,6 +102,7 @@
101102
"get_reference_by_id",
102103
"get_reference_with_owner_check",
103104
"get_reference_ids_by_ids",
105+
"get_reference_paths_by_ids",
104106
"get_reference_tags",
105107
"get_references_by_paths_and_asset_ids",
106108
"get_references_for_prefixes",

app/assets/database/queries/asset_reference.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1064,6 +1064,27 @@ def get_references_by_paths_and_asset_ids(
10641064
return winners
10651065

10661066

1067+
def get_reference_paths_by_ids(
1068+
session: Session,
1069+
reference_ids: list[str],
1070+
) -> dict[str, str]:
1071+
"""Map reference id -> file_path for live, file-backed references."""
1072+
if not reference_ids:
1073+
return {}
1074+
1075+
paths: dict[str, str] = {}
1076+
for chunk in iter_chunks(reference_ids, MAX_BIND_PARAMS):
1077+
rows = session.execute(
1078+
select(AssetReference.id, AssetReference.file_path).where(
1079+
AssetReference.id.in_(chunk),
1080+
AssetReference.file_path.is_not(None),
1081+
AssetReference.deleted_at.is_(None),
1082+
)
1083+
)
1084+
paths.update({rid: fp for rid, fp in rows})
1085+
return paths
1086+
1087+
10671088
def get_reference_ids_by_ids(
10681089
session: Session,
10691090
reference_ids: list[str],

app/assets/scanner.py

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,11 @@ class _AssetAccumulator(TypedDict):
5757
refs: list[_RefInfo]
5858

5959

60+
# Temp is deliberately absent: it is wiped before every scan, so walking it finds nothing.
6061
RootType = Literal["models", "input", "output"]
6162

6263

63-
def get_prefixes_for_root(root: RootType) -> list[str]:
64+
def get_scan_prefixes_for_root(root: RootType) -> list[str]:
6465
if root == "models":
6566
bases: list[str] = []
6667
for _bucket, paths, _exts in get_comfy_models_folders():
@@ -73,10 +74,15 @@ def get_prefixes_for_root(root: RootType) -> list[str]:
7374
return []
7475

7576

76-
def get_all_known_prefixes() -> list[str]:
77-
"""Get all known asset prefixes across all root types."""
78-
all_roots: tuple[RootType, ...] = ("models", "input", "output")
79-
return [p for root in all_roots for p in get_prefixes_for_root(root)]
77+
def get_owned_prefixes() -> list[str]:
78+
"""Every directory an asset may live in; references outside these are marked missing."""
79+
scan_roots: tuple[RootType, ...] = ("models", "input", "output")
80+
prefixes = [p for root in scan_roots for p in get_scan_prefixes_for_root(root)]
81+
return prefixes + get_temp_prefixes()
82+
83+
84+
def get_temp_prefixes() -> list[str]:
85+
return [os.path.abspath(folder_paths.get_temp_directory())]
8086

8187

8288
def collect_models_files() -> list[str]:
@@ -107,7 +113,21 @@ def sync_references_with_filesystem(
107113
collect_existing_paths: bool = False,
108114
update_missing_tags: bool = False,
109115
) -> set[str] | None:
110-
"""Reconcile asset references with filesystem for a root.
116+
return sync_prefixes_with_filesystem(
117+
session,
118+
get_scan_prefixes_for_root(root),
119+
collect_existing_paths=collect_existing_paths,
120+
update_missing_tags=update_missing_tags,
121+
)
122+
123+
124+
def sync_prefixes_with_filesystem(
125+
session,
126+
prefixes: list[str],
127+
collect_existing_paths: bool = False,
128+
update_missing_tags: bool = False,
129+
) -> set[str] | None:
130+
"""Reconcile asset references with filesystem under the given prefixes.
111131
112132
- Toggle needs_verify per reference using mtime/size stat check
113133
- For hashed assets with at least one stat-unchanged ref: delete stale missing refs
@@ -117,14 +137,13 @@ def sync_references_with_filesystem(
117137
118138
Args:
119139
session: Database session
120-
root: Root type to scan
140+
prefixes: Absolute directory prefixes whose references to reconcile
121141
collect_existing_paths: If True, return set of surviving file paths
122142
update_missing_tags: If True, update 'missing' tags based on file status
123143
124144
Returns:
125145
Set of surviving absolute paths if collect_existing_paths=True, else None
126146
"""
127-
prefixes = get_prefixes_for_root(root)
128147
if not prefixes:
129148
return set() if collect_existing_paths else None
130149

@@ -251,6 +270,16 @@ def sync_root_safely(root: RootType) -> set[str]:
251270
return set()
252271

253272

273+
def sync_temp_references_safely() -> None:
274+
"""Retire temp references whose file is gone; temp is never scanned, so nothing else stats them."""
275+
try:
276+
with create_session() as sess:
277+
sync_prefixes_with_filesystem(sess, get_temp_prefixes())
278+
sess.commit()
279+
except Exception as e:
280+
logging.exception("temp reference sync failed: %s", e)
281+
282+
254283
def mark_missing_outside_prefixes_safely(prefixes: list[str]) -> int:
255284
"""Mark references as missing when outside the given prefixes.
256285
@@ -384,7 +413,7 @@ def get_unenriched_assets_for_roots(
384413
"""
385414
prefixes: list[str] = []
386415
for root in roots:
387-
prefixes.extend(get_prefixes_for_root(root))
416+
prefixes.extend(get_scan_prefixes_for_root(root))
388417

389418
if not prefixes:
390419
return []

app/assets/seeder.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@
1515
build_asset_specs,
1616
collect_paths_for_roots,
1717
enrich_assets_batch,
18-
get_all_known_prefixes,
19-
get_prefixes_for_root,
18+
get_owned_prefixes,
19+
get_scan_prefixes_for_root,
2020
get_unenriched_assets_for_roots,
2121
insert_asset_specs,
2222
mark_missing_outside_prefixes_safely,
2323
sync_root_safely,
24+
sync_temp_references_safely,
2425
)
2526
from app.database.db import dependencies_available
2627

@@ -413,7 +414,7 @@ def mark_missing_outside_prefixes(self) -> int:
413414
)
414415
return 0
415416

416-
all_prefixes = get_all_known_prefixes()
417+
all_prefixes = get_owned_prefixes()
417418
marked = mark_missing_outside_prefixes_safely(all_prefixes)
418419
if marked > 0:
419420
logging.info("Marked %d references as missing", marked)
@@ -523,7 +524,7 @@ def _log_scan_config(self, roots: tuple[RootType, ...]) -> None:
523524
os.path.abspath(folder_paths.models_dir),
524525
)
525526
else:
526-
prefixes = get_prefixes_for_root(root)
527+
prefixes = get_scan_prefixes_for_root(root)
527528
if prefixes:
528529
logging.info("Asset scan [%s] directories: %s", root, prefixes)
529530

@@ -548,10 +549,11 @@ def _run_scan(self) -> None:
548549
return
549550

550551
if self._prune_first:
551-
all_prefixes = get_all_known_prefixes()
552+
all_prefixes = get_owned_prefixes()
552553
marked = mark_missing_outside_prefixes_safely(all_prefixes)
553554
if marked > 0:
554555
logging.info("Marked %d refs as missing before scan", marked)
556+
sync_temp_references_safely()
555557

556558
if self._check_pause_and_cancel():
557559
logging.info("Asset scan cancelled after pruning phase")

app/assets/services/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
get_asset_by_hash,
55
get_asset_detail,
66
list_assets_page,
7+
get_preview_file_paths,
78
resolve_asset_for_download,
89
set_asset_preview,
910
update_asset_metadata,
@@ -83,6 +84,7 @@
8384
"list_tags",
8485
"cleanup_unreferenced_assets",
8586
"remove_tags",
87+
"get_preview_file_paths",
8688
"resolve_asset_for_download",
8789
"set_asset_preview",
8890
"update_asset_metadata",

app/assets/services/asset_management.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
reference_exists_for_asset_id,
2222
delete_reference_by_id,
2323
fetch_reference_and_asset,
24+
get_reference_paths_by_ids,
2425
soft_delete_reference_by_id,
2526
fetch_reference_asset_and_tags,
2627
get_asset_by_hash as queries_get_asset_by_hash,
@@ -424,6 +425,14 @@ def resolve_hash_to_path(
424425
)
425426

426427

428+
def get_preview_file_paths(preview_ids: list[str]) -> dict[str, str]:
429+
"""Map preview reference id -> file_path, in one query for the whole page."""
430+
if not preview_ids:
431+
return {}
432+
with create_session() as session:
433+
return get_reference_paths_by_ids(session, reference_ids=preview_ids)
434+
435+
427436
def resolve_asset_for_download(
428437
reference_id: str,
429438
owner_id: str = "",

0 commit comments

Comments
 (0)