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)".
986 lines
40 KiB
Python
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()
|