Merge PR #21: videos in the gallery and a file-format filter

From cocorang and wkdtjs. The PR's own "v1.58.2" CHANGELOG section is left out (1.58.2 shipped
other changes); the 1.59 entry and the follow-up fixes come in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-13 14:20:07 +08:00
co-authored by Claude Opus 5.5
10 changed files with 496 additions and 33 deletions
+1 -1
View File
@@ -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`
+46 -8
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
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'})
+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:
+89
View File
@@ -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));
}
}
}
+16
View File
@@ -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...',
+58 -19
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 { 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();
}
+12 -2
View File
@@ -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 <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,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 = `<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> ${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;
+163
View File
@@ -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;
}
+8
View File
@@ -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),
+100 -1
View File
@@ -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;