Keep very large pictures from freezing the gallery

A 20480x13312 upscale is past Pillow's pixel limit, so its card image
failed and the catch-all fallback served the 324 MB original as the
thumbnail: the gallery and the AI workbench decoded it every time.

- A picture past the limit is shrunk once by a child process
  (huge_picture.py, low priority, one at a time) and cached; a card is
  never a large original, one with no card image gets an SVG with its size.
- /anomalous/output_preview gives the viewers the picture or a cached
  4096 px copy of a very large one; the viewer (moved to
  ui_gallery_viewer.js), the image workbench's stage and the AI pages'
  larger pictures use it, with a note and "Open the original".
- The listing carries each file's size (and big ones' pixel size); such a
  card says "Very large".
- The browser-side PNG metadata read stops at the image data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-29 14:23:22 +08:00
co-authored by Claude Opus 5.5
parent 57bc795ce7
commit 8c044d0d5a
26 changed files with 555 additions and 156 deletions
+4 -2
View File
@@ -534,7 +534,9 @@ covered by the `styles.css` manifest.
`/anomalous/`; other devices may look), as one middleware, and lets other devices change
things too when the server has an `allow_remote_changes` file (cloud servers); see backend.md.
- `api/media_routes.py`, `api/gallery_routes.py`, `api/translation_routes.py`, and
`api/folder_types.py` own the formerly mixed utility route families.
`api/folder_types.py` own the formerly mixed utility route families. `api/huge_picture.py`
is run by `media_routes.shrunk_in_child` as a child process to shrink a picture past Pillow's
pixel limit (a card image, or the viewers' 4096 px copy from `gallery_routes` output_preview).
- `model_policies.py` owns shared backend rename and protected-category policy.
- `model_sidecars.py` says where a model's side files are, shared with the scanner: beside it,
or the plugin's own ones (`.info`, `.anomalous.json`, `.civitai_bak.*`) in the folder's hidden
@@ -979,7 +981,7 @@ covered by the `styles.css` manifest.
`model_location.js`), `ui_model_editor.js` owns metadata editing (cover, name, source link, notes;
styles `styles/31-model-info.css`), and
`model_widget_path.js` puts a model path into a loader's widget. `ui_gallery.js` (cards in
`ui_gallery_card.js`, filters in `gallery_filter.js` / `ui_gallery_filter.js`) and `ui_gallery_detail.js` own generated-output browsing and workbench lifecycle,
`ui_gallery_card.js`, filters in `gallery_filter.js` / `ui_gallery_filter.js`, the large view in `ui_gallery_viewer.js`) and `ui_gallery_detail.js` own generated-output browsing and workbench lifecycle,
with stage interaction in `ui_image_stage.js` and metadata tabs in
`ui_image_inspector.js`.
- `ui_recipes.js` / `ui_recipe_detail.js` and `ui_notebooks.js`
+3
View File
@@ -2,6 +2,9 @@
## v1.63.0 — unreleased
- **超大图不再卡住图库**:几亿像素的图(比如把大图再放大 4 倍,存出来几百 MB),以前缩略图做不出来时,AMB 会把整张原图当缩略图发给浏览器,图库和 AI 工作台一翻到它就卡。现在缩略图在后台单独的进程里做一次就存下来(2.7 亿像素的图约 6 秒),不占 ComfyUI 出图的内存;大到连这样也做不了的,卡片上显示尺寸和大小,不再加载原图。卡片角上标“超大图 · 324 MB”。点开看大图、图片详情、AI 工作台里放大看的,都是缩到 4096 像素的预览,下面写着原图多大,要看原图点“打开原图”(在浏览器新标签页里打开)。图片详情读参数也不再把整张图下载下来。
- **Very large pictures no longer freeze the gallery**: for a picture of hundreds of megapixels (a big one upscaled 4x, hundreds of MB), a card image that could not be made used to be the whole original, sent to the browser every time the gallery or the AI workbench showed it. Now it is made once in a separate background process and kept (about 6 s for 272 megapixels), outside ComfyUI's own memory; one too large even for that shows its size instead, never the original. Its card says "Very large · 324 MB". The large view, the image details and the AI workbench's larger pictures show a 4096 px preview with a note saying how large the original is, and "Open the original" opens it in a new browser tab. Reading a picture's settings in its details no longer downloads the whole file.
- 做法按用途合并成 7 个:局部重画、换背景、放大、换模型精修、照着画、修脸修手、图片处理。AI 只说"用途 + 想要哪种"(比如"放大,2x"、"照着画,姿势"),AMB 按你装了什么、画布上是什么模型,自动挑能跑的、效果最好的那一块;没装的照样按节点包列出,AI 能告诉你该装什么。开新任务时 AI 只看到 7 行,做法再多也不会变长。
- The ways are grouped into 7 uses: repaint, new background, upscale, redraw with another model, follow a picture, faces and hands, picture work. The AI names a use and the kind it wants ("upscale, 2x", "follow, pose") and Anomalous picks the best block this ComfyUI can run, by what is installed and the canvas's model; the missing ones are still listed by pack, so the AI can say what to install. A new task's directory is 7 lines, however many ways there are.
+1
View File
@@ -110,6 +110,7 @@ def setup_routes(app):
app.router.add_get('/anomalous/gallery_images', gallery_routes.api_get_gallery_images)
app.router.add_post('/anomalous/delete_gallery_image', gallery_routes.api_delete_gallery_image)
app.router.add_get('/anomalous/output_thumbnail', gallery_routes.api_output_thumbnail)
app.router.add_get('/anomalous/output_preview', gallery_routes.api_output_preview)
app.router.add_get('/anomalous/kept_images', kept_images.api_kept_images)
app.router.add_get('/anomalous/quick_index', quick_index.api_quick_index)
app.router.add_get('/anomalous/resolve_hash', model_resolution.api_resolve_hash)
+45 -4
View File
@@ -14,6 +14,9 @@ from .media_routes import (
CARD_THUMBNAIL_STATIC_EXTENSIONS,
_build_card_thumbnail,
_build_video_poster,
picture_placeholder,
picture_size,
shrunk_in_child,
)
from .path_utils import require_filename, resolve_within
from .trash import move_to_trash, trash_failure
@@ -25,6 +28,10 @@ GALLERY_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif'}
# Videos a browser plays in the viewer; not .avi (Chrome and Edge cannot).
GALLERY_VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mov', '.mkv'}
GALLERY_MEDIA = ("all", "image", "video")
BIG_FILE_BYTES = 32 * 1024 * 1024 # from this size the listing reads a picture's pixel size
PREVIEW_EDGE = 4096
PREVIEW_PIXELS = 36_000_000 # past this (or PREVIEW_BYTES) the viewers show a PREVIEW_EDGE copy
PREVIEW_BYTES = 64 * 1024 * 1024
_gallery_snapshot_lock = threading.Lock()
_gallery_snapshot = {"root": None, "expires": 0, "images": []}
@@ -36,18 +43,25 @@ def _collect_gallery_images(output_dir):
ext = os.path.splitext(filename)[1].lower()
if ext not in GALLERY_IMAGE_EXTENSIONS and ext not in GALLERY_VIDEO_EXTENSIONS:
continue
path = os.path.join(root, filename)
try:
mtime = os.path.getmtime(os.path.join(root, filename))
stat = os.stat(path)
except OSError:
continue
subfolder = os.path.relpath(root, output_dir)
images.append({
item = {
"filename": filename,
"subfolder": "" if subfolder == "." else subfolder.replace(os.sep, '/'),
"type": "output",
"mtime": mtime,
"mtime": stat.st_mtime,
"bytes": stat.st_size,
"media_type": "video" if ext in GALLERY_VIDEO_EXTENSIONS else "image",
})
}
# A big file's pixel size from its header: the card says it is a very large picture.
size = picture_size(path) if stat.st_size >= BIG_FILE_BYTES and ext in CARD_THUMBNAIL_STATIC_EXTENSIONS else None
if size:
item["width"], item["height"] = size
images.append(item)
images.sort(key=lambda item: (item['mtime'], item['subfolder'], item['filename']), reverse=True)
return images
@@ -142,6 +156,8 @@ async def api_output_thumbnail(request):
ext = os.path.splitext(file_path)[1].lower()
if ext in CARD_THUMBNAIL_STATIC_EXTENSIONS:
served = await asyncio.to_thread(_build_card_thumbnail, file_path)
if served is None:
return picture_placeholder(file_path)
elif ext in CARD_POSTER_VIDEO_EXTENSIONS:
poster = await asyncio.to_thread(_build_video_poster, file_path)
if poster:
@@ -150,6 +166,31 @@ async def api_output_thumbnail(request):
return web.FileResponse(served, headers={'Cache-Control': 'no-cache'})
async def api_output_preview(request):
"""GET /anomalous/output_preview?filename&subfolder[&info=1] - an output picture as the viewers
show it: itself, or for a very large one (past PREVIEW_PIXELS or PREVIEW_BYTES) a cached
PREVIEW_EDGE px copy, so the browser never decodes hundreds of megapixels. `info=1`: JSON
{width, height, bytes, preview} instead, for the viewer's note and its "Open the original"."""
output_dir = folder_paths.get_output_directory()
try:
filename = require_filename(request.query.get('filename'))
file_path = resolve_within(resolve_within(output_dir, request.query.get('subfolder', '')), filename)
except ValueError:
return web.json_response({"status": "error", "message": "Invalid parameters"}, status=400)
if not os.path.isfile(file_path) or os.path.splitext(file_path)[1].lower() not in GALLERY_IMAGE_EXTENSIONS:
return web.json_response({"status": "error", "message": "File not found"}, status=404)
size, file_bytes = picture_size(file_path), os.path.getsize(file_path)
large = file_bytes > PREVIEW_BYTES or bool(size) and size[0] * size[1] > PREVIEW_PIXELS
if request.query.get('info') == '1':
return web.json_response({"width": size and size[0], "height": size and size[1], "bytes": file_bytes, "preview": large})
if not large:
return web.FileResponse(file_path, headers={'Cache-Control': 'no-cache'})
preview = await asyncio.to_thread(shrunk_in_child, file_path, PREVIEW_EDGE, 'preview')
if not preview:
return picture_placeholder(file_path)
return web.FileResponse(preview, headers={'Cache-Control': 'no-cache', 'Content-Type': 'image/webp'})
async def api_delete_gallery_image(request):
try:
data = await request.json()
+21
View File
@@ -0,0 +1,21 @@
"""Run by media_routes.py as a child process: a smaller WebP of a picture too large to decode
inside ComfyUI (past Pillow's pixel limit), so the memory it takes goes back when it ends.
Arguments: the picture, the WebP to write, the longest edge, the most pixels it may decode."""
import sys
from PIL import Image, ImageOps
def shrink(source, target, edge, most_pixels):
Image.MAX_IMAGE_PIXELS = most_pixels
with Image.open(source) as image:
image.thumbnail((edge, edge), Image.Resampling.LANCZOS) # a JPEG decodes at a fraction; others whole, once
image = ImageOps.exif_transpose(image) # on the small one: turning the whole picture would copy it
if image.mode not in ('RGB', 'RGBA'):
image = image.convert('RGBA' if 'transparency' in image.info else 'RGB')
image.save(target, format='WEBP', quality=84, method=4)
if __name__ == '__main__':
shrink(sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4]))
+3 -1
View File
@@ -160,8 +160,10 @@ def _input_facts(path):
def _image_content(path):
"""An MCP image item: the 512 px thumbnail the gallery uses, or None when too big or unreadable."""
thumb = _build_card_thumbnail(path)
if not thumb:
return None
try:
thumb = _build_card_thumbnail(path)
with open(thumb, "rb") as source:
data = source.read(MAX_IMAGE_BYTES + 1)
except OSError:
+100 -4
View File
@@ -3,15 +3,21 @@
Card images are 512 px WebP copies of the covers (a video cover gets one of its first
frame, the poster), kept in the user folder so they survive ComfyUI restarts (ComfyUI
empties its temp folder on every start). Listing a folder queues the covers not made yet
for one background worker, so scrolling and the next visit find them ready.
for one background worker, so scrolling and the next visit find them ready. A picture past
Pillow's pixel limit (a 4x upscale of a big one) is shrunk once in a child process
(huge_picture.py), so ComfyUI's own memory never holds it; a card never falls back to a large
original, it gets a placeholder with the picture's size instead.
"""
import asyncio
import collections
import hashlib
import json
import logging
import os
import struct
import subprocess
import sys
import tempfile
import threading
import time
@@ -28,6 +34,14 @@ CARD_THUMBNAIL_CACHE_LIMIT = 256 * 1024 * 1024
CARD_THUMBNAIL_STATIC_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.avif'}
CARD_POSTER_VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mov', '.avi', '.mkv'}
CARD_WARM_QUEUE_LIMIT = 512
ORIGINAL_CARD_BYTES = 8 * 1024 * 1024 # an unreadable picture this small is shown as it is
HUGE_PICTURE_PIXELS = 400_000_000 # the most a child process decodes (about 1.2 GB as RGB)
HUGE_DECODE_SECONDS = 180
_HUGE_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'huge_picture.py')
_TOO_LARGE = object() # _build_card_thumbnail_impl: past Pillow's limit, for the child process
_huge_lock = threading.Lock() # one child decode at a time
_huge_failed = set() # cache paths a child could not make: not tried again for that file
log = logging.getLogger(__name__)
_thumbnail_cleanup_lock = threading.Lock()
_thumbnail_generation_slots = threading.BoundedSemaphore(2)
_thumbnail_last_cleanup = 0.0
@@ -173,14 +187,34 @@ def _save_card_image(image, cache_dir, cached_path):
_prune_thumbnail_cache(cache_dir, created=True)
def picture_size(path):
"""(width, height) of a picture from its header without decoding it (past Pillow's pixel
limit too, for PNG); None when unknown."""
from PIL import Image
try:
with open(path, 'rb') as file:
head = file.read(24)
if head[:8] == b'\x89PNG\r\n\x1a\n' and head[12:16] == b'IHDR':
return struct.unpack('>II', head[16:24])
with Image.open(path) as image:
return image.size
except (OSError, ValueError, Image.DecompressionBombError):
return None
def _build_card_thumbnail_impl(source_path):
"""Return a cached 512px WebP card image, or the original on any safe fallback."""
"""A cached 512px WebP card image; the original when it is small already (or an animation);
_TOO_LARGE past Pillow's pixel limit; None when it cannot be read and is not small."""
try:
from PIL import Image, ImageOps
cache_dir, cached_path = _cached_card_path(source_path)
if _reuse_cached(cache_dir, cached_path):
return cached_path
size = picture_size(source_path)
if size and size[0] * size[1] > Image.MAX_IMAGE_PIXELS:
return _TOO_LARGE
with Image.open(source_path) as image:
if getattr(image, 'is_animated', False):
@@ -191,13 +225,73 @@ def _build_card_thumbnail_impl(source_path):
_save_card_image(image, cache_dir, cached_path)
return cached_path
except Exception:
return source_path
try:
return source_path if os.path.getsize(source_path) <= ORIGINAL_CARD_BYTES else None
except OSError:
return None
def _build_card_thumbnail(source_path):
"""The card image of a picture (_build_card_thumbnail_impl), one past Pillow's pixel limit
made by a child process (shrunk_in_child). None: show picture_placeholder instead."""
# Bound simultaneous decodes so opening a large folder cannot monopolize CPU/RAM.
with _thumbnail_generation_slots:
return _build_card_thumbnail_impl(source_path)
made = _build_card_thumbnail_impl(source_path)
# Outside the slots: a huge decode takes long and must not hold the other cards up.
return shrunk_in_child(source_path, CARD_THUMBNAIL_EDGE) if made is _TOO_LARGE else made
def shrunk_in_child(source_path, edge, kind=''):
"""A cached WebP of a very large picture, at most `edge` px, made once by a child process at
low priority (its memory goes back when it ends), one at a time. None past
HUGE_PICTURE_PIXELS, or when it failed for this file before."""
size = picture_size(source_path)
if not size or size[0] * size[1] > HUGE_PICTURE_PIXELS:
return None
try:
cache_dir, cached_path = _cached_card_path(source_path, kind)
except OSError:
return None
with _huge_lock:
if _reuse_cached(cache_dir, cached_path):
return cached_path
if cached_path in _huge_failed:
return None
temp_path = f"{cached_path}.{os.getpid()}.huge.tmp"
flags = getattr(subprocess, 'BELOW_NORMAL_PRIORITY_CLASS', 0) | getattr(subprocess, 'CREATE_NO_WINDOW', 0)
try:
subprocess.run([sys.executable, '-I', _HUGE_SCRIPT, source_path, temp_path, str(edge), str(HUGE_PICTURE_PIXELS)],
check=True, capture_output=True, timeout=HUGE_DECODE_SECONDS, creationflags=flags)
os.replace(temp_path, cached_path)
except (OSError, subprocess.SubprocessError) as error:
_huge_failed.add(cached_path)
log.warning("Anomalous: could not shrink the %dx%d picture %s: %s", size[0], size[1],
os.path.basename(source_path), (getattr(error, 'stderr', None) or b'')[-300:] or error)
return None
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
_prune_thumbnail_cache(cache_dir, created=True)
return cached_path
def picture_placeholder(source_path):
"""The card of a picture with no card image (too large, unreadable): its pixel size and file
size on a plain tile, numbers only (nothing to translate)."""
size = picture_size(source_path)
try:
megabytes = f"{os.path.getsize(source_path) / 1048576:.0f} MB"
except OSError:
megabytes = ""
pixels = f"{size[0]} × {size[1]}" if size else ""
svg = ('<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">'
'<rect width="512" height="512" fill="#2b2f36"/>'
'<g fill="none" stroke="#8b93a1" stroke-width="10" stroke-linejoin="round">'
'<rect x="176" y="150" width="160" height="130" rx="14"/><path d="m186 262 46-44 34 30 28-22 42 36"/></g>'
'<circle cx="226" cy="196" r="14" fill="#8b93a1"/>'
f'<text x="256" y="340" fill="#d5d9e0" font-family="sans-serif" font-size="30" text-anchor="middle">{pixels}</text>'
f'<text x="256" y="382" fill="#8b93a1" font-family="sans-serif" font-size="26" text-anchor="middle">{megabytes}</text></svg>')
return web.Response(body=svg.encode('utf-8'), content_type='image/svg+xml', headers={'Cache-Control': 'no-cache'})
def _build_video_poster_impl(source_path):
@@ -313,6 +407,8 @@ async def api_serve_image(request):
variant = request.query.get('variant')
if variant == 'card' and ext in CARD_THUMBNAIL_STATIC_EXTENSIONS:
served_path = await asyncio.to_thread(_build_card_thumbnail, file_path)
if served_path is None:
return picture_placeholder(file_path)
if served_path != file_path:
content_type = 'image/webp'
elif variant == 'poster':
+5 -2
View File
@@ -104,8 +104,11 @@ live in `material_schema.py`, `material_assets.py`, and `material_store.py`;
material (deduplicated by node type and values like other parameter materials).
The former mixed utility routes are separated: `media_routes.py` owns card
thumbnails and model/output media lookups, `gallery_routes.py` owns the bounded
output snapshot and deletion (searched through `image_search.py`, which reads
thumbnails and model/output media lookups (a picture past Pillow's pixel limit is shrunk once
by a child process, `huge_picture.py`; a card is never a large original, a picture with none
gets an SVG placeholder with its size), `gallery_routes.py` owns the bounded
output snapshot (with each file's size, and the pixel size of big ones), the viewers'
`output_preview` (the picture, or a cached 4096 px copy of a very large one) and deletion (searched through `image_search.py`, which reads
PNG text chunks only and caches per-image records by mtime), `translation_routes.py`
owns provider fallback, `folder_types.py` owns configured visibility and scan scope, and `audio_catalog.py`
owns generated audio: the output-audio history, temp Preview Audio results and
+13
View File
@@ -123,3 +123,16 @@ body:has(#anomalous-toolbox-modal[style*="display: flex"]) #anomalous-sidebar-to
**以后怎么做**
- `node tools/check_structure.mjs` 现在会拒绝 `:has()` 里出现 `[style…]`,报出文件和行号。
- 同一个坑第二次出现,就把它做成检查,不再只补一段经验。
## 2026-10-29 · 一张 2.7 亿像素的图让图库卡死
**现象**:AI 把 5120×3328 的图再放大 4 倍,存成 20480×13312、324 MB 的 PNG。之后 AMB 图库一翻到它就很卡,AI 工作台里也一样。
**根因**:Pillow 默认拒绝打开超过约 1.79 亿像素的图(防“解压炸弹”)。缩略图生成用 `except Exception: return source_path` 兜底,失败时把原图当缩略图发给浏览器;浏览器每次都要下载 324 MB、解开 2.7 亿像素。看大图、AI 时间线放大、图片详情读参数也都直接拿原图。
**怎么发现的**:用户说图库“真的卡”,按文件大小找到这张图,在 ComfyUI 的 Python 里打开它,报 DecompressionBombError。
**以后怎么做**
- 缩略图失败的兜底只能是“小文件原样”或占位图,永远不是大原图(`media_routes.ORIGINAL_CARD_BYTES`)。
- 超过 Pillow 上限的图在子进程里缩(`huge_picture.py`),内存用完就还,ComfyUI 进程不碰它。
- 要比缩略图大的地方用 `/anomalous/output_preview`,不用 `/view` 原图;只有用户点“打开原图”才给原图。
+1 -1
View File
@@ -75,7 +75,7 @@ AMB 更新以后,AI 软件还拿着旧的工具列表来调用时,标题栏
- 从上往下读,编号 1、2、3……;AI 连着查资料、连着改画布的几步折成一行(`ui_ai_chain.js`),点开看每一步。
- 每一步下面写它做了什么(`ui_ai_step_body.js`),出的东西能直接去:图片进看图器、模型进模型页、画布改动能定位和撤销(`ui_ai_results.js`)。
- 图片上鼠标移上去有工具(`ui_ai_images.js`):「图片与参数」、「保存为…」、勾两张对比,还有 ✎ 标注。
- 时间线和展台里的图先用缩略图(`/anomalous/output_thumbnail`),显示得比缩略图大时才换原图,打开任务不用等大图。
- 时间线和展台里的图先用缩略图(`/anomalous/output_thumbnail`),显示得比缩略图大时才换原图(`/anomalous/output_preview`:超大图换的是 4096 像素的预览),打开任务不用等大图。
### 标注:「标给 AI 看:哪里要改」(`ui_ai_marker.js`)
+3 -1
View File
@@ -1,7 +1,7 @@
// The AI workbench's rules (web/modules/ai_task_view.js): folding reads, words, counts, addresses.
import assert from 'node:assert/strict';
import {
activityIds, audioResult, chainTools, duration, flowStages, foldLooks, groupSteps, isVideo, panelGist, lookTools, newestPicture, outputCount, outputUrl, outputPicture,
activityIds, audioResult, chainTools, duration, flowStages, foldLooks, groupSteps, isVideo, panelGist, lookTools, newestPicture, outputCount, outputUrl, outputPicture, pictureShown,
pictureKey, picturePreview, uniquePictures, stepDetail, taskCounts, taskOutputs, taskResults, taskState, toolKey,
} from '../web/modules/ai_task_view.js';
@@ -78,6 +78,8 @@ assert.equal(repeated.length, 2);
assert.notEqual(pictureKey(repeated[0]), pictureKey(repeated[1]));
assert.match(picturePreview({ filename: 'a b.png', subfolder: 'x/y' }), /^\/anomalous\/output_thumbnail\?filename=a\+b.png&subfolder=x%2Fy$/);
assert.equal(picturePreview({ filename: 'a.mp4' }), outputUrl({ filename: 'a.mp4' }));
assert.equal(pictureShown({ filename: 'a b.png', subfolder: 'x' }), '/anomalous/output_preview?filename=a+b.png&subfolder=x', 'larger views: never the huge original');
assert.equal(pictureShown({ filename: 'a.png', folder: 'input' }), outputUrl({ filename: 'a.png', folder: 'input' }));
// How long a step took.
assert.equal(duration(400), null);
+31
View File
@@ -0,0 +1,31 @@
// A PNG's workflow read in the browser (web/modules/png_metadata.js): the download stops at the image data.
import assert from 'node:assert/strict';
import { parsePngMetadataFromUrl } from '../web/modules/png_metadata.js';
function chunk(type, data) {
const out = new Uint8Array(12 + data.length);
const view = new DataView(out.buffer);
view.setUint32(0, data.length);
out.set(new TextEncoder().encode(type), 4);
out.set(data, 8);
return out; // the CRC is not checked
}
const workflow = { nodes: [{ id: 1, type: 'KSampler' }] };
const text = new TextEncoder().encode(`workflow\0${JSON.stringify(workflow)}`);
const pixels = new Uint8Array(4 * 1024 * 1024); // stands for hundreds of megabytes of picture
const parts = [new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', new Uint8Array(13)), chunk('tEXt', text), chunk('IDAT', pixels), chunk('IEND', new Uint8Array(0))];
const file = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
parts.reduce((at, part) => { file.set(part, at); return at + part.length; }, 0);
let pulled = 0;
globalThis.fetch = async () => new Response(new ReadableStream({
pull(controller) {
if (pulled >= file.length) return controller.close();
controller.enqueue(file.subarray(pulled, pulled + 7)); // small pieces: chunk headers split across reads
pulled += 7;
},
}));
assert.deepEqual(await parsePngMetadataFromUrl('/view?filename=a.png&type=output'), workflow);
assert.ok(pulled < 1024, `read ${pulled} bytes: it stops at the image data`);
console.log('png_metadata: ok');
+52
View File
@@ -1,5 +1,7 @@
"""Card images: kept in the user folder, posters for video covers, background warm-up."""
import asyncio
import json
import os
import sys
import tempfile
@@ -69,6 +71,56 @@ class CardImageTests(unittest.TestCase):
handle.write(b'not a video')
self.assertIsNone(media_routes._build_video_poster(broken))
def test_a_picture_past_the_pixel_limit_is_shrunk_by_a_child_never_served_whole(self):
picture = os.path.join(self.root, 'upscaled.png')
Image.new('RGB', (1600, 1040), (90, 140, 200)).save(picture)
self.assertEqual(media_routes.picture_size(picture), (1600, 1040))
# Below the size, the same steps as a 20480x13312 upscale past Pillow's limit.
with mock.patch.object(Image, 'MAX_IMAGE_PIXELS', 1000):
served = media_routes._build_card_thumbnail(picture)
self.assertNotEqual(served, picture)
with mock.patch.object(Image, 'MAX_IMAGE_PIXELS', None):
with Image.open(served) as image:
self.assertEqual((image.format, image.size), ('WEBP', (512, 333)))
self.assertEqual(media_routes._build_card_thumbnail(picture), served, 'made once, then kept')
other = os.path.join(self.root, 'bigger.png')
Image.new('RGB', (2000, 2000)).save(other)
with mock.patch.object(media_routes, 'HUGE_PICTURE_PIXELS', 3_000_000):
self.assertIsNone(media_routes._build_card_thumbnail(other), 'past what a child decodes: a placeholder')
response = media_routes.picture_placeholder(other)
self.assertEqual(response.content_type, 'image/svg+xml')
self.assertIn('2000 × 2000', response.body.decode('utf-8'))
def test_an_unreadable_large_file_is_never_the_card(self):
broken = os.path.join(self.root, 'broken.png')
with open(broken, 'wb') as handle:
handle.write(b'\0' * 64)
self.assertEqual(media_routes._build_card_thumbnail(broken), broken, 'small: shown as it is')
with mock.patch.object(media_routes, 'ORIGINAL_CARD_BYTES', 10):
self.assertIsNone(media_routes._build_card_thumbnail(broken))
def test_viewers_get_a_smaller_copy_of_a_very_large_output(self):
from aiohttp.test_utils import make_mocked_request
from api import gallery_routes
output = os.path.join(self.root, 'output')
os.makedirs(output)
Image.new('RGB', (900, 600), (1, 2, 3)).save(os.path.join(output, 'big.png'))
Image.new('RGB', (300, 200)).save(os.path.join(output, 'small.png'))
ask = lambda query: asyncio.run(gallery_routes.api_output_preview(make_mocked_request('GET', f'/anomalous/output_preview?{query}')))
with mock.patch.object(gallery_routes.folder_paths, 'get_output_directory', return_value=output), \
mock.patch.object(gallery_routes, 'PREVIEW_PIXELS', 400_000), mock.patch.object(gallery_routes, 'PREVIEW_EDGE', 256):
info = json.loads(ask('filename=big.png&info=1').body)
self.assertEqual(info, {"width": 900, "height": 600, "bytes": os.path.getsize(os.path.join(output, 'big.png')), "preview": True})
served = ask('filename=big.png')
with Image.open(served._path) as image:
self.assertEqual(image.size, (256, 171))
self.assertTrue(os.path.samefile(ask('filename=small.png')._path, os.path.join(output, 'small.png')), 'a usual picture is itself')
self.assertEqual(ask('filename=..%2Fx.png').status, 400)
with mock.patch.object(gallery_routes, 'BIG_FILE_BYTES', 1):
listed = {item['filename']: item for item in gallery_routes._collect_gallery_images(output)}
self.assertEqual((listed['big.png']['width'], listed['big.png']['height']), (900, 600))
self.assertGreater(listed['small.png']['bytes'], 0)
def test_warm_up_makes_the_images_in_the_background(self):
cover = os.path.join(self.root, 'a.png')
Image.new('RGB', (900, 900)).save(cover)
+6
View File
@@ -248,6 +248,12 @@ export function uniquePictures(images) {
});
}
/** What a view larger than a thumbnail shows of an output picture: itself, or a 4096 px copy of a very large one (gallery_routes.py output_preview). */
export function pictureShown(result) {
const source = outputPicture(result);
return source ? `/anomalous/output_preview?${new URLSearchParams({ filename: source.filename, subfolder: source.subfolder })}` : outputUrl(result);
}
/** Thumbnails for output pictures; other media retain their actual ComfyUI address. */
export function picturePreview(result) {
const source = outputPicture(result);
+2 -1
View File
@@ -7,7 +7,8 @@ import { createDOM, renderSidebar, loadFolders } from './ui_sidebar.js';
import { closeWorkspace, hideAllPanels } from './ui_browser_navigation.js';
import { triggerDirectModelScan } from './scan_runner.js';
import { openScanPage, renderScanPage, leaveScanPage } from './ui_scan_page.js';
import { loadGalleryImages, refreshGalleryImages, showGeneratedGallery, showGallerySelectMode, showGalleryViewer } from './ui_gallery.js';
import { loadGalleryImages, refreshGalleryImages, showGeneratedGallery, showGallerySelectMode } from './ui_gallery.js';
import { showGalleryViewer } from './ui_gallery_viewer.js';
import { showNotebooks, openCombo, openComboByFilename, refreshNotebooks, saveCurrentNotebook, deleteCurrentNotebook } from './ui_notebooks.js';
import { renderNotebookEditor } from './ui_notebook_editor.js';
import { sendNotebookToCanvas } from './notebook_canvas.js';
+8
View File
@@ -621,6 +621,10 @@ export const i18n = {
galleryLoading: '加载中...',
galleryErrorPrefix: '错误: ',
galleryDelete: '删除',
galleryHuge: '超大图 · {bytes}',
galleryViewerPreview: '原图太大({size} · {bytes}),这里看的是缩小到 4096 像素的预览。',
galleryViewerOriginal: '打开原图',
galleryViewerOriginalTip: '在浏览器新标签页里打开原图:很大,可能要等一会儿,也可能很卡',
galleryKeep: "收藏:存成工作流、搭配或提示词",
compareCardTitle: "对比参数:先点这张,再点另一张的 ⇄,或和当前画布比",
compareTitle: "参数对比",
@@ -4067,6 +4071,10 @@ export const i18n = {
galleryLoading: 'Loading...',
galleryErrorPrefix: 'Error: ',
galleryDelete: 'Delete',
galleryHuge: 'Very large · {bytes}',
galleryViewerPreview: 'The original is very large ({size} · {bytes}); this is a preview of 4096 pixels.',
galleryViewerOriginal: 'Open the original',
galleryViewerOriginalTip: 'Opens the original in a new browser tab: it is large, may take a while and may be slow',
galleryKeep: "Keep it as a workflow, a combo or its prompts",
compareCardTitle: "Compare parameters: press this one, then another image's ⇄, or compare with the canvas",
compareTitle: "Compare parameters",
+28 -1
View File
@@ -4,12 +4,39 @@
* it when the server could not say. Null for anything that is not such a PNG. No DOM.
*/
/**
* The response's bytes up to the first IDAT chunk's header, where the text chunks end: the
* download stops there, so a picture of hundreds of megabytes is not fetched whole.
*/
async function headBeforePixels(response) {
const reader = response.body?.getReader?.();
if (!reader) return response.arrayBuffer();
let bytes = new Uint8Array(0);
let next = 8; // where the next chunk's header starts
for (;;) {
const { done, value } = await reader.read();
if (done) return bytes.buffer;
const joined = new Uint8Array(bytes.length + value.length);
joined.set(bytes);
joined.set(value, bytes.length);
bytes = joined;
const view = new DataView(bytes.buffer);
while (next + 8 <= bytes.length) {
if (view.getUint32(next + 4) === 0x49444154) { // 'IDAT'
void reader.cancel();
return bytes.buffer;
}
next += 12 + view.getUint32(next);
}
}
}
export async function parsePngMetadataFromUrl(url, options = {}) {
if (!url) return null;
try {
const response = await fetch(url, { cache: 'force-cache', signal: options.signal });
if (!response.ok) return null;
const buffer = await response.arrayBuffer();
const buffer = await headBeforePixels(response);
const view = new DataView(buffer);
if (view.byteLength < 32) return null;
// PNG magic bytes: 137, 80, 78, 71, 13, 10, 26, 10
+4 -4
View File
@@ -7,7 +7,7 @@
* marks the picture for the AI (setMarker: the workbench opens ui_ai_marker.js for its task).
*/
import { translate as t } from './locales.js';
import { isVideo, outputPicture, outputUrl, pictureKey, picturePreview, uniquePictures } from './ai_task_view.js';
import { isVideo, outputPicture, outputUrl, pictureKey, picturePreview, pictureShown, uniquePictures } from './ai_task_view.js';
import { openParamCompare } from './ui_param_compare.js';
import { showImageWorkbench } from './ui_gallery_detail.js';
import { openKeepMenu } from './ui_keep_menu.js';
@@ -108,12 +108,12 @@ export function compareBar() {
/**
* Output picture `image` in `img`: its 512 px thumbnail first (quick to load and to draw, so a task
* opens without waiting for its pictures), then the file itself if the picture shows larger than
* the thumbnail can be sharp. Other pictures (not in the output folder) as they are.
* opens without waiting for its pictures), then the file itself (a smaller copy of a very large one)
* if the picture shows larger than the thumbnail can be sharp. Other pictures (not in the output folder) as they are.
*/
export function showPicture(img, image) {
const thumbnail = picturePreview(image);
const full = outputUrl(image);
const full = pictureShown(image);
img.decoding = 'async';
if (thumbnail !== full) {
img.addEventListener('load', () => {
+2 -2
View File
@@ -14,7 +14,7 @@
import { translate as t } from './locales.js';
import { anomalousAlert } from './ui_dialog.js';
import { outputUrl } from './ai_task_view.js';
import { pictureShown } from './ai_task_view.js';
import { deliveryState } from './ui_ai_delivery.js';
const TOOLS = [['box', '□'], ['circle', '○'], ['arrow', '↗'], ['pen', '✎'], ['mask', '▧']];
@@ -192,7 +192,7 @@ export function openMarker(image, taskId, sent) {
const stage = el('div', 'anomalous-ai-marker-stage');
const picture = el('img', 'anomalous-ai-marker-picture');
picture.alt = image.filename;
picture.src = outputUrl({ ...image, folder: 'output' });
picture.src = pictureShown({ ...image, folder: 'output' });
const canvas = el('canvas', 'anomalous-ai-marker-canvas');
stage.append(picture, canvas);
+2 -2
View File
@@ -6,7 +6,7 @@
import { translate as t } from './locales.js';
import { timeLabel } from './activity_log.js';
import { outputUrl, taskCounts, taskState } from './ai_task_view.js';
import { picturePreview, taskCounts, taskState } from './ai_task_view.js';
function el(tag, className, text) {
const node = document.createElement(tag);
@@ -34,7 +34,7 @@ export function drawRecentAiTasks(owner, container, { fetchTasks, open }) {
if (summary.cover) {
const image = el('img', 'anomalous-ai-recent-cover');
image.loading = 'lazy';
image.src = outputUrl(summary.cover);
image.src = picturePreview(summary.cover);
image.alt = '';
row.append(image);
}
+2 -126
View File
@@ -1,13 +1,14 @@
/**
* ui_gallery.js
* The output gallery's listing and search (cards: ui_gallery_card.js), a model's generated
* images, choosing a cover, and the full-size viewer.
* images and choosing a cover. Its large view is ui_gallery_viewer.js.
*/
import { app } from "../../../scripts/app.js";
import { translate } from './locales.js';
import { showImageWorkbench } from './ui_gallery_detail.js';
import { createGalleryCard } from './ui_gallery_card.js';
import { showGalleryViewer } from './ui_gallery_viewer.js';
import { loadKeptImages } from './image_keep.js';
import { createSearchChips } from './ui_search_chips.js';
import { newestCompareButton } from './ui_param_compare.js';
@@ -445,128 +446,3 @@ export function showGallerySelectMode(model) {
this.loadGalleryImages(1, true);
}
}
export function showGalleryViewer(src, isVideo = false) {
let viewer = document.getElementById('anomalous-gallery-viewer');
if (!viewer) {
viewer = document.createElement('dialog');
viewer.id = 'anomalous-gallery-viewer';
viewer.className = 'anomalous-gallery-viewer';
const closeBtn = document.createElement('div');
closeBtn.className = 'anomalous-gallery-viewer-close';
closeBtn.innerHTML = '&times;';
const img = document.createElement('img');
img.id = 'anomalous-gallery-viewer-img';
const video = document.createElement('video');
video.id = 'anomalous-gallery-viewer-video';
video.controls = true;
video.autoplay = true;
video.loop = true;
video.style.display = 'none';
// Double-click: full screen and back. Some Chrome builds already do this for a video
// with controls; the check afterwards keeps it from toggling twice.
video.addEventListener('dblclick', () => {
const before = Boolean(document.fullscreenElement);
setTimeout(() => {
if (Boolean(document.fullscreenElement) !== before) return;
if (before) document.exitFullscreen?.().catch(() => {});
else video.requestFullscreen?.().catch(() => {});
}, 200);
});
viewer.appendChild(img);
viewer.appendChild(video);
viewer.appendChild(closeBtn);
let scale = 1;
let translateX = 0;
let translateY = 0;
let isDragging = false;
let startX = 0, startY = 0;
const resetTransform = () => {
scale = 1; translateX = 0; translateY = 0;
img.style.transform = `translate(0px, 0px) scale(1)`;
img.style.cursor = 'grab';
};
viewer.stopVideo = () => {
video.pause();
video.style.display = 'none';
video.removeAttribute('src');
video.load(); // drops the file, so its sound cannot go on
};
// However it closes (the ×, a click beside the picture, Esc), the video stops at once.
// The dialog's 'close' event comes later (half a second, seen), when another video
// may already play in it: it only catches a close nothing else saw.
const stop = () => {
resetTransform();
viewer.stopVideo();
};
const closeViewer = () => {
stop();
viewer.close();
};
viewer.addEventListener('cancel', stop); // Esc
viewer.addEventListener('close', () => {
if (!viewer.open) stop();
});
closeBtn.onclick = closeViewer;
viewer.onclick = (e) => {
if (e.target === viewer) closeViewer();
};
viewer.addEventListener('wheel', (e) => {
e.preventDefault();
const zoomFactor = 0.1;
if (e.deltaY < 0) scale += zoomFactor;
else scale = Math.max(0.1, scale - zoomFactor);
img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
});
img.addEventListener('mousedown', (e) => {
e.preventDefault();
isDragging = true;
startX = e.clientX - translateX;
startY = e.clientY - translateY;
img.style.cursor = 'grabbing';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
translateX = e.clientX - startX;
translateY = e.clientY - startY;
img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
});
window.addEventListener('mouseup', () => {
isDragging = false;
img.style.cursor = 'grab';
});
document.body.appendChild(viewer);
}
const img = document.getElementById('anomalous-gallery-viewer-img');
const video = document.getElementById('anomalous-gallery-viewer-video');
if (isVideo) {
img.style.display = 'none';
video.style.display = 'block';
video.src = src;
video.play().catch(() => {});
} else {
viewer.stopVideo();
img.style.display = 'block';
img.src = src;
img.style.transform = `translate(0px, 0px) scale(1)`;
img.style.cursor = 'grab';
}
if (!viewer.open) viewer.showModal();
}
+10 -1
View File
@@ -17,9 +17,13 @@ import { keptKey } from './image_keep.js';
import { isComparePicked, pressCompare } from './ui_param_compare.js';
import { openGalleryCardMenu } from './gallery_card_menu.js';
import { isGalleryVideo } from './gallery_filter.js';
import { formatSize } from './ui_model_download.js';
const DETAILS_ICON = `<svg class="anomalous-gallery-details-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><line x1="2" y1="5" x2="8" y2="5"></line><line x1="12" y1="5" x2="14" y2="5"></line><circle cx="10" cy="5" r="2"></circle><line x1="2" y1="11" x2="4" y2="11"></line><line x1="8" y1="11" x2="14" y2="11"></line><circle cx="6" cy="11" r="2"></circle></svg>`;
const PLAY_ICON = '<svg viewBox="0 0 24 24" width="10" height="10" fill="currentColor" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/></svg>';
// From these the card says "very large" (as gallery_routes.py's PREVIEW_PIXELS, PREVIEW_BYTES).
const HUGE_PIXELS = 36_000_000;
const HUGE_BYTES = 64 * 1024 * 1024;
const DELETE_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>';
function button(className, title, onClick) {
@@ -161,7 +165,7 @@ function keepButton(owner, image) {
return star;
}
/** The card of `imgData` ({ filename, subfolder, media_type } from GET /anomalous/gallery_images). */
/** The card of `imgData` ({ filename, subfolder, media_type, bytes, width, height } from GET /anomalous/gallery_images). */
export function createGalleryCard(owner, imgData, { showViewer }) {
const image = { type: 'output', filename: imgData.filename, subfolder: imgData.subfolder || '' };
const query = `filename=${encodeURIComponent(image.filename)}&subfolder=${encodeURIComponent(image.subfolder)}`;
@@ -214,6 +218,11 @@ export function createGalleryCard(owner, imgData, { showViewer }) {
details.append(Object.assign(document.createElement('span'), { textContent: t('materialViewParameters') }));
card.append(img, details, remove);
if ((imgData.width || 0) * (imgData.height || 0) > HUGE_PIXELS || imgData.bytes > HUGE_BYTES) {
card.append(Object.assign(document.createElement('span'), {
className: 'anomalous-gallery-huge-badge', textContent: t('galleryHuge', { bytes: formatSize(imgData.bytes) }),
}));
}
// Only PNG outputs carry the workflow that can be kept or compared.
if (/\.png$/i.test(image.filename)) {
const compare = button('anomalous-gallery-compare', t('compareCardTitle'), () => pressCompare(owner, image, card));
+3 -2
View File
@@ -32,6 +32,7 @@ import {
materialNodeHeading,
} from './material_inspector.js';
import { parsePngMetadataFromUrl } from './png_metadata.js';
import { shownUrl } from './ui_gallery_viewer.js';
const t = (key, params) => translate(key, params);
@@ -247,7 +248,7 @@ async function loadWorkbenchImage(index) {
// 2. Update Image & reset pan/zoom
if (wb.stageImg) {
wb.stageImg.src = item.url;
wb.stageImg.src = shownUrl(item.url);
if (wb.panZoom) wb.panZoom.reset();
}
@@ -481,7 +482,7 @@ export async function showImageWorkbench(owner, sourceImage, imageUrl, options =
const img = document.createElement('img');
img.className = 'anomalous-workbench-stage-img';
img.src = items[currentIndex].url;
img.src = shownUrl(items[currentIndex].url);
img.alt = 'Workbench Stage';
wb.stageImg = img;
stage.appendChild(img);
+167
View File
@@ -0,0 +1,167 @@
/**
* The large view of a picture or video (the gallery's, the AI pages', the image workbench's
* stage): wheel to zoom, drag to move, Esc or a click beside it to close. An output picture is
* shown through /anomalous/output_preview (shownUrl), which gives the picture itself or, for a
* very large one (a 4x upscale of hundreds of megapixels), a 4096 px copy; then a note says so
* and opens the original in a browser tab when the user wants it.
*/
import { translate as t } from './locales.js';
import { formatSize } from './ui_model_download.js';
/** Where a picture address is shown from: an output picture's (`/view?…&type=output`) through output_preview, others as they are. */
export function shownUrl(src) {
const url = new URL(src, window.location.href);
if (url.pathname !== '/view' || (url.searchParams.get('type') || 'output') !== 'output') return src;
return `/anomalous/output_preview?${new URLSearchParams({ filename: url.searchParams.get('filename') || '', subfolder: url.searchParams.get('subfolder') || '' })}`;
}
/** The viewer's note for a large picture shown smaller (`original`: its own address), or nothing. */
async function noteLarge(note, original, shown) {
note.hidden = true;
if (shown === original) return;
const info = await fetch(`${shown}&info=1`).then(response => (response.ok ? response.json() : null)).catch(() => null);
if (!info?.preview || note.dataset.src !== original) return; // another picture came meanwhile
note.replaceChildren(Object.assign(document.createElement('span'), {
textContent: t('galleryViewerPreview', { size: info.width ? `${info.width} × ${info.height}` : '', bytes: formatSize(info.bytes) }),
}));
const open = document.createElement('button');
open.type = 'button';
open.textContent = t('galleryViewerOriginal');
open.title = t('galleryViewerOriginalTip');
open.onclick = () => window.open(original, '_blank', 'noopener');
note.append(open);
note.hidden = false;
}
export function showGalleryViewer(src, isVideo = false) {
let viewer = document.getElementById('anomalous-gallery-viewer');
if (!viewer) {
viewer = document.createElement('dialog');
viewer.id = 'anomalous-gallery-viewer';
viewer.className = 'anomalous-gallery-viewer';
const closeBtn = document.createElement('div');
closeBtn.className = 'anomalous-gallery-viewer-close';
closeBtn.innerHTML = '&times;';
const img = document.createElement('img');
img.id = 'anomalous-gallery-viewer-img';
const video = document.createElement('video');
video.id = 'anomalous-gallery-viewer-video';
video.controls = true;
video.autoplay = true;
video.loop = true;
video.style.display = 'none';
// Double-click: full screen and back. Some Chrome builds already do this for a video
// with controls; the check afterwards keeps it from toggling twice.
video.addEventListener('dblclick', () => {
const before = Boolean(document.fullscreenElement);
setTimeout(() => {
if (Boolean(document.fullscreenElement) !== before) return;
if (before) document.exitFullscreen?.().catch(() => {});
else video.requestFullscreen?.().catch(() => {});
}, 200);
});
const note = document.createElement('div');
note.className = 'anomalous-gallery-viewer-note';
note.hidden = true;
viewer.appendChild(img);
viewer.appendChild(video);
viewer.appendChild(closeBtn);
viewer.appendChild(note);
let scale = 1;
let translateX = 0;
let translateY = 0;
let isDragging = false;
let startX = 0, startY = 0;
const resetTransform = () => {
scale = 1; translateX = 0; translateY = 0;
img.style.transform = `translate(0px, 0px) scale(1)`;
img.style.cursor = 'grab';
};
viewer.stopVideo = () => {
video.pause();
video.style.display = 'none';
video.removeAttribute('src');
video.load(); // drops the file, so its sound cannot go on
};
// However it closes (the ×, a click beside the picture, Esc), the video stops at once.
// The dialog's 'close' event comes later (half a second, seen), when another video
// may already play in it: it only catches a close nothing else saw.
const stop = () => {
resetTransform();
viewer.stopVideo();
};
const closeViewer = () => {
stop();
viewer.close();
};
viewer.addEventListener('cancel', stop); // Esc
viewer.addEventListener('close', () => {
if (!viewer.open) stop();
});
closeBtn.onclick = closeViewer;
viewer.onclick = (e) => {
if (e.target === viewer) closeViewer();
};
viewer.addEventListener('wheel', (e) => {
e.preventDefault();
const zoomFactor = 0.1;
if (e.deltaY < 0) scale += zoomFactor;
else scale = Math.max(0.1, scale - zoomFactor);
img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
});
img.addEventListener('mousedown', (e) => {
e.preventDefault();
isDragging = true;
startX = e.clientX - translateX;
startY = e.clientY - translateY;
img.style.cursor = 'grabbing';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
translateX = e.clientX - startX;
translateY = e.clientY - startY;
img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
});
window.addEventListener('mouseup', () => {
isDragging = false;
img.style.cursor = 'grab';
});
document.body.appendChild(viewer);
}
const img = document.getElementById('anomalous-gallery-viewer-img');
const video = document.getElementById('anomalous-gallery-viewer-video');
const note = viewer.querySelector('.anomalous-gallery-viewer-note');
note.dataset.src = src;
note.hidden = true;
if (isVideo) {
img.style.display = 'none';
video.style.display = 'block';
video.src = src;
video.play().catch(() => {});
} else {
viewer.stopVideo();
img.style.display = 'block';
img.src = shownUrl(src);
void noteLarge(note, src, img.getAttribute('src'));
img.style.transform = `translate(0px, 0px) scale(1)`;
img.style.cursor = 'grab';
}
if (!viewer.open) viewer.showModal();
}
+1 -1
View File
@@ -1,7 +1,7 @@
@import url("./styles/00-foundation-models.css?v=20261017-roomy");
@import url("./styles/01-shell-tools.css?v=20261020-tidy");
@import url("./styles/02-notebooks.css?v=20261022-hand");
@import url("./styles/03-gallery-recipes.css?v=20261020-tidy");
@import url("./styles/03-gallery-recipes.css?v=20261029-huge");
@import url("./styles/04-dialogs-materials.css?v=20261020-tidy");
@import url("./styles/05-recipe-studio-overrides.css?v=20261011-wfd-3");
@import url("./styles/06-material-workbench.css?v=20261020-tidy");
+38 -1
View File
@@ -58,7 +58,8 @@
}
/* A video's card: its first frame, with the format on a ▶ badge (over pictures, so always dark). */
.anomalous-gallery-video-badge {
.anomalous-gallery-video-badge,
.anomalous-gallery-huge-badge {
position: absolute;
top: 8px;
left: 8px;
@@ -77,6 +78,9 @@
pointer-events: none;
}
/* A picture of tens of megapixels or more: its card says so (the viewer shows a smaller copy). */
.anomalous-gallery-huge-badge { background: rgba(180, 83, 9, 0.85); letter-spacing: 0; }
/* Settings → Gallery: each format, pressed while shown. */
.anomalous-gallery-formats {
display: flex;
@@ -494,6 +498,39 @@ dialog.anomalous-gallery-viewer[open] {
color: var(--amb-text-main);
}
/* Under a very large picture shown smaller (ui_gallery_viewer.js): what it is, and the original in a tab. */
.anomalous-gallery-viewer-note {
position: absolute;
bottom: 18px;
left: 50%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 8px 12px;
max-width: min(92vw, 720px);
padding: 8px 14px;
border-radius: 10px;
background: rgba(20, 22, 26, 0.88);
color: #e5e7eb;
font-size: 13px;
transform: translateX(-50%);
}
.anomalous-gallery-viewer-note[hidden] { display: none; }
.anomalous-gallery-viewer-note button {
padding: 4px 12px;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: #fff;
font: inherit;
cursor: pointer;
}
.anomalous-gallery-viewer-note button:hover { background: rgba(255, 255, 255, 0.18); }
/* Workflow Recipes: Studio Topbar and Modern Layout System */
.anomalous-recipe-body {
display: flex;