From 7b6430e20ab8f49fd192a34714660dd6357e19cc Mon Sep 17 00:00:00 2001 From: Vito Date: Fri, 16 Oct 2026 04:56:07 -0700 Subject: [PATCH] fix: stop misleading thumbnail prompts on gallery startup (#168) * fix: count pending thumbnails accurately across gallery roots * fix: clear thumbnails across all configured gallery roots * fix: log skipped thumbnail directory cleanup --- py/api/images.py | 267 ++++++++++++++++------------ tests/js/gallery-thumbnails.test.js | 90 ++++++++++ tests/test_api_images.py | 172 +++++++++++++++++- web/js/gallery.js | 67 +++---- 4 files changed, 439 insertions(+), 157 deletions(-) create mode 100644 tests/js/gallery-thumbnails.test.js diff --git a/py/api/images.py b/py/api/images.py index d03e27d..2cbdb0c 100644 --- a/py/api/images.py +++ b/py/api/images.py @@ -214,9 +214,9 @@ def _thumbnail_complete_payload(total, stats, start, remaining=0, processed=None def _thumbnail_is_fresh(src, dst): - """True when ``dst`` exists and is newer than ``src`` (blocking stat).""" + """True when ``dst`` exists and is at least as new as ``src`` (blocking stat).""" try: - return dst.stat().st_mtime > src.stat().st_mtime + return dst.stat().st_mtime >= src.stat().st_mtime except OSError: return False @@ -382,6 +382,10 @@ class ImageRoutesMixin: async def delete_image_route(request): return await self.delete_image(request) + @routes.get("/prompt_manager/images/thumbnails/status") + async def thumbnail_status_route(request): + return await self.get_thumbnail_status(request) + @routes.post("/prompt_manager/images/generate-thumbnails") async def generate_thumbnails_route(request): return await self.generate_thumbnails(request) @@ -815,19 +819,15 @@ class ImageRoutesMixin: return error thumbnail_size = self._thumbnail_size(data.get("quality", "medium")) - output_dir = self._find_comfyui_output_dir() - if not output_dir: + output_dirs = self._get_all_output_dirs() + if not output_dirs: return web.json_response( {"success": False, "error": "ComfyUI output directory not found"}, status=404, ) - output_path = Path(output_dir) result = await self._run_in_executor( - self._generate_thumbnails_sync, - output_path, - output_path / "thumbnails", - thumbnail_size, + self._generate_gallery_thumbnails_sync, output_dirs, thumbnail_size ) self.invalidate_gallery_cache() return web.json_response(result) @@ -842,6 +842,22 @@ class ImageRoutesMixin: """Blocking thumbnail generation loop (run in executor).""" start = _time.monotonic() plan = self._plan_thumbnails(output_path, thumbnails_dir) + payload = self._execute_thumbnail_plan(plan, thumbnail_size, start) + payload["thumbnails_path"] = _public_path(thumbnails_dir) + return payload + + def _generate_gallery_thumbnails_sync( + self, output_dirs: list[Path], thumbnail_size: tuple[int, int] + ) -> dict: + start = _time.monotonic() + plan = self._plan_gallery_thumbnails(output_dirs) + payload = self._execute_thumbnail_plan(plan, thumbnail_size, start) + payload["thumbnails_path"] = _public_path(output_dirs[0] / "thumbnails") + return payload + + def _execute_thumbnail_plan( + self, plan: dict, thumbnail_size: tuple[int, int], start: float + ) -> dict: targets = plan["targets"] stats = _new_thumbnail_stats() stats["skipped"] = plan["skipped"] @@ -855,10 +871,50 @@ class ImageRoutesMixin: processed=len(targets), ) payload["success"] = True - payload["thumbnails_path"] = _public_path(thumbnails_dir) self.logger.info(payload["message"]) return payload + def _plan_gallery_thumbnails(self, output_dirs: list[Path]) -> dict: + """Plan every gallery root with one shared per-run generation cap.""" + combined = {"targets": [], "skipped": 0, "remaining": 0} + for root in output_dirs: + root = Path(root) + plan = self._plan_thumbnails(root, root / "thumbnails") + available = max(0, MAX_THUMBNAIL_FILES - len(combined["targets"])) + combined["targets"].extend(plan["targets"][:available]) + combined["skipped"] += plan["skipped"] + combined["remaining"] += plan["remaining"] + max( + 0, len(plan["targets"]) - available + ) + return combined + + async def get_thumbnail_status(self, request: web.Request) -> web.Response: + """Count pending thumbnails off-loop without generating files.""" + try: + output_dirs = self._get_all_output_dirs() + plan = await self._run_in_executor( + self._plan_gallery_thumbnails, output_dirs + ) + pending = len(plan["targets"]) + plan["remaining"] + collection = json.dumps(sorted(str(_canonical(p)) for p in output_dirs)) + return web.json_response( + { + "success": True, + "total_images": pending + plan["skipped"], + "pending": pending, + "ready": plan["skipped"], + "collection_id": hashlib.sha256(collection.encode()).hexdigest()[ + :16 + ], + }, + headers={"Cache-Control": "no-store"}, + ) + except Exception as exc: + self.logger.exception("Thumbnail status failed") + return web.json_response( + {"success": False, "error": _safe_error(exc)}, status=500 + ) + def _plan_thumbnails(self, output_path, thumbnails_dir): """Decide which media files need a thumbnail (blocking walk). @@ -923,7 +979,7 @@ class ImageRoutesMixin: try: if not src.is_file(): return {**result, "action": "error", "error": "File no longer exists"} - if dst.exists() and dst.stat().st_mtime > src.stat().st_mtime: + if _thumbnail_is_fresh(src, dst): return {**result, "action": "skipped"} dst.parent.mkdir(parents=True, exist_ok=True) if is_video: @@ -957,21 +1013,20 @@ class ImageRoutesMixin: ) await response.prepare(request) try: - output_dir = self._find_comfyui_output_dir() - if not output_dir: + output_dirs = self._get_all_output_dirs() + if not output_dirs: await self._emit_sse( response, "error", {"error": "ComfyUI output directory not found"} ) return response - output_path = Path(output_dir) await self._emit_sse( response, "status", {"phase": "scanning", "message": "Scanning for images and videos..."}, ) plan = await self._run_in_executor( - self._plan_thumbnails, output_path, output_path / "thumbnails" + self._plan_gallery_thumbnails, output_dirs ) await self._stream_thumbnails( response, @@ -1196,119 +1251,109 @@ class ImageRoutesMixin: ) return False - async def clear_thumbnails(self, request): - """Safely clear only our generated thumbnails, never touch original images.""" - try: + async def clear_thumbnails(self, request: web.Request) -> web.Response: + """Clear generated thumbnails across gallery roots, preserving originals. - # Find ComfyUI output directory - output_dir = self._find_comfyui_output_dir() - if not output_dir: + Invalid thumbnail directories return 400; unavailable gallery roots + return 404, and unexpected filesystem failures return 500. + """ + try: + output_dirs = self._get_all_output_dirs() + if not output_dirs: return web.json_response( {"success": False, "error": "ComfyUI output directory not found"}, status=404, ) - - output_path = Path(output_dir) - thumbnails_dir = output_path / "thumbnails" - - if not thumbnails_dir.exists(): - return web.json_response( - { - "success": True, - "message": "No thumbnails directory found - nothing to clear", - "cleared_files": 0, - } - ) - - # Verify this is actually our thumbnails directory: a real child - # of the output root (symlinks resolved), not merely a path that - # shares its prefix such as "2/thumbnails". - try: - if ( - not _is_within(thumbnails_dir, output_path) - or thumbnails_dir.name != "thumbnails" - ): - self.logger.error( - "Safety check failed: thumbnails directory path invalid: " - f"{thumbnails_dir}" - ) - return web.json_response( - { - "success": False, - "error": "Safety check failed: invalid thumbnails " - "directory path", - }, - status=400, - ) - - except Exception as e: - self.logger.error(f"Path validation failed: {e}") - return web.json_response( - {"success": False, "error": "Path validation failed"}, status=500 - ) - - # Count files before deletion - cleared_count = 0 - cleared_size = 0 - - for root, dirs, files in os.walk(thumbnails_dir): - for file in files: - file_path = Path(root) / file - - if "_thumb" in file.lower() and any( - file.lower().endswith(ext) - for ext in [".png", ".jpg", ".jpeg", ".webp", ".gif"] - ): - try: - file_size = file_path.stat().st_size - file_path.unlink() - cleared_count += 1 - cleared_size += file_size - self.logger.debug(f"Cleared thumbnail: {file_path}") - except Exception as e: - self.logger.warning( - f"Failed to delete thumbnail {file_path}: {e}" - ) - - # Remove empty directories within thumbnails folder - try: - for root, dirs, files in os.walk(thumbnails_dir, topdown=False): - if root != str(thumbnails_dir): - try: - Path(root).rmdir() - except OSError: - pass - except Exception as e: - self.logger.debug(f"Directory cleanup info: {e}") - - def format_size(bytes_size): - for unit in ["B", "KB", "MB", "GB"]: - if bytes_size < 1024.0: - return f"{bytes_size:.1f} {unit}" - bytes_size /= 1024.0 - return f"{bytes_size:.1f} TB" - - self.logger.info( - f"Thumbnail cleanup: cleared {cleared_count} files " - f"({format_size(cleared_size)})" + cleared_count, cleared_size = await self._run_in_executor( + self._clear_gallery_thumbnails_sync, output_dirs ) + self.invalidate_gallery_cache() + def format_size(bytes_size: int) -> str: + size = float(bytes_size) + for unit in ["B", "KB", "MB", "GB"]: + if size < 1024.0: + return f"{size:.1f} {unit}" + size /= 1024.0 + return f"{size:.1f} TB" + + size_formatted = format_size(cleared_size) + self.logger.info( + f"Thumbnail cleanup: cleared {cleared_count} files ({size_formatted})" + ) return web.json_response( { "success": True, "cleared_files": cleared_count, "cleared_size": cleared_size, - "cleared_size_formatted": format_size(cleared_size), + "cleared_size_formatted": size_formatted, "message": f"Cleared {cleared_count} thumbnail files " - "({format_size(cleared_size)})", + f"({size_formatted})", } ) - - except Exception as e: - self.logger.error(f"Clear thumbnails error: {e}") + except ValueError as exc: + self.logger.error(f"Thumbnail path validation failed: {exc}") return web.json_response( - {"success": False, "error": _safe_error(e)}, status=500 + {"success": False, "error": _safe_error(exc)}, status=400 ) + except Exception as exc: + self.logger.error(f"Clear thumbnails error: {exc}") + return web.json_response( + {"success": False, "error": _safe_error(exc)}, status=500 + ) + + def _clear_gallery_thumbnails_sync( + self, output_dirs: list[Path] + ) -> tuple[int, int]: + """Validate all thumbnail directories before deleting generated files. + + Raises ValueError for symlinked or out-of-root thumbnail directories. + Filesystem errors during path validation propagate to the route. + """ + directories = [] + for output_path in output_dirs: + output_path = Path(output_path) + thumbnails_dir = output_path / "thumbnails" + if not thumbnails_dir.exists() and not thumbnails_dir.is_symlink(): + continue + if ( + thumbnails_dir.is_symlink() + or not _is_within(thumbnails_dir, output_path) + or _canonical(thumbnails_dir) == _canonical(output_path) + ): + raise ValueError( + "Safety check failed: invalid thumbnails directory path" + ) + directories.append(thumbnails_dir) + + cleared_count = 0 + cleared_size = 0 + for thumbnails_dir in directories: + for root, _dirs, files in os.walk(thumbnails_dir): + for name in files: + path = Path(root) / name + if ( + "_thumb" not in name.lower() + or path.suffix.lower() not in GALLERY_IMAGE_EXTENSIONS + or path.is_symlink() + ): + continue + try: + size = path.stat().st_size + path.unlink() + cleared_count += 1 + cleared_size += size + except OSError as exc: + self.logger.warning(f"Failed to delete thumbnail {path}: {exc}") + for root, _dirs, _files in os.walk(thumbnails_dir, topdown=False): + if Path(root) != thumbnails_dir: + try: + Path(root).rmdir() + except OSError as exc: + self.logger.debug( + "Keeping thumbnail directory %s: %s", root, exc + ) + return cleared_count, cleared_size @staticmethod def _resolve_client_media_path(raw_path, allowed_dirs): diff --git a/tests/js/gallery-thumbnails.test.js b/tests/js/gallery-thumbnails.test.js new file mode 100644 index 0000000..535e945 --- /dev/null +++ b/tests/js/gallery-thumbnails.test.js @@ -0,0 +1,90 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +function fixture(status, settings = {}) { + const timers = []; + const prompts = []; + const requests = []; + const buttons = {}; + const storage = new Map(); + const document = { + addEventListener() {}, + createElement: () => ({ addEventListener() {} }), + body: { appendChild() {}, removeChild() {} }, + getElementById: id => buttons[id] ||= { addEventListener(type, handler) { this[type] = handler; } }, + }; + const context = vm.createContext({ document, window: {}, console: { debug() {}, warn() {} }, + setTimeout: callback => timers.push(callback), + localStorage: { setItem: (key, value) => storage.set(key, value) }, + ApiClient: { fetchUrl: async () => ({ json: async () => ({ success: true, + total: 10000, images: Array.from({ length: 50 }, () => ({ thumbnail_url: null })), + }) }) }, + }); + const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context); + const manager = Object.create(Manager.prototype); + Object.assign(manager, { getSettings: () => ({ checkThumbnailsAtStartup: true, ...settings }), + api: { get: async url => { requests.push(url); return status; } }, + }); + const originalPrompt = manager.showThumbnailGenerationPrompt.bind(manager); + manager.showThumbnailGenerationPrompt = (...args) => prompts.push(args); + async function check() { + await manager.checkThumbnailsAtStartup(); + await timers.shift()(); + } + return { manager, check, prompts, requests, settings, buttons, storage, originalPrompt }; +} + +test('startup uses exact collection counts instead of extrapolating the newest 50 images', async () => { + const f = fixture({ success: true, total_images: 10000, pending: 50, ready: 9950, collection_id: 'a' }); + await f.check(); + assert.deepEqual(f.requests, ['/prompt_manager/images/thumbnails/status']); + assert.equal(f.prompts.length, 0); +}); + +test('startup prompt reports the actual number needing generation', async () => { + const f = fixture({ success: true, total_images: 10000, pending: 1200, collection_id: 'a' }); + await f.check(); + assert.equal(f.prompts.length, 1); + assert.deepEqual(f.prompts[0].slice(0, 2), [10000, 1200]); +}); + +test('a complete collection never prompts again', async () => { + const f = fixture({ success: true, total_images: 10000, pending: 0, collection_id: 'a' }); + await f.check(); + await f.check(); + assert.equal(f.prompts.length, 0); +}); + +test('Not Now persists the collection state and suppresses the unchanged prompt', async () => { + const status = { success: true, total_images: 100, pending: 30, collection_id: 'a' }; + const f = fixture(status); + await f.check(); + f.originalPrompt(...f.prompts[0]); + f.buttons.thumbnailPromptNo.click(); + Object.assign(f.settings, JSON.parse(f.storage.get('gallerySettings'))); + f.prompts.length = 0; + await f.check(); + assert.equal(f.prompts.length, 0); + status.pending = 40; + await f.check(); + assert.equal(f.prompts.length, 1); +}); + +test('disabled checks do not request collection status', async () => { + const f = fixture({}, { checkThumbnailsAtStartup: false }); + await f.check(); + assert.equal(f.requests.length, 0); + assert.equal(f.prompts.length, 0); +}); + +test('saving gallery settings retains the remembered thumbnail dismissal', () => { + const f = fixture({}, { thumbnailPromptDismissed: 'remembered-state' }); + f.manager.showNotification = () => {}; + f.manager.hideSettings = () => {}; + f.manager.applySettings = () => {}; + f.manager.saveSettings(); + assert.equal(JSON.parse(f.storage.get('gallerySettings')).thumbnailPromptDismissed, 'remembered-state'); +}); diff --git a/tests/test_api_images.py b/tests/test_api_images.py index 9da9829..06b88b9 100644 --- a/tests/test_api_images.py +++ b/tests/test_api_images.py @@ -486,7 +486,7 @@ class TestThumbnailProgressStream(ThumbnailTestCase): # PIL work ran in the executor in batches (never on the loop), plus the scan. self.assertGreaterEqual(calls.count("_generate_batch"), 1) self.assertNotIn("_generate_one", calls) - self.assertIn("_plan_thumbnails", calls) + self.assertIn("_plan_gallery_thumbnails", calls) async def test_capped_run_reports_remaining_and_the_next_run_finishes(self): for name in ("a", "b", "c"): @@ -573,7 +573,7 @@ class TestThumbnailProgressStream(ThumbnailTestCase): self.assertEqual(events[-1][1]["total_images"], 0) async def test_missing_output_dir_sends_error_event(self): - self.api._find_comfyui_output_dir = lambda: None + self.api._get_all_output_dirs = lambda: [] resp = await self.client.request("POST", self.PROGRESS_URL) @@ -616,7 +616,7 @@ class TestGenerateThumbnailsPost(ThumbnailTestCase): self.assertEqual(data2["count"], 0) async def test_missing_output_dir_is_404(self): - self.api._find_comfyui_output_dir = lambda: None + self.api._get_all_output_dirs = lambda: [] resp = await self.client.request( "POST", "/prompt_manager/images/generate-thumbnails", json={} @@ -1420,7 +1420,7 @@ class TestThumbnailUnits(ImageRouteCoverageCase): self.assertEqual(events[-1][0], "error") async def test_post_generate_reports_internal_failure(self): - self.api._find_comfyui_output_dir = _raise + self.api._get_all_output_dirs = _raise status, _ = await self._json( "POST", "/prompt_manager/images/generate-thumbnails", json={} ) @@ -1536,7 +1536,7 @@ class TestClearThumbnails(ImageRouteCoverageCase): URL = "/prompt_manager/images/clear-thumbnails" async def test_missing_output_dir_is_404(self): - self.api._find_comfyui_output_dir = lambda: None + self.api._get_all_output_dirs = lambda: [] status, _ = await self._json("POST", self.URL) self.assertEqual(status, 404) @@ -1569,7 +1569,7 @@ class TestClearThumbnails(ImageRouteCoverageCase): self.assertEqual(data["cleared_files"], 0) async def test_internal_failure_is_500(self): - self.api._find_comfyui_output_dir = _raise + self.api._get_all_output_dirs = _raise status, _ = await self._json("POST", self.URL) self.assertEqual(status, 500) @@ -1581,7 +1581,7 @@ class TestClearThumbnails(ImageRouteCoverageCase): out.mkdir() sibling = make_png(self.comfy_dir / "out2" / "thumbnails" / "a_thumb.png") self._make_symlink(sibling.parent, out / "thumbnails") - self.api._find_comfyui_output_dir = lambda: str(out) + self.api._get_all_output_dirs = lambda: [out] status, data = await self._json("POST", self.URL) @@ -1589,6 +1589,79 @@ class TestClearThumbnails(ImageRouteCoverageCase): self.assertFalse(data["success"]) self.assertTrue(sibling.is_file()) + async def test_clears_all_roots_and_combines_counts_without_touching_originals( + self, + ): + second = self.comfy_dir / "extra-output" + roots = [self.output_dir, second] + self.api._get_all_output_dirs = lambda: roots + expected_size = 0 + for root in roots: + make_png(root / "original_thumb.png") + thumb = make_png(root / "thumbnails" / "sub" / "generated_thumb.png") + expected_size += thumb.stat().st_size + (root / "thumbnails" / "notes.txt").write_text("keep") + status, data = await self._json("POST", self.URL) + self.assertEqual(status, 200) + self.assertEqual(data["cleared_files"], 2) + self.assertEqual(data["cleared_size"], expected_size) + self.assertNotIn("{format_size", data["message"]) + for root in roots: + self.assertTrue((root / "original_thumb.png").is_file()) + self.assertTrue((root / "thumbnails" / "notes.txt").is_file()) + self.assertFalse((root / "thumbnails" / "sub").exists()) + + async def test_missing_thumbnails_in_first_root_does_not_skip_later_roots(self): + second = self.comfy_dir / "extra-output" + thumb = make_png(second / "thumbnails" / "a_thumb.png") + self.api._get_all_output_dirs = lambda: [self.output_dir, second] + status, data = await self._json("POST", self.URL) + self.assertEqual(status, 200) + self.assertEqual(data["cleared_files"], 1) + self.assertFalse(thumb.exists()) + + async def test_invalid_later_root_is_rejected_before_deleting_any_files(self): + first_thumb = make_png(self.output_dir / "thumbnails" / "a_thumb.png") + second = self.comfy_dir / "extra-output" + second.mkdir() + outside = make_png(self.comfy_dir / "elsewhere" / "a_thumb.png") + self._make_symlink(outside.parent, second / "thumbnails") + self.api._get_all_output_dirs = lambda: [self.output_dir, second] + status, data = await self._json("POST", self.URL) + self.assertEqual(status, 400) + self.assertFalse(data["success"]) + self.assertTrue(first_thumb.is_file()) + self.assertTrue(outside.is_file()) + + async def test_thumbnail_symlink_back_to_root_cannot_delete_originals(self): + original = make_png(self.output_dir / "original_thumb.png") + self._make_symlink(self.output_dir, self.output_dir / "thumbnails") + status, data = await self._json("POST", self.URL) + self.assertEqual(status, 400) + self.assertFalse(data["success"]) + self.assertTrue(original.is_file()) + + async def test_cleanup_runs_off_loop_and_invalidates_gallery_cache(self): + import threading + + make_png(self.output_dir / "thumbnails" / "a_thumb.png") + loop_thread = threading.get_ident() + deletion_threads = [] + original_unlink = Path.unlink + + def unlink(path, *args, **kwargs): + deletion_threads.append(threading.get_ident()) + return original_unlink(path, *args, **kwargs) + + self.api._gallery_cache = {"previous": object()} + with patch.object(Path, "unlink", unlink): + status, data = await self._json("POST", self.URL) + self.assertEqual(status, 200) + self.assertEqual(data["cleared_files"], 1) + self.assertTrue(deletion_threads) + self.assertNotIn(loop_thread, deletion_threads) + self.assertEqual(self.api._gallery_cache, {}) + class TestImagePromptLookup(ImageRouteCoverageCase): @@ -1819,3 +1892,88 @@ class TestOutputScanHelpers(ImageAPITestCase): targets = [(Path(f"/x/{i}.png"), Path(f"/x/t{i}.jpg"), False) for i in range(3)] await self.api._stream_thumbnails(GoneResponse(), targets, (256, 256)) self.assertEqual(generated, []) + + +class TestThumbnailStartupStatus(ThumbnailTestCase): + """Startup counts and generation cover the same configured collection.""" + + STATUS_URL = "/prompt_manager/images/thumbnails/status" + + async def test_status_reports_exact_pending_count_without_generating(self): + for name in ("ready", "missing", "outdated"): + make_png(self.output_dir / f"{name}.png") + thumbs = self.output_dir / "thumbnails" + ready = make_png(thumbs / "ready_thumb.png") + outdated = make_png(thumbs / "outdated_thumb.png") + source_time = (self.output_dir / "ready.png").stat().st_mtime + os.utime(ready, (source_time + 10, source_time + 10)) + os.utime(outdated, (source_time - 10, source_time - 10)) + calls = self._spy_executor() + + response = await self.client.get(self.STATUS_URL) + + self.assertEqual(response.status, 200) + data = await response.json() + self.assertTrue(data["success"]) + self.assertEqual(data["total_images"], 3) + self.assertEqual(data["pending"], 2) + self.assertEqual(data["ready"], 1) + self.assertTrue(data["collection_id"]) + self.assertIn("_plan_gallery_thumbnails", calls) + self.assertFalse((thumbs / "missing_thumb.png").exists()) + + async def test_both_generation_routes_cover_all_gallery_roots(self): + second = self.comfy_dir / "another-output" + make_png(self.output_dir / "a.png") + make_png(second / "b.png") + self.api._get_all_output_dirs = lambda: [self.output_dir, second] + for route in ( + "/prompt_manager/images/generate-thumbnails", + self.PROGRESS_URL, + ): + with self.subTest(route=route): + for root in (self.output_dir, second): + for thumb in (root / "thumbnails").glob("*_thumb.png"): + thumb.unlink() + response = await self.client.post(route, json={}) + if route == self.PROGRESS_URL: + data = dict(parse_sse(await response.text()))["complete"] + else: + data = await response.json() + self.assertEqual(data["count"], 2) + self.assertTrue((second / "thumbnails" / "b_thumb.png").exists()) + status = await (await self.client.get(self.STATUS_URL)).json() + self.assertEqual(status["pending"], 0) + self.assertEqual(status["total_images"], 2) + + async def test_pending_count_includes_files_beyond_the_generation_cap(self): + for name in ("a", "b", "c"): + make_png(self.output_dir / f"{name}.png") + with patch.object(images_module, "MAX_THUMBNAIL_FILES", 1): + response = await self.client.get(self.STATUS_URL) + self.assertEqual(response.status, 200) + data = await response.json() + self.assertEqual(data["pending"], 3) + + async def test_generation_cap_is_shared_across_roots(self): + second = self.comfy_dir / "another-output" + make_png(self.output_dir / "a.png") + make_png(second / "b.png") + self.api._get_all_output_dirs = lambda: [self.output_dir, second] + with patch.object(images_module, "MAX_THUMBNAIL_FILES", 1): + response = await self.client.post(self.PROGRESS_URL) + data = dict(parse_sse(await response.text()))["complete"] + self.assertEqual(data["count"], 1) + self.assertEqual(data["remaining"], 1) + self.assertEqual(data["total_images"], 2) + + async def test_equal_timestamps_do_not_regenerate_existing_thumbnails(self): + source = make_png(self.output_dir / "ready.png") + thumb = make_png(self.output_dir / "thumbnails" / "ready_thumb.png") + timestamp = int(source.stat().st_mtime) + os.utime(source, (timestamp, timestamp)) + os.utime(thumb, (timestamp, timestamp)) + response = await self.client.post(self.PROGRESS_URL) + data = dict(parse_sse(await response.text()))["complete"] + self.assertEqual(data["count"], 0) + self.assertEqual(data["skipped"], 1) diff --git a/web/js/gallery.js b/web/js/gallery.js index f02c675..beff22f 100644 --- a/web/js/gallery.js +++ b/web/js/gallery.js @@ -1758,6 +1758,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; debugMode: document.getElementById('debugModeToggle').checked, apiTimeout: parseInt(document.getElementById('apiTimeoutInput').value), thumbnailsGenerated: this.getSettings().thumbnailsGenerated, // Preserve this + thumbnailPromptDismissed: this.getSettings().thumbnailPromptDismissed, checkThumbnailsAtStartup: document.getElementById('checkThumbnailsAtStartup').checked, // Video settings videoAutoplay: document.getElementById('videoAutoplayToggle').checked, @@ -2514,37 +2515,16 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; } try { - // Check a sample of images to see if any are missing thumbnails - const response = await ApiClient.fetchUrl('/prompt_manager/images/output?limit=50&offset=0'); - const data = await response.json(); - - console.debug('Checking images for missing thumbnails:', data); - - if (data.success && data.images && data.images.length > 0) { - // Count images missing thumbnails - let missingThumbnails = 0; - for (const image of data.images) { - if (!image.thumbnail_url) { - missingThumbnails++; - } - } - - console.debug(`Found ${missingThumbnails} images without thumbnails in sample of ${data.images.length}`); - - // If more than 10% of sampled images are missing thumbnails, show prompt - const missingPercentage = (missingThumbnails / data.images.length) * 100; - if (missingThumbnails > 0 && missingPercentage > 10) { - // Estimate total missing based on sample - const estimatedMissing = Math.round((missingThumbnails / data.images.length) * data.total); - console.debug(`Estimated ${estimatedMissing} total images missing thumbnails (${missingPercentage.toFixed(1)}% of sample)`); - this.showThumbnailGenerationPrompt(data.total, estimatedMissing); - } else if (missingThumbnails > 0) { - console.debug(`Only ${missingPercentage.toFixed(1)}% of images missing thumbnails, not showing prompt`); - } else { - console.debug('All sampled images have thumbnails'); - } - } else { - console.debug('No images found or failed to fetch images'); + const data = await this.api.get('/prompt_manager/images/thumbnails/status'); + const total = data.total_images; + const pending = data.pending; + if (!data.success || !Number.isSafeInteger(total) || + !Number.isSafeInteger(pending) || total <= 0 || + pending <= 0 || pending > total) return; + const signature = JSON.stringify([data.collection_id, total, pending]); + if (settings.thumbnailPromptDismissed === signature) return; + if (pending / total > 0.1) { + this.showThumbnailGenerationPrompt(total, pending, signature); } } catch (error) { console.debug('Could not check for missing thumbnails at startup:', error); @@ -2552,7 +2532,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; }, 1000); // Wait 1 second for page to settle } - showThumbnailGenerationPrompt(imageCount, missingCount = imageCount) { + showThumbnailGenerationPrompt(imageCount, missingCount = imageCount, signature = null) { const modal = document.createElement('div'); modal.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50'; modal.innerHTML = ` @@ -2563,10 +2543,10 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; Generate Thumbnails?

- Found ${missingCount} images without thumbnails (${imageCount} total images). Would you like to generate thumbnails for faster loading? + ${missingCount} images need new or updated thumbnails (${imageCount} total images). Generate them for faster loading?

- 💡 Thumbnails speed up gallery loading significantly. You can change this setting in Gallery Settings. + Only missing or outdated thumbnails will be generated. Existing current thumbnails will be kept. You can change startup checks in Gallery Settings.