Merge master into qa/video-edit-combined

# Conflicts:
#	comfy_api/latest/_input_impl/video_types.py
This commit is contained in:
Claude
2026-08-20 23:33:45 +00:00
126 changed files with 10668 additions and 1117 deletions

View File

@@ -43,7 +43,7 @@ def test_uses_persisted_loader_path_without_recomputing():
loader_path="SENTINEL/stored.safetensors",
)
resp = _build_asset_response(result)
resp = _build_asset_response(result, {})
assert resp.loader_path == "SENTINEL/stored.safetensors"
@@ -67,7 +67,7 @@ def test_null_stored_loader_path_is_served_as_null(tmp_path: Path):
mock_fp.models_dir = str(models)
result = _make_result(file_path=str(f), loader_path=None)
resp = _build_asset_response(result)
resp = _build_asset_response(result, {})
assert resp.loader_path is None
assert resp.display_name == "checkpoints/bar.safetensors"
@@ -77,7 +77,7 @@ def test_all_path_fields_null_without_file_path():
"""API-created / hash-only references (no file_path) expose no paths."""
result = _make_result(file_path=None, loader_path=None)
resp = _build_asset_response(result)
resp = _build_asset_response(result, {})
assert resp.loader_path is None
assert resp.display_name is None

View File

@@ -0,0 +1,300 @@
from datetime import datetime
from pathlib import Path
from unittest.mock import patch
import pytest
from app.assets.api.routes import _build_asset_response
from app.assets.services.schemas import AssetData, AssetDetailResult, ReferenceData
_TS = datetime(2024, 1, 1, 0, 0, 0)
@pytest.fixture
def sandboxed_comfy_roots(tmp_path: Path):
with patch("app.assets.services.path_utils.folder_paths") as fp:
fp.get_input_directory.return_value = str(tmp_path / "input")
fp.get_output_directory.return_value = str(tmp_path / "output")
fp.get_temp_directory.return_value = str(tmp_path / "temp")
fp.models_dir = str(tmp_path / "models")
yield tmp_path
def _make_result(
*,
ref_id: str = "ref-1",
name: str = "ComfyUI_temp_abcde_00001_.png",
file_path: str | None = None,
mime_type: str | None = "image/png",
preview_id: str | None = None,
tags: list[str] | None = None,
user_metadata: dict | None = None,
with_asset: bool = True,
) -> AssetDetailResult:
ref = ReferenceData(
id=ref_id,
name=name,
file_path=file_path,
loader_path=None,
user_metadata=user_metadata,
preview_id=preview_id,
created_at=_TS,
updated_at=_TS,
last_access_time=_TS,
)
asset = (
AssetData(hash="blake3:abc", size_bytes=1024, mime_type=mime_type)
if with_asset
else None
)
return AssetDetailResult(ref=ref, asset=asset, tags=tags or [])
@pytest.mark.parametrize(
("root", "relative"),
[
("temp", "ComfyUI_temp_abcde_00001_.png"),
("output", "ComfyUI_00001_.png"),
("input", "example.png"),
],
)
def test_every_view_root_gets_a_preview_url(
sandboxed_comfy_roots: Path, root: str, relative: str
):
resp = _build_asset_response(
_make_result(name=relative, file_path=str(sandboxed_comfy_roots / root / relative)),
{},
)
assert resp.preview_url == f"/api/view?type={root}&filename={relative}", (
f"a file in {root} must get a preview URL; temp is the one the old "
f"tag chain fell off the end of"
)
@pytest.mark.parametrize(
"tags",
[[], ["input"], ["output"], ["models", "model_type:checkpoints"]],
)
def test_preview_url_does_not_depend_on_tags(
sandboxed_comfy_roots: Path, tags: list[str]
):
resp = _build_asset_response(
_make_result(file_path=str(sandboxed_comfy_roots / "temp" / "a.png"), tags=tags), {}
)
assert resp.preview_url == "/api/view?type=temp&filename=a.png", (
"tags are user-editable; removing one must not destroy the preview"
)
def test_preview_url_does_not_depend_on_the_metadata_filename(
sandboxed_comfy_roots: Path,
):
resp = _build_asset_response(
_make_result(
file_path=str(sandboxed_comfy_roots / "output" / "a.png"), user_metadata=None
),
{},
)
assert resp.preview_url == "/api/view?type=output&filename=a.png", (
"a reference carrying no metadata filename must still get a preview"
)
def test_subfolder_is_split_out_and_both_halves_encoded(sandboxed_comfy_roots: Path):
resp = _build_asset_response(
_make_result(
name="my shot.png",
file_path=str(sandboxed_comfy_roots / "output" / "runs & takes" / "my shot.png"),
),
{},
)
assert resp.preview_url == (
"/api/view?type=output&filename=my%20shot.png&subfolder=runs%20%26%20takes"
), "an unencoded & or space in the path would break the query string"
def test_preview_id_resolves_through_the_page_lookup(sandboxed_comfy_roots: Path):
result = _make_result(
file_path=str(sandboxed_comfy_roots / "models" / "checkpoints" / "m.safetensors"),
mime_type="application/safetensors",
preview_id="preview-ref",
)
resp = _build_asset_response(
result, {"preview-ref": str(sandboxed_comfy_roots / "output" / "thumb.png")}
)
assert resp.preview_url == "/api/view?type=output&filename=thumb.png", (
"a nominated preview stands in for content with no visual form"
)
assert resp.preview_id == "preview-ref"
def test_unresolvable_preview_id_yields_no_url(sandboxed_comfy_roots: Path):
result = _make_result(
file_path=str(sandboxed_comfy_roots / "output" / "a.png"), preview_id="gone"
)
resp = _build_asset_response(result, {})
assert resp.preview_url is None, (
"a preview absent from the lookup is soft-deleted, invisible or "
"path-less, so advertising it would promise a URL that 404s - and the "
"asset's own bytes are a different picture, not a degraded one"
)
@pytest.mark.parametrize(
("name", "mime_type"),
[
("notes.txt", "text/plain"),
("notes.md", "text/markdown"),
("rows.csv", "text/csv"),
("page.html", "text/html"),
],
)
def test_text_is_previewable(sandboxed_comfy_roots: Path, name: str, mime_type: str):
resp = _build_asset_response(
_make_result(
name=name,
file_path=str(sandboxed_comfy_roots / "output" / name),
mime_type=mime_type,
),
{},
)
assert resp.preview_url == f"/api/view?type=output&filename={name}", (
"text assets are rendered as a snippet from preview_url, so withholding "
"it leaves that with nothing to fetch; the dangerous members stay safe "
"because /api/view forces them to download, not because they get no URL"
)
@pytest.mark.parametrize(
"mime_type",
["application/safetensors", "application/gguf", "application/octet-stream"],
)
def test_no_preview_url_for_content_a_browser_cannot_render(
sandboxed_comfy_roots: Path, mime_type: str
):
resp = _build_asset_response(
_make_result(
name="model.safetensors",
file_path=str(sandboxed_comfy_roots / "input" / "model.safetensors"),
mime_type=mime_type,
),
{},
)
assert resp.preview_url is None, (
"content a browser cannot render must not advertise itself as a preview"
)
@pytest.mark.parametrize(
("name", "expected"),
[
("shot.png", "/api/view?type=temp&filename=shot.png"),
("clip.mp4", "/api/view?type=temp&filename=clip.mp4"),
("model.safetensors", None),
],
)
def test_missing_mime_type_falls_back_to_the_path(
sandboxed_comfy_roots: Path, name: str, expected: str | None
):
resp = _build_asset_response(
_make_result(
name=name, file_path=str(sandboxed_comfy_roots / "temp" / name), mime_type=None
),
{},
)
assert resp.preview_url == expected, (
"a previewable file must not lose its preview just because the scan "
"that found it recorded no mime type"
)
@pytest.mark.parametrize(
("name", "stored_filename", "expected"),
[
("untitled", "shot.png", "/api/view?type=temp&filename=shot.png"),
("shot.png", "weights.safetensors", None),
],
)
def test_previewability_follows_the_path_not_the_editable_name(
sandboxed_comfy_roots: Path, name: str, stored_filename: str, expected: str | None
):
resp = _build_asset_response(
_make_result(
name=name,
file_path=str(sandboxed_comfy_roots / "temp" / stored_filename),
mime_type=None,
),
{},
)
assert resp.preview_url == expected, (
"name is editable through PUT /api/assets/{id}, so deriving "
"previewability from it would let a rename create or destroy a preview "
"without the bytes changing"
)
def test_mime_type_parameters_do_not_defeat_the_media_check(
sandboxed_comfy_roots: Path,
):
resp = _build_asset_response(
_make_result(
file_path=str(sandboxed_comfy_roots / "temp" / "a.png"),
mime_type="IMAGE/PNG; charset=binary",
),
{},
)
assert resp.preview_url == "/api/view?type=temp&filename=a.png"
def test_no_preview_url_for_a_model(sandboxed_comfy_roots: Path):
resp = _build_asset_response(
_make_result(
name="m.png",
file_path=str(sandboxed_comfy_roots / "models" / "checkpoints" / "m.png"),
),
{},
)
assert resp.preview_url is None, (
"models is not a root /api/view can address, whatever the file is"
)
def test_no_preview_url_for_a_path_outside_every_root(sandboxed_comfy_roots: Path):
resp = _build_asset_response(_make_result(file_path="/elsewhere/a.png"), {})
assert resp.preview_url is None, (
"/api/view cannot address a file outside the roots it serves"
)
def test_no_preview_url_without_a_file_path(sandboxed_comfy_roots: Path):
resp = _build_asset_response(_make_result(file_path=None), {})
assert resp.preview_url is None, (
"an API-created reference has no path, so no view URL can be derived"
)
def test_no_preview_url_without_content(sandboxed_comfy_roots: Path):
resp = _build_asset_response(
_make_result(
file_path=str(sandboxed_comfy_roots / "temp" / "a.png"), with_asset=False
),
{},
)
assert resp.preview_url is None, "no asset row means there is nothing to preview"

View File

@@ -1,10 +1,14 @@
import time
import uuid
import warnings
import pytest
import requests
from helpers import assert_hash_fields_consistent
from app.assets.api import routes as assets_routes
from app.assets.api import schemas_in
def test_list_assets_paging_and_sort(http: requests.Session, api_base: str, asset_factory, make_asset_bytes):
names = ["a1_u.safetensors", "a2_u.safetensors", "a3_u.safetensors"]
@@ -337,3 +341,418 @@ def test_list_assets_name_contains_literal_underscore(
assert b["name"] not in names, "Underscore must be escaped — should not match 'fooxbar'"
assert c["name"] not in names, "Underscore must be escaped — should not match 'foobar'"
assert body["total"] == 1
def test_list_assets_tags_any_alone(http, api_base, asset_factory, make_asset_bytes):
scope = f"lf-any-{uuid.uuid4().hex[:6]}"
t = ["models", "model_type:checkpoints", "unit-tests", scope]
a = asset_factory("any_a.safetensors", [*t, f"{scope}-alpha"], {}, make_asset_bytes("any_a"))
b = asset_factory("any_b.safetensors", [*t, f"{scope}-beta"], {}, make_asset_bytes("any_b"))
c = asset_factory("any_c.safetensors", [*t, f"{scope}-gamma"], {}, make_asset_bytes("any_c"))
r = http.get(
api_base + "/api/assets",
params={"tags_any": f"{scope}-alpha,{scope}-beta", "limit": "50"},
timeout=120,
)
body = r.json()
assert r.status_code == 200, body
names = [x["name"] for x in body["assets"]]
assert a["name"] in names
assert b["name"] in names
assert c["name"] not in names
def test_list_assets_tags_any_with_tags_all(http, api_base, asset_factory, make_asset_bytes):
scope = f"lf-anyall-{uuid.uuid4().hex[:6]}"
t = ["models", "model_type:checkpoints", "unit-tests", scope]
alpha, beta = f"{scope}-alpha", f"{scope}-beta"
x = asset_factory("aa_x.safetensors", [*t, alpha], {}, make_asset_bytes("aa_x"))
y = asset_factory("aa_y.safetensors", [*t, beta], {}, make_asset_bytes("aa_y"))
w = asset_factory("aa_w.safetensors", t, {}, make_asset_bytes("aa_w"))
d = asset_factory(
"aa_d.safetensors",
["models", "model_type:checkpoints", "unit-tests", f"{scope}-other", alpha],
{},
make_asset_bytes("aa_d"),
)
r = http.get(
api_base + "/api/assets",
params={"tags_all": f"unit-tests,{scope}", "tags_any": f"{alpha},{beta}", "limit": "50"},
timeout=120,
)
body = r.json()
assert r.status_code == 200, body
names = [a["name"] for a in body["assets"]]
assert x["name"] in names
assert y["name"] in names
assert w["name"] not in names, "asset matching tags_all but not tags_any must be excluded"
assert d["name"] not in names, "asset matching tags_any but not tags_all must be excluded"
def test_list_assets_tags_none_wins_over_tags_any(http, api_base, asset_factory, make_asset_bytes):
scope = f"lf-nonewins-{uuid.uuid4().hex[:6]}"
t = ["models", "model_type:checkpoints", "unit-tests", scope]
alpha, beta = f"{scope}-alpha", f"{scope}-beta"
x = asset_factory("nw_x.safetensors", [*t, alpha], {}, make_asset_bytes("nw_x"))
y = asset_factory("nw_y.safetensors", [*t, alpha, beta], {}, make_asset_bytes("nw_y"))
r = http.get(
api_base + "/api/assets",
params={"tags_any": alpha, "tags_none": beta, "limit": "50"},
timeout=120,
)
body = r.json()
assert r.status_code == 200, body
names = [a["name"] for a in body["assets"]]
assert x["name"] in names
assert y["name"] not in names, "tags_none must exclude an asset even when it matches tags_any"
def test_list_assets_empty_tag_filter_lists_behave_as_absent(http, api_base, asset_factory, make_asset_bytes):
scope = f"lf-empty-{uuid.uuid4().hex[:6]}"
t = ["models", "model_type:checkpoints", "unit-tests", scope]
a = asset_factory("em_a.safetensors", t, {}, make_asset_bytes("em_a"))
b = asset_factory("em_b.safetensors", t, {}, make_asset_bytes("em_b"))
expected = {a["name"], b["name"]}
# Empty new-name lists impose no constraint.
r1 = http.get(
api_base + "/api/assets",
params={"tags_all": f"unit-tests,{scope}", "tags_any": "", "tags_none": ""},
timeout=120,
)
b1 = r1.json()
assert r1.status_code == 200, b1
assert {x["name"] for x in b1["assets"]} == expected
# An empty new-name param alongside old names must not trigger validation.
r2 = http.get(
api_base + "/api/assets",
params={"include_tags": f"unit-tests,{scope}", "tags_any": ""},
timeout=120,
)
b2 = r2.json()
assert r2.status_code == 200, b2
assert {x["name"] for x in b2["assets"]} == expected
# An empty tags_all next to include_tags is not a mixed-spelling conflict.
r3 = http.get(
api_base + "/api/assets",
params={"include_tags": f"unit-tests,{scope}", "tags_all": ""},
timeout=120,
)
b3 = r3.json()
assert r3.status_code == 200, b3
assert {x["name"] for x in b3["assets"]} == expected
def test_list_assets_old_names_match_new_names(http, api_base, asset_factory, make_asset_bytes):
scope = f"lf-alias-{uuid.uuid4().hex[:6]}"
t = ["models", "model_type:checkpoints", "unit-tests", scope]
alpha, beta = f"{scope}-alpha", f"{scope}-beta"
asset_factory("al_a.safetensors", [*t, alpha], {}, make_asset_bytes("al_a"))
asset_factory("al_b.safetensors", [*t, beta], {}, make_asset_bytes("al_b"))
def names_for(params: dict) -> tuple[list, int]:
r = http.get(api_base + "/api/assets", params={**params, "sort": "name", "order": "asc"}, timeout=120)
body = r.json()
assert r.status_code == 200, body
return [x["name"] for x in body["assets"]], body["total"]
# include_tags ≡ tags_all
old_names, old_total = names_for({"include_tags": f"unit-tests,{scope}"})
new_names, new_total = names_for({"tags_all": f"unit-tests,{scope}"})
assert old_names == new_names
assert old_total == new_total
# exclude_tags ≡ tags_none (and old/new spellings mix across slots)
old_names, old_total = names_for({"include_tags": f"unit-tests,{scope}", "exclude_tags": alpha})
new_names, new_total = names_for({"tags_all": f"unit-tests,{scope}", "tags_none": alpha})
mixed_names, mixed_total = names_for({"include_tags": f"unit-tests,{scope}", "tags_none": alpha})
assert old_names == new_names == mixed_names == ["al_b.safetensors"]
assert old_total == new_total == mixed_total == 1
@pytest.mark.parametrize(
"params,expected_parameters",
[
({"include_tags": "mx-x", "tags_all": "mx-y"}, ["include_tags", "tags_all"]),
({"exclude_tags": "mx-x", "tags_none": "mx-y"}, ["exclude_tags", "tags_none"]),
],
ids=["include_tags_with_tags_all", "exclude_tags_with_tags_none"],
)
def test_list_assets_mixed_tag_spellings_rejected(http, api_base, params, expected_parameters):
r = http.get(api_base + "/api/assets", params=params, timeout=120)
body = r.json()
assert r.status_code == 400, body
assert body["error"]["code"] == "INVALID_TAG_FILTER"
assert body["error"]["details"]["parameters"] == expected_parameters
@pytest.mark.parametrize(
"params,conflicting,parameters",
[
(
{"tags_all": "cf-x", "tags_none": "cf-x"},
["cf-x"],
["tags_all", "tags_none"],
),
(
{"include_tags": "cf-x", "tags_none": "cf-x"},
["cf-x"],
["include_tags", "tags_none"],
),
(
{"tags_all": "cf-a,cf-b", "tags_none": "cf-b,cf-c"},
["cf-b"],
["tags_all", "tags_none"],
),
],
ids=["new_names", "include_tags_remapped", "partial_overlap"],
)
def test_list_assets_all_none_conflict_rejected(http, api_base, params, conflicting, parameters):
r = http.get(api_base + "/api/assets", params=params, timeout=120)
body = r.json()
assert r.status_code == 400, body
assert body["error"]["code"] == "INVALID_TAG_FILTER"
assert body["error"]["details"]["conflicting_tags"] == conflicting
assert body["error"]["details"]["parameters"] == parameters
def test_list_assets_any_none_overlap_accepted(http, api_base, asset_factory, make_asset_bytes):
scope = f"lf-deadterm-{uuid.uuid4().hex[:6]}"
t = ["models", "model_type:checkpoints", "unit-tests", scope]
alpha, beta = f"{scope}-alpha", f"{scope}-beta"
x = asset_factory("dt_x.safetensors", [*t, alpha], {}, make_asset_bytes("dt_x"))
y = asset_factory("dt_y.safetensors", [*t, beta], {}, make_asset_bytes("dt_y"))
# alpha is a dead term (in both tags_any and tags_none) but the query is valid.
r = http.get(
api_base + "/api/assets",
params={"tags_any": f"{alpha},{beta}", "tags_none": alpha, "limit": "50"},
timeout=120,
)
body = r.json()
assert r.status_code == 200, body
names = [a["name"] for a in body["assets"]]
assert y["name"] in names
assert x["name"] not in names
def test_list_assets_legacy_include_exclude_conflict_still_200(http, api_base, asset_factory, make_asset_bytes):
scope = f"lf-legacy-{uuid.uuid4().hex[:6]}"
t = ["models", "model_type:checkpoints", "unit-tests", scope]
asset_factory("lg_a.safetensors", t, {}, make_asset_bytes("lg_a"))
# Old names only: the self-contradictory query stays an empty 200, never a 400.
r = http.get(
api_base + "/api/assets",
params={"include_tags": scope, "exclude_tags": scope},
timeout=120,
)
body = r.json()
assert r.status_code == 200, body
assert body["assets"] == []
def test_tags_refine_new_tag_filters(http, api_base, asset_factory, make_asset_bytes):
scope = f"rf-{uuid.uuid4().hex[:6]}"
t = ["models", "model_type:checkpoints", "unit-tests", scope]
alpha, beta = f"{scope}-alpha", f"{scope}-beta"
asset_factory("rf_a.safetensors", [*t, alpha], {}, make_asset_bytes("rf_a"))
asset_factory("rf_b.safetensors", [*t, beta], {}, make_asset_bytes("rf_b"))
r = http.get(
api_base + "/api/assets/tags/refine",
params={"tags_any": f"{alpha},{beta}", "tags_none": alpha},
timeout=120,
)
body = r.json()
assert r.status_code == 200, body
counts = body["tag_counts"]
assert counts.get(beta) == 1
assert alpha not in counts
r2 = http.get(
api_base + "/api/assets/tags/refine",
params={"tags_all": "rf-x", "tags_none": "rf-x"},
timeout=120,
)
body2 = r2.json()
assert r2.status_code == 400, body2
assert body2["error"]["code"] == "INVALID_TAG_FILTER"
assert body2["error"]["details"]["conflicting_tags"] == ["rf-x"]
def test_list_assets_cross_slot_old_new_combinations(http, api_base, asset_factory, make_asset_bytes):
"""Old and new spellings of *different* slots combine freely; only
same-slot mixing is rejected."""
scope = f"lf-cross-{uuid.uuid4().hex[:6]}"
t = ["models", "model_type:checkpoints", "unit-tests", scope]
alpha, beta = f"{scope}-alpha", f"{scope}-beta"
a = asset_factory("cs_a.safetensors", [*t, alpha], {}, make_asset_bytes("cs_a"))
b = asset_factory("cs_b.safetensors", [*t, beta], {}, make_asset_bytes("cs_b"))
def names_for(params: dict) -> set:
r = http.get(api_base + "/api/assets", params=params, timeout=120)
body = r.json()
assert r.status_code == 200, body
return {x["name"] for x in body["assets"]}
assert names_for(
{"include_tags": f"unit-tests,{scope}", "tags_any": alpha}
) == {a["name"]}
assert names_for(
{"tags_all": f"unit-tests,{scope}", "exclude_tags": alpha}
) == {b["name"]}
assert names_for(
{"tags_any": f"{alpha},{beta}", "exclude_tags": alpha}
) == {b["name"]}
def test_list_assets_repeated_query_keys_concatenate(http, api_base, asset_factory, make_asset_bytes):
"""Repeated occurrences of a tag param concatenate before the CSV split
(Core-local behavior, not a cross-platform guarantee)."""
scope = f"lf-repeat-{uuid.uuid4().hex[:6]}"
t = ["models", "model_type:checkpoints", "unit-tests", scope]
alpha, beta = f"{scope}-alpha", f"{scope}-beta"
a = asset_factory("rp_a.safetensors", [*t, alpha], {}, make_asset_bytes("rp_a"))
b = asset_factory("rp_b.safetensors", [*t, beta], {}, make_asset_bytes("rp_b"))
# requests encodes a list value as repeated keys: tags_any=<alpha>&tags_any=<beta>
r = http.get(
api_base + "/api/assets",
params={"tags_any": [alpha, beta], "limit": "50"},
timeout=120,
)
body = r.json()
assert r.status_code == 200, body
names = {x["name"] for x in body["assets"]}
assert {a["name"], b["name"]} <= names
def test_list_assets_tags_any_cursor_pagination_consistent(http, api_base, asset_factory, make_asset_bytes):
scope = f"lf-anypage-{uuid.uuid4().hex[:6]}"
t = ["models", "model_type:checkpoints", "unit-tests", scope]
alpha = f"{scope}-alpha"
expected = set()
for i in range(3):
made = asset_factory(f"pg_{i}.safetensors", [*t, alpha], {}, make_asset_bytes(f"pg_{i}"))
expected.add(made["name"])
r1 = http.get(
api_base + "/api/assets",
params={"tags_any": alpha, "limit": "2", "sort": "name", "order": "asc"},
timeout=120,
)
b1 = r1.json()
assert r1.status_code == 200, b1
assert b1["total"] == 3
assert b1["has_more"] is True
assert b1.get("next_cursor"), "expected a keyset cursor on the first page"
r2 = http.get(
api_base + "/api/assets",
params={
"tags_any": alpha,
"limit": "2",
"sort": "name",
"order": "asc",
"after": b1["next_cursor"],
},
timeout=120,
)
b2 = r2.json()
assert r2.status_code == 200, b2
assert b2["has_more"] is False
page1 = {x["name"] for x in b1["assets"]}
page2 = {x["name"] for x in b2["assets"]}
assert not page1 & page2, "cursor pages must not overlap"
assert page1 | page2 == expected
def test_tags_refine_mixed_spellings_rejected_and_legacy_conflict_kept(http, api_base):
r = http.get(
api_base + "/api/assets/tags/refine",
params={"include_tags": "rfmx-x", "tags_all": "rfmx-y"},
timeout=120,
)
body = r.json()
assert r.status_code == 400, body
assert body["error"]["code"] == "INVALID_TAG_FILTER"
assert body["error"]["details"]["parameters"] == ["include_tags", "tags_all"]
# Old names only: the refine route keeps legacy behaviour too.
r2 = http.get(
api_base + "/api/assets/tags/refine",
params={"include_tags": "rfmx-z", "exclude_tags": "rfmx-z"},
timeout=120,
)
body2 = r2.json()
assert r2.status_code == 200, body2
assert body2["tag_counts"] == {}
def test_list_assets_tag_values_case_sensitive(http, api_base, asset_factory, make_asset_bytes):
"""Case-distinct tags are distinct; the all/none conflict check is byte-exact."""
scope = f"lf-case-{uuid.uuid4().hex[:6]}"
t = ["models", "model_type:checkpoints", "unit-tests", scope]
upper, lower = f"{scope}-ALPHA", f"{scope}-alpha"
a = asset_factory("cx_a.safetensors", [*t, upper], {}, make_asset_bytes("cx_a"))
b = asset_factory("cx_b.safetensors", [*t, lower], {}, make_asset_bytes("cx_b"))
def names_for(params: dict) -> set:
r = http.get(api_base + "/api/assets", params=params, timeout=120)
body = r.json()
assert r.status_code == 200, body
return {x["name"] for x in body["assets"]}
assert names_for({"tags_all": f"unit-tests,{scope},{upper}"}) == {a["name"]}
assert names_for({"tags_any": lower, "limit": "50"}) == {b["name"]}
# Case-distinct all/none pair is NOT a conflict — byte-exact comparison.
assert names_for({"tags_all": f"unit-tests,{scope},{upper}", "tags_none": lower}) == {a["name"]}
def test_tag_list_cap_applies_to_all_spellings(http, api_base):
"""The cap covers the legacy spellings too."""
big = ",".join(f"cap-{i}" for i in range(101))
for param in ("tags_any", "include_tags"):
r = http.get(api_base + "/api/assets", params={param: big}, timeout=120)
body = r.json()
assert r.status_code == 400, body
assert body["error"]["code"] == "INVALID_TAG_FILTER"
assert body["error"]["details"]["parameter"] == param
assert body["error"]["details"]["max"] == 100
exact = ",".join(f"cap-{i}" for i in range(100))
r = http.get(api_base + "/api/assets", params={"tags_any": exact}, timeout=120)
assert r.status_code == 200, r.json()
# The cap counts normalized (deduped) tags, not raw CSV items.
dups = ",".join("cap-dup" for _ in range(150))
r = http.get(api_base + "/api/assets", params={"tags_any": dups}, timeout=120)
assert r.status_code == 200, r.json()
def test_resolve_tag_filters_no_deprecation_warning():
"""The deprecated-field warning is for API clients; the server's own remap
shim must not fire it on every request."""
for q in (
schemas_in.ListAssetsQuery(tags_all="a", tags_none="b"),
schemas_in.TagsRefineQuery(tags_any="c"),
):
with warnings.catch_warnings():
warnings.simplefilter("error", DeprecationWarning)
assets_routes._resolve_tag_filters(q)
def test_tag_filter_alias_fields_marked_deprecated():
for model in (schemas_in.ListAssetsQuery, schemas_in.TagsRefineQuery):
props = model.model_json_schema()["properties"]
for field in ("include_tags", "exclude_tags"):
assert props[field].get("deprecated") is True, (model.__name__, field)
for field in ("tags_all", "tags_any", "tags_none"):
assert "deprecated" not in props[field], (model.__name__, field)

View File

@@ -0,0 +1,216 @@
import contextlib
import json
import re
import uuid
import requests
def test_preview_url_serves_the_asset(
http: requests.Session, api_base: str, asset_factory, make_asset_bytes
):
scope = f"preview-url-{uuid.uuid4().hex[:6]}"
name = f"{scope}.png"
data = make_asset_bytes(name, 2048)
body = asset_factory(name, ["output", "unit-tests", scope], {}, data)
assert re.fullmatch(r"/api/view\?type=output&filename=[^&]+", body["preview_url"]), (
f"unexpected preview URL shape: {body['preview_url']!r}"
)
r = http.get(api_base + body["preview_url"], timeout=120)
assert r.status_code == 200, r.text
assert r.content == data, "the preview URL must serve the asset's own bytes"
def test_preview_url_honours_range_requests(
http: requests.Session, api_base: str, asset_factory, make_asset_bytes
):
scope = f"preview-range-{uuid.uuid4().hex[:6]}"
name = f"{scope}.png"
data = make_asset_bytes(name, 2048)
body = asset_factory(name, ["output", "unit-tests", scope], {}, data)
r = http.get(
api_base + body["preview_url"], headers={"Range": "bytes=10-109"}, timeout=120
)
assert r.status_code == 206, (
f"expected a partial response, got {r.status_code}: native <video>/<audio> "
f"seek by byte range and will not play a source that ignores it"
)
assert r.content == data[10:110], "the served range must be the requested one"
def test_preview_url_needs_no_user_header(
api_base: str, asset_factory, make_asset_bytes
):
scope = f"preview-anon-{uuid.uuid4().hex[:6]}"
name = f"{scope}.png"
data = make_asset_bytes(name, 1024)
body = asset_factory(name, ["input", "unit-tests", scope], {}, data)
with requests.Session() as bare:
r = bare.get(api_base + body["preview_url"], timeout=120)
assert r.status_code == 200, (
f"a browser fetching <img src> cannot attach a Comfy-User header, so the "
f"preview URL must resolve without one; got {r.status_code}: {r.text}"
)
assert r.content == data
def test_preview_url_survives_tag_removal(
http: requests.Session, api_base: str, asset_factory, make_asset_bytes
):
scope = f"preview-tags-{uuid.uuid4().hex[:6]}"
name = f"{scope}.png"
data = make_asset_bytes(name, 2048)
body = asset_factory(name, ["input", "unit-tests", scope], {}, data)
aid = body["id"]
preview_url = body["preview_url"]
assert preview_url, "an uploaded image starts out with a preview"
r = http.delete(
f"{api_base}/api/assets/{aid}/tags", json={"tags": ["input"]}, timeout=120
)
assert r.status_code == 200, r.text
after = http.get(f"{api_base}/api/assets/{aid}", timeout=120).json()
assert "input" not in after["tags"]
assert after["preview_url"] == preview_url, (
"dropping the tag that used to select the view type must not take the "
"preview with it"
)
assert http.get(api_base + preview_url, timeout=120).status_code == 200
def test_preview_url_is_the_nominated_preview_when_one_is_set(
http: requests.Session, api_base: str, asset_factory, make_asset_bytes
):
scope = f"preview-id-{uuid.uuid4().hex[:6]}"
thumb_name = f"{scope}_thumb.png"
thumb_data = make_asset_bytes(thumb_name, 1024)
thumb = asset_factory(thumb_name, ["input", "unit-tests", scope], {}, thumb_data)
model_name = f"{scope}.safetensors"
files = {"file": (model_name, make_asset_bytes(model_name, 2048), "application/octet-stream")}
form_data = {
"tags": json.dumps(["models", "model_type:checkpoints", "unit-tests", scope]),
"name": model_name,
"preview_id": thumb["id"],
}
r = http.post(api_base + "/api/assets", files=files, data=form_data, timeout=120)
model = r.json()
assert r.status_code in (200, 201), model
try:
assert model["preview_id"] == thumb["id"]
assert model["preview_url"] == thumb["preview_url"], (
"a nominated preview stands in for content with no visual form"
)
got = http.get(api_base + model["preview_url"], timeout=120)
assert got.status_code == 200, got.text
assert got.content == thumb_data
finally:
with contextlib.suppress(Exception):
http.delete(f"{api_base}/api/assets/{model['id']}", timeout=30)
def test_soft_deleted_preview_is_not_advertised(
http: requests.Session, api_base: str, asset_factory, make_asset_bytes
):
scope = f"preview-gone-{uuid.uuid4().hex[:6]}"
thumb_name = f"{scope}_thumb.png"
thumb = asset_factory(
thumb_name, ["input", "unit-tests", scope], {}, make_asset_bytes(thumb_name, 1024)
)
parent_name = f"{scope}_parent.png"
parent = asset_factory(
parent_name, ["input", "unit-tests", scope], {}, make_asset_bytes(parent_name, 1024)
)
r = http.put(
f"{api_base}/api/assets/{parent['id']}",
json={"preview_id": thumb["id"]},
timeout=120,
)
assert r.status_code == 200, r.text
assert r.json()["preview_url"] == thumb["preview_url"]
assert http.delete(f"{api_base}/api/assets/{thumb['id']}", timeout=30).status_code in (200, 204)
after = http.get(f"{api_base}/api/assets/{parent['id']}", timeout=120).json()
assert after["preview_id"] == thumb["id"]
assert after.get("preview_url") is None, (
"soft delete leaves the parent's preview_id pointing at it, so the "
"response has to drop the URL rather than promise one that 404s"
)
def test_no_preview_url_for_content_a_browser_cannot_render(
http: requests.Session, api_base: str, asset_factory, make_asset_bytes
):
scope = f"preview-model-{uuid.uuid4().hex[:6]}"
name = f"{scope}.safetensors"
body = asset_factory(
name,
["models", "model_type:checkpoints", "unit-tests", scope],
{},
make_asset_bytes(name, 2048),
)
assert body.get("preview_url") is None, (
"model weights have no preview of their own"
)
listed = http.get(
api_base + "/api/assets", params={"include_tags": scope}, timeout=120
).json()["assets"]
assert [a.get("preview_url") for a in listed] == [None], (
"the list route must withhold it too, not just the detail route"
)
def test_text_asset_gets_a_preview_url_that_serves_its_content(
http: requests.Session, api_base: str, asset_factory, make_asset_bytes
):
scope = f"preview-text-{uuid.uuid4().hex[:6]}"
name = f"{scope}.txt"
data = b"line one\nline two\n" + make_asset_bytes(name, 256)
body = asset_factory(name, ["output", "unit-tests", scope], {}, data)
assert body.get("preview_url"), (
"text assets are rendered as a snippet fetched from preview_url"
)
r = http.get(api_base + body["preview_url"], timeout=120)
assert r.status_code == 200, r.text
assert r.content == data
def test_dangerous_text_preview_is_still_forced_to_download(
http: requests.Session, api_base: str, asset_factory
):
scope = f"preview-html-{uuid.uuid4().hex[:6]}"
name = f"{scope}.html"
data = f"<html><script>alert('{scope}')</script></html>".encode()
body = asset_factory(name, ["output", "unit-tests", scope], {}, data)
assert body.get("preview_url"), "text/html matches the previewable prefix"
r = http.get(api_base + body["preview_url"], timeout=120)
r.content
assert r.status_code == 200
ct = r.headers.get("Content-Type", "").lower()
cd = r.headers.get("Content-Disposition", "").lower()
assert ct.startswith("application/octet-stream"), (
f"admitting text/ to the previewable set must not let HTML render "
f"inline in the app origin; got {ct!r}"
)
assert "attachment" in cd, f"expected a forced download, got {cd!r}"

View File

@@ -150,7 +150,7 @@ def test_needs_verify_toggling(session, temp_dir, case):
)
session.commit()
with patch("app.assets.scanner.get_prefixes_for_root", return_value=[str(temp_dir)]):
with patch("app.assets.scanner.get_scan_prefixes_for_root", return_value=[str(temp_dir)]):
sync_references_with_filesystem(session, "models")
session.commit()
@@ -185,7 +185,7 @@ def test_is_missing_flag(session, temp_dir, case):
_make_asset(session, "a1", fp, "r1", asset_hash="blake3:abc", mtime_ns=mtime)
session.commit()
with patch("app.assets.scanner.get_prefixes_for_root", return_value=[str(temp_dir)]):
with patch("app.assets.scanner.get_scan_prefixes_for_root", return_value=[str(temp_dir)]):
sync_references_with_filesystem(session, "models")
session.commit()
@@ -200,7 +200,7 @@ def test_seed_asset_all_missing_deletes_asset(session, temp_dir):
_make_asset(session, "seed1", fp, "r1", asset_hash=None, mtime_ns=999)
session.commit()
with patch("app.assets.scanner.get_prefixes_for_root", return_value=[str(temp_dir)]):
with patch("app.assets.scanner.get_scan_prefixes_for_root", return_value=[str(temp_dir)]):
sync_references_with_filesystem(session, "models")
session.commit()
@@ -215,7 +215,7 @@ def test_seed_asset_some_exist_returns_survivors(session, temp_dir):
_make_asset(session, "seed1", fp, "r1", asset_hash=None, mtime_ns=mtime)
session.commit()
with patch("app.assets.scanner.get_prefixes_for_root", return_value=[str(temp_dir)]):
with patch("app.assets.scanner.get_scan_prefixes_for_root", return_value=[str(temp_dir)]):
survivors = sync_references_with_filesystem(
session, "models", collect_existing_paths=True,
)
@@ -240,7 +240,7 @@ def test_hashed_asset_prunes_missing_refs_when_one_is_ok(session, temp_dir):
session.add(ref_gone)
session.commit()
with patch("app.assets.scanner.get_prefixes_for_root", return_value=[str(temp_dir)]):
with patch("app.assets.scanner.get_scan_prefixes_for_root", return_value=[str(temp_dir)]):
sync_references_with_filesystem(session, "models")
session.commit()
@@ -255,7 +255,7 @@ def test_hashed_asset_all_missing_keeps_refs(session, temp_dir):
_make_asset(session, "h1", fp, "r1", asset_hash="blake3:aaa", mtime_ns=999)
session.commit()
with patch("app.assets.scanner.get_prefixes_for_root", return_value=[str(temp_dir)]):
with patch("app.assets.scanner.get_scan_prefixes_for_root", return_value=[str(temp_dir)]):
sync_references_with_filesystem(session, "models")
session.commit()
@@ -272,7 +272,7 @@ def test_missing_tag_added_when_all_refs_gone(session, temp_dir):
_make_asset(session, "h1", fp, "r1", asset_hash="blake3:aaa", mtime_ns=999)
session.commit()
with patch("app.assets.scanner.get_prefixes_for_root", return_value=[str(temp_dir)]):
with patch("app.assets.scanner.get_scan_prefixes_for_root", return_value=[str(temp_dir)]):
sync_references_with_filesystem(
session, "models", update_missing_tags=True,
)
@@ -295,7 +295,7 @@ def test_missing_tag_removed_when_ref_ok(session, temp_dir):
))
session.commit()
with patch("app.assets.scanner.get_prefixes_for_root", return_value=[str(temp_dir)]):
with patch("app.assets.scanner.get_scan_prefixes_for_root", return_value=[str(temp_dir)]):
sync_references_with_filesystem(
session, "models", update_missing_tags=True,
)
@@ -313,7 +313,7 @@ def test_missing_tags_not_touched_when_flag_false(session, temp_dir):
_make_asset(session, "h1", fp, "r1", asset_hash="blake3:aaa", mtime_ns=999)
session.commit()
with patch("app.assets.scanner.get_prefixes_for_root", return_value=[str(temp_dir)]):
with patch("app.assets.scanner.get_scan_prefixes_for_root", return_value=[str(temp_dir)]):
sync_references_with_filesystem(
session, "models", update_missing_tags=False,
)
@@ -329,7 +329,7 @@ def test_returns_none_when_collect_false(session, temp_dir):
_make_asset(session, "a1", fp, "r1", asset_hash="blake3:abc", mtime_ns=mtime)
session.commit()
with patch("app.assets.scanner.get_prefixes_for_root", return_value=[str(temp_dir)]):
with patch("app.assets.scanner.get_scan_prefixes_for_root", return_value=[str(temp_dir)]):
result = sync_references_with_filesystem(
session, "models", collect_existing_paths=False,
)
@@ -338,7 +338,7 @@ def test_returns_none_when_collect_false(session, temp_dir):
def test_returns_empty_set_for_no_prefixes(session):
with patch("app.assets.scanner.get_prefixes_for_root", return_value=[]):
with patch("app.assets.scanner.get_scan_prefixes_for_root", return_value=[]):
result = sync_references_with_filesystem(
session, "models", collect_existing_paths=True,
)
@@ -348,7 +348,7 @@ def test_returns_empty_set_for_no_prefixes(session):
def test_no_references_is_noop(session, temp_dir):
"""No crash and no side effects when there are no references."""
with patch("app.assets.scanner.get_prefixes_for_root", return_value=[str(temp_dir)]):
with patch("app.assets.scanner.get_scan_prefixes_for_root", return_value=[str(temp_dir)]):
survivors = sync_references_with_filesystem(
session, "models", collect_existing_paths=True,
)
@@ -388,7 +388,7 @@ def test_sync_does_not_resurrect_soft_deleted_ref(session, temp_dir):
_soft_delete_ref(session, "r1")
session.commit()
with patch("app.assets.scanner.get_prefixes_for_root", return_value=[str(temp_dir)]):
with patch("app.assets.scanner.get_scan_prefixes_for_root", return_value=[str(temp_dir)]):
sync_references_with_filesystem(session, "models")
session.commit()
@@ -472,7 +472,7 @@ def test_sync_ignores_soft_deleted_seed_asset(session, temp_dir):
_soft_delete_ref(session, "r1")
session.commit()
with patch("app.assets.scanner.get_prefixes_for_root", return_value=[str(temp_dir)]):
with patch("app.assets.scanner.get_scan_prefixes_for_root", return_value=[str(temp_dir)]):
sync_references_with_filesystem(session, "models")
session.commit()

View File

@@ -0,0 +1,180 @@
import os
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.assets.database.models import Asset, AssetReference, Base
from app.assets.database.queries.asset_reference import (
mark_references_missing_outside_prefixes,
)
from app.assets.scanner import (
collect_paths_for_roots,
get_owned_prefixes,
get_temp_prefixes,
sync_prefixes_with_filesystem,
)
from app.assets.services.file_utils import get_mtime_ns
@pytest.fixture(autouse=True)
def autoclean_unit_test_assets():
"""Override parent autouse fixture - temp asset tests don't need server cleanup."""
yield
@pytest.fixture
def session():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as sess:
yield sess
@pytest.fixture
def comfy_dirs():
with tempfile.TemporaryDirectory() as base:
dirs = {
name: Path(base) / name
for name in ("models", "input", "output", "temp", "elsewhere")
}
for d in dirs.values():
d.mkdir()
with (
patch("folder_paths.get_input_directory", return_value=str(dirs["input"])),
patch("folder_paths.get_output_directory", return_value=str(dirs["output"])),
patch("folder_paths.get_temp_directory", return_value=str(dirs["temp"])),
patch(
"app.assets.scanner.get_comfy_models_folders",
return_value=[("checkpoints", [str(dirs["models"])], set())],
),
):
yield dirs
def _write(directory: Path, name: str) -> str:
p = directory / name
p.write_bytes(b"\x00" * 100)
return str(p)
def _register(
session: Session,
file_path: str,
ref_id: str,
*,
mtime_ns: int,
asset_hash: str | None = "",
) -> None:
if asset_hash == "":
asset_hash = f"blake3:{ref_id}"
session.add(Asset(id=f"asset-{ref_id}", hash=asset_hash, size_bytes=100))
session.flush()
session.add(
AssetReference(
id=ref_id,
asset_id=f"asset-{ref_id}",
name=os.path.basename(file_path),
owner_id="",
file_path=file_path,
mtime_ns=mtime_ns,
)
)
session.flush()
def _mtime(path: str) -> int:
return get_mtime_ns(os.stat(path, follow_symlinks=True))
def test_owned_prefixes_include_temp(comfy_dirs):
owned = get_owned_prefixes()
assert str(comfy_dirs["temp"]) in owned, (
"temp must be owned, or the prune disowns assets whose files are present"
)
for name in ("models", "input", "output"):
assert str(comfy_dirs[name]) in owned, f"{name} must stay owned"
def test_discovery_does_not_walk_temp(comfy_dirs):
temp_file = _write(comfy_dirs["temp"], "preview.png")
output_file = _write(comfy_dirs["output"], "render.png")
with patch("app.assets.scanner.collect_models_files", return_value=[]):
paths = collect_paths_for_roots(("models", "input", "output"))
assert output_file in paths, "scan roots must still be walked"
assert temp_file not in paths, (
"temp is wiped before every scan, so walking it only ever finds nothing"
)
def test_prune_keeps_live_temp_reference(session, comfy_dirs):
temp_file = _write(comfy_dirs["temp"], "preview.png")
stray_file = _write(comfy_dirs["elsewhere"], "stray.png")
_register(session, temp_file, "temp-ref", mtime_ns=_mtime(temp_file))
_register(session, stray_file, "stray-ref", mtime_ns=_mtime(stray_file))
session.commit()
marked = mark_references_missing_outside_prefixes(session, get_owned_prefixes())
session.commit()
session.expire_all()
assert marked == 1, "only the reference outside every owned directory is disowned"
assert session.get(AssetReference, "temp-ref").is_missing is False, (
"a temp file on disk is not missing, however often the prune runs"
)
assert session.get(AssetReference, "stray-ref").is_missing is True, (
"owning temp must not stop the prune disowning files elsewhere"
)
def test_temp_sync_marks_deleted_file_missing(session, comfy_dirs):
temp_file = _write(comfy_dirs["temp"], "preview.png")
_register(session, temp_file, "temp-ref", mtime_ns=_mtime(temp_file))
session.commit()
os.remove(temp_file)
sync_prefixes_with_filesystem(session, get_temp_prefixes())
session.commit()
session.expire_all()
assert session.get(AssetReference, "temp-ref").is_missing is True, (
"nothing else stats temp, so this pass is what retires a wiped file"
)
def test_temp_sync_drops_unhashed_asset_whose_file_is_gone(session, comfy_dirs):
temp_file = _write(comfy_dirs["temp"], "preview.png")
_register(session, temp_file, "temp-ref", mtime_ns=_mtime(temp_file), asset_hash=None)
session.commit()
os.remove(temp_file)
sync_prefixes_with_filesystem(session, get_temp_prefixes())
session.commit()
session.expire_all()
assert session.get(AssetReference, "temp-ref") is None, (
"an unhashed asset with no surviving reference is retired, not kept as missing"
)
assert session.get(Asset, "asset-temp-ref") is None, (
"the orphaned asset row goes with its last reference"
)
def test_temp_sync_keeps_live_file(session, comfy_dirs):
temp_file = _write(comfy_dirs["temp"], "preview.png")
_register(session, temp_file, "temp-ref", mtime_ns=_mtime(temp_file))
session.commit()
sync_prefixes_with_filesystem(session, get_temp_prefixes())
session.commit()
session.expire_all()
ref = session.get(AssetReference, "temp-ref")
assert ref.is_missing is False, "the file is still there"
assert ref.needs_verify is False, "an unchanged file needs no re-verification"

View File

@@ -0,0 +1,57 @@
import asyncio
import base64
from io import BytesIO
import torch
from PIL import Image
from comfy.cli_args import args
if not torch.cuda.is_available():
args.cpu = True
from comfy_api_nodes.apis.gemini import ( # noqa: E402
GeminiCandidate,
GeminiContent,
GeminiGenerateContentResponse,
GeminiInlineData,
GeminiPart,
)
from comfy_api_nodes.nodes_gemini import get_image_from_response # noqa: E402
def image_part(mode, color):
buffer = BytesIO()
Image.new(mode, (4, 4), color).save(buffer, format="PNG")
return GeminiPart(
inlineData=GeminiInlineData(
data=base64.b64encode(buffer.getvalue()).decode(),
mimeType="image/png",
)
)
def response(*parts):
return GeminiGenerateContentResponse(
candidates=[GeminiCandidate(content=GeminiContent(parts=list(parts), role="model"))]
)
def test_rgb_only_response_stays_three_channels():
out = asyncio.run(get_image_from_response(response(image_part("RGB", (10, 20, 30)))))
assert out.shape == (1, 4, 4, 3)
def test_mixed_rgb_and_rgba_parts_are_padded_to_the_same_width():
out = asyncio.run(
get_image_from_response(
response(
image_part("RGB", (10, 20, 30)),
image_part("RGBA", (10, 20, 30, 0)),
)
)
)
assert out.shape == (2, 4, 4, 4)
# the part that had no alpha is padded opaque, the transparent one is preserved
assert out[0, ..., 3].min() == 1.0
assert out[1, ..., 3].max() == 0.0

View File

@@ -0,0 +1,80 @@
from io import BytesIO
import pytest
import torch
from PIL import Image
from comfy.cli_args import args
if not torch.cuda.is_available():
args.cpu = True
from comfy_api_nodes.util.conversions import bytesio_to_image_tensor, pad_images_to_common_channels # noqa: E402
def encode(image: Image.Image, image_format: str = "PNG") -> BytesIO:
buffer = BytesIO()
image.save(buffer, format=image_format)
buffer.seek(0)
return buffer
def test_rgb_png_stays_three_channels():
tensor = bytesio_to_image_tensor(encode(Image.new("RGB", (4, 4), (10, 20, 30))))
assert tensor.shape == (1, 4, 4, 3)
def test_jpeg_stays_three_channels():
tensor = bytesio_to_image_tensor(encode(Image.new("RGB", (4, 4), (10, 20, 30)), "JPEG"))
assert tensor.shape == (1, 4, 4, 3)
def test_grayscale_is_expanded_to_rgb():
tensor = bytesio_to_image_tensor(encode(Image.new("L", (4, 4), 128)))
assert tensor.shape == (1, 4, 4, 3)
def test_rgba_png_keeps_its_alpha():
tensor = bytesio_to_image_tensor(encode(Image.new("RGBA", (4, 4), (10, 20, 30, 0))))
assert tensor.shape == (1, 4, 4, 4)
assert tensor[..., 3].max() == 0.0
def test_palette_png_with_transparency_keeps_its_alpha():
image = Image.new("P", (4, 4), 1)
image.putpalette([0, 0, 0, 255, 255, 255])
image.info["transparency"] = 0
image.putpixel((0, 0), 0)
tensor = bytesio_to_image_tensor(encode(image))
assert tensor.shape == (1, 4, 4, 4)
assert tensor[0, 0, 0, 3] == 0.0
assert tensor[0, 1, 1, 3] == 1.0
@pytest.mark.parametrize("mode,channels", [("RGB", 3), ("RGBA", 4)])
def test_explicit_mode_is_respected(mode, channels):
tensor = bytesio_to_image_tensor(encode(Image.new("RGBA", (4, 4), (10, 20, 30, 128))), mode=mode)
assert tensor.shape == (1, 4, 4, channels)
def test_pad_mixed_channels_concatenates():
rgb = torch.rand(1, 4, 4, 3)
rgba = torch.rand(2, 4, 4, 4)
padded = pad_images_to_common_channels([rgb, rgba])
result = torch.cat(padded, dim=0)
assert result.shape == (3, 4, 4, 4)
def test_pad_adds_opaque_alpha_and_keeps_rgb_values():
rgb = torch.rand(1, 4, 4, 3)
rgba = torch.rand(1, 4, 4, 4)
padded_rgb, padded_rgba = pad_images_to_common_channels([rgb, rgba])
assert torch.equal(padded_rgb[..., :3], rgb)
assert padded_rgb[..., 3].min() == 1.0
assert padded_rgba is rgba
def test_pad_leaves_homogeneous_channels_unchanged():
images = [torch.rand(1, 4, 4, 3), torch.rand(2, 4, 4, 3)]
padded = pad_images_to_common_channels(images)
assert all(p is i for p, i in zip(padded, images))

View File

@@ -2,8 +2,9 @@ import io
from comfy_api.input_impl.video_types import (
container_to_output_format,
get_open_write_kwargs,
video_encoder_options,
)
from comfy_api.util import VideoContainer
from comfy_api.util import VideoCodec, VideoContainer
def test_container_to_output_format_empty_string():
@@ -36,7 +37,7 @@ def test_get_open_write_kwargs_filepath_no_format():
kwargs_specific = get_open_write_kwargs("output.avi", "mp4", "avi")
fail_msg = "Format should not be set for file paths (Specific)"
assert "format" not in kwargs_specific, fail_msg
assert kwargs_specific["options"]["movflags"] == "use_metadata_tags"
assert "options" not in kwargs_specific
def test_get_open_write_kwargs_base_options_mode():
@@ -90,3 +91,16 @@ def test_get_open_write_kwargs_bytesio_specific_format_list():
fail_msg = "Format should be a valid format from the specified format list when output format is not AUTO"
assert kwargs["format"] in to_fmt, fail_msg
def test_get_open_write_kwargs_does_not_pass_movflags_to_matroska_or_webm():
for format, suffix in ((VideoContainer.MKV, "mkv"), (VideoContainer.WEBM, "webm")):
assert "options" not in get_open_write_kwargs(f"output.{suffix}", "mp4", format)
assert "options" not in get_open_write_kwargs(io.BytesIO(), "mp4", format)
def test_av1_zero_crf_uses_lossless_mode():
assert video_encoder_options(VideoCodec.AV1, 0) == {"svtav1-params": "lossless=1"}
assert video_encoder_options(VideoCodec.AV1, 30.0) == {"crf": "30.0"}
assert video_encoder_options(VideoCodec.H264, 0) == {"crf": "0"}
assert video_encoder_options(VideoCodec.AV1, None) == {}

View File

@@ -5,11 +5,13 @@ import os
import sys
import av
import io
import numpy as np
from fractions import Fraction
from comfy_api.input_impl.video_types import VideoFromFile, VideoFromComponents
from comfy_api.util.video_types import VideoComponents, VideoContainer, VideoCodec
from comfy_api.input.basic_types import AudioInput
from av.error import InvalidDataError
from av.video.reformatter import ColorPrimaries, ColorRange, ColorTrc
EPSILON = 0.0001
@@ -132,6 +134,11 @@ def test_video_from_file_get_dimensions(simple_video_file):
assert height == 4
def test_video_color_space_defaults_to_srgb(simple_video_file, video_components):
assert VideoFromFile(simple_video_file).get_color_space() == "sRGB"
assert VideoFromComponents(video_components).get_color_space() == "sRGB"
def test_video_from_file_bytesio_input():
"""VideoFromFile works with BytesIO input"""
buffer = io.BytesIO()
@@ -258,6 +265,456 @@ def test_save_to_h264_crf_controls_quality(tmp_path):
assert os.path.getsize(transcoded) < os.path.getsize(high_quality)
def video_packet_bytes(path):
with av.open(path) as container:
return [bytes(packet) for packet in container.demux(container.streams.video[0]) if packet.size]
def decoded_video_frames(path):
with av.open(path) as container:
frames = []
for frame in container.decode(video=0):
bytes_per_sample = max(component.bits for component in frame.format.components)
bytes_per_sample = (bytes_per_sample + 7) // 8
plane_sizes = (
(frame.width * bytes_per_sample, frame.height),
((frame.width // 2) * bytes_per_sample, frame.height // 2),
((frame.width // 2) * bytes_per_sample, frame.height // 2),
)
frames.append(tuple(
b"".join(
bytes(plane)[row * plane.line_size:row * plane.line_size + row_size]
for row in range(rows)
)
for plane, (row_size, rows) in zip(frame.planes, plane_sizes)
))
return frames
@pytest.mark.parametrize(
"format,suffix,codec",
[
(VideoContainer.MP4, "mp4", VideoCodec.H264),
(VideoContainer.MKV, "mkv", VideoCodec.H264),
(VideoContainer.MKV, "mkv", VideoCodec.AV1),
(VideoContainer.WEBM, "webm", VideoCodec.AV1),
],
)
def test_video_from_components_auto_color_space_matches_srgb(tmp_path, format, suffix, codec):
components = VideoComponents(
images=torch.rand(3, 64, 64, 3, generator=torch.Generator().manual_seed(23)),
frame_rate=Fraction(30),
)
auto = str(tmp_path / f"auto.{suffix}")
srgb = str(tmp_path / f"srgb.{suffix}")
VideoFromComponents(components).save_to(
auto,
format=format,
codec=codec,
crf=0,
)
VideoFromComponents(components).save_to(
srgb,
format=format,
codec=codec,
crf=0,
color_space="sRGB",
)
assert decoded_video_frames(auto) == decoded_video_frames(srgb)
for path in (auto, srgb):
with av.open(path) as container:
stream = container.streams.video[0]
assert stream.color_primaries == ColorPrimaries.BT709
assert stream.color_trc == ColorTrc.IEC61966_2_1
assert stream.colorspace == 1
assert stream.color_range == ColorRange.MPEG
def create_hdr_av1_video(path, transfer, color_range):
images = np.random.default_rng(17).integers(0, 65536, (3, 64, 64, 3), dtype=np.uint16)
with av.open(path, mode="w") as container:
stream = container.add_stream("libsvtav1", rate=30)
stream.width = 64
stream.height = 64
stream.pix_fmt = "yuv420p10le"
stream.options = {"svtav1-params": "lossless=1"}
stream.color_primaries = ColorPrimaries.BT2020
stream.color_trc = transfer
stream.colorspace = 9
stream.color_range = color_range
for image in images:
frame = av.VideoFrame.from_ndarray(image, format="rgb48le").reformat(format="yuv420p10le")
frame.color_primaries = ColorPrimaries.BT2020
frame.color_trc = transfer
frame.colorspace = 9
frame.color_range = color_range
container.mux(stream.encode(frame))
container.mux(stream.encode(None))
def test_save_to_av1_crf_controls_quality(tmp_path):
generator = torch.Generator().manual_seed(11)
components = VideoComponents(
images=torch.rand(12, 64, 64, 3, generator=generator),
frame_rate=Fraction(30),
)
high_quality = str(tmp_path / "high_quality.mkv")
low_quality = str(tmp_path / "low_quality.mkv")
VideoFromComponents(components).save_to(
high_quality,
format=VideoContainer.MKV,
codec=VideoCodec.AV1,
crf=0,
)
VideoFromComponents(components).save_to(
low_quality,
format=VideoContainer.MKV,
codec=VideoCodec.AV1,
crf=63,
)
assert os.path.getsize(high_quality) > os.path.getsize(low_quality)
@pytest.mark.parametrize(
"format,suffix,codec,video_codec,audio_codec,audio_rate",
[
(VideoContainer.AUTO, "mp4", VideoCodec.AUTO, "h264", "aac", 44100),
(VideoContainer.MP4, "mp4", VideoCodec.H264, "h264", "aac", 44100),
(VideoContainer.MP4, "mp4", VideoCodec.AV1, "av1", "aac", 44100),
(VideoContainer.MKV, "mkv", VideoCodec.AUTO, "h264", "aac", 44100),
(VideoContainer.MKV, "mkv", VideoCodec.H264, "h264", "aac", 44100),
(VideoContainer.MKV, "mkv", VideoCodec.AV1, "av1", "aac", 44100),
(VideoContainer.WEBM, "webm", VideoCodec.AUTO, "av1", "opus", 48000),
(VideoContainer.WEBM, "webm", VideoCodec.AV1, "av1", "opus", 48000),
],
)
def test_save_components_container_codec_and_audio_matrix(
tmp_path, format, suffix, codec, video_codec, audio_codec, audio_rate
):
components = VideoComponents(
images=torch.rand(3, 64, 64, 3),
audio=AudioInput({
"waveform": torch.rand(1, 2, 4410),
"sample_rate": 44100,
}),
frame_rate=Fraction(30),
)
path = str(tmp_path / f"components.{suffix}")
VideoFromComponents(components).save_to(
path,
format=format,
codec=codec,
crf=30,
metadata={"prompt": {"test": "video"}},
)
with av.open(path) as container:
assert container.streams.video[0].codec.canonical_name == video_codec
assert container.streams.video[0].format.name == "yuv420p"
assert container.streams.audio[0].codec.canonical_name == audio_codec
assert container.streams.audio[0].sample_rate == audio_rate
prompt = container.metadata.get("PROMPT", container.metadata.get("prompt"))
assert prompt == '{"test": "video"}'
assert sum(1 for _ in container.decode(video=0)) == 3
with av.open(path) as container:
assert sum(frame.samples for frame in container.decode(audio=0)) > 0
@pytest.mark.parametrize(
"color_space,transfer,pix_fmt,primaries,colorspace",
[
("sRGB", ColorTrc.IEC61966_2_1, "yuv420p", ColorPrimaries.BT709, 1),
("HDR", ColorTrc.ARIB_STD_B67, "yuv420p10le", ColorPrimaries.BT2020, 9),
("HDR PQ", ColorTrc.SMPTE2084, "yuv420p10le", ColorPrimaries.BT2020, 9),
],
)
def test_save_to_av1_mkv_color_space(tmp_path, color_space, transfer, pix_fmt, primaries, colorspace):
components = VideoComponents(
images=torch.rand(2, 64, 64, 3),
frame_rate=Fraction(30),
)
path = str(tmp_path / "hdr.mkv")
remuxed = str(tmp_path / "remuxed.mkv")
VideoFromComponents(components).save_to(
path,
format=VideoContainer.MKV,
codec=VideoCodec.AV1,
crf=30,
color_space=color_space,
metadata={"prompt": {"test": "hdr"}},
)
with av.open(path) as container:
stream = container.streams.video[0]
assert stream.codec.canonical_name == "av1"
assert stream.format.name == pix_fmt
assert stream.color_primaries == primaries
assert stream.color_trc == transfer
assert stream.colorspace == colorspace
assert stream.color_range == ColorRange.MPEG
assert container.metadata["PROMPT"] == '{"test": "hdr"}'
assert VideoFromFile(path).get_color_space() == color_space
source_packets = video_packet_bytes(path)
VideoFromFile(path).save_to(
remuxed,
format=VideoContainer.MKV,
codec=VideoCodec.AV1,
)
with av.open(remuxed) as container:
stream = container.streams.video[0]
assert stream.codec.canonical_name == "av1"
assert stream.format.name == pix_fmt
assert stream.color_primaries == primaries
assert stream.color_trc == transfer
assert container.metadata["PROMPT"] == '{"test": "hdr"}'
assert video_packet_bytes(remuxed) == source_packets
@pytest.mark.parametrize(
"transfer,color_range,color_space",
[
(ColorTrc.SMPTE2084, ColorRange.MPEG, None),
(ColorTrc.SMPTE2084, ColorRange.MPEG, "HDR PQ"),
(ColorTrc.ARIB_STD_B67, ColorRange.MPEG, None),
(ColorTrc.ARIB_STD_B67, ColorRange.MPEG, "HDR"),
(ColorTrc.ARIB_STD_B67, ColorRange.JPEG, "HDR"),
],
)
def test_save_to_loaded_hdr_preserves_color(tmp_path, transfer, color_range, color_space):
source = str(tmp_path / "source.mkv")
remuxed = str(tmp_path / "auto_encoding.mkv")
reencoded = str(tmp_path / "auto_color_space.webm")
create_hdr_av1_video(source, transfer, color_range)
video = VideoFromFile(source)
video.save_to(remuxed, format=VideoContainer.MKV, codec=VideoCodec.AV1)
video.save_to(
reencoded,
format=VideoContainer.WEBM,
codec=VideoCodec.AV1,
crf=0,
color_space=color_space,
)
assert video_packet_bytes(remuxed) == video_packet_bytes(source)
source_frames = decoded_video_frames(source)
reencoded_frames = decoded_video_frames(reencoded)
assert len(source_frames) == len(reencoded_frames)
assert all(np.array_equal(source_frame, output_frame) for source_frame, output_frame in zip(source_frames, reencoded_frames))
for path in (remuxed, reencoded):
with av.open(path) as container:
stream = container.streams.video[0]
assert stream.codec.canonical_name == "av1"
assert stream.format.name == "yuv420p10le"
assert stream.color_primaries == ColorPrimaries.BT2020
assert stream.color_trc == transfer
assert stream.colorspace == 9
assert stream.color_range == color_range
@pytest.mark.parametrize("color_space", ["sRGB", "HDR PQ"])
def test_save_to_loaded_hdr_rejects_color_conversion(tmp_path, color_space):
source = str(tmp_path / "source.mkv")
output = str(tmp_path / "wrong_transfer.webm")
create_hdr_av1_video(source, ColorTrc.ARIB_STD_B67, ColorRange.MPEG)
with pytest.raises(ValueError, match=f"Cannot save HDR video as {color_space} without color conversion"):
VideoFromFile(source).save_to(
output,
format=VideoContainer.WEBM,
codec=VideoCodec.AV1,
crf=30,
color_space=color_space,
)
assert not os.path.exists(output)
def test_save_to_av1_webm_transcodes_audio(tmp_path):
components = VideoComponents(
images=torch.rand(2, 64, 64, 3),
audio=AudioInput({
"waveform": torch.rand(1, 2, 4410),
"sample_rate": 44100,
}),
frame_rate=Fraction(30),
)
source = str(tmp_path / "source.mp4")
path = str(tmp_path / "output.webm")
VideoFromComponents(components).save_to(source, color_space="HDR")
VideoFromFile(source).save_to(
path,
format=VideoContainer.WEBM,
codec=VideoCodec.AV1,
crf=30,
color_space="HDR",
)
with av.open(path) as container:
video_stream = container.streams.video[0]
assert video_stream.codec.canonical_name == "av1"
assert video_stream.format.name == "yuv420p10le"
assert video_stream.color_primaries == ColorPrimaries.BT2020
assert video_stream.color_trc == ColorTrc.ARIB_STD_B67
assert video_stream.colorspace == 9
assert container.streams.audio[0].codec.name == "opus"
assert container.streams.audio[0].sample_rate == 48000
assert sum(1 for _ in container.decode(video=0)) == 2
with av.open(path) as container:
assert sum(frame.samples for frame in container.decode(audio=0)) > 0
@pytest.mark.parametrize("source_codec", [VideoCodec.H264, VideoCodec.AV1])
def test_save_loaded_mkv_to_webm_auto_transcodes_incompatible_streams(tmp_path, source_codec):
components = VideoComponents(
images=torch.rand(3, 64, 64, 3),
audio=AudioInput({
"waveform": torch.rand(1, 2, 4410),
"sample_rate": 44100,
}),
frame_rate=Fraction(30),
)
source = str(tmp_path / "source.mkv")
output = str(tmp_path / "output.webm")
VideoFromComponents(components).save_to(
source,
format=VideoContainer.MKV,
codec=source_codec,
crf=63 if source_codec == VideoCodec.AV1 else 30,
)
VideoFromFile(source).save_to(
output,
format=VideoContainer.WEBM,
codec=VideoCodec.AUTO,
)
with av.open(output) as container:
assert container.streams.video[0].codec.canonical_name == "av1"
assert container.streams.audio[0].codec.canonical_name == "opus"
assert container.streams.audio[0].sample_rate == 48000
assert sum(1 for _ in container.decode(video=0)) == 3
with av.open(output) as container:
assert sum(frame.samples for frame in container.decode(audio=0)) > 0
def test_save_loaded_h264_mkv_to_webm_h264_rejected_before_creating_output(tmp_path):
components = VideoComponents(
images=torch.rand(1, 64, 64, 3),
frame_rate=Fraction(30),
)
source = str(tmp_path / "source.mkv")
output = tmp_path / "output.webm"
VideoFromComponents(components).save_to(
source,
format=VideoContainer.MKV,
codec=VideoCodec.H264,
)
with pytest.raises(ValueError, match="WebM output requires the AV1 codec"):
VideoFromFile(source).save_to(
str(output),
format=VideoContainer.WEBM,
codec=VideoCodec.H264,
)
assert not output.exists()
def test_save_loaded_webm_auto_remuxes_compatible_streams(tmp_path):
components = VideoComponents(
images=torch.rand(3, 64, 64, 3),
audio=AudioInput({
"waveform": torch.rand(1, 2, 4410),
"sample_rate": 44100,
}),
frame_rate=Fraction(30),
)
source = str(tmp_path / "source.webm")
output = str(tmp_path / "output.webm")
VideoFromComponents(components).save_to(
source,
format=VideoContainer.WEBM,
codec=VideoCodec.AV1,
crf=63,
)
source_packets = video_packet_bytes(source)
VideoFromFile(source).save_to(
output,
format=VideoContainer.WEBM,
codec=VideoCodec.AUTO,
)
assert video_packet_bytes(output) == source_packets
with av.open(output) as container:
assert container.streams.video[0].codec.canonical_name == "av1"
assert container.streams.audio[0].codec.canonical_name == "opus"
assert sum(1 for _ in container.decode(video=0)) == 3
@pytest.mark.parametrize("format", [VideoContainer.MKV, VideoContainer.WEBM])
def test_save_to_av1_file_like_output(format):
components = VideoComponents(
images=torch.rand(1, 64, 64, 3),
frame_rate=Fraction(30),
)
output = io.BytesIO()
VideoFromComponents(components).save_to(
output,
format=format,
codec=VideoCodec.AV1,
crf=63,
)
output.seek(0)
with av.open(output) as container:
assert container.streams.video[0].codec.canonical_name == "av1"
assert sum(1 for _ in container.decode(video=0)) == 1
def test_save_to_rejects_h264_webm_before_creating_output(tmp_path):
components = VideoComponents(
images=torch.rand(1, 64, 64, 3),
frame_rate=Fraction(30),
)
path = tmp_path / "invalid.webm"
with pytest.raises(ValueError, match="WebM output requires the AV1 codec"):
VideoFromComponents(components).save_to(
str(path),
format=VideoContainer.WEBM,
codec=VideoCodec.H264,
)
assert not path.exists()
def test_save_to_rejects_unknown_color_space(tmp_path):
components = VideoComponents(
images=torch.rand(1, 64, 64, 3),
frame_rate=Fraction(30),
)
with pytest.raises(ValueError, match="Unsupported video color space: HLG"):
VideoFromComponents(components).save_to(
str(tmp_path / "invalid.mkv"),
format=VideoContainer.MKV,
codec=VideoCodec.AV1,
color_space="HLG",
)
def test_save_to_mp4_writes_metadata_before_media(video_components, tmp_path):
encoded = tmp_path / "encoded.mp4"
remuxed = tmp_path / "remuxed.mp4"

View File

@@ -0,0 +1,56 @@
"""Tests that dataset node config declared as class attributes reaches the schema.
``ImageProcessingNode`` and ``TextProcessingNode`` let subclasses configure
themselves with plain class attributes, and their shared ``define_schema()`` is
what forwards those attributes into ``io.Schema``. Anything it forgets to
forward is silently dropped from /object_info, so this pins the forwarding
itself rather than any single field.
"""
import dataclasses
import pytest
from comfy_api.latest import io
from comfy_extras import nodes_dataset
# Structural schema members, not per-node config; a node class would never
# declare these as class attributes.
IGNORED_FIELDS = {"inputs", "outputs", "hidden", "node_id"}
SCHEMA_FIELDS = [
f.name for f in dataclasses.fields(io.Schema) if f.name not in IGNORED_FIELDS
]
def _node_classes():
"""Every concrete node defined in nodes_dataset."""
found = []
for obj in vars(nodes_dataset).values():
if not isinstance(obj, type) or not issubclass(obj, io.ComfyNode):
continue
if obj.__module__ != nodes_dataset.__name__:
continue
if getattr(obj, "node_id", "") is None:
continue # abstract base class, define_schema() would raise
found.append(obj)
return sorted(found, key=lambda c: c.__name__)
@pytest.mark.parametrize("node_cls", _node_classes(), ids=lambda c: c.__name__)
def test_class_attributes_are_forwarded_to_schema(node_cls):
schema = node_cls.define_schema()
for name in SCHEMA_FIELDS:
declared = getattr(node_cls, name, None)
if not declared:
continue # unset, or left at the base class default
assert getattr(schema, name) == declared, (
f"{node_cls.__name__}.{name} is not forwarded into io.Schema by "
f"define_schema(), so /object_info reports "
f"{name}={getattr(schema, name)!r} instead of {declared!r}"
)
def test_node_classes_are_discovered():
"""Guard against the parametrization above collapsing to zero cases."""
assert _node_classes()

View File

@@ -187,7 +187,7 @@ class TestMathExpressionExecute:
self._exec("a / b", a=1, b=0)
def test_sqrt_negative_raises(self):
with pytest.raises(ValueError, match="math domain error"):
with pytest.raises(ValueError, match="math domain error|expected a nonnegative input"):
self._exec("sqrt(a)", a=-1)
def test_overflow_inf_raises(self):

View File

@@ -0,0 +1,30 @@
from unittest.mock import patch, MagicMock
mock_nodes = MagicMock()
mock_nodes.MAX_RESOLUTION = 16384
mock_server = MagicMock()
with patch.dict("sys.modules", {"nodes": mock_nodes, "server": mock_server}):
from comfy_extras.nodes_preview_any import PreviewAny
class TestPreviewAnyMain:
@staticmethod
def _exec(source) -> dict:
return PreviewAny().main(source)
def test_dict_keeps_non_ascii(self):
result = self._exec({"greeting": "你好"})
assert "你好" in result["ui"]["text"][0]
assert "\\u" not in result["ui"]["text"][0]
assert result["result"][0] == result["ui"]["text"][0]
def test_list_keeps_non_ascii(self):
result = self._exec(["你好", "こんにちは"])
assert "こんにちは" in result["result"][0]
assert "\\u" not in result["result"][0]
def test_string_passthrough(self):
result = self._exec("你好")
assert result["ui"]["text"][0] == "你好"
assert result["result"][0] == "你好"

View File

@@ -0,0 +1,61 @@
"""Gemma4 chat template regression tests."""
import pytest
import torch
from comfy.cli_args import args
if not torch.cuda.is_available():
args.cpu = True
import comfy.text_encoders.gemma4 as gemma4 # noqa: E402
PROMPT = "describe a cute anime girl with fennec ears"
THOUGHT_BLOCK = "<|channel>thought\n<channel|>"
# E2B/E4B and 12B/31B ship different canonical chat templates: only the latter prime a
# closed thought block when thinking is off.
NO_PRIMING = [gemma4.Gemma4_E2B, gemma4.Gemma4_E4B]
PRIMING = [gemma4.Gemma4_31B, gemma4.Gemma4_12B]
class _CaptureTemplate:
"""Stands in for SDTokenizer.tokenize_with_weights so the built template is checked without model files."""
llama_text = ""
def tokenize_with_weights(self, text, return_word_ids=False, **kwargs):
self.llama_text = text
return {}
def build_template(variant, **kwargs):
prime = variant.tokenizer.tokenizer_class.prime_empty_thought
probe = type("Probe", (gemma4.Gemma4_Tokenizer, _CaptureTemplate), {"prime_empty_thought": prime})()
probe.tokenize_with_weights(PROMPT, **kwargs)
return probe.llama_text
@pytest.mark.parametrize("variant", NO_PRIMING + PRIMING)
def test_thinking_enabled_only_asks_via_the_system_turn(variant):
template = build_template(variant, skip_template=False, thinking=True)
assert template == f"<|turn>system\n<|think|>\n<turn|>\n<|turn>user\n{PROMPT}<turn|>\n<|turn>model\n"
@pytest.mark.parametrize("variant", NO_PRIMING)
def test_thinking_disabled_does_not_prime_a_thought_channel(variant):
template = build_template(variant, skip_template=False, thinking=False)
assert template == f"<|turn>user\n{PROMPT}<turn|>\n<|turn>model\n"
assert "channel" not in template
assert "<|think|>" not in template
@pytest.mark.parametrize("variant", PRIMING)
def test_thinking_disabled_primes_a_thought_channel(variant):
template = build_template(variant, skip_template=False, thinking=False)
assert template == f"<|turn>user\n{PROMPT}<turn|>\n<|turn>model\n{THOUGHT_BLOCK}"
@pytest.mark.parametrize("variant", NO_PRIMING + PRIMING)
@pytest.mark.parametrize("thinking", [False, True])
def test_skip_template_passes_text_through_unchanged(variant, thinking):
assert build_template(variant, skip_template=True, thinking=thinking) == PROMPT

View File

@@ -0,0 +1,27 @@
from unittest.mock import MagicMock
import torch
from comfy.cli_args import args as cli_args
if not torch.cuda.is_available():
cli_args.cpu = True
import comfy.nested_tensor # noqa: E402
import nodes # noqa: E402
def test_vae_decode_tiled_unwraps_nested_tensor():
video = torch.zeros(1, 4, 2, 8, 8)
audio = torch.zeros(1, 2, 2, 40)
samples = {"samples": comfy.nested_tensor.NestedTensor((video, audio))}
vae = MagicMock()
vae.temporal_compression_decode.return_value = None
vae.spacial_compression_decode.return_value = 8
vae.decode_tiled.return_value = torch.zeros(1, 3, 2, 8, 8)
nodes.VAEDecodeTiled().decode(vae, samples, tile_size=512)
decoded_arg = vae.decode_tiled.call_args[0][0]
assert decoded_arg is video

View File

@@ -399,7 +399,7 @@ class TestSeederMarkMissing:
with (
patch("app.assets.seeder.dependencies_available", return_value=True),
patch(
"app.assets.seeder.get_all_known_prefixes",
"app.assets.seeder.get_owned_prefixes",
return_value=["/models", "/input", "/output"],
),
patch(
@@ -454,8 +454,9 @@ class TestSeederMarkMissing:
with (
patch("app.assets.seeder.dependencies_available", return_value=True),
patch("app.assets.seeder.get_all_known_prefixes", return_value=["/models"]),
patch("app.assets.seeder.get_owned_prefixes", return_value=["/models"]),
patch("app.assets.seeder.mark_missing_outside_prefixes_safely", side_effect=track_mark),
patch("app.assets.seeder.sync_temp_references_safely"),
patch("app.assets.seeder.sync_root_safely", side_effect=track_sync),
patch("app.assets.seeder.collect_paths_for_roots", return_value=[]),
patch("app.assets.seeder.build_asset_specs", return_value=([], set(), 0)),
@@ -469,6 +470,29 @@ class TestSeederMarkMissing:
assert call_order[0] == "mark_missing"
assert "sync_models" in call_order
def test_prune_first_flag_reconciles_temp_references(
self, fresh_seeder: _AssetSeeder
):
with (
patch("app.assets.seeder.dependencies_available", return_value=True),
patch("app.assets.seeder.get_owned_prefixes", return_value=["/models"]),
patch("app.assets.seeder.mark_missing_outside_prefixes_safely", return_value=0),
patch("app.assets.seeder.sync_temp_references_safely") as sync_temp,
patch("app.assets.seeder.sync_root_safely", return_value=set()),
patch("app.assets.seeder.collect_paths_for_roots", return_value=[]),
patch("app.assets.seeder.build_asset_specs", return_value=([], set(), 0)),
patch("app.assets.seeder.insert_asset_specs", return_value=0),
patch("app.assets.seeder.get_unenriched_assets_for_roots", return_value=[]),
patch("app.assets.seeder.enrich_assets_batch", return_value=(0, 0)),
):
fresh_seeder.start(roots=("models",), prune_first=True)
fresh_seeder.wait(timeout=5.0)
assert sync_temp.called, (
"temp is not a scan root, so the scan must reconcile it explicitly "
"or files wiped at startup stay listed"
)
class TestSeederPhases:
"""Test phased scanning behavior."""