mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-08-25 02:20:18 +08:00
Merge master into qa/video-edit-combined
# Conflicts: # comfy_api/latest/_input_impl/video_types.py
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
216
tests-unit/assets_test/test_preview_url.py
Normal file
216
tests-unit/assets_test/test_preview_url.py
Normal 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}"
|
||||
@@ -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()
|
||||
|
||||
|
||||
180
tests-unit/assets_test/test_temp_assets.py
Normal file
180
tests-unit/assets_test/test_temp_assets.py
Normal 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"
|
||||
@@ -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
|
||||
80
tests-unit/comfy_api_nodes_test/image_conversions_test.py
Normal file
80
tests-unit/comfy_api_nodes_test/image_conversions_test.py
Normal 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))
|
||||
@@ -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) == {}
|
||||
|
||||
@@ -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"
|
||||
|
||||
56
tests-unit/comfy_extras_test/nodes_dataset_test.py
Normal file
56
tests-unit/comfy_extras_test/nodes_dataset_test.py
Normal 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()
|
||||
@@ -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):
|
||||
|
||||
30
tests-unit/comfy_extras_test/nodes_preview_any_test.py
Normal file
30
tests-unit/comfy_extras_test/nodes_preview_any_test.py
Normal 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] == "你好"
|
||||
61
tests-unit/comfy_test/gemma4_template_test.py
Normal file
61
tests-unit/comfy_test/gemma4_template_test.py
Normal 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
|
||||
27
tests-unit/comfy_test/test_vae_decode_tiled_nested.py
Normal file
27
tests-unit/comfy_test/test_vae_decode_tiled_nested.py
Normal 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
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user