diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e9e643f..db46be8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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` diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ea4a66..b2d0b0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/api/gallery_routes.py b/api/gallery_routes.py index 1fd1298..f813f3d 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 +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'}) 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/docs/architecture/frontend.md b/docs/architecture/frontend.md index a36b2cb..a050396 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -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). diff --git a/tests/test_listing_indices.py b/tests/test_listing_indices.py index e6da570..7e1cbab 100644 --- a/tests/test_listing_indices.py +++ b/tests/test_listing_indices.py @@ -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() diff --git a/web/modules/gallery_filter.js b/web/modules/gallery_filter.js new file mode 100644 index 0000000..a171c04 --- /dev/null +++ b/web/modules/gallery_filter.js @@ -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(',')); +} diff --git a/web/modules/locales.js b/web/modules/locales.js index b3ca663..e0d7882 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -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...', diff --git a/web/modules/ui_gallery.js b/web/modules/ui_gallery.js index a815856..9edf289 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 { 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(); } diff --git a/web/modules/ui_gallery_card.js b/web/modules/ui_gallery_card.js index 962fc35..cce8fef 100644 --- a/web/modules/ui_gallery_card.js +++ b/web/modules/ui_gallery_card.js @@ -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 = ``; +const PLAY_ICON = ''; const DELETE_ICON = ''; 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 : 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,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. diff --git a/web/modules/ui_gallery_filter.js b/web/modules/ui_gallery_filter.js new file mode 100644 index 0000000..d7843bf --- /dev/null +++ b/web/modules/ui_gallery_filter.js @@ -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; +} diff --git a/web/modules/ui_settings_page.js b/web/modules/ui_settings_page.js index b99704a..55809ac 100644 --- a/web/modules/ui_settings_page.js +++ b/web/modules/ui_settings_page.js @@ -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()]], diff --git a/web/styles.css b/web/styles.css index 4b5198d..523ab3a 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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"); diff --git a/web/styles/03-gallery-recipes.css b/web/styles/03-gallery-recipes.css index 6d7cc15..69647e6 100644 --- a/web/styles/03-gallery-recipes.css +++ b/web/styles/03-gallery-recipes.css @@ -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;