Files
ComfyAssets-ComfyUI_PromptM…/tests/test_thumbnail_jobs.py
T
Vito 9ac471eb72 perf(images): share and warm the Images listing (#348, PR 1) (#349)
* perf(images): share and warm one listing per output root (#348)

The Images page, its sidebar counts, the subfolder list and the legacy
gallery each rebuilt the output listing when the 30 s cache expired, all
at once, and the subfolder list computed relative paths on the event
loop. Thumbnail writes also threw the listing away.

Keep one RootListing per output root (py/api/gallery_listing.py):
- one walk per root at a time; concurrent readers wait for it (shielded)
- a listing older than 30 s is served at once and refreshed in the
  background
- the walk, the comparison with the previous listing and both folder
  sets run in the executor; an unchanged walk keeps the same list object
- os.scandir probe: a missing root fails the walk and keeps the previous
  listing (one warning per failure streak)
- Rescan waits for a walk started after the click and is a 500 when it
  fails, even with a previous listing
- deletes keep the blocking invalidation; a generation counter that
  survives drops discards walks that raced it
- thumbnail jobs, conversion and the legacy generate/clear routes no
  longer drop the listing
- warm-up on the host's on_startup, cancelled on on_cleanup

Adds tools/vendor/perf/images_load_probe.mjs (maintainer tool, not CI)
and the TESTING.md section "Images listing cache (#348)".

* fix(images): drop listings on the loop when invalidated from a worker thread (#348)

A settings save (gallery roots change) and the browser-test seeds call
invalidate_gallery_cache() from the executor. Listing records are only
touched on the event loop, so an off-loop call now hands the drop to the
loop and waits for it, keeping the call synchronous for its callers.

The shared-Rescan test waits for the later Rescans to take their walk
number instead of sleeping.

* test(images): bound the shared-listing test waits (#348)

A macOS py3.12 run stopped inside the shared-listing tests and sat until
the 6 h job limit with no traceback. It does not reproduce locally (about
300 runs on 3.12 and 3.14, pinned to one core). Requests and background
settles in TestSharedListing now give up after 30 s, so a recurrence
fails with the stuck await instead of hanging the run.

* test(images): let the settle loops yield so a finished walk leaves the set (#348)

On CI (ubuntu and macOS py3.12) the runner's watchdog dumped
test_a_root_that_recovers_warns_again_on_its_next_failure spinning in
_settle: asyncio.gather over tasks that are already done returns without
yielding, so the set's discard callback, queued when the walk finished,
never ran and the loop never ended (wait_for could not fire either).
Delaying discard by one loop turn reproduces it locally; both settle
loops now yield one turn after each gather.

* test(browser): poll the import review's rows instead of reading them once

gridOf reads the dialog's rows once; right after the click the review may
not have rendered them yet (firefox CI: received undefined).

* perf(images): walk the output with scandir entries and build folders from strings (#348)

The live test showed a read during a background refresh taking 2.2 s instead
of 0.07 s: the walk held the GIL for about 2 s in the server. py-spy split
that walk into two halves: Path.relative_to per file in folder_sets, and
Path.stat + Path.is_symlink + Path joins per file in the scan.

- _walk_media visits folders in os.walk's order with os.scandir entries:
  the file type comes from the listing (no lstat per file) and the mtime
  from entry.stat (one call, none on Windows). _iter_media_files is now a
  thin wrapper, so its other caller is unchanged.
- folder_sets slices the path strings (in the path flavour's separator)
  instead of calling relative_to, falling back to it for a path that does
  not start with the root string.

On the maintainer's 19,935-file output folder: scan 0.386 s -> 0.112 s,
folder sets 0.452 s -> 0.007 s, both results identical to the previous code.
A reference copy of the os.walk version checks order, filters, the depth
cap, symlinks and the file cap.

* perf(images): build the workspace caches behind the warm-up and changed walks (#348)

The live test after the faster walk still took 2.9-5.2 s for the first
Images open after a restart. py-spy showed the walk was now small; the time
went to the workspace's own caches, cold after a restart and built by three
parallel first requests at once: the favorites listing (Path.relative_to per
file), the per-file values and newest order, and the prompt-link read.

- Once the host's startup warm-up has run, a stored walk that changed a
  listing schedules one executor job that builds what a first page reads,
  through the request path itself (_select_workspace_page with the default
  filters, then _workspace_favorite_paths). A walk stored during a build
  asks for one more build; an unchanged walk (same list kept) builds
  nothing; a failed build only warns; on_cleanup stops it. Servers that
  never ran the warm-up (the test servers) start no build.
- _favorite_listing slices the path strings as _listed does, falling back
  to relative_to for a path outside the root string.

* perf(images): start a stale listing's refresh walk a second after the read (#348)

After the faster walk, a read that found the listing stale still took
0.5-1.0 s against 0.07 s: it was served from the old listing but ran beside
the refresh walk it had just started, sharing the GIL. The refresh now starts
REFRESH_DELAY (1 s) after that read through a per-root rewalk_handle, so the
page's own requests finish first. One pending refresh per root and none while
a walk runs; any walk start (a cold read, Rescan, the warm-up) cancels a
pending one; on_cleanup cancels them before its first await.

* test(browser): accept KB or MB for the picked safety backup's size

The restore preview showed '2.5 MB' for the test server's database copy in a
CI coverage shard: its size depends on what earlier tests on that server
wrote, and the formatter switches to MB at 1 MiB.

* fix(images): keep the 3.10-3.13 suffix rule in the scandir walk and harden the warm-up (#348)

Review of the walk speedup:
- os.path.splitext drops names like ..png that Path.suffix listed on Python
  3.10-3.13 (3.14 changed Path.suffix, which hid it from the parity test on
  a 3.14 venv). _name_suffix spells out the 3.10-3.13 rule; the test now
  asserts ..png and ...webp are listed and g. is not.
- _warm_workspace reads the roots inside its try, so a failing
  _get_all_output_dirs warns instead of leaving an unretrieved task error.
- The folder_sets fallback test now takes the fallback (a Windows case
  mismatch); the coalescing test pins the walk count.
2026-10-26 11:46:54 -07:00

986 lines
40 KiB
Python

"""Thumbnail generation as an Activity job (#249 System S5, Task 12).
``GET /prompt_manager/images/thumbnails/plan`` counts what a run would do and
writes nothing. ``POST /prompt_manager/jobs/thumbnails`` starts the
``thumbnails`` job (admission ``guarded``, cancellable at a file boundary).
The legacy gallery stream ``POST /images/generate-thumbnails/progress`` starts
or attaches to that job and streams its unchanged events from it, so a client
that goes away no longer stops the work.
Ordering is driven by threading/asyncio events, never by timers.
"""
import asyncio
import hashlib
import os
import sys
import threading
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from tests.test_api_images import ThumbnailTestCase, make_png, parse_sse
except ImportError: # discovered with tests/ as the top-level directory
from test_api_images import ThumbnailTestCase, make_png, parse_sse
from PIL import Image # noqa: E402
import py.api.images as images_module # noqa: E402
from py.api.thumbnail_jobs import thumbnail_options # noqa: E402
from py.config import PromptManagerConfig # noqa: E402
PLAN_URL = "/prompt_manager/images/thumbnails/plan"
JOB_URL = "/prompt_manager/jobs/thumbnails"
def _tree_state(root):
"""{relative path: (size, mtime_ns, sha256)} of every file under ``root``."""
state = {}
for folder, _dirs, files in os.walk(root):
for name in files:
path = Path(folder) / name
data = path.read_bytes()
stat = path.stat()
state[path.relative_to(root).as_posix()] = (
stat.st_size,
stat.st_mtime_ns,
hashlib.sha256(data).hexdigest(),
)
return state
class ThumbnailJobCase(ThumbnailTestCase):
"""Helpers: start, gate and wait for thumbnail jobs."""
async def _start(self, **body):
resp = await self.client.post(JOB_URL, json=body)
return resp.status, await resp.json()
async def _finished(self, job_id):
job = self.api._jobs.get(job_id)
await asyncio.wait_for(job.task, 10)
return job
def _gate_generation(self):
"""Block every file in the worker until released; returns (entered, release)."""
entered = threading.Event()
release = threading.Event()
original = self.api._generate_one
def gated(*args, **kwargs):
entered.set()
release.wait(10)
return original(*args, **kwargs)
self.api._generate_one = gated
self.addCleanup(release.set)
return entered, release
async def _wait(self, event):
loop = asyncio.get_running_loop()
self.assertTrue(await loop.run_in_executor(None, event.wait, 10))
@staticmethod
def _watch_subscribers(job):
"""(subscribed, left): threading events for a stream joining or leaving."""
subscribed, left = threading.Event(), threading.Event()
class Watched(set):
def add(self, item):
super().add(item)
subscribed.set()
def discard(self, item):
super().discard(item)
left.set()
job._subscribers = Watched(job._subscribers)
return subscribed, left
def _status_lines(self):
"""Every status line any job is given, in order."""
from py.api.jobs import Job
lines = []
original = Job.update
def update(job, **kwargs):
if kwargs.get("status_line") is not None:
lines.append(kwargs["status_line"])
return original(job, **kwargs)
patcher = patch.object(Job, "update", update)
patcher.start()
self.addCleanup(patcher.stop)
return lines
def _use(self, fmt="webp", quality=80, edge=512):
"""Set the server's thumbnail settings for this test (#317)."""
saved = PromptManagerConfig.get_config()["thumbnails"]
self.addCleanup(PromptManagerConfig.update_config, {"thumbnails": saved})
PromptManagerConfig.update_config(
{"thumbnails": {"format": fmt, "quality": quality, "edge": edge}}
)
def _one_worker(self):
original = PromptManagerConfig.WORKER_THREADS
PromptManagerConfig.WORKER_THREADS = 1 # batches of 4
self.addCleanup(setattr, PromptManagerConfig, "WORKER_THREADS", original)
def _run_scan(self):
gate = asyncio.Event()
async def runner(job):
await gate.wait()
job = self.api._jobs.start(
"scan", "Scan for metadata", admission="records", scope={}, runner=runner
)
async def release():
gate.set()
await asyncio.wait_for(job.task, 5)
self.addAsyncCleanup(release)
return job
class TestThumbnailPlan(ThumbnailJobCase):
async def test_plan_is_read_only_and_counts_cap(self):
for name in ("a", "b", "c"):
make_png(self.output_dir / f"{name}.png", size=(64, 64))
fresh = make_png(self._thumb_path("a"))
source_time = (self.output_dir / "a.png").stat().st_mtime
os.utime(fresh, (source_time + 10, source_time + 10))
before = _tree_state(self.comfy_dir)
self._use("webp", 80, 512)
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 1):
missing = await self.client.get(PLAN_URL, params={"mode": "missing"})
rebuild = await self.client.get(PLAN_URL, params={"mode": "rebuild"})
self.assertEqual(missing.status, 200)
self.assertEqual(
await missing.json(),
{
"success": True,
"targets": 1,
"fresh": 1,
"outdated": 0,
"remaining": 1,
"roots": ["output"],
"mode": "missing",
"format": "webp",
"size": 512,
"quality": 80,
},
)
data = await rebuild.json()
# Rebuild regenerates fresh previews too: all three are work, one fits the cap.
self.assertEqual((data["targets"], data["fresh"], data["remaining"]), (1, 1, 2))
self.assertEqual(data["mode"], "rebuild")
self.assertEqual(_tree_state(self.comfy_dir), before)
self.assertEqual(self.api._jobs.list(), [])
async def test_plan_defaults_to_missing_previews_with_the_settings(self):
self._use("webp", 80, 512)
make_png(self.output_dir / "a.png")
data = await (await self.client.get(PLAN_URL)).json()
self.assertEqual(
(data["mode"], data["format"], data["size"], data["quality"]),
("missing", "webp", 512, 80),
)
self.assertEqual(data["targets"], 1)
async def test_mode_validated_and_size_quality_ignored(self):
# #317: format, quality and size limit are the server's settings; an
# older client's size or quality is ignored, an unknown mode refused.
self._use("webp", 80, 512)
make_png(self.output_dir / "a.png")
for params in ({"mode": "all"}, {"mode": ""}):
with self.subTest(params=params):
resp = await self.client.get(PLAN_URL, params=params)
body = await resp.json()
self.assertEqual(resp.status, 400)
self.assertEqual(
(body["success"], body["code"]), (False, "invalid-request")
)
for body in ({"mode": "rebuild-all"}, {"mode": None}, []):
with self.subTest(body=body):
resp = await self.client.post(JOB_URL, json=body)
self.assertEqual(resp.status, 400)
self.assertEqual((await resp.json())["code"], "invalid-request")
resp = await self.client.get(PLAN_URL, params={"size": "9999", "quality": "x"})
data = await resp.json()
self.assertEqual((data["size"], data["quality"]), (512, 80))
self.assertEqual(self.api._jobs.list(), [])
self.assertFalse(self._thumb_path("a").exists())
class TestThumbnailJob(ThumbnailJobCase):
async def test_job_generates_missing_previews_at_the_chosen_edge(self):
make_png(self.output_dir / "a.png", size=(800, 400))
make_png(self.output_dir / "sub" / "b.png", size=(64, 64))
(self.output_dir / "bad.png").write_bytes(b"\x89PNG not really")
self._use("webp", 80, 200)
status, body = await self._start(mode="missing", size=999, quality="high")
self.assertEqual(status, 202)
summary = body["job"]
self.assertEqual(
(summary["kind"], summary["title"], summary["admission"]),
("thumbnails", "Thumbnail generation", "guarded"),
)
self.assertTrue(summary["cancellable"])
self.assertEqual(set(summary["counters"]), {"generated", "skipped", "errors"})
self.assertEqual(
summary["scope"],
{"mode": "missing", "format": "webp", "size": 200, "quality": 80},
)
job = await self._finished(summary["id"])
self.assertEqual(job.state, "finished")
self.assertEqual(job.counters, {"generated": 2, "skipped": 0, "errors": 1})
self.assertEqual((job.processed, job.total, job.progress), (3, 3, 100))
self.assertEqual(job.scope["output_folders"], 1)
with Image.open(self._thumb_path("a")) as thumb:
self.assertEqual(thumb.size, (200, 100))
rows = {row["path"]: row for row in job.outcomes["generated"]}
self.assertEqual(set(rows), {"a.png", "sub/b.png"})
self.assertEqual(
(rows["a.png"]["root"], rows["a.png"]["root_index"]), ("output", 0)
)
self.assertEqual(job.outcomes["errors"][0]["path"], "bad.png")
self.assertTrue(any("Thumbnails finished" in line for line in job.log))
# Rows and log lines never carry the server's absolute layout.
self.assertNotIn(str(self.comfy_dir), repr(job.detail()))
async def test_fresh_previews_are_counted_as_skipped(self):
make_png(self.output_dir / "a.png")
make_png(self.output_dir / "b.png")
fresh = make_png(self._thumb_path("a"))
source_time = (self.output_dir / "a.png").stat().st_mtime
os.utime(fresh, (source_time + 10, source_time + 10))
_, body = await self._start()
job = await self._finished(body["job"]["id"])
self.assertEqual(job.counters, {"generated": 1, "skipped": 1, "errors": 0})
self.assertEqual([r["path"] for r in job.outcomes["skipped"]], ["a.png"])
async def test_rebuild_never_touches_originals(self):
for name in ("a", "b"):
make_png(self.output_dir / f"{name}.png", size=(400, 400))
self._use("webp", 80, 128)
_, body = await self._start()
await self._finished(body["job"]["id"])
originals = {
name: hashlib.sha256(
(self.output_dir / f"{name}.png").read_bytes()
).hexdigest()
for name in ("a", "b")
}
self._use("webp", 95, 256)
_, body = await self._start(mode="rebuild")
job = await self._finished(body["job"]["id"])
self.assertEqual(job.counters, {"generated": 2, "skipped": 0, "errors": 0})
for name in ("a", "b"):
digest = hashlib.sha256(
(self.output_dir / f"{name}.png").read_bytes()
).hexdigest()
self.assertEqual(digest, originals[name], name)
with Image.open(self._thumb_path(name)) as thumb:
self.assertEqual(thumb.size, (256, 256))
async def test_rebuild_keeps_old_preview_when_generation_fails(self):
source = make_png(self.output_dir / "a.png", size=(64, 64))
_, body = await self._start()
await self._finished(body["job"]["id"])
preview = self._thumb_path("a").read_bytes()
source.write_bytes(b"\x89PNG broken now")
later = self._thumb_path("a").stat().st_mtime + 10
os.utime(self._thumb_path("a"), (later, later)) # still fresh
_, body = await self._start(mode="rebuild")
job = await self._finished(body["job"]["id"])
self.assertEqual(job.counters["errors"], 1)
self.assertEqual(self._thumb_path("a").read_bytes(), preview)
leftovers = [p.name for p in self._thumb_path("a").parent.iterdir()]
self.assertEqual(leftovers, ["a_thumb.webp"]) # no temp file left
async def test_rebuild_keeps_an_old_video_preview_instead_of_a_placeholder(self):
clip = self.output_dir / "clip.mp4"
clip.write_bytes(b"\x00")
preview = make_png(self.output_dir / "thumbnails" / "clip_thumb.webp")
old = preview.read_bytes()
with (
patch.dict(sys.modules, {"cv2": None}),
patch("subprocess.Popen", side_effect=FileNotFoundError("no ffmpeg")),
):
_, body = await self._start(mode="rebuild")
job = await self._finished(body["job"]["id"])
self.assertEqual(job.counters["errors"], 1)
self.assertEqual(preview.read_bytes(), old)
async def test_quality_sets_the_encoder_not_the_edge(self):
noise = bytes(
(i * 7919 + (i >> 3) * 104729) % 251 for i in range(600 * 600 * 3)
)
with Image.frombytes("RGB", (600, 600), noise) as img:
img.save(self.output_dir / "photo.jpg", "JPEG", quality=95)
sizes = {}
for quality in ("low", "high"):
self._use("jpeg", 40 if quality == "low" else 95, 300)
_, body = await self._start(mode="rebuild")
await self._finished(body["job"]["id"])
thumb = self._thumb_path("photo", ".jpg")
with Image.open(thumb) as image:
self.assertEqual(image.size, (300, 300))
sizes[quality] = thumb.stat().st_size
self.assertLess(sizes["low"], sizes["high"])
async def test_a_job_keeps_the_listing_and_the_next_page_shows_the_preview(self):
make_png(self.output_dir / "a.png", size=(64, 64))
url = "/prompt_manager/images/output?mode=workspace"
before = (await (await self.client.get(url)).json())["images"][0]
files = self.api._root_listings[str(self.output_dir)].files
status, body = await self._start(mode="missing")
self.assertEqual(status, 202)
await self._finished(body["job"]["id"])
# Previews are not part of the listing (#348): no rewalk is forced.
self.assertIs(self.api._root_listings[str(self.output_dir)].files, files)
after = (await (await self.client.get(url)).json())["images"][0]
self.assertNotIn("v=", before["thumbnail_url"])
self.assertIn("v=", after["thumbnail_url"])
async def test_no_output_folder_fails_the_job(self):
self.api._get_all_output_dirs = lambda: []
_, body = await self._start()
job = await self._finished(body["job"]["id"])
self.assertEqual(job.state, "failed")
self.assertEqual(job.status_line, "ComfyUI output directory not found")
async def test_cancel_stops_at_file_boundary_keeping_written_previews(self):
for i in range(6):
make_png(self.output_dir / f"p{i}.png")
original = PromptManagerConfig.WORKER_THREADS
PromptManagerConfig.WORKER_THREADS = 1
self.addCleanup(setattr, PromptManagerConfig, "WORKER_THREADS", original)
written = []
real_write = images_module._write_image_thumbnail
job_ids = []
def counting(src, dst, *args, **kwargs):
real_write(src, dst, *args, **kwargs)
written.append(dst)
if len(written) == 2:
self.assertTrue(self.api._jobs.cancel(job_ids[0]))
with patch.object(images_module, "_write_image_thumbnail", counting):
_, body = await self._start()
job_ids.append(body["job"]["id"])
job = await self._finished(body["job"]["id"])
self.assertEqual(job.state, "cancelled")
self.assertEqual(job.counters["generated"], 2)
self.assertEqual(len(written), 2)
for dst in written:
self.assertTrue(dst.is_file())
made = sorted((self.output_dir / "thumbnails").glob("*_thumb.webp"))
self.assertEqual(made, sorted(written))
self.assertTrue(any("Thumbnails cancelled" in line for line in job.log))
async def test_a_second_thumbnail_job_is_refused_naming_the_first(self):
make_png(self.output_dir / "a.png")
entered, release = self._gate_generation()
_, first = await self._start()
await self._wait(entered)
status, body = await self._start(mode="rebuild")
self.assertEqual((status, body["code"]), (409, "busy"))
self.assertEqual(body["running"]["id"], first["job"]["id"])
release.set()
await self._finished(first["job"]["id"])
async def test_thumbnail_job_refused_while_scan_runs(self):
make_png(self.output_dir / "a.png")
self._run_scan()
status, body = await self._start()
self.assertEqual((status, body["code"]), (409, "busy"))
self.assertEqual(body["running"]["title"], "Scan for metadata")
self.assertFalse(self._thumb_path("a").exists())
async def test_scan_refused_while_thumbnail_job_runs(self):
make_png(self.output_dir / "a.png")
entered, release = self._gate_generation()
_, started = await self._start()
await self._wait(entered)
resp = await self.client.post("/prompt_manager/jobs/scan")
body = await resp.json()
self.assertEqual((resp.status, body["code"]), (409, "busy"))
self.assertEqual(body["running"]["title"], "Thumbnail generation")
release.set()
await self._finished(started["job"]["id"])
async def test_clear_refused_while_thumbnail_job_runs(self):
make_png(self.output_dir / "a.png")
kept = make_png(self.output_dir / "thumbnails" / "old_thumb.png")
entered, release = self._gate_generation()
_, started = await self._start()
await self._wait(entered)
resp = await self.client.post("/prompt_manager/images/clear-thumbnails")
self.assertEqual(resp.status, 409)
self.assertEqual(
await resp.json(),
{"success": False, "error": "Thumbnail generation already running"},
)
self.assertTrue(kept.exists())
release.set()
await self._finished(started["job"]["id"])
async def test_every_exit_sets_a_truthful_final_status_line(self):
"""I3: finished, nothing to do, cancelled and failed each say what happened."""
lines = self._status_lines()
make_png(self.output_dir / "a.png")
(self.output_dir / "bad.png").write_bytes(b"\x89PNG nope")
_, body = await self._start()
job = await self._finished(body["job"]["id"])
self.assertEqual(job.status_line, "1 preview written · 1 error")
# Every preview fresh: the job finishes with nothing to do.
source_time = (self.output_dir / "a.png").stat().st_mtime
bad_thumb = make_png(self._thumb_path("bad"))
for thumb in (self._thumb_path("a"), bad_thumb):
os.utime(thumb, (source_time + 10, source_time + 10))
os.utime(self.output_dir / "bad.png", (source_time, source_time))
_, body = await self._start()
job = await self._finished(body["job"]["id"])
self.assertEqual((job.state, job.total), ("finished", 0))
self.assertEqual(
job.status_line, "No previews to generate · 2 already up to date"
)
self.assertNotIn("Processing file 0 of 0", lines)
async def test_an_empty_library_says_there_is_nothing_to_preview(self):
lines = self._status_lines()
_, body = await self._start()
job = await self._finished(body["job"]["id"])
self.assertEqual(
(job.state, job.status_line), ("finished", "No images or videos to preview")
)
self.assertNotIn("Processing file 0 of 0", lines)
async def test_a_cancelled_job_says_what_it_kept(self):
for i in range(6):
make_png(self.output_dir / f"p{i}.png")
self._one_worker()
real_write = images_module._write_image_thumbnail
job_ids = []
written = []
def counting(src, dst, *args, **kwargs):
real_write(src, dst, *args, **kwargs)
written.append(dst)
if len(written) == 1:
self.api._jobs.cancel(job_ids[0])
with patch.object(images_module, "_write_image_thumbnail", counting):
_, body = await self._start()
job_ids.append(body["job"]["id"])
job = await self._finished(body["job"]["id"])
self.assertEqual(job.state, "cancelled")
self.assertEqual(job.status_line, "Cancelled · 1 preview written")
async def test_an_internal_error_says_the_job_failed(self):
def broken(*_args, **_kwargs):
raise RuntimeError("boom")
self.api._plan_gallery_thumbnails = broken
_, body = await self._start()
job = await self._finished(body["job"]["id"])
self.assertEqual(
(job.state, job.status_line),
("failed", "Thumbnail generation stopped on an internal error."),
)
async def test_cancel_during_planning_stops_the_walk_and_starts_nothing(self):
"""M2: the walk checks Cancel per file; no ``start`` frame is published."""
for i in range(10):
make_png(self.output_dir / f"p{i}.png")
entered, release = threading.Event(), threading.Event()
self.addCleanup(release.set)
checked = []
real_state = images_module._preview_state
def gated_state(src, *args):
checked.append(src.name)
if len(checked) == 1:
entered.set()
release.wait(10)
return real_state(src, *args)
with patch.object(images_module, "_preview_state", gated_state):
stream = asyncio.ensure_future(self.client.post(self.PROGRESS_URL))
await self._wait(entered)
job = self.api._jobs.active("thumbnails")
self.assertTrue(self.api._jobs.cancel(job.id))
release.set()
events = parse_sse(await (await stream).text())
await asyncio.wait_for(job.task, 10)
self.assertEqual(checked, ["p0.png"]) # the walk stopped at the next file
self.assertEqual([name for name, _ in events], ["job", "status", "error"])
self.assertEqual(job.state, "cancelled")
self.assertEqual(job.status_line, "Cancelled before any preview was written")
self.assertEqual(job.total, 0)
self.assertFalse((self.output_dir / "thumbnails").exists())
async def test_a_locked_preview_is_a_readable_coded_error(self):
"""M6: Windows refuses to replace a preview another process has open."""
make_png(self.output_dir / "a.png")
locked = PermissionError(13, "Access is denied")
locked.winerror = 5
def refuse(*_args, **_kwargs):
raise locked
with patch.object(images_module, "_write_image_thumbnail", refuse):
_, body = await self._start(mode="rebuild")
job = await self._finished(body["job"]["id"])
self.assertEqual(job.counters["errors"], 1)
[row] = job.outcomes["errors"]
self.assertEqual(row["detail"], images_module.PREVIEW_IN_USE)
self.assertNotIn("WinError", repr(job.detail()))
self.assertTrue(any(images_module.PREVIEW_IN_USE in line for line in job.log))
async def test_a_locked_video_preview_is_the_same_coded_error(self):
clip = self.output_dir / "clip.mp4"
clip.write_bytes(b"\x00")
locked = PermissionError(13, "Access is denied")
locked.winerror = 32
def refuse(*_args, **_kwargs):
raise locked
with (
patch.dict(sys.modules, {"cv2": None}),
patch("subprocess.Popen", side_effect=FileNotFoundError("no ffmpeg")),
patch.object(images_module, "write_thumbnail", refuse),
):
_, body = await self._start()
job = await self._finished(body["job"]["id"])
[row] = job.outcomes["errors"]
self.assertEqual(row["detail"], images_module.PREVIEW_IN_USE)
async def test_a_permission_error_off_windows_names_the_permission(self):
make_png(self.output_dir / "a.png")
def refuse(*_args, **_kwargs):
raise PermissionError(13, "Permission denied")
with patch.object(images_module, "_write_image_thumbnail", refuse):
_, body = await self._start()
job = await self._finished(body["job"]["id"])
[row] = job.outcomes["errors"]
self.assertEqual(row["detail"], images_module.PREVIEW_NOT_WRITABLE)
async def test_ffmpeg_fallback_keeps_the_aspect_ratio_and_the_settings(self):
"""M1: without OpenCV, ffmpeg's frame keeps its aspect ratio; #317: it is
read as a PNG on stdout and encoded with the server's settings."""
import io
(self.output_dir / "clip.mp4").write_bytes(b"\x00")
commands = []
frame = io.BytesIO()
Image.new("RGB", (640, 360), (0, 99, 0)).save(frame, "PNG")
def fake_ffmpeg(cmd, **_kwargs):
commands.append(cmd)
done = type("Done", (), {"returncode": 0})()
done.communicate = lambda input=None, timeout=None: (frame.getvalue(), b"")
done.poll = lambda: 0
done.kill = lambda: None
return done
self._use("webp", 70, 320)
with (
patch.dict(sys.modules, {"cv2": None}),
patch("subprocess.Popen", side_effect=fake_ffmpeg),
):
_, body = await self._start(mode="rebuild")
job = await self._finished(body["job"]["id"])
self.assertEqual(job.counters["generated"], 1)
cmd = commands[-1]
self.assertNotIn("-q:v", cmd)
self.assertEqual(
cmd[cmd.index("-vf") + 1],
"scale='min(iw,320)':'min(ih,320)':force_original_aspect_ratio=decrease",
)
self.assertEqual(cmd[-5:], ["-f", "image2pipe", "-vcodec", "png", "-"])
# Review M6: the input is always read as a file, never a protocol URL.
self.assertTrue(cmd[cmd.index("-i") + 1].startswith("file:"))
with Image.open(self._thumb_path("clip")) as thumb:
self.assertEqual((thumb.format, thumb.size), ("WEBP", (320, 180)))
async def test_the_placeholder_uses_the_settings(self):
(self.output_dir / "clip.mp4").write_bytes(b"\x00")
saved = []
real_write = images_module.write_thumbnail
def recording(frame, dst, settings, **kwargs):
saved.append(settings)
return real_write(frame, dst, settings, **kwargs)
self._use("jpeg", 33, 200)
with (
patch.dict(sys.modules, {"cv2": None}),
patch("subprocess.Popen", side_effect=FileNotFoundError("no ffmpeg")),
patch.object(images_module, "write_thumbnail", recording),
):
_, body = await self._start()
await self._finished(body["job"]["id"])
self.assertEqual(
[(x.format, x.quality, x.edge) for x in saved], [("jpeg", 33, 200)]
)
with Image.open(self._thumb_path("clip", ".jpg")) as thumb:
self.assertEqual(thumb.size, (200, 200))
# The legacy stream's frames and their keys, as the gallery page reads them.
LEGACY_KEYS = {
"job": {"id", "started"},
"status": {"phase", "message"},
"start": {
"total_images",
"skipped",
"remaining",
"phase",
"image_count",
"video_count",
"message",
},
"progress": {
"processed",
"total_images",
"generated",
"skipped",
"error_count",
"percentage",
"rate",
"eta",
"elapsed",
"current_file",
"file_type",
"action",
},
"complete": {
"count",
"skipped",
"total_images",
"remaining",
"errors",
"error_count",
"elapsed_time",
"processing_rate",
"message",
},
}
class TestBatchesContinue(ThumbnailJobCase):
"""MAX_THUMBNAIL_FILES bounds one batch, not the job: after each batch
the job plans again and goes on until nothing is left, it is cancelled,
or a batch makes no progress (files that fail every time are never
planned again)."""
def _sources(self, count, broken=False):
for i in range(count):
path = self.output_dir / f"s{i}.png"
if broken:
path.write_bytes(b"\x89PNG broken")
else:
make_png(path, size=(32, 32))
async def test_one_job_makes_every_preview_past_the_cap(self):
self._sources(8)
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 3):
status, body = await self._start(mode="missing")
self.assertEqual(status, 202)
job = await self._finished(body["job"]["id"])
self.assertEqual(job.state, "finished")
self.assertEqual(job.counters["generated"], 8)
for i in range(8):
self.assertTrue(self._thumb_path(f"s{i}").is_file(), i)
summary = job.summary()
self.assertEqual((summary["total"], summary["processed"]), (8, 8))
self.assertEqual(summary["progress"], 100)
self.assertEqual(job.status_line, "8 previews written")
async def test_rebuild_goes_through_every_file_once(self):
self._sources(5)
calls = []
real = self.api._generate_batch
def counting(batch, *args, **kwargs):
calls.extend(src.name for src, _dst, _video in batch)
return real(batch, *args, **kwargs)
self.api._generate_batch = counting
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 2):
_, body = await self._start(mode="rebuild")
job = await self._finished(body["job"]["id"])
self.assertEqual(sorted(calls), [f"s{i}.png" for i in range(5)])
self.assertEqual(job.counters["generated"], 5)
async def test_cancel_between_batches_stops_the_job(self):
self._sources(8)
real = self.api._plan_gallery_thumbnails
plans = []
job_ids = []
def cancelling(*args, **kwargs):
plans.append(1)
if len(plans) == 2:
self.assertTrue(self.api._jobs.cancel(job_ids[0]))
return real(*args, **kwargs)
self.api._plan_gallery_thumbnails = cancelling
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 3):
_, body = await self._start(mode="missing")
job_ids.append(body["job"]["id"])
job = await self._finished(job_ids[0])
self.assertEqual(job.state, "cancelled")
self.assertEqual(job.counters["generated"], 3)
self.assertEqual(len(plans), 2)
async def test_a_batch_where_everything_fails_stops(self):
self._sources(8, broken=True)
plans = []
real = self.api._plan_gallery_thumbnails
def counting(*args, **kwargs):
plans.append(1)
return real(*args, **kwargs)
self.api._plan_gallery_thumbnails = counting
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 3):
_, body = await self._start(mode="missing")
job = await asyncio.wait_for(self._finished(body["job"]["id"]), 10)
self.assertEqual(job.state, "finished")
self.assertEqual(len(plans), 1, "no second plan after a batch with no progress")
self.assertEqual(job.counters["errors"], 3)
self.assertIn("5 more on the next run", job.status_line)
async def test_failed_files_are_never_planned_again(self):
# One bad file per batch still lets the job go on, without retrying it.
for i in range(6):
path = self.output_dir / f"m{i}.png"
if i in (0, 3):
path.write_bytes(b"\x89PNG broken")
else:
make_png(path, size=(32, 32))
seen = []
real = self.api._generate_batch
def counting(batch, *args, **kwargs):
seen.extend(src.name for src, _dst, _video in batch)
return real(batch, *args, **kwargs)
self.api._generate_batch = counting
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 2):
_, body = await self._start(mode="missing")
job = await self._finished(body["job"]["id"])
self.assertEqual(sorted(seen), sorted(f"m{i}.png" for i in range(6)))
self.assertEqual((job.counters["generated"], job.counters["errors"]), (4, 2))
class TestLegacyStreamOnTheJob(ThumbnailJobCase):
async def test_legacy_progress_events_unchanged_on_success(self):
make_png(self.output_dir / "a.png", size=(400, 400))
make_png(self.output_dir / "b.png")
self._use("webp", 80, 200)
resp = await self.client.post(f"{self.PROGRESS_URL}?quality=low")
events = parse_sse(await resp.text())
self.assertEqual(
[name for name, _ in events],
["job", "status", "start", "progress", "progress", "complete"],
)
for name, data in events:
self.assertEqual(set(data), LEGACY_KEYS[name], name)
complete = events[-1][1]
self.assertEqual((complete["count"], complete["total_images"]), (2, 2))
self.assertEqual(
events[1][1],
{"phase": "scanning", "message": "Scanning for images and videos..."},
)
with Image.open(self._thumb_path("a")) as thumb:
# #317 R3: the server's size limit, not the legacy preset (low = 150).
self.assertEqual(max(thumb.size), 200)
[job] = self.api._jobs.list()
self.assertEqual(
(job.kind, job.title, job.state, job.cancellable),
("thumbnails", "Thumbnail generation", "finished", True),
)
self.assertEqual(job.counters["generated"], 2)
# I1: the first frame names the job this stream started, for its Cancel.
self.assertEqual(events[0][1], {"id": job.id, "started": True})
async def test_legacy_file_errors_still_stream(self):
make_png(self.output_dir / "good.png")
(self.output_dir / "bad.png").write_bytes(b"\x89PNG nope")
resp = await self.client.post(self.PROGRESS_URL)
events = parse_sse(await resp.text())
self.assertEqual(
[d for e, d in events if e == "file_error"],
[{"file": "bad.png", "error": "Not a valid image file"}],
)
async def test_thumbnail_job_survives_client_disconnect(self):
"""I2: the stream sees its client go after batch 1; every batch still runs.
One worker thread makes batches of 4, so 9 files take three batches. The
first file waits until the client is gone; the second batch waits until
the stream has seen it go (its subscription dropped) and records whether
the job was still running then. A stream that stopped the work on
disconnect would leave 4 previews; the job writes all 9.
"""
for i in range(9):
make_png(self.output_dir / f"p{i}.png")
self._one_worker()
first, closed = threading.Event(), threading.Event()
state = {"calls": 0, "left": None, "running_when_left": None}
original = self.api._generate_one
def gated(*args, **kwargs):
state["calls"] += 1
if state["calls"] == 1:
first.set()
closed.wait(10)
elif state["calls"] == 5: # the second batch
state["running_when_left"] = state["left"].wait(10)
return original(*args, **kwargs)
self.api._generate_one = gated
self.addCleanup(closed.set)
resp = await self.client.post(self.PROGRESS_URL)
self.assertEqual(resp.status, 200)
await self._wait(first)
job = self.api._jobs.active("thumbnails")
_subscribed, state["left"] = self._watch_subscribers(job)
resp.close() # the gallery tab went away
closed.set()
await self._wait(state["left"]) # the server saw the client go
self.assertEqual(job.subscriber_count, 0)
await asyncio.wait_for(job.task, 10)
self.assertEqual(job.counters["generated"], 9)
self.assertEqual(job.state, "finished")
self.assertTrue(state["running_when_left"])
for i in range(9):
self.assertTrue(self._thumb_path(f"p{i}").is_file())
async def test_legacy_stream_attaches_to_a_running_job(self):
make_png(self.output_dir / "a.png")
entered, release = self._gate_generation()
self._use("webp", 80, 256)
_, started = await self._start(mode="missing")
await self._wait(entered)
job = self.api._jobs.get(started["job"]["id"])
subscribed, _left = self._watch_subscribers(job)
attached = asyncio.ensure_future(self.client.post(self.PROGRESS_URL))
await self._wait(subscribed) # M4: an event with a ceiling, never a poll
release.set()
events = parse_sse(await (await attached).text())
self.assertEqual(events[-1][0], "complete")
# I1: the stream names the job it did not start: the gallery never cancels it.
self.assertEqual(events[0], ("job", {"id": job.id, "started": False}))
# One job: the stream followed the running one with its own options.
[listed] = self.api._jobs.list()
self.assertIs(listed, job)
self.assertEqual(job.scope["size"], 256)
async def test_cancel_from_activity_ends_the_legacy_stream_with_an_error(self):
for i in range(2):
make_png(self.output_dir / f"p{i}.png")
entered, release = self._gate_generation()
stream = asyncio.ensure_future(self.client.post(self.PROGRESS_URL))
await self._wait(entered)
job = self.api._jobs.active("thumbnails")
self.assertTrue(self.api._jobs.cancel(job.id))
release.set()
events = parse_sse(await (await stream).text())
self.assertEqual(events[-1][0], "error")
self.assertEqual(events[-1][1]["error"], "Thumbnail generation was cancelled")
await asyncio.wait_for(job.task, 10)
self.assertEqual(job.state, "cancelled")
async def test_a_job_started_while_the_stream_prepares_is_attached(self):
"""M3: a job that starts during ``prepare`` is followed, not refused."""
from aiohttp import web
make_png(self.output_dir / "a.png")
entered, release = self._gate_generation()
real_prepare = web.StreamResponse.prepare
raced = []
async def prepare(response, request):
if not raced and type(response) is web.StreamResponse:
options, _ = thumbnail_options({"size": 256})
raced.append(self.api._start_thumbnail_job(options))
return await real_prepare(response, request)
with patch.object(web.StreamResponse, "prepare", prepare):
stream = asyncio.ensure_future(self.client.post(self.PROGRESS_URL))
await self._wait(entered)
release.set()
events = parse_sse(await (await stream).text())
[job] = raced
self.assertEqual(events[0], ("job", {"id": job.id, "started": False}))
self.assertEqual(events[-1][0], "complete")
self.assertEqual(self.api._jobs.list(), [job])
async def test_a_refused_stream_names_no_job(self):
make_png(self.output_dir / "a.png")
self._run_scan()
events = parse_sse(await (await self.client.post(self.PROGRESS_URL)).text())
self.assertEqual(
events,
[
(
"error",
{
"error": "Scan for metadata already running",
"message": "Scan for metadata already running",
},
)
],
)
if __name__ == "__main__":
unittest.main()