Merge feature/next: videos in the gallery (PR #21)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ARCHITECTURE.md
#	docs/architecture/frontend.md
#	web/modules/ui_settings_page.js
This commit is contained in:
DemonGatanjieu
2026-10-13 14:29:19 +08:00
14 changed files with 449 additions and 49 deletions
+4 -2
View File
@@ -408,7 +408,9 @@ covered by the `styles.css` manifest.
shortcuts (the second keeps its old "materials" ids), their fallback when ComfyUI's keybinding does not fire, and the
settings control that opens ComfyUI's keybinding editor.
- `ui_model_sources.js` renders the Models page's Sources view (the last type chip): where each model is downloaded, for the open workflow or every model, with each link editable and saved in place; `model_source_links.js` owns its data and actions (collecting the workflow's models, resolving them here, saving a link to the model's information and the workflow, the canvas note and the clipboard list).
- `ui_gallery_card.js` builds one output image's gallery card (viewer, workbench, cover pick, drag, star, delete);
- `ui_gallery_card.js` builds one output's gallery card (viewer, workbench, cover pick, drag, star, delete; a video's card
plays in the viewer and has no workbench, star or menu); `gallery_filter.js` holds what the gallery lists (All / Pictures /
Videos and the formats hidden in Settings, per browser) and `ui_gallery_filter.js` draws both controls;
`gallery_card_menu.js` is its right-click menu (insert as a Load Image node reading the output file, parameters);
`ui_keep_menu.js` is the star's keep menu (also the workbench's Keep): the whole workflow, the combo or the prompts,
each saying where it goes, opening what was kept already or removing it (to the Recycle Bin through the recipe,
@@ -466,7 +468,7 @@ covered by the `styles.css` manifest.
coordinates detail display (the file card with Show in folder in `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`) and `ui_gallery_detail.js` own generated-image browsing and workbench lifecycle,
`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,
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`
+4
View File
@@ -118,6 +118,10 @@
- **Settings → Translation → DeepL key**: prompts are translated with Google's free endpoint (MyMemory when Google cannot be reached); with your own DeepL API key, DeepL comes first. Saving the key translates one sentence to show it works, or says why not (a key DeepL refuses, a used-up month). The Free plan gives 500,000 characters a month. The key stays on this computer and is never put in a backup.
- A DeepL key saved by hand in `config.json` before did not work any more: DeepL stopped taking the key as a request parameter in 2025. It now goes in the Authorization header.
### 🎬 Videos in the gallery (图库显示视频)
- **The gallery shows your video outputs** (MP4, WebM, MOV, MKV) next to the pictures: each card shows the video's first frame with a ▶ badge naming its format, and clicking it plays the video in the viewer. Closing the viewer (×, a click beside it or Esc) stops it. Videos are deleted (to the Recycle Bin) and dragged onto the canvas like pictures; View parameters, the star and the right-click menu stay with pictures, which carry the parameters. A first contribution from outside: thanks to cocorang and wkdtjs (PR #21).
- **All / Pictures / Videos** beside the gallery's search, shown once your output folder has a video. **Settings → Gallery** hides formats you never want to see (GIF, say); click a format again to show it. Both are remembered in this browser. The recipe cover picker and AI apps (MCP) still see pictures only.
### 🧪 Tests in the repository (测试公开)
- The plugin's tests are now part of the repository: `tests/` (Python tests for the backend, `.mjs` tests for the frontend rules), `node tools/run_tests.mjs` runs them all with the structure check, and [docs/guides/testing.md](docs/guides/testing.md) says how to run and write them. They need no network or GPU and only touch temporary folders. Developer notes no longer point at tests that were removed earlier.
+55 -10
View File
@@ -9,13 +9,22 @@ from aiohttp import web
import folder_paths
from .image_search import filter_images
from .media_routes import CARD_THUMBNAIL_STATIC_EXTENSIONS, _build_card_thumbnail
from .media_routes import (
CARD_POSTER_VIDEO_EXTENSIONS,
CARD_THUMBNAIL_STATIC_EXTENSIONS,
_build_card_thumbnail,
_build_video_poster,
)
from .path_utils import require_filename, resolve_within
from .trash import move_to_trash, trash_failure
GALLERY_SNAPSHOT_SECONDS = 10
MAX_CACHED_GALLERY_IMAGES = 50_000
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")
_gallery_snapshot_lock = threading.Lock()
_gallery_snapshot = {"root": None, "expires": 0, "images": []}
@@ -24,7 +33,8 @@ def _collect_gallery_images(output_dir):
images = []
for root, _, files in os.walk(output_dir):
for filename in files:
if os.path.splitext(filename)[1].lower() not in {'.png', '.jpg', '.jpeg', '.webp', '.gif'}:
ext = os.path.splitext(filename)[1].lower()
if ext not in GALLERY_IMAGE_EXTENSIONS and ext not in GALLERY_VIDEO_EXTENSIONS:
continue
try:
mtime = os.path.getmtime(os.path.join(root, filename))
@@ -34,7 +44,9 @@ def _collect_gallery_images(output_dir):
images.append({
"filename": filename,
"subfolder": "" if subfolder == "." else subfolder.replace(os.sep, '/'),
"type": "output", "mtime": mtime,
"type": "output",
"mtime": mtime,
"media_type": "video" if ext in GALLERY_VIDEO_EXTENSIONS else "image",
})
images.sort(key=lambda item: (item['mtime'], item['subfolder'], item['filename']), reverse=True)
return images
@@ -58,33 +70,61 @@ def _invalidate_gallery_snapshot():
_gallery_snapshot.update(root=None, expires=0, images=[])
def _gallery_listing(output_dir, refresh, query):
def _format(filename):
"""The format name the gallery's settings use: the extension, with .jpeg counted as jpg."""
ext = os.path.splitext(filename)[1].lower().lstrip('.')
return 'jpg' if ext == 'jpeg' else ext
def _gallery_listing(output_dir, refresh, query, media="image", hidden=frozenset()):
"""Outputs, newest first. `media`: "image" (the default, for callers that show pictures),
"video" or "all"; `hidden`: format names left out (see _format)."""
images = _gallery_images(output_dir, refresh)
if media != "all" or hidden:
images = [item for item in images
if media in ("all", item["media_type"]) and _format(item["filename"]) not in hidden]
return filter_images(os.path.realpath(output_dir), images, query) if query else images
def _gallery_page_source(output_dir, refresh, query, media, hidden):
"""The listing, and whether the output folder has any video (the gallery offers its
Pictures / Videos switch only then)."""
images = _gallery_listing(output_dir, refresh, query, media, hidden)
has_videos = any(item["media_type"] == "video" for item in _gallery_images(output_dir))
return images, has_videos
async def api_get_gallery_images(request):
"""GET /anomalous/gallery_images?page&limit&refresh&term=..&term=.. (or q) - Output images, optionally searched by embedded generation info."""
"""GET /anomalous/gallery_images?page&limit&refresh&term=..&term=.. (or q)&media=all|image|video&hide=mkv,gif
- Outputs (pictures only unless `media` says otherwise), optionally searched by embedded
generation info, without the `hide` formats."""
try:
page = max(1, int(request.query.get('page', 1)))
limit = min(200, max(1, int(request.query.get('limit', 50))))
# Repeated `term` params keep phrases whole; `q` is split on whitespace.
terms = [term for term in request.query.getall('term', []) if term.strip()]
query = terms or request.query.get('q', '').strip()
images = await asyncio.to_thread(_gallery_listing, folder_paths.get_output_directory(), request.query.get('refresh') == '1', query)
media = request.query.get('media', 'image')
if media not in GALLERY_MEDIA:
raise ValueError(media)
hidden = frozenset(part.strip().lower().lstrip('.') for part in request.query.get('hide', '').split(','))
images, has_videos = await asyncio.to_thread(
_gallery_page_source, folder_paths.get_output_directory(), request.query.get('refresh') == '1',
query, media, hidden - {''})
total = len(images)
start = (page - 1) * limit
return web.json_response({"images": images[start:start + limit], "total": total,
"page": page, "pages": (total + limit - 1) // limit})
"page": page, "pages": (total + limit - 1) // limit,
"has_videos": has_videos})
except (TypeError, ValueError):
return web.json_response({"error": "Invalid pagination"}, status=400)
return web.json_response({"error": "Invalid page or filter"}, status=400)
except OSError:
return web.json_response({"error": "Could not list output images"}, status=500)
async def api_output_thumbnail(request):
"""GET /anomalous/output_thumbnail?filename&subfolder - the gallery grid's 512 px WebP of an
output image (cached like model cards); the original stays at ComfyUI's /view."""
output image/video (cached like model cards); the original stays at ComfyUI's /view."""
output_dir = folder_paths.get_output_directory()
try:
filename = require_filename(request.query.get('filename'))
@@ -94,8 +134,13 @@ async def api_output_thumbnail(request):
if not os.path.isfile(file_path):
return web.json_response({"status": "error", "message": "File not found"}, status=404)
served = file_path
if os.path.splitext(file_path)[1].lower() in CARD_THUMBNAIL_STATIC_EXTENSIONS:
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)
elif ext in CARD_POSTER_VIDEO_EXTENSIONS:
poster = await asyncio.to_thread(_build_video_poster, file_path)
if poster:
served = poster
# Revalidate (cheap 304s): an output file can be replaced under the same name.
return web.FileResponse(served, headers={'Cache-Control': 'no-cache'})
+3 -2
View File
@@ -26,7 +26,7 @@ from .path_utils import require_filename, resolve_folder_subdir, resolve_within
CARD_THUMBNAIL_EDGE = 512
CARD_THUMBNAIL_CACHE_LIMIT = 256 * 1024 * 1024
CARD_THUMBNAIL_STATIC_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.avif'}
CARD_POSTER_VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mov'}
CARD_POSTER_VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mov', '.avi', '.mkv'}
CARD_WARM_QUEUE_LIMIT = 512
_thumbnail_cleanup_lock = threading.Lock()
_thumbnail_generation_slots = threading.BoundedSemaphore(2)
@@ -302,7 +302,8 @@ async def api_serve_image(request):
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo'
'.avi': 'video/x-msvideo',
'.mkv': 'video/x-matroska'
}
content_type = content_types.get(ext)
if content_type is None:
+3 -1
View File
@@ -74,7 +74,9 @@ The main surfaces are:
- Grid and model detail: model browsing plus coordinated detail display in
`ui_detail.js`, metadata editing in `ui_model_editor.js`; `model_widget_path.js` puts a
model path into a loader's widget. The scan page's model picker is `ui_scan_picker.js`, its settings card `ui_scan_settings.js`.
- Gallery: generated outputs and the full-screen Image Detail Studio Workbench.
- Gallery: generated outputs (pictures and videos; All / Pictures / Videos beside the search
and the hidden formats in Settings, `gallery_filter.js` + `ui_gallery_filter.js`) and the
full-screen Image Detail Studio Workbench, for pictures only.
`ui_gallery_card.js` builds each output's card; its star and the workbench's Keep open
`ui_keep_menu.js`, which keeps the image as a Workflow Recipe, a combo or its prompts
(`image_keep.js`; `GET /anomalous/kept_images` says what each image was kept as).
+13
View File
@@ -69,5 +69,18 @@ class ListingIndexTests(unittest.TestCase):
self.assertEqual(collect.call_count, 3)
def test_gallery_lists_pictures_unless_asked_and_hides_formats(self):
from api import gallery_routes
with tempfile.TemporaryDirectory() as directory:
for name in ('a.png', 'b.jpeg', 'c.mp4', 'd.mkv', 'e.avi', 'f.txt'):
Path(directory, name).write_bytes(b'x')
names = lambda items: sorted(item['filename'] for item in items)
self.assertEqual(names(gallery_routes._gallery_listing(directory, True, '')), ['a.png', 'b.jpeg'])
self.assertEqual(names(gallery_routes._gallery_listing(directory, False, '', 'video')), ['c.mp4', 'd.mkv'])
self.assertEqual(names(gallery_routes._gallery_listing(directory, False, '', 'all', frozenset({'jpg', 'mkv'}))),
['a.png', 'c.mp4'])
images, has_videos = gallery_routes._gallery_page_source(directory, False, '', 'image', frozenset())
self.assertEqual((len(images), has_videos), (2, True))
if __name__ == '__main__':
unittest.main()
+67
View File
@@ -0,0 +1,67 @@
/**
* What the output gallery lists: everything, pictures or videos (the switch beside its search),
* minus the formats hidden in Settings. Both are this browser's own (localStorage). A format is
* stored when hidden, so one added later shows. The server's side is GET
* /anomalous/gallery_images?media=..&hide=.. (api/gallery_routes.py).
*/
export const GALLERY_IMAGE_FORMATS = ['png', 'jpg', 'webp', 'gif', 'avif']; // jpg covers .jpeg
export const GALLERY_VIDEO_FORMATS = ['mp4', 'webm', 'mov', 'mkv'];
export const GALLERY_MEDIA = ['all', 'image', 'video'];
const HIDDEN_KEY = 'anomalous_gallery_hidden_formats';
const MEDIA_KEY = 'anomalous_gallery_media';
function read(key) {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function write(key, value) {
try {
localStorage.setItem(key, value);
} catch {
// Storage blocked (private window): the choice lasts until the page reloads.
}
}
/** An output entry ({ filename, media_type } from the listing) is a video. */
export function isGalleryVideo(item) {
if (item?.media_type) return item.media_type === 'video';
return GALLERY_VIDEO_FORMATS.includes(String(item?.filename || '').split('.').pop().toLowerCase());
}
export function hiddenGalleryFormats() {
try {
const list = JSON.parse(read(HIDDEN_KEY) || '[]');
return new Set(Array.isArray(list) ? list.filter(format => typeof format === 'string') : []);
} catch {
return new Set();
}
}
export function setGalleryFormatHidden(format, hidden) {
const formats = hiddenGalleryFormats();
if (hidden) formats.add(format);
else formats.delete(format);
write(HIDDEN_KEY, JSON.stringify([...formats]));
}
export function galleryMedia() {
const media = read(MEDIA_KEY);
return GALLERY_MEDIA.includes(media) ? media : 'all';
}
export function setGalleryMedia(media) {
write(MEDIA_KEY, media);
}
/** Adds the gallery's filter to a listing request. */
export function appendGalleryFilter(params) {
params.set('media', galleryMedia());
const hidden = [...hiddenGalleryFormats()];
if (hidden.length) params.set('hide', hidden.join(','));
}
+18
View File
@@ -689,6 +689,15 @@ export const i18n = {
gallerySearchCount: '找到 {count} 张',
gallerySearchEmpty: '没有找到匹配的图片',
gallerySearching: '正在搜索…',
galleryFilterAll: '全部',
galleryFilterImages: '图片',
galleryFilterVideos: '视频',
galleryFormatsTitle: '显示的格式',
galleryFormatsHelp: '点一下某个格式,图库里就不再显示它,再点一下恢复。视频能否播放取决于浏览器,MP4 和 WebM 最稳。',
galleryFormatShown: '显示中,点一下隐藏',
galleryFormatHidden: '已隐藏,点一下显示',
galleryViewVideo: '播放视频',
settingsGallery: '图库',
galleryClose: '关闭图库',
galleryHistoryTitle: '历史生成图库: {name}',
galleryScanning: '加载中,正在扫描图片元数据...',
@@ -3052,6 +3061,15 @@ export const i18n = {
gallerySearchCount: '{count} found',
gallerySearchEmpty: 'No matching images',
gallerySearching: 'Searching…',
galleryFilterAll: 'All',
galleryFilterImages: 'Images',
galleryFilterVideos: 'Videos',
galleryFormatsTitle: 'Formats shown',
galleryFormatsHelp: 'Click a format to hide it from the gallery, and again to show it. Whether a video plays depends on the browser; MP4 and WebM are the safest.',
galleryFormatShown: 'Shown; click to hide',
galleryFormatHidden: 'Hidden; click to show',
galleryViewVideo: 'Play video',
settingsGallery: 'Gallery',
galleryClose: 'Close Gallery',
galleryHistoryTitle: 'Generated History: {name}',
galleryScanning: 'Loading, scanning metadata...',
+49 -16
View File
@@ -10,6 +10,8 @@ import { showImageWorkbench } from './ui_gallery_detail.js';
import { createGalleryCard } from './ui_gallery_card.js';
import { loadKeptImages } from './image_keep.js';
import { createSearchChips } from './ui_search_chips.js';
import { createGalleryMediaSwitch } from './ui_gallery_filter.js';
import { appendGalleryFilter } from './gallery_filter.js';
const t = (key, params) => translate(key, params);
@@ -29,8 +31,12 @@ export async function refreshGalleryImages() {
/** Search blocks above the output gallery; the terms live on the browser as `gallerySearchTerms`. */
/** Search blocks above the output gallery (the terms live on the browser as `gallerySearchTerms`),
* with All / Pictures / Videos beside them. */
export function createGallerySearchBar(owner) {
const header = document.createElement('div');
header.className = 'anomalous-gallery-header';
const search = createSearchChips({
placeholder: t('gallerySearchPlaceholder'),
help: t('gallerySearchHelp'),
@@ -41,7 +47,10 @@ export function createGallerySearchBar(owner) {
});
search.element.classList.add('anomalous-gallery-search');
owner.gallerySearchCount = search.countElement;
return search.element;
owner.galleryMediaSwitch = createGalleryMediaSwitch(() => owner.loadGalleryImages(1, true, { refresh: false }));
header.append(search.element, owner.galleryMediaSwitch);
return header;
}
export async function loadGalleryImages(page = 1, reset = false, { refresh = reset } = {}) {
@@ -60,12 +69,14 @@ export async function loadGalleryImages(page = 1, reset = false, { refresh = res
const params = new URLSearchParams({ page: String(page), limit: '50' });
if (refresh) params.set('refresh', '1');
terms.forEach(term => params.append('term', term));
appendGalleryFilter(params);
const res = await fetch(`/anomalous/gallery_images?${params}`);
const data = await res.json();
if (termsKey !== JSON.stringify(this.gallerySearchTerms || [])) {
this.galleryReloadPending = true;
return;
}
this.galleryMediaSwitch?.refresh(Boolean(data.has_videos));
if (this.gallerySearchCount) {
this.gallerySearchCount.textContent = query && Number.isFinite(data.total) ? t('gallerySearchCount', { count: data.total }) : '';
}
@@ -87,6 +98,7 @@ export async function loadGalleryImages(page = 1, reset = false, { refresh = res
return {
filename: imgData.filename,
subfolder: imgData.subfolder || '',
media_type: imgData.media_type,
url: `/view?filename=${q_file}&subfolder=${q_sub}&type=output`,
sourceImage: {
type: 'output',
@@ -433,7 +445,7 @@ export function showGallerySelectMode(model) {
export function showGalleryViewer(src) {
export function showGalleryViewer(src, isVideo = false) {
let viewer = document.getElementById('anomalous-gallery-viewer');
if (!viewer) {
viewer = document.createElement('dialog');
@@ -447,7 +459,15 @@ export function showGalleryViewer(src) {
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';
viewer.appendChild(img);
viewer.appendChild(video);
viewer.appendChild(closeBtn);
let scale = 1;
@@ -456,22 +476,26 @@ export function showGalleryViewer(src) {
let isDragging = false;
let startX = 0, startY = 0;
const resetImgTransform = () => {
const resetTransform = () => {
scale = 1; translateX = 0; translateY = 0;
img.style.transform = `translate(0px, 0px) scale(1)`;
img.style.cursor = 'grab';
};
closeBtn.onclick = () => {
viewer.close();
resetImgTransform();
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.
viewer.addEventListener('close', () => {
resetTransform();
viewer.stopVideo();
});
closeBtn.onclick = () => viewer.close();
viewer.onclick = (e) => {
if (e.target === viewer) {
viewer.close();
resetImgTransform();
}
if (e.target === viewer) viewer.close();
};
viewer.addEventListener('wheel', (e) => {
@@ -506,11 +530,20 @@ export function showGalleryViewer(src) {
}
const img = document.getElementById('anomalous-gallery-viewer-img');
img.src = src;
const video = document.getElementById('anomalous-gallery-viewer-video');
// Reset scale and translation when opening a new image
img.style.transform = `translate(0px, 0px) scale(1)`;
img.style.cursor = 'grab';
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();
}
+27 -10
View File
@@ -1,9 +1,11 @@
/**
* One output image's card in the gallery: the picture (opens the viewer, or becomes a
* One output's card in the gallery: the picture (opens the viewer, or becomes a
* model's cover while one is being chosen; drags onto the canvas), Parameters (the image
* workbench), the star (ui_keep_menu.js: keep it as a workflow, a combo or its prompts;
* filled once kept as anything), Delete (to the Recycle Bin, after a confirmation) and the
* right-click menu (gallery_card_menu.js: insert as a Load Image node, parameters).
* right-click menu (gallery_card_menu.js: insert as a Load Image node, parameters). A video's
* card shows its first frame with a ▶ badge and plays in the viewer; it has no Parameters,
* star or menu (those read a picture).
*/
import { translate as t } from './locales.js';
@@ -12,8 +14,10 @@ import { showImageWorkbench } from './ui_gallery_detail.js';
import { openKeepMenu } from './ui_keep_menu.js';
import { keptKey } from './image_keep.js';
import { openGalleryCardMenu } from './gallery_card_menu.js';
import { isGalleryVideo } from './gallery_filter.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>';
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) {
@@ -67,14 +71,16 @@ async function useAsCover(owner, image) {
}
function openWorkbench(owner, image, url) {
const list = owner.galleryImagesList || [];
// The workbench steps through pictures only (it reads their parameters).
const pictures = () => (owner.galleryImagesList || []).filter(item => !isGalleryVideo(item));
const list = pictures();
const index = list.findIndex(item => sameItem(item, image));
void showImageWorkbench(owner, image, url, {
items: list,
currentIndex: Math.max(0, index),
loadMore: async () => {
if (owner.galleryHasMore && !owner.galleryLoading) await owner.loadGalleryImages(owner.galleryCurrentPage + 1);
return owner.galleryImagesList || [];
return pictures();
},
});
}
@@ -116,7 +122,7 @@ function keepButton(owner, image) {
return star;
}
/** The card of `imgData` ({ filename, subfolder } from GET /anomalous/gallery_images). */
/** The card of `imgData` ({ filename, subfolder, media_type } 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)}`;
@@ -124,10 +130,13 @@ export function createGalleryCard(owner, imgData, { showViewer }) {
const card = document.createElement('div');
card.className = 'anomalous-gallery-card';
const isVideo = isGalleryVideo(imgData);
card.classList.toggle('is-video', isVideo);
const img = document.createElement('img');
img.src = `/anomalous/output_thumbnail?${query}`; // drags and the viewer use the original
img.loading = 'lazy';
img.title = t('materialViewOriginal');
img.title = isVideo ? t('galleryViewVideo') : t('materialViewOriginal');
// The card is dragged, not the <img>: Chrome adds a dragged <img> as a file, and ComfyUI
// would load that thumbnail (no workflow in it) instead of fetching the original's link.
img.draggable = false;
@@ -143,18 +152,26 @@ export function createGalleryCard(owner, imgData, { showViewer }) {
setTimeout(() => owner.modal?.classList.add('anomalous-material-dragging'), 0);
});
card.addEventListener('dragend', () => owner.modal?.classList.remove('anomalous-material-dragging'));
img.onclick = () => (owner.gallerySelectModel ? useAsCover(owner, image) : showViewer(url));
img.onclick = () => (owner.gallerySelectModel ? useAsCover(owner, image) : showViewer(url, isVideo));
const remove = button('anomalous-gallery-delete', t('galleryDelete'), () => deleteImage(owner, image, card));
remove.innerHTML = DELETE_ICON; // static markup only
if (isVideo) {
const badge = document.createElement('span');
badge.className = 'anomalous-gallery-video-badge';
badge.innerHTML = PLAY_ICON; // static markup only
badge.append(image.filename.split('.').pop().toUpperCase());
card.append(img, remove, badge);
return card;
}
card.addEventListener('contextmenu', (event) => {
if (!owner.gallerySelectModel) openGalleryCardMenu(event, image, { owner, openDetails: () => openWorkbench(owner, image, url) });
});
const details = button('anomalous-gallery-details', t('materialViewDetails'), () => {
if (!owner.gallerySelectModel) openWorkbench(owner, image, url);
});
details.innerHTML = DETAILS_ICON; // static markup only
details.append(Object.assign(document.createElement('span'), { textContent: t('materialViewParameters') }));
const remove = button('anomalous-gallery-delete', t('galleryDelete'), () => deleteImage(owner, image, card));
remove.innerHTML = DELETE_ICON; // static markup only
card.append(img, details, remove);
// Only PNG outputs carry the workflow that can be kept.
+82
View File
@@ -0,0 +1,82 @@
/**
* The output gallery's filters (state in gallery_filter.js): All / Pictures / Videos beside its
* search, shown once the output folder has a video (or while Pictures or Videos is chosen), and
* Settings → Gallery's formats, each clicked to hide it or show it again.
*/
import { translate as t } from './locales.js';
import {
GALLERY_IMAGE_FORMATS,
GALLERY_VIDEO_FORMATS,
galleryMedia,
hiddenGalleryFormats,
setGalleryFormatHidden,
setGalleryMedia,
} from './gallery_filter.js';
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
/** The switch; `onChange()` after a click, `switch.refresh(hasVideos)` after each listing. */
export function createGalleryMediaSwitch(onChange) {
const box = el('div', 'anomalous-gallery-media');
box.setAttribute('role', 'radiogroup');
box.hidden = galleryMedia() === 'all';
const choices = [['all', 'galleryFilterAll'], ['image', 'galleryFilterImages'], ['video', 'galleryFilterVideos']]
.map(([media, labelKey]) => {
const choice = el('button', 'anomalous-gallery-media-btn', t(labelKey));
choice.type = 'button';
choice.setAttribute('role', 'radio');
choice.dataset.media = media;
choice.onclick = () => {
if (galleryMedia() === media) return;
setGalleryMedia(media);
mark();
onChange();
};
return choice;
});
const mark = () => {
const current = galleryMedia();
for (const choice of choices) choice.setAttribute('aria-checked', String(choice.dataset.media === current));
};
box.append(...choices);
mark();
box.refresh = (hasVideos) => {
box.hidden = !hasVideos && galleryMedia() === 'all';
mark();
};
return box;
}
/** Settings → Gallery: one line of pictures, one of videos; `onChange()` after a click. */
export function createGalleryFormatSettings(onChange) {
const box = el('div', 'anomalous-gallery-formats');
const hidden = hiddenGalleryFormats();
for (const [labelKey, formats] of [['galleryFilterImages', GALLERY_IMAGE_FORMATS], ['galleryFilterVideos', GALLERY_VIDEO_FORMATS]]) {
const line = el('div', 'anomalous-gallery-formats-line');
line.append(el('span', 'anomalous-gallery-formats-label', t(labelKey)));
for (const format of formats) {
const toggle = el('button', 'anomalous-gallery-format', format.toUpperCase());
toggle.type = 'button';
const mark = (shown) => {
toggle.setAttribute('aria-pressed', String(shown));
toggle.title = t(shown ? 'galleryFormatShown' : 'galleryFormatHidden');
};
mark(!hidden.has(format));
toggle.onclick = () => {
const shown = toggle.getAttribute('aria-pressed') !== 'true';
setGalleryFormatHidden(format, !shown);
mark(shown);
onChange();
};
line.append(toggle);
}
box.append(line);
}
return box;
}
+9 -1
View File
@@ -24,6 +24,7 @@ import { saveApiKey } from './ui_scan_settings.js';
import { exportBackup, importBackup } from './ui_backup.js';
import { followsSelection, setFollowsSelection } from './shell_open_rules.js';
import { FOLDER_PRESETS, expandFolder } from './download_places.js';
import { createGalleryFormatSettings } from './ui_gallery_filter.js';
const t = (key, params) => translate(key, params);
// The full written guide: the README, at its Chinese half for Chinese.
@@ -185,6 +186,13 @@ function cardsGroup(owner, redraw) {
);
}
function galleryGroup(owner) {
return group('settingsGallery',
// The gallery lists again when it is next shown.
row('galleryFormatsTitle', 'galleryFormatsHelp', createGalleryFormatSettings(() => { owner.galleryLoaded = false; })),
);
}
function foldersGroup(owner) {
return group('settingsFolders',
row('sidebarManageFolders', 'settingsFoldersHelp',
@@ -381,7 +389,7 @@ function helpGroup(owner) {
// Tab -> its title and its cards, in order.
const TABS = [
['look', 'settingsTabLook', (owner, redraw) => [appearanceGroup(owner, redraw), cardsGroup(owner, redraw), openingGroup(owner, redraw)]],
['look', 'settingsTabLook', (owner, redraw) => [appearanceGroup(owner, redraw), cardsGroup(owner, redraw), galleryGroup(owner), openingGroup(owner, redraw)]],
['models', 'settingsTabModels', (owner, redraw) => [foldersGroup(owner), downloadGroup(owner, redraw)]],
['workflows', 'settingsTabWorkflows', (owner) => [workflowGroup(owner)]],
['prompts', 'settingsTabPrompts', () => [translationGroup()]],
+1 -1
View File
@@ -1,7 +1,7 @@
@import url("./styles/00-foundation-models.css?v=20261007-theme-2");
@import url("./styles/01-shell-tools.css?v=20261007-theme-2");
@import url("./styles/02-notebooks.css?v=20261012-click-2");
@import url("./styles/03-gallery-recipes.css?v=20261013-menu-1");
@import url("./styles/03-gallery-recipes.css?v=20261013-video-1");
@import url("./styles/04-dialogs-materials.css?v=20261007-theme-2");
@import url("./styles/05-recipe-studio-overrides.css?v=20261007-theme-2");
@import url("./styles/06-material-workbench.css?v=20261007-theme-2");
+114 -6
View File
@@ -7,6 +7,118 @@
background: var(--bg-color, var(--amb-bg-card));
}
/* The search, and All / Pictures / Videos beside it (ui_gallery_filter.js) once there are videos. */
.anomalous-gallery-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
margin: 12px 20px 0;
flex-shrink: 0;
}
.anomalous-gallery-header .anomalous-gallery-search {
flex: 1 1 260px;
margin: 0;
}
.anomalous-gallery-media {
display: inline-flex;
gap: 2px;
padding: 3px;
border: 1px solid var(--amb-border);
border-radius: 10px;
background: rgba(var(--amb-ink-rgb), 0.03);
}
.anomalous-gallery-media[hidden] {
display: none;
}
.anomalous-gallery-media-btn {
padding: 4px 12px;
border: none;
border-radius: 7px;
background: transparent;
color: var(--amb-text-muted);
font: inherit;
font-size: 12px;
white-space: nowrap;
cursor: pointer;
}
.anomalous-gallery-media-btn:hover {
color: var(--amb-text-main);
}
.anomalous-gallery-media-btn[aria-checked="true"] {
background: color-mix(in srgb, var(--amb-link) 18%, transparent);
color: var(--amb-text-main);
font-weight: 600;
}
/* A video's card: its first frame, with the format on a ▶ badge (over pictures, so always dark). */
.anomalous-gallery-video-badge {
position: absolute;
top: 8px;
left: 8px;
z-index: 2;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
background: rgba(0, 0, 0, 0.75);
color: #fff;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
pointer-events: none;
}
/* Settings → Gallery: each format, pressed while shown. */
.anomalous-gallery-formats {
display: flex;
flex-direction: column;
gap: 6px;
flex-shrink: 0;
}
.anomalous-gallery-formats-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.anomalous-gallery-formats-label {
min-width: 3em;
color: var(--amb-text-muted);
font-size: 12px;
}
.anomalous-gallery-format {
padding: 3px 9px;
border: 1px dashed var(--amb-border);
border-radius: 999px;
background: transparent;
color: var(--amb-text-muted);
font: inherit;
font-size: 11px;
font-weight: 600;
text-decoration: line-through;
cursor: pointer;
}
.anomalous-gallery-format[aria-pressed="true"] {
border-style: solid;
border-color: color-mix(in srgb, var(--amb-link) 45%, transparent);
background: color-mix(in srgb, var(--amb-link) 16%, transparent);
color: var(--amb-text-main);
text-decoration: none;
}
.anomalous-gallery-grid {
flex: 1;
padding: 20px;
@@ -337,7 +449,8 @@ dialog.anomalous-gallery-viewer[open] {
display: flex;
}
.anomalous-gallery-viewer img {
.anomalous-gallery-viewer img,
.anomalous-gallery-viewer video {
max-width: 95%;
max-height: 95%;
object-fit: contain;
@@ -2222,11 +2335,6 @@ dialog.anomalous-gallery-viewer[open] {
display: none;
}
.anomalous-gallery-search {
margin: 12px 20px 0;
flex-shrink: 0;
}
/* Workflow share codes (main.js AMB_WorkflowShare): the menu, export and import dialogs. */
.anomalous-share-overlay {
position: fixed;