diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index a7d3671..bbbadb1 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -428,7 +428,7 @@ covered by the `styles.css` manifest.
safe docked sidebar preservation upon node addition, and multi-type node dispatch; `ui_detail.js`
coordinates detail display (the file card with Show in folder in `model_location.js`), `ui_model_editor.js` owns metadata editing, and
`ui_model_selector.js` owns advanced selection. `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`, format filtering state in `gallery_filter.js`, and quick filter and settings UI in `ui_gallery_filter.js`) and `ui_gallery_detail.js` own generated-image 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`
diff --git a/api/gallery_routes.py b/api/gallery_routes.py
index 1fd1298..6a55445 100644
--- a/api/gallery_routes.py
+++ b/api/gallery_routes.py
@@ -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
+SUPPORTED_GALLERY_EXTENSIONS = {
+ '.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif',
+ '.mp4', '.webm', '.mov', '.avi', '.mkv',
+}
_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 SUPPORTED_GALLERY_EXTENSIONS:
continue
try:
mtime = os.path.getmtime(os.path.join(root, filename))
@@ -34,7 +44,10 @@ 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,
+ "ext": ext.lstrip('.'),
+ "media_type": "video" if ext in CARD_POSTER_VIDEO_EXTENSIONS else "image",
})
images.sort(key=lambda item: (item['mtime'], item['subfolder'], item['filename']), reverse=True)
return images
@@ -58,20 +71,40 @@ def _invalidate_gallery_snapshot():
_gallery_snapshot.update(root=None, expires=0, images=[])
-def _gallery_listing(output_dir, refresh, query):
+def _gallery_listing(output_dir, refresh, query, allowed_exts=None):
images = _gallery_images(output_dir, refresh)
+ if allowed_exts is not None:
+ images = [img for img in images if os.path.splitext(img['filename'])[1].lower().lstrip('.') in allowed_exts]
return filter_images(os.path.realpath(output_dir), images, query) if query else images
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=..&ext=.. - Output images/videos, optionally filtered by extension."""
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)
+
+ # Optional extension filter (?ext=png&ext=mp4 or ?ext=png,mp4)
+ ext_filter = None
+ if 'ext' in request.query:
+ allowed_exts = set()
+ for raw in request.query.getall('ext', []):
+ for part in raw.split(','):
+ cleaned = part.strip().lower().lstrip('.')
+ if cleaned:
+ allowed_exts.add(cleaned)
+ ext_filter = allowed_exts
+
+ images = await asyncio.to_thread(
+ _gallery_listing,
+ folder_paths.get_output_directory(),
+ request.query.get('refresh') == '1',
+ query,
+ ext_filter,
+ )
total = len(images)
start = (page - 1) * limit
return web.json_response({"images": images[start:start + limit], "total": total,
@@ -84,7 +117,7 @@ async def api_get_gallery_images(request):
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 +127,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'})
diff --git a/api/media_routes.py b/api/media_routes.py
index f808a0f..1849504 100644
--- a/api/media_routes.py
+++ b/api/media_routes.py
@@ -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:
diff --git a/web/modules/gallery_filter.js b/web/modules/gallery_filter.js
new file mode 100644
index 0000000..453f620
--- /dev/null
+++ b/web/modules/gallery_filter.js
@@ -0,0 +1,89 @@
+/**
+ * gallery_filter.js
+ * Source of truth for active/hidden output formats in the Gallery.
+ * Persists extension preferences to localStorage ('anomalous_gallery_extensions').
+ */
+
+export const GALLERY_IMAGE_EXTENSIONS = ['png', 'webp', 'jpg', 'jpeg', 'gif'];
+export const GALLERY_VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'mkv'];
+export const ALL_GALLERY_EXTENSIONS = [...GALLERY_IMAGE_EXTENSIONS, ...GALLERY_VIDEO_EXTENSIONS];
+
+const STORAGE_KEY = 'anomalous_gallery_extensions';
+
+function cleanExt(ext) {
+ return String(ext || '').trim().toLowerCase().replace(/^\.+/, '');
+}
+
+/** Returns the Set of currently active extensions (lowercase without dot). */
+export function getActiveGalleryExtensions() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ if (Array.isArray(parsed)) {
+ return new Set(parsed.map(cleanExt).filter(e => ALL_GALLERY_EXTENSIONS.includes(e)));
+ }
+ }
+ } catch (_) {}
+ return new Set(ALL_GALLERY_EXTENSIONS);
+}
+
+/** Saves the active extensions to localStorage. */
+export function setActiveGalleryExtensions(extensions) {
+ const arr = Array.from(extensions || []).map(cleanExt).filter(e => ALL_GALLERY_EXTENSIONS.includes(e));
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(arr));
+ return new Set(arr);
+}
+
+/** Toggles a single extension on/off. */
+export function toggleGalleryExtension(ext) {
+ const cleaned = cleanExt(ext);
+ const active = getActiveGalleryExtensions();
+ if (active.has(cleaned)) {
+ active.delete(cleaned);
+ } else {
+ active.add(cleaned);
+ }
+ setActiveGalleryExtensions(active);
+ return active;
+}
+
+/** Sets all extensions in a group (all, images, videos) to enabled or disabled. */
+export function setGalleryGroup(group, enabled) {
+ const active = getActiveGalleryExtensions();
+ let targets = [];
+ if (group === 'all') {
+ targets = ALL_GALLERY_EXTENSIONS;
+ } else if (group === 'images') {
+ targets = GALLERY_IMAGE_EXTENSIONS;
+ } else if (group === 'videos') {
+ targets = GALLERY_VIDEO_EXTENSIONS;
+ }
+
+ for (const ext of targets) {
+ if (enabled) active.add(ext);
+ else active.delete(ext);
+ }
+ setActiveGalleryExtensions(active);
+ return active;
+}
+
+/** True if every supported extension is active. */
+export function isAllGalleryExtensionsActive() {
+ const active = getActiveGalleryExtensions();
+ return ALL_GALLERY_EXTENSIONS.every(ext => active.has(ext));
+}
+
+/** Appends active extensions to URLSearchParams. If all are active, omits to query everything. */
+export function appendExtensionParams(params) {
+ const active = getActiveGalleryExtensions();
+ // If not all are active, append each active extension
+ if (active.size < ALL_GALLERY_EXTENSIONS.length) {
+ if (active.size === 0) {
+ params.set('ext', 'none');
+ } else {
+ active.forEach(ext => params.append('ext', ext));
+ }
+ }
+}
+
diff --git a/web/modules/locales.js b/web/modules/locales.js
index b3108ca..e6fd575 100644
--- a/web/modules/locales.js
+++ b/web/modules/locales.js
@@ -629,6 +629,14 @@ export const i18n = {
gallerySearchCount: '找到 {count} 张',
gallerySearchEmpty: '没有找到匹配的图片',
gallerySearching: '正在搜索…',
+ galleryFilterAll: '全部',
+ galleryFilterImages: '图片',
+ galleryFilterVideos: '视频',
+ galleryExtensionToggle: '切换格式过滤',
+ galleryExtensionsTitle: '画廊显示格式',
+ galleryExtensionsHelp: '设置在画廊中显示或隐藏的文件格式(图片、视频)',
+ galleryViewVideo: '查看视频',
+ settingsGallery: '画廊设置',
galleryClose: '关闭图库',
galleryHistoryTitle: '历史生成图库: {name}',
galleryScanning: '加载中,正在扫描图片元数据...',
@@ -2777,6 +2785,14 @@ export const i18n = {
gallerySearchCount: '{count} found',
gallerySearchEmpty: 'No matching images',
gallerySearching: 'Searching…',
+ galleryFilterAll: 'All',
+ galleryFilterImages: 'Images',
+ galleryFilterVideos: 'Videos',
+ galleryExtensionToggle: 'Toggle format filter',
+ galleryExtensionsTitle: 'Gallery File Formats',
+ galleryExtensionsHelp: 'Configure which output file formats (images, videos) to display or hide in Gallery',
+ galleryViewVideo: 'View video',
+ settingsGallery: 'Gallery',
galleryClose: 'Close Gallery',
galleryHistoryTitle: 'Generated History: {name}',
galleryScanning: 'Loading, scanning metadata...',
diff --git a/web/modules/ui_gallery.js b/web/modules/ui_gallery.js
index a815856..07623d8 100644
--- a/web/modules/ui_gallery.js
+++ b/web/modules/ui_gallery.js
@@ -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 { createGalleryFilterBar } from './ui_gallery_filter.js';
+import { appendExtensionParams } from './gallery_filter.js';
const t = (key, params) => translate(key, params);
@@ -29,8 +31,11 @@ export async function refreshGalleryImages() {
-/** Search blocks above the output gallery; the terms live on the browser as `gallerySearchTerms`. */
+/** Search blocks and format filters above the output gallery. */
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 +46,14 @@ export function createGallerySearchBar(owner) {
});
search.element.classList.add('anomalous-gallery-search');
owner.gallerySearchCount = search.countElement;
- return search.element;
+
+ const filterBar = createGalleryFilterBar(owner, () => {
+ owner.loadGalleryImages(1, true, { refresh: false });
+ });
+ owner.galleryFilterBar = filterBar;
+
+ header.append(search.element, filterBar);
+ return header;
}
export async function loadGalleryImages(page = 1, reset = false, { refresh = reset } = {}) {
@@ -60,6 +72,7 @@ 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));
+ appendExtensionParams(params);
const res = await fetch(`/anomalous/gallery_images?${params}`);
const data = await res.json();
if (termsKey !== JSON.stringify(this.gallerySearchTerms || [])) {
@@ -433,7 +446,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 +460,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,24 +477,28 @@ 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.onclick = (e) => {
- if (e.target === viewer) {
- viewer.close();
- resetImgTransform();
+ const v = document.getElementById('anomalous-gallery-viewer-video');
+ if (v) {
+ v.pause();
+ v.style.display = 'none';
+ v.removeAttribute('src');
}
};
+ const closeViewer = () => {
+ viewer.close();
+ resetTransform();
+ };
+
+ closeBtn.onclick = closeViewer;
+ viewer.onclick = (e) => {
+ if (e.target === viewer) closeViewer();
+ };
+
viewer.addEventListener('wheel', (e) => {
e.preventDefault();
const zoomFactor = 0.1;
@@ -506,11 +531,25 @@ export function showGalleryViewer(src) {
}
const img = document.getElementById('anomalous-gallery-viewer-img');
- img.src = src;
+ const video = document.getElementById('anomalous-gallery-viewer-video');
+ const isVid = isVideo || /\.(mp4|webm|mov|avi|mkv)(\?|$)/i.test(src);
- // Reset scale and translation when opening a new image
- img.style.transform = `translate(0px, 0px) scale(1)`;
- img.style.cursor = 'grab';
+ if (isVid && video) {
+ img.style.display = 'none';
+ video.style.display = 'block';
+ video.src = src;
+ video.play().catch(() => {});
+ } else {
+ if (video) {
+ video.pause();
+ video.style.display = 'none';
+ video.removeAttribute('src');
+ }
+ img.style.display = 'block';
+ img.src = src;
+ img.style.transform = `translate(0px, 0px) scale(1)`;
+ img.style.cursor = 'grab';
+ }
if (!viewer.open) viewer.showModal();
}
diff --git a/web/modules/ui_gallery_card.js b/web/modules/ui_gallery_card.js
index 962fc35..8734212 100644
--- a/web/modules/ui_gallery_card.js
+++ b/web/modules/ui_gallery_card.js
@@ -124,10 +124,13 @@ export function createGalleryCard(owner, imgData, { showViewer }) {
const card = document.createElement('div');
card.className = 'anomalous-gallery-card';
+ const isVideo = /\.(mp4|webm|mov|avi|mkv)$/i.test(image.filename);
+ if (isVideo) card.classList.add('is-video');
+
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
: Chrome adds a dragged
as a file, and ComfyUI
// would load that thumbnail (no workflow in it) instead of fetching the original's link.
img.draggable = false;
@@ -143,7 +146,7 @@ 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));
card.addEventListener('contextmenu', (event) => {
if (!owner.gallerySelectModel) openGalleryCardMenu(event, image, { owner, openDetails: () => openWorkbench(owner, image, url) });
});
@@ -157,6 +160,13 @@ export function createGalleryCard(owner, imgData, { showViewer }) {
remove.innerHTML = DELETE_ICON; // static markup only
card.append(img, details, remove);
+ if (isVideo) {
+ const ext = image.filename.split('.').pop().toUpperCase();
+ const badge = document.createElement('span');
+ badge.className = 'anomalous-gallery-video-badge';
+ badge.innerHTML = ` ${ext}`;
+ card.append(badge);
+ }
// Only PNG outputs carry the workflow that can be kept.
if (/\.png$/i.test(image.filename)) card.append(keepButton(owner, image));
return card;
diff --git a/web/modules/ui_gallery_filter.js b/web/modules/ui_gallery_filter.js
new file mode 100644
index 0000000..b2faafa
--- /dev/null
+++ b/web/modules/ui_gallery_filter.js
@@ -0,0 +1,163 @@
+/**
+ * ui_gallery_filter.js
+ * UI components for gallery extension filtering: the quick filter bar
+ * in the Gallery header, and the configuration section in Settings.
+ */
+
+import { translate } from './locales.js';
+import {
+ ALL_GALLERY_EXTENSIONS,
+ GALLERY_IMAGE_EXTENSIONS,
+ GALLERY_VIDEO_EXTENSIONS,
+ getActiveGalleryExtensions,
+ toggleGalleryExtension,
+ setGalleryGroup,
+ isAllGalleryExtensionsActive,
+} from './gallery_filter.js';
+
+const t = (key, params) => translate(key, params);
+
+function el(tag, className, text) {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text !== undefined) node.textContent = text;
+ return node;
+}
+
+/**
+ * Creates the filter bar rendered inside the Gallery header.
+ * Displays quick group presets (All, Images, Videos) and format chips (PNG, WEBP, MP4, etc.).
+ */
+export function createGalleryFilterBar(owner, onFilterChange) {
+ const bar = el('div', 'anomalous-gallery-filter-bar');
+
+ const updateChips = () => {
+ const active = getActiveGalleryExtensions();
+ const allActive = isAllGalleryExtensionsActive();
+
+ bar.querySelectorAll('[data-ext]').forEach(chip => {
+ const ext = chip.getAttribute('data-ext');
+ chip.classList.toggle('is-active', active.has(ext));
+ chip.setAttribute('aria-pressed', String(active.has(ext)));
+ });
+
+ const allChip = bar.querySelector('[data-group="all"]');
+ if (allChip) {
+ allChip.classList.toggle('is-active', allActive);
+ allChip.setAttribute('aria-pressed', String(allActive));
+ }
+
+ const imagesActive = GALLERY_IMAGE_EXTENSIONS.some(e => active.has(e));
+ const imagesAll = GALLERY_IMAGE_EXTENSIONS.every(e => active.has(e));
+ const imgChip = bar.querySelector('[data-group="images"]');
+ if (imgChip) {
+ imgChip.classList.toggle('is-active', imagesAll);
+ imgChip.classList.toggle('is-partial', imagesActive && !imagesAll);
+ }
+
+ const videosActive = GALLERY_VIDEO_EXTENSIONS.some(e => active.has(e));
+ const videosAll = GALLERY_VIDEO_EXTENSIONS.every(e => active.has(e));
+ const vidChip = bar.querySelector('[data-group="videos"]');
+ if (vidChip) {
+ vidChip.classList.toggle('is-active', videosAll);
+ vidChip.classList.toggle('is-partial', videosActive && !videosAll);
+ }
+ };
+
+ const addGroupChip = (groupKey, labelKey) => {
+ const chip = el('button', 'anomalous-gallery-filter-chip is-group', t(labelKey));
+ chip.type = 'button';
+ chip.setAttribute('data-group', groupKey);
+ chip.onclick = (e) => {
+ e.preventDefault();
+ const active = getActiveGalleryExtensions();
+ let targets = [];
+ if (groupKey === 'all') targets = ALL_GALLERY_EXTENSIONS;
+ else if (groupKey === 'images') targets = GALLERY_IMAGE_EXTENSIONS;
+ else if (groupKey === 'videos') targets = GALLERY_VIDEO_EXTENSIONS;
+
+ const allInGroupActive = targets.every(ext => active.has(ext));
+ setGalleryGroup(groupKey, !allInGroupActive);
+ updateChips();
+ onFilterChange?.();
+ };
+ bar.appendChild(chip);
+ };
+
+ // Preset group buttons
+ addGroupChip('all', 'galleryFilterAll');
+ addGroupChip('images', 'galleryFilterImages');
+ addGroupChip('videos', 'galleryFilterVideos');
+
+ // Separator divider
+ bar.appendChild(el('span', 'anomalous-gallery-filter-sep'));
+
+ // Format pills
+ ALL_GALLERY_EXTENSIONS.forEach(ext => {
+ const isVid = GALLERY_VIDEO_EXTENSIONS.includes(ext);
+ const chip = el('button', `anomalous-gallery-filter-chip is-ext ${isVid ? 'is-video' : ''}`, ext.toUpperCase());
+ chip.type = 'button';
+ chip.setAttribute('data-ext', ext);
+ chip.title = `${t('galleryExtensionToggle')}: .${ext}`;
+ chip.onclick = (e) => {
+ e.preventDefault();
+ toggleGalleryExtension(ext);
+ updateChips();
+ onFilterChange?.();
+ };
+ bar.appendChild(chip);
+ });
+
+ updateChips();
+ bar.refresh = updateChips;
+ return bar;
+}
+
+/**
+ * Creates the extension configuration control for the Settings page.
+ */
+export function createGalleryExtensionSettings(owner, redraw) {
+ const box = el('div', 'anomalous-gallery-settings-exts');
+ const active = getActiveGalleryExtensions();
+
+ const chipsWrap = el('div', 'anomalous-gallery-settings-chips');
+ ALL_GALLERY_EXTENSIONS.forEach(ext => {
+ const isVid = GALLERY_VIDEO_EXTENSIONS.includes(ext);
+ const btn = el('button', `anomalous-scan-segment-btn ${active.has(ext) ? 'is-active' : ''}`, `.${ext}`);
+ btn.type = 'button';
+ btn.onclick = () => {
+ toggleGalleryExtension(ext);
+ redraw();
+ };
+ chipsWrap.appendChild(btn);
+ });
+
+ const actions = el('div', 'anomalous-gallery-settings-actions');
+ const selectAllBtn = el('button', 'anomalous-scan-secondary anomalous-scan-small-btn', t('galleryFilterAll'));
+ selectAllBtn.type = 'button';
+ selectAllBtn.onclick = () => {
+ setGalleryGroup('all', true);
+ redraw();
+ };
+
+ const imagesOnlyBtn = el('button', 'anomalous-scan-secondary anomalous-scan-small-btn', t('galleryFilterImages'));
+ imagesOnlyBtn.type = 'button';
+ imagesOnlyBtn.onclick = () => {
+ setGalleryGroup('images', true);
+ setGalleryGroup('videos', false);
+ redraw();
+ };
+
+ const videosOnlyBtn = el('button', 'anomalous-scan-secondary anomalous-scan-small-btn', t('galleryFilterVideos'));
+ videosOnlyBtn.type = 'button';
+ videosOnlyBtn.onclick = () => {
+ setGalleryGroup('videos', true);
+ setGalleryGroup('images', false);
+ redraw();
+ };
+
+ actions.append(selectAllBtn, imagesOnlyBtn, videosOnlyBtn);
+ box.append(chipsWrap, actions);
+ return box;
+}
+
diff --git a/web/modules/ui_settings_page.js b/web/modules/ui_settings_page.js
index 51d148d..34e4df0 100644
--- a/web/modules/ui_settings_page.js
+++ b/web/modules/ui_settings_page.js
@@ -21,6 +21,7 @@ import { fetchDownloadSettings, hfMirrorOn, saveDownloadSettings } from './model
import { saveApiKey } from './ui_scan_page.js';
import { exportBackup, importBackup } from './ui_backup.js';
import { followsSelection, setFollowsSelection } from './shell_open_rules.js';
+import { createGalleryExtensionSettings } from './ui_gallery_filter.js';
const t = (key, params) => translate(key, params);
// The full written guide: the README, at its Chinese half for Chinese.
@@ -182,6 +183,12 @@ function cardsGroup(owner, redraw) {
);
}
+function galleryGroup(owner, redraw) {
+ return group('settingsGallery',
+ row('galleryExtensionsTitle', 'galleryExtensionsHelp', createGalleryExtensionSettings(owner, redraw)),
+ );
+}
+
function foldersGroup(owner) {
return group('settingsFolders',
row('sidebarManageFolders', 'settingsFoldersHelp',
@@ -361,6 +368,7 @@ function render(owner) {
el('h1', 'anomalous-scan-title', t('sidebarSettings')),
appearanceGroup(owner, redraw),
cardsGroup(owner, redraw),
+ galleryGroup(owner, redraw),
foldersGroup(owner),
workflowGroup(owner),
downloadGroup(owner, redraw),
diff --git a/web/styles/03-gallery-recipes.css b/web/styles/03-gallery-recipes.css
index 6d7cc15..d4734b8 100644
--- a/web/styles/03-gallery-recipes.css
+++ b/web/styles/03-gallery-recipes.css
@@ -7,6 +7,104 @@
background: var(--bg-color, var(--amb-bg-card));
}
+.anomalous-gallery-header {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ background: var(--bg-color, var(--amb-bg-card));
+ border-bottom: 1px solid var(--amb-border-subtle);
+ padding-bottom: 10px;
+}
+
+.anomalous-gallery-filter-bar {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 0 16px;
+}
+
+.anomalous-gallery-filter-sep {
+ width: 1px;
+ height: 14px;
+ background: var(--amb-border-subtle);
+ margin: 0 4px;
+}
+
+.anomalous-gallery-filter-chip {
+ background: var(--amb-bg-page);
+ color: var(--amb-text-muted);
+ border: 1px solid var(--amb-border-subtle);
+ border-radius: 9999px;
+ font-size: 11px;
+ font-weight: 600;
+ padding: 3px 10px;
+ cursor: pointer;
+ line-height: 1.2;
+ transition: all 0.15s ease;
+}
+
+.anomalous-gallery-filter-chip:hover {
+ color: var(--amb-text-main);
+ border-color: var(--amb-border-strong);
+}
+
+.anomalous-gallery-filter-chip.is-active {
+ background: var(--amb-accent-soft, rgba(64, 128, 255, 0.15));
+ border-color: var(--amb-accent, #3b82f6);
+ color: var(--amb-text-main);
+}
+
+.anomalous-gallery-filter-chip.is-video.is-active {
+ border-color: #8b5cf6;
+ background: rgba(139, 92, 246, 0.18);
+}
+
+.anomalous-gallery-filter-chip.is-group.is-partial {
+ border-color: var(--amb-border-strong);
+ color: var(--amb-text-soft);
+ background: var(--amb-bg-card-hover);
+}
+
+.anomalous-gallery-video-badge {
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ background: rgba(0, 0, 0, 0.75);
+ color: #fff;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.5px;
+ padding: 2px 6px;
+ border-radius: 4px;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ pointer-events: none;
+ backdrop-filter: blur(4px);
+ z-index: 2;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+}
+
+.anomalous-gallery-settings-exts {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ width: 100%;
+}
+
+.anomalous-gallery-settings-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.anomalous-gallery-settings-actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 4px;
+}
+
.anomalous-gallery-grid {
flex: 1;
padding: 20px;
@@ -337,7 +435,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;