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:
+4
-2
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(','));
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user