Skip to content

Commit a0a055b

Browse files
mattmilleraiguill
andauthored
feat(assets): extract image dimensions at ingest and emit on asset responses (Comfy-Org#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 Comfy-Org#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 Comfy-Org#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>
1 parent a1c434e commit a0a055b

5 files changed

Lines changed: 460 additions & 1 deletion

File tree

app/assets/scanner.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
verify_file_unchanged,
3434
)
3535
from app.assets.services.hashing import HashCheckpoint, compute_blake3_hash
36+
from app.assets.services.image_dimensions import extract_image_dimensions
3637
from app.assets.services.metadata_extract import extract_file_metadata
3738
from app.assets.services.path_utils import (
3839
compute_relative_filename,
@@ -506,6 +507,10 @@ def enrich_asset(
506507

507508
if extract_metadata and metadata:
508509
system_metadata = metadata.to_user_metadata()
510+
if mime_type and mime_type.startswith("image/"):
511+
dims = extract_image_dimensions(file_path, mime_type=mime_type)
512+
if dims:
513+
system_metadata.update(dims)
509514
set_reference_system_metadata(session, reference_id, system_metadata)
510515

511516
if full_hash:
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Image dimension extraction for asset ingest.
2+
3+
Reads only the image header via Pillow to capture width/height cheaply,
4+
without a full pixel decode. Returns a metadata dict suitable for merging
5+
into ``AssetReference.system_metadata``.
6+
"""
7+
from __future__ import annotations
8+
9+
import logging
10+
from typing import Any
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
def extract_image_dimensions(
16+
file_path: str, mime_type: str | None = None
17+
) -> dict[str, Any] | None:
18+
"""Extract image dimensions for the file at ``file_path``.
19+
20+
Args:
21+
file_path: Absolute path to a file on disk.
22+
mime_type: Optional MIME type hint. When provided and not prefixed
23+
with ``image/``, extraction is skipped without touching the file.
24+
25+
Returns:
26+
``{"kind": "image", "width": W, "height": H}`` when the file is a
27+
recognizable image with positive dimensions, otherwise ``None``.
28+
29+
The dict shape is intended to be merged into ``system_metadata`` so the
30+
asset response surfaces ``metadata.kind`` plus dimension fields for image
31+
assets. Forward-compatible: future media kinds (e.g. ``"video"`` with
32+
duration/fps) can extend this shape without schema changes.
33+
"""
34+
if mime_type is not None and not mime_type.startswith("image/"):
35+
return None
36+
37+
try:
38+
from PIL import Image, UnidentifiedImageError
39+
except ImportError:
40+
logger.debug(
41+
"Pillow not available; skipping image dimension extraction for %s",
42+
file_path,
43+
)
44+
return None
45+
46+
try:
47+
with Image.open(file_path) as img:
48+
width, height = img.size
49+
except (OSError, UnidentifiedImageError, ValueError) as exc:
50+
logger.debug(
51+
"Failed to read image dimensions from %s: %s", file_path, exc
52+
)
53+
return None
54+
55+
if (
56+
not isinstance(width, int)
57+
or not isinstance(height, int)
58+
or width <= 0
59+
or height <= 0
60+
):
61+
return None
62+
63+
return {"kind": "image", "width": width, "height": height}

app/assets/services/ingest.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@
1717
get_reference_by_file_path,
1818
get_reference_tags,
1919
get_or_create_reference,
20+
list_references_by_asset_id,
2021
reference_exists,
2122
remove_missing_tag_for_asset_id,
2223
set_reference_metadata,
24+
set_reference_system_metadata,
2325
set_reference_tags,
2426
update_asset_hash_and_mime,
2527
upsert_asset,
@@ -29,6 +31,7 @@
2931
from app.assets.helpers import get_utc_now, normalize_tags
3032
from app.assets.services.bulk_ingest import batch_insert_seed_assets
3133
from app.assets.services.file_utils import get_size_and_mtime_ns
34+
from app.assets.services.image_dimensions import extract_image_dimensions
3235
from app.assets.services.path_utils import (
3336
compute_relative_filename,
3437
get_name_and_tags_from_asset_path,
@@ -118,6 +121,14 @@ def _ingest_file_from_path(
118121
user_metadata=user_metadata,
119122
)
120123

124+
_maybe_store_image_dimensions(
125+
session,
126+
reference_id=reference_id,
127+
file_path=locator,
128+
mime_type=mime_type,
129+
current_system_metadata=ref.system_metadata,
130+
)
131+
121132
try:
122133
remove_missing_tag_for_asset_id(session, asset_id=asset.id)
123134
except Exception:
@@ -288,6 +299,13 @@ def _register_existing_asset(
288299
user_metadata=new_meta,
289300
)
290301

302+
_backfill_image_dimensions_from_siblings(
303+
session,
304+
asset_id=asset.id,
305+
new_reference_id=ref.id,
306+
current_system_metadata=ref.system_metadata,
307+
)
308+
291309
if tags is not None:
292310
set_reference_tags(
293311
session,
@@ -334,6 +352,87 @@ def _update_metadata_with_filename(
334352
)
335353

336354

355+
_IMAGE_DIMENSION_KEYS = ("kind", "width", "height")
356+
357+
358+
def _maybe_store_image_dimensions(
359+
session: Session,
360+
reference_id: str,
361+
file_path: str,
362+
mime_type: str | None,
363+
current_system_metadata: dict | None,
364+
) -> None:
365+
"""Populate ``kind``/``width``/``height`` on system_metadata for image refs.
366+
367+
Non-image MIME types are a no-op. Pre-existing keys (e.g. enricher-written
368+
safetensors metadata, download provenance) are preserved by merge.
369+
"""
370+
if not mime_type or not mime_type.startswith("image/"):
371+
return
372+
373+
dims = extract_image_dimensions(file_path, mime_type=mime_type)
374+
if not dims:
375+
return
376+
377+
current = current_system_metadata or {}
378+
merged = dict(current)
379+
merged.update(dims)
380+
if merged != current:
381+
set_reference_system_metadata(
382+
session,
383+
reference_id=reference_id,
384+
system_metadata=merged,
385+
)
386+
387+
388+
def _backfill_image_dimensions_from_siblings(
389+
session: Session,
390+
asset_id: str,
391+
new_reference_id: str,
392+
current_system_metadata: dict | None,
393+
) -> None:
394+
"""Copy image dimension keys from any sibling reference of the same asset.
395+
396+
The from-hash path doesn't read the file bytes, so dimensions can't be
397+
extracted there directly. When another reference of the same asset already
398+
carries image dimensions, copy them onto the new reference so consumers
399+
see consistent metadata regardless of how the asset was registered.
400+
401+
Best-effort: missing siblings, non-image siblings, or absent dimension
402+
keys leave the target reference unchanged.
403+
"""
404+
current = current_system_metadata or {}
405+
if current.get("kind") == "image" and "width" in current and "height" in current:
406+
return
407+
408+
for sibling in list_references_by_asset_id(session, asset_id):
409+
if sibling.id == new_reference_id:
410+
continue
411+
meta = sibling.system_metadata or {}
412+
if meta.get("kind") != "image":
413+
continue
414+
width = meta.get("width")
415+
height = meta.get("height")
416+
if (
417+
type(width) is not int
418+
or type(height) is not int
419+
or width <= 0
420+
or height <= 0
421+
):
422+
continue
423+
merged = dict(current)
424+
merged["kind"] = "image"
425+
merged["width"] = width
426+
merged["height"] = height
427+
if merged != current:
428+
set_reference_system_metadata(
429+
session,
430+
reference_id=new_reference_id,
431+
system_metadata=merged,
432+
)
433+
return
434+
435+
337436
def _sanitize_filename(name: str | None, fallback: str) -> str:
338437
n = os.path.basename((name or "").strip() or fallback)
339438
return n if n else fallback
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Tests for the image_dimensions service."""
2+
from __future__ import annotations
3+
4+
from pathlib import Path
5+
6+
import pytest
7+
from PIL import Image
8+
9+
from app.assets.services.image_dimensions import extract_image_dimensions
10+
11+
12+
def _make_png(path: Path, size: tuple[int, int]) -> Path:
13+
img = Image.new("RGB", size, color=(123, 45, 67))
14+
img.save(path, format="PNG")
15+
return path
16+
17+
18+
def _make_jpeg(path: Path, size: tuple[int, int]) -> Path:
19+
img = Image.new("RGB", size, color=(10, 20, 30))
20+
img.save(path, format="JPEG", quality=80)
21+
return path
22+
23+
24+
class TestExtractImageDimensions:
25+
def test_extracts_png_dimensions(self, tmp_path: Path):
26+
f = _make_png(tmp_path / "rect.png", (320, 240))
27+
28+
result = extract_image_dimensions(str(f), mime_type="image/png")
29+
30+
assert result == {"kind": "image", "width": 320, "height": 240}
31+
32+
def test_extracts_jpeg_dimensions(self, tmp_path: Path):
33+
f = _make_jpeg(tmp_path / "shot.jpg", (1920, 1080))
34+
35+
result = extract_image_dimensions(str(f), mime_type="image/jpeg")
36+
37+
assert result == {"kind": "image", "width": 1920, "height": 1080}
38+
39+
def test_works_when_mime_type_is_none(self, tmp_path: Path):
40+
f = _make_png(tmp_path / "no_mime.png", (50, 100))
41+
42+
result = extract_image_dimensions(str(f), mime_type=None)
43+
44+
assert result == {"kind": "image", "width": 50, "height": 100}
45+
46+
def test_skips_non_image_mime_without_touching_file(self, tmp_path: Path):
47+
# Path doesn't need to exist — non-image MIME short-circuits.
48+
result = extract_image_dimensions(
49+
str(tmp_path / "model.safetensors"),
50+
mime_type="application/octet-stream",
51+
)
52+
53+
assert result is None
54+
55+
@pytest.mark.parametrize(
56+
"mime",
57+
["application/json", "text/plain", "video/mp4", "audio/mpeg"],
58+
)
59+
def test_skips_all_non_image_mime_types(self, tmp_path: Path, mime: str):
60+
f = tmp_path / "file.bin"
61+
f.write_bytes(b"\x00\x01\x02")
62+
63+
assert extract_image_dimensions(str(f), mime_type=mime) is None
64+
65+
def test_returns_none_for_missing_file(self, tmp_path: Path):
66+
result = extract_image_dimensions(
67+
str(tmp_path / "does_not_exist.png"), mime_type="image/png"
68+
)
69+
70+
assert result is None
71+
72+
def test_returns_none_for_corrupt_image(self, tmp_path: Path):
73+
f = tmp_path / "corrupt.png"
74+
f.write_bytes(b"not actually a png file")
75+
76+
result = extract_image_dimensions(str(f), mime_type="image/png")
77+
78+
assert result is None
79+
80+
def test_returns_none_for_empty_file(self, tmp_path: Path):
81+
f = tmp_path / "empty.png"
82+
f.write_bytes(b"")
83+
84+
result = extract_image_dimensions(str(f), mime_type="image/png")
85+
86+
assert result is None

0 commit comments

Comments
 (0)