mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-07-10 16:37:36 +08:00
* feat(assets): add namespaced model type tags * fix(assets): mark path-derived upload tags automatic * fix(assets): merge duplicate scan specs * test(assets): make duplicate path normalization portable * feat(assets): add loader_path as the authoritative loader locator (#14796) * fix(assets): filter model_type tags by bucket extension sets Buckets sharing a base directory (e.g. diffusion_models and a custom unet_gguf) tagged every file in the directory regardless of whether the bucket could load it, so .safetensors files were tagged model_type:unet_gguf and vice versa. Carry each bucket's registered extension set through get_comfy_models_folders and only emit a model_type tag when the file extension matches, keeping the empty-set match-all convention from folder_paths.filter_files_extensions. Files under a model base matching no bucket now keep only the models tag instead of every directory-matching model_type tag. * feat(assets): replace response file_path with persisted loader_path The old file_path response field was a namespaced storage locator (models/checkpoints/foo.safetensors): not an absolute path, not unique identity, and not the value a loader consumes. Nothing needs that shape on the wire (hash/ID-based locating is the long-term direction), so it is dropped rather than renamed; the storage-root matching stays internal, powering display_name. What loaders DO need is the in-root loader path (category dropped: models/checkpoints/foo/bar.safetensors -> foo/bar.safetensors). Serve it as a first-class loader_path field, persisted on asset_references (migration 0006) and written by every ingest pipeline at insert, so responses read the column verbatim. Like the model_type tags, loader_path is a seed-time derivative of the model folder registry, maintained by the same scan lifecycle (new files seed fresh values, pruning retires rows whose bucket disappeared). Rows predating the column serve a null loader_path; databases from before this stack already need recreating for the base branch's tag changes. loader_path resolves every registered base including extra_model_paths entries; display_name only the canonical storage roots. A file can therefore be loadable with no display name (extra-path models) or the reverse (unregistered files under the models root), and loader_path is null exactly when no loader can resolve the file. * test(assets): lock loader_path matrix (asymmetry, null, persist/read) Cover the behaviour that has no production change but is easy to regress: the extra-path asymmetry (loadable but no storage namespace), null loader_path persistence for orphan files, and the response reading the stored column with a compute fallback for un-backfilled rows. * fix(assets): persist subfolder-qualified loader_path for ingested outputs ingest_existing_file built its seed spec with the file's basename, so outputs saved into a subfolder persisted loader_path (and the user_metadata filename that preview URLs split for their subfolder param) as just the basename: the served locator pointed at a file that does not exist at that path. Scanner and seeder specs already derive fname via compute_loader_path; use the same derivation here. * fix(assets): only extension-matching buckets contribute a loader_path The model-base match in get_asset_category_and_relative_path ignored each bucket's extension set, so a file inside a registered base whose extension the bucket cannot load (e.g. a .txt uploaded into model_type:checkpoints) advertised a loader_path that no loader list would ever resolve, while the tag side of the same stack already excluded it. Apply the extension check used for backend tags (empty set accepts any extension), keeping loader_path null exactly when no loader can resolve the file. * fix(assets): refresh loader_path when re-ingesting an existing reference upsert_reference only wrote loader_path on the INSERT branch, so re-ingesting an existing reference (an output overwritten in place, or a file re-registered after its loader_path derivation changed) kept the stale or NULL value forever. Write it on the UPDATE branch too, with a null-safe change guard so a loader_path difference alone is enough to trigger the update, and identical values stay a no-op. * fix(assets): repair semantic merge breakage from #14796 and master Two textually-clean but semantically-broken merges: - routes.py lost its folder_paths import when #14796's import block superseded the base's, while the content-type hardening added via the base's master merge still calls folder_paths.is_dangerous_content_type. - master's SVG download-hardening test uploads with the pre-namespacing bare checkpoints tag, which this branch's destination validation rejects; use model_type:checkpoints. --------- Co-authored-by: guill <jacob.e.segal@gmail.com>
468 lines
15 KiB
Python
468 lines
15 KiB
Python
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 (
|
|
asset_exists_by_hash,
|
|
reference_exists_for_asset_id,
|
|
delete_reference_by_id,
|
|
fetch_reference_and_asset,
|
|
soft_delete_reference_by_id,
|
|
fetch_reference_asset_and_tags,
|
|
get_asset_by_hash as queries_get_asset_by_hash,
|
|
get_reference_by_id,
|
|
get_reference_with_owner_check,
|
|
list_references_page,
|
|
list_all_file_paths_by_asset_id,
|
|
list_references_by_asset_id,
|
|
set_reference_metadata,
|
|
set_reference_preview,
|
|
set_reference_tags,
|
|
update_asset_hash_and_mime,
|
|
update_reference_access_time,
|
|
update_reference_name,
|
|
update_reference_updated_at,
|
|
)
|
|
from app.assets.helpers import select_best_live_path
|
|
from app.assets.services.path_utils import compute_loader_path
|
|
from app.assets.services.schemas import (
|
|
AssetData,
|
|
AssetDetailResult,
|
|
AssetSummaryData,
|
|
DownloadResolutionResult,
|
|
ListAssetsResult,
|
|
UserMetadata,
|
|
extract_asset_data,
|
|
extract_reference_data,
|
|
)
|
|
from app.database.db import create_session
|
|
|
|
|
|
def get_asset_detail(
|
|
reference_id: str,
|
|
owner_id: str = "",
|
|
) -> AssetDetailResult | None:
|
|
with create_session() as session:
|
|
result = fetch_reference_asset_and_tags(
|
|
session,
|
|
reference_id=reference_id,
|
|
owner_id=owner_id,
|
|
)
|
|
if not result:
|
|
return None
|
|
|
|
ref, asset, tags = result
|
|
return AssetDetailResult(
|
|
ref=extract_reference_data(ref),
|
|
asset=extract_asset_data(asset),
|
|
tags=tags,
|
|
)
|
|
|
|
|
|
def update_asset_metadata(
|
|
reference_id: str,
|
|
name: str | None = None,
|
|
tags: Sequence[str] | None = None,
|
|
user_metadata: UserMetadata = None,
|
|
tag_origin: str = "manual",
|
|
owner_id: str = "",
|
|
mime_type: str | None = None,
|
|
preview_id: str | None = None,
|
|
) -> AssetDetailResult:
|
|
with create_session() as session:
|
|
ref = get_reference_with_owner_check(session, reference_id, owner_id)
|
|
|
|
touched = False
|
|
if name is not None and name != ref.name:
|
|
update_reference_name(session, reference_id=reference_id, name=name)
|
|
touched = True
|
|
|
|
computed_filename = compute_loader_path(ref.file_path) if ref.file_path else None
|
|
|
|
new_meta: dict | None = None
|
|
if user_metadata is not None:
|
|
new_meta = dict(user_metadata)
|
|
elif computed_filename:
|
|
current_meta = ref.user_metadata or {}
|
|
if current_meta.get("filename") != computed_filename:
|
|
new_meta = dict(current_meta)
|
|
|
|
if new_meta is not None:
|
|
if computed_filename:
|
|
new_meta["filename"] = computed_filename
|
|
set_reference_metadata(
|
|
session, reference_id=reference_id, user_metadata=new_meta
|
|
)
|
|
touched = True
|
|
|
|
if tags is not None:
|
|
set_reference_tags(
|
|
session,
|
|
reference_id=reference_id,
|
|
tags=tags,
|
|
origin=tag_origin,
|
|
)
|
|
touched = True
|
|
|
|
if mime_type is not None:
|
|
updated = update_asset_hash_and_mime(
|
|
session, asset_id=ref.asset_id, mime_type=mime_type
|
|
)
|
|
if updated:
|
|
touched = True
|
|
|
|
if preview_id is not None:
|
|
set_reference_preview(
|
|
session,
|
|
reference_id=reference_id,
|
|
preview_reference_id=preview_id,
|
|
)
|
|
touched = True
|
|
|
|
if touched and user_metadata is None:
|
|
update_reference_updated_at(session, reference_id=reference_id)
|
|
|
|
result = fetch_reference_asset_and_tags(
|
|
session,
|
|
reference_id=reference_id,
|
|
owner_id=owner_id,
|
|
)
|
|
if not result:
|
|
raise RuntimeError("State changed during update")
|
|
|
|
ref, asset, tag_list = result
|
|
detail = AssetDetailResult(
|
|
ref=extract_reference_data(ref),
|
|
asset=extract_asset_data(asset),
|
|
tags=tag_list,
|
|
)
|
|
session.commit()
|
|
|
|
return detail
|
|
|
|
|
|
def delete_asset_reference(
|
|
reference_id: str,
|
|
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
|
|
deleted = soft_delete_reference_by_id(
|
|
session, reference_id=reference_id, owner_id=owner_id
|
|
)
|
|
session.commit()
|
|
return deleted
|
|
|
|
ref_row = get_reference_by_id(session, reference_id=reference_id)
|
|
asset_id = ref_row.asset_id if ref_row else None
|
|
file_path = ref_row.file_path if ref_row else None
|
|
|
|
deleted = delete_reference_by_id(
|
|
session, reference_id=reference_id, owner_id=owner_id
|
|
)
|
|
if not deleted:
|
|
session.commit()
|
|
return False
|
|
|
|
if not asset_id:
|
|
session.commit()
|
|
return True
|
|
|
|
still_exists = reference_exists_for_asset_id(session, asset_id=asset_id)
|
|
if still_exists:
|
|
session.commit()
|
|
return True
|
|
|
|
# Orphaned asset - gather ALL file paths (including
|
|
# soft-deleted / missing refs) so their on-disk files get cleaned up.
|
|
file_paths = list_all_file_paths_by_asset_id(session, asset_id=asset_id)
|
|
# Also include the just-deleted file path
|
|
if file_path:
|
|
file_paths.append(file_path)
|
|
|
|
asset_row = session.get(Asset, asset_id)
|
|
if asset_row is not None:
|
|
session.delete(asset_row)
|
|
|
|
session.commit()
|
|
|
|
# Delete files after commit
|
|
for p in file_paths:
|
|
with contextlib.suppress(Exception):
|
|
if p and os.path.isfile(p):
|
|
os.remove(p)
|
|
|
|
return True
|
|
|
|
|
|
def set_asset_preview(
|
|
reference_id: str,
|
|
preview_reference_id: str | None = None,
|
|
owner_id: str = "",
|
|
) -> AssetDetailResult:
|
|
with create_session() as session:
|
|
get_reference_with_owner_check(session, reference_id, owner_id)
|
|
|
|
set_reference_preview(
|
|
session,
|
|
reference_id=reference_id,
|
|
preview_reference_id=preview_reference_id,
|
|
)
|
|
|
|
result = fetch_reference_asset_and_tags(
|
|
session, reference_id=reference_id, owner_id=owner_id
|
|
)
|
|
if not result:
|
|
raise RuntimeError("State changed during preview update")
|
|
|
|
ref, asset, tags = result
|
|
detail = AssetDetailResult(
|
|
ref=extract_reference_data(ref),
|
|
asset=extract_asset_data(asset),
|
|
tags=tags,
|
|
)
|
|
session.commit()
|
|
|
|
return detail
|
|
|
|
|
|
def asset_exists(asset_hash: str) -> bool:
|
|
with create_session() as session:
|
|
return asset_exists_by_hash(session, asset_hash=asset_hash)
|
|
|
|
|
|
def get_asset_by_hash(asset_hash: str) -> AssetData | None:
|
|
with create_session() as session:
|
|
asset = queries_get_asset_by_hash(session, asset_hash=asset_hash)
|
|
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,
|
|
exclude_tags: Sequence[str] | None = None,
|
|
name_contains: str | None = None,
|
|
metadata_filter: dict | None = None,
|
|
limit: int = 20,
|
|
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,
|
|
owner_id=owner_id,
|
|
include_tags=include_tags,
|
|
exclude_tags=exclude_tags,
|
|
name_contains=name_contains,
|
|
metadata_filter=metadata_filter,
|
|
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(
|
|
AssetSummaryData(
|
|
ref=extract_reference_data(ref),
|
|
asset=extract_asset_data(ref.asset),
|
|
tags=tag_map.get(ref.id, []),
|
|
)
|
|
)
|
|
|
|
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(
|
|
asset_hash: str,
|
|
owner_id: str = "",
|
|
) -> DownloadResolutionResult | None:
|
|
"""Resolve a blake3 hash to an on-disk file path.
|
|
|
|
Only references visible to *owner_id* are considered (owner-less
|
|
references are always visible).
|
|
|
|
Returns a DownloadResolutionResult with abs_path, content_type, and
|
|
download_name, or None if no asset or live path is found.
|
|
"""
|
|
with create_session() as session:
|
|
asset = queries_get_asset_by_hash(session, asset_hash)
|
|
if not asset:
|
|
return None
|
|
refs = list_references_by_asset_id(session, asset_id=asset.id)
|
|
visible = [
|
|
r for r in refs
|
|
if r.owner_id == "" or r.owner_id == owner_id
|
|
]
|
|
abs_path = select_best_live_path(visible)
|
|
if not abs_path:
|
|
return None
|
|
display_name = os.path.basename(abs_path)
|
|
for ref in visible:
|
|
if ref.file_path == abs_path and ref.name:
|
|
display_name = ref.name
|
|
break
|
|
ctype = (
|
|
asset.mime_type
|
|
or mimetypes.guess_type(display_name)[0]
|
|
or "application/octet-stream"
|
|
)
|
|
return DownloadResolutionResult(
|
|
abs_path=abs_path,
|
|
content_type=ctype,
|
|
download_name=display_name,
|
|
)
|
|
|
|
|
|
def resolve_asset_for_download(
|
|
reference_id: str,
|
|
owner_id: str = "",
|
|
) -> DownloadResolutionResult:
|
|
with create_session() as session:
|
|
pair = fetch_reference_and_asset(
|
|
session, reference_id=reference_id, owner_id=owner_id
|
|
)
|
|
if not pair:
|
|
raise ValueError(f"AssetReference {reference_id} not found")
|
|
|
|
ref, asset = pair
|
|
|
|
# For references with file_path, use that directly
|
|
if ref.file_path and os.path.isfile(ref.file_path):
|
|
abs_path = ref.file_path
|
|
else:
|
|
# For API-created refs without file_path, find a path from other refs
|
|
refs = list_references_by_asset_id(session, asset_id=asset.id)
|
|
abs_path = select_best_live_path(refs)
|
|
if not abs_path:
|
|
raise FileNotFoundError(
|
|
f"No live path for AssetReference {reference_id} "
|
|
f"(asset id={asset.id}, name={ref.name})"
|
|
)
|
|
|
|
# Capture ORM attributes before commit (commit expires loaded objects)
|
|
ref_name = ref.name
|
|
asset_mime = asset.mime_type
|
|
|
|
update_reference_access_time(session, reference_id=reference_id)
|
|
session.commit()
|
|
|
|
ctype = (
|
|
asset_mime
|
|
or mimetypes.guess_type(ref_name or abs_path)[0]
|
|
or "application/octet-stream"
|
|
)
|
|
download_name = ref_name or os.path.basename(abs_path)
|
|
return DownloadResolutionResult(
|
|
abs_path=abs_path,
|
|
content_type=ctype,
|
|
download_name=download_name,
|
|
)
|