Skip to content

Commit c03f92e

Browse files
committed
Derive asset preview URLs from the content endpoint
preview_url was assembled from a /api/view link whose type was chosen by matching the asset's tags against "input" then "output". Anything written anywhere else - temp above all, where preview nodes put their images - fell off the end of that chain and came back with no preview at all. Tags are user-editable, so removing one also silently destroyed the URL. Point the preview at the asset's own content endpoint instead, which resolves a reference wherever its file lives. Model weights and other content a browser cannot render get no preview URL, since pointing one at their bytes would only make the client download the whole file. Which content is previewable is resolved the same way /content resolves the type it serves - the stored mime type, falling back to the reference name - so a scan that recorded no mime type does not cost an image its preview.
1 parent 27bca65 commit c03f92e

3 files changed

Lines changed: 279 additions & 27 deletions

File tree

app/assets/api/routes.py

Lines changed: 18 additions & 27 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
@@ -207,42 +208,32 @@ def _validate_sort_field(requested: str | None) -> str:
207208
return "created_at"
208209

209210

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:
213-
return None
214-
filename = user_metadata.get("filename")
215-
if not filename:
216-
return None
211+
# Anything else has no visual form: a preview of its own bytes would just make the client download it all.
212+
PREVIEWABLE_MIME_PREFIXES = ("image/", "video/", "audio/")
217213

218-
if "input" in tags:
219-
view_type = "input"
220-
elif "output" in tags:
221-
view_type = "output"
222-
else:
223-
return None
224214

225-
subfolder = ""
226-
if "/" in filename:
227-
subfolder, filename = filename.rsplit("/", 1)
215+
def _has_previewable_content(asset: schemas.AssetData | None, name: str | None) -> bool:
216+
if asset is None:
217+
return False
218+
# A scan run without metadata extraction leaves mime NULL; resolve from the name as /content does.
219+
raw = asset.mime_type or mimetypes.guess_type(name or "")[0] or ""
220+
return raw.split(";", 1)[0].strip().lower().startswith(PREVIEWABLE_MIME_PREFIXES)
221+
228222

229-
encoded_filename = urllib.parse.quote(filename, safe="")
230-
url = f"/api/view?type={view_type}&filename={encoded_filename}"
231-
if subfolder:
232-
url += f"&subfolder={urllib.parse.quote(subfolder, safe='')}"
233-
return url
223+
def _build_preview_url(reference_id: str) -> str:
224+
# Asking for inline does not weaken /content: it still forces dangerous types to download.
225+
return f"/api/assets/{reference_id}/content?disposition=inline"
234226

235227

236228
def _build_asset_response(result: schemas.AssetDetailResult | schemas.UploadResult) -> schemas_out.Asset:
237229
"""Build an Asset response from a service result."""
238230
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
231+
# A nominated preview is one whatever it holds, so no media check here.
232+
preview_url = _build_preview_url(result.ref.preview_id)
233+
elif _has_previewable_content(result.asset, result.ref.name):
234+
preview_url = _build_preview_url(result.ref.id)
244235
else:
245-
preview_url = _build_preview_url_from_view(result.tags, result.ref.user_metadata)
236+
preview_url = None
246237
if result.ref.file_path:
247238
display_name = compute_display_name(result.ref.file_path)
248239
# In-root loader path (model category dropped): what model loaders consume.
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
from datetime import datetime
2+
3+
import pytest
4+
5+
from app.assets.api.routes import _build_asset_response
6+
from app.assets.services.schemas import AssetData, AssetDetailResult, ReferenceData
7+
8+
_TS = datetime(2024, 1, 1, 0, 0, 0)
9+
10+
11+
def _make_result(
12+
*,
13+
ref_id: str = "ref-1",
14+
name: str = "ComfyUI_temp_abcde_00001_.png",
15+
file_path: str | None = "/base/temp/ComfyUI_temp_abcde_00001_.png",
16+
mime_type: str | None = "image/png",
17+
preview_id: str | None = None,
18+
tags: list[str] | None = None,
19+
user_metadata: dict | None = None,
20+
with_asset: bool = True,
21+
) -> AssetDetailResult:
22+
ref = ReferenceData(
23+
id=ref_id,
24+
name=name,
25+
file_path=file_path,
26+
loader_path=None,
27+
user_metadata=user_metadata,
28+
preview_id=preview_id,
29+
created_at=_TS,
30+
updated_at=_TS,
31+
last_access_time=_TS,
32+
)
33+
asset = (
34+
AssetData(hash="blake3:abc", size_bytes=1024, mime_type=mime_type)
35+
if with_asset
36+
else None
37+
)
38+
return AssetDetailResult(ref=ref, asset=asset, tags=tags or [])
39+
40+
41+
def test_temp_image_gets_a_preview_url():
42+
resp = _build_asset_response(_make_result())
43+
44+
assert resp.preview_url == "/api/assets/ref-1/content?disposition=inline", (
45+
"a reference outside input and output must still resolve a preview"
46+
)
47+
48+
49+
@pytest.mark.parametrize(
50+
"tags",
51+
[[], ["input"], ["output"], ["models", "model_type:checkpoints"]],
52+
)
53+
def test_preview_url_does_not_depend_on_tags(tags: list[str]):
54+
resp = _build_asset_response(_make_result(tags=tags))
55+
56+
assert resp.preview_url == "/api/assets/ref-1/content?disposition=inline", (
57+
"tags are user-editable; removing one must not destroy the preview"
58+
)
59+
60+
61+
def test_preview_url_does_not_depend_on_the_metadata_filename():
62+
resp = _build_asset_response(_make_result(user_metadata=None))
63+
64+
assert resp.preview_url == "/api/assets/ref-1/content?disposition=inline", (
65+
"a reference carrying no metadata filename must still get a preview"
66+
)
67+
68+
69+
def test_preview_id_wins_over_the_asset_itself():
70+
result = _make_result(preview_id="preview-ref")
71+
72+
resp = _build_asset_response(result)
73+
74+
assert resp.preview_url == "/api/assets/preview-ref/content?disposition=inline"
75+
assert resp.preview_id == "preview-ref"
76+
77+
78+
def test_preview_id_is_used_whatever_the_asset_content_is():
79+
result = _make_result(
80+
mime_type="application/safetensors", preview_id="preview-ref"
81+
)
82+
83+
resp = _build_asset_response(result)
84+
85+
assert resp.preview_url == "/api/assets/preview-ref/content?disposition=inline", (
86+
"a nominated preview stands in for content with no visual form"
87+
)
88+
89+
90+
@pytest.mark.parametrize(
91+
"mime_type",
92+
["application/safetensors", "application/gguf", "application/octet-stream"],
93+
)
94+
def test_no_preview_url_for_content_a_browser_cannot_render(mime_type: str):
95+
resp = _build_asset_response(
96+
_make_result(name="model.safetensors", mime_type=mime_type)
97+
)
98+
99+
assert resp.preview_url is None, (
100+
"content a browser cannot render must not advertise its own bytes as a preview"
101+
)
102+
103+
104+
@pytest.mark.parametrize(
105+
("name", "expected"),
106+
[
107+
("shot.png", "/api/assets/ref-1/content?disposition=inline"),
108+
("clip.mp4", "/api/assets/ref-1/content?disposition=inline"),
109+
("model.safetensors", None),
110+
],
111+
)
112+
def test_missing_mime_type_falls_back_to_the_name(name: str, expected: str | None):
113+
resp = _build_asset_response(_make_result(name=name, mime_type=None))
114+
115+
assert resp.preview_url == expected, (
116+
"a previewable file must not lose its preview just because the scan "
117+
"that found it recorded no mime type"
118+
)
119+
120+
121+
def test_unrecognised_name_and_no_mime_yields_no_preview():
122+
resp = _build_asset_response(_make_result(name="blob", mime_type=None))
123+
124+
assert resp.preview_url is None, (
125+
"nothing identifies this as media, so do not advertise a preview"
126+
)
127+
128+
129+
def test_mime_type_parameters_do_not_defeat_the_media_check():
130+
resp = _build_asset_response(_make_result(mime_type="IMAGE/PNG; charset=binary"))
131+
132+
assert resp.preview_url == "/api/assets/ref-1/content?disposition=inline"
133+
134+
135+
def test_no_preview_url_without_content():
136+
resp = _build_asset_response(_make_result(with_asset=False))
137+
138+
assert resp.preview_url is None, (
139+
"no asset row means the content endpoint has nothing to serve"
140+
)
141+
142+
143+
def test_reference_without_file_path_still_gets_a_preview_url():
144+
resp = _build_asset_response(_make_result(file_path=None))
145+
146+
assert resp.preview_url == "/api/assets/ref-1/content?disposition=inline", (
147+
"a NULL file_path still resolves through a sibling reference"
148+
)
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import contextlib
2+
import json
3+
import uuid
4+
5+
import requests
6+
7+
8+
def test_preview_url_outside_input_and_output(
9+
http: requests.Session, api_base: str, asset_factory, make_asset_bytes
10+
):
11+
scope = f"preview-url-{uuid.uuid4().hex[:6]}"
12+
name = f"{scope}.png"
13+
data = make_asset_bytes(name, 2048)
14+
15+
body = asset_factory(
16+
name, ["models", "model_type:checkpoints", "unit-tests", scope], {}, data
17+
)
18+
19+
assert body["preview_url"] == f"/api/assets/{body['id']}/content?disposition=inline", (
20+
"an image in a directory /api/view never covered must still get a preview"
21+
)
22+
23+
r = http.get(api_base + body["preview_url"], timeout=120)
24+
assert r.status_code == 200, r.text
25+
assert r.content == data, "the preview URL must serve the asset's own bytes"
26+
assert "inline" in r.headers.get("Content-Disposition", "")
27+
assert r.headers.get("Content-Type", "").startswith("image/png")
28+
29+
30+
def test_preview_url_survives_tag_removal(
31+
http: requests.Session, api_base: str, asset_factory, make_asset_bytes
32+
):
33+
scope = f"preview-tags-{uuid.uuid4().hex[:6]}"
34+
name = f"{scope}.png"
35+
data = make_asset_bytes(name, 2048)
36+
37+
body = asset_factory(name, ["input", "unit-tests", scope], {}, data)
38+
aid = body["id"]
39+
preview_url = body["preview_url"]
40+
assert preview_url, "an uploaded image starts out with a preview"
41+
42+
r = http.delete(
43+
f"{api_base}/api/assets/{aid}/tags", json={"tags": ["input"]}, timeout=120
44+
)
45+
assert r.status_code == 200, r.text
46+
47+
after = http.get(f"{api_base}/api/assets/{aid}", timeout=120).json()
48+
assert "input" not in after["tags"]
49+
assert after["preview_url"] == preview_url, (
50+
"dropping the tag that used to select the view type must not take the "
51+
"preview with it"
52+
)
53+
assert http.get(api_base + preview_url, timeout=120).status_code == 200
54+
55+
56+
def test_preview_url_is_the_nominated_preview_when_one_is_set(
57+
http: requests.Session, api_base: str, asset_factory, make_asset_bytes
58+
):
59+
scope = f"preview-id-{uuid.uuid4().hex[:6]}"
60+
thumb_name = f"{scope}_thumb.png"
61+
thumb_data = make_asset_bytes(thumb_name, 1024)
62+
thumb = asset_factory(thumb_name, ["input", "unit-tests", scope], {}, thumb_data)
63+
64+
model_name = f"{scope}.safetensors"
65+
model_data = make_asset_bytes(model_name, 2048)
66+
files = {"file": (model_name, model_data, "application/octet-stream")}
67+
form_data = {
68+
"tags": json.dumps(["models", "model_type:checkpoints", "unit-tests", scope]),
69+
"name": model_name,
70+
"preview_id": thumb["id"],
71+
}
72+
r = http.post(api_base + "/api/assets", files=files, data=form_data, timeout=120)
73+
model = r.json()
74+
assert r.status_code in (200, 201), model
75+
76+
try:
77+
assert model["preview_id"] == thumb["id"]
78+
assert (
79+
model["preview_url"]
80+
== f"/api/assets/{thumb['id']}/content?disposition=inline"
81+
), "a nominated preview stands in for content with no visual form"
82+
got = http.get(api_base + model["preview_url"], timeout=120)
83+
assert got.status_code == 200, got.text
84+
assert got.content == thumb_data
85+
finally:
86+
with contextlib.suppress(Exception):
87+
http.delete(f"{api_base}/api/assets/{model['id']}", timeout=30)
88+
89+
90+
def test_no_preview_url_for_content_a_browser_cannot_render(
91+
http: requests.Session, api_base: str, asset_factory, make_asset_bytes
92+
):
93+
scope = f"preview-model-{uuid.uuid4().hex[:6]}"
94+
name = f"{scope}.safetensors"
95+
96+
body = asset_factory(
97+
name,
98+
["models", "model_type:checkpoints", "unit-tests", scope],
99+
{},
100+
make_asset_bytes(name, 2048),
101+
)
102+
103+
assert body.get("preview_url") is None, (
104+
"model weights have no preview; advertising one would only make the "
105+
"client download the whole file to discover that"
106+
)
107+
108+
listed = http.get(
109+
api_base + "/api/assets", params={"include_tags": scope}, timeout=120
110+
).json()["assets"]
111+
assert [a.get("preview_url") for a in listed] == [None], (
112+
"the list route must withhold it too, not just the detail route"
113+
)

0 commit comments

Comments
 (0)