Merge feature/next: gallery videos play in their card, fill the viewer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-13 15:14:00 +08:00
co-authored by Claude Opus 5.5
7 changed files with 78 additions and 8 deletions
+1 -1
View File
@@ -119,7 +119,7 @@
- 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) like pictures, and a video dragged onto the canvas becomes a Load Video node; 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).
- **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 plays it muted while the pointer is on it (or always, as Settings → Video covers says for model covers); clicking it plays the video with sound in the viewer, filling the window, and a double-click goes full screen. Closing the viewer (×, a click beside it or Esc) stops it. Videos are deleted (to the Recycle Bin) like pictures, and a video dragged onto the canvas becomes a Load Video node; 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 / Images / 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 (测试公开)
+2 -2
View File
@@ -1178,7 +1178,7 @@ export const i18n = {
settingsThemeAbyssal: "深渊绯红",
settingsCards: "模型卡片与内存",
settingsCardQualityHelp: "缩略图是 512 像素的小图,存在 ComfyUI 的用户文件夹里,重启后不用重做;打开模型详情仍是原图。原图在卡片上更清楚,但一屏几十张大图会明显多占内存和显存。",
settingsVideoHelp: "视频封面平时显示第一帧,鼠标停上才播放。一直播放会让屏幕上的视频同时解码,更占显存和电量。",
settingsVideoHelp: "模型的视频封面和图库里的视频平时显示第一帧,鼠标停上才(静音)播放。一直播放会让屏幕上的视频同时解码,更占显存和电量。",
settingsCacheTitle: "缩略图缓存",
settingsCacheLoading: "正在统计…",
settingsCacheHelp: "已缓存 {count} 张,占 {size}(最多 {limit},满了自动清掉最久没用的)。清空后看到时会重新生成,不影响模型和封面。",
@@ -3550,7 +3550,7 @@ export const i18n = {
settingsThemeAbyssal: "Abyssal Scarlet",
settingsCards: "Model cards and memory",
settingsCardQualityHelp: "Thumbnails are 512 px copies kept in ComfyUI's user folder, so a restart does not redo them; a model's detail still shows the original. Originals look sharper on cards but dozens of large pictures on screen take clearly more memory and video memory.",
settingsVideoHelp: "A video cover shows its first frame and plays when the pointer is on it. Always playing decodes every video on screen at once, which takes more video memory and power.",
settingsVideoHelp: "Video covers and the gallery's videos show their first frame and play (muted) when the pointer is on them. Always playing decodes every video on screen at once, which takes more video memory and power.",
settingsCacheTitle: "Thumbnail cache",
settingsCacheLoading: "Counting…",
settingsCacheHelp: "{count} cached, {size} (at most {limit}; the least recently used go when it is full). Emptied, they are made again when shown; models and covers are not touched.",
+10
View File
@@ -465,6 +465,16 @@ export function showGalleryViewer(src, isVideo = false) {
video.autoplay = true;
video.loop = true;
video.style.display = 'none';
// Double-click: full screen and back. Some Chrome builds already do this for a video
// with controls; the check afterwards keeps it from toggling twice.
video.addEventListener('dblclick', () => {
const before = Boolean(document.fullscreenElement);
setTimeout(() => {
if (Boolean(document.fullscreenElement) !== before) return;
if (before) document.exitFullscreen?.().catch(() => {});
else video.requestFullscreen?.().catch(() => {});
}, 200);
});
viewer.appendChild(img);
viewer.appendChild(video);
+39 -1
View File
@@ -4,7 +4,8 @@
* 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). A video's
* card shows its first frame with a ▶ badge and plays in the viewer; it has no Parameters,
* card shows its first frame with a ▶ badge, plays muted in place (previewInPlace) and with
* sound in the viewer; it has no Parameters,
* star or menu (those read a picture).
*/
@@ -102,6 +103,42 @@ async function deleteImage(owner, image, card) {
}
}
/**
* A video card plays its video muted where it is: while the pointer is on it, or while it is in
* view when Settings → Video covers says always play (as model covers do). The <video> exists
* only while it plays, so a page of videos decodes nothing at rest.
*/
function previewInPlace(owner, card, url) {
let video = null;
const start = () => {
if (video) return;
video = Object.assign(document.createElement('video'), {
className: 'anomalous-gallery-card-video', src: url, muted: true, loop: true, playsInline: true,
});
card.querySelector('img').after(video);
video.play().catch(() => {});
};
const stop = () => {
if (!video) return;
video.pause();
video.removeAttribute('src');
video.load(); // lets the decoder go
video.remove();
video = null;
};
if (owner.energySaving) {
card.addEventListener('mouseenter', start);
card.addEventListener('mouseleave', stop);
return;
}
// Leaving the gallery hides the cards, and hidden or removed cards stop too.
owner.galleryVideoObserver ??= new IntersectionObserver((entries) => {
for (const entry of entries) entry.target.anomalousPreview?.[entry.isIntersecting ? 'start' : 'stop']();
}, { rootMargin: '100px' });
card.anomalousPreview = { start, stop };
owner.galleryVideoObserver.observe(card);
}
/** The star: ☆, or ★ once the image is kept as anything. */
function keepButton(owner, image) {
const star = button('anomalous-gallery-keep', t('galleryKeep'), () => openKeepMenu(owner, star, image, {
@@ -162,6 +199,7 @@ export function createGalleryCard(owner, imgData, { showViewer }) {
badge.innerHTML = PLAY_ICON; // static markup only
badge.append(image.filename.split('.').pop().toUpperCase());
card.append(img, remove, badge);
previewInPlace(owner, card, url);
return card;
}
card.addEventListener('contextmenu', (event) => {
+5 -1
View File
@@ -168,7 +168,11 @@ function appearanceGroup(owner, redraw) {
}
function cardsGroup(owner, redraw) {
const reload = () => { owner.loadModels(); redraw(); };
const reload = () => {
owner.galleryLoaded = false; // the gallery's video cards follow Video covers too
owner.loadModels();
redraw();
};
return group('settingsCards',
row('sidebarCardQuality', 'settingsCardQualityHelp', segment(
[['balanced', 'sidebarOptimizedThumbnail'], ['original', 'sidebarOriginalCover']], owner.cardThumbnailMode, value => {
+1 -1
View File
@@ -1,7 +1,7 @@
@import url("./styles/00-foundation-models.css?v=20261007-theme-2");
@import url("./styles/01-shell-tools.css?v=20261007-theme-2");
@import url("./styles/02-notebooks.css?v=20261012-click-2");
@import url("./styles/03-gallery-recipes.css?v=20261013-video-1");
@import url("./styles/03-gallery-recipes.css?v=20261013-video-2");
@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");
+20 -2
View File
@@ -173,6 +173,17 @@
background: var(--amb-bg-page);
}
/* A video card's muted preview, over its first frame; clicks and drags go to the card. */
.anomalous-gallery-card-video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
background: var(--amb-bg-page);
pointer-events: none;
}
.anomalous-gallery-delete {
position: absolute;
top: 8px;
@@ -449,8 +460,7 @@ dialog.anomalous-gallery-viewer[open] {
display: flex;
}
.anomalous-gallery-viewer img,
.anomalous-gallery-viewer video {
.anomalous-gallery-viewer img {
max-width: 95%;
max-height: 95%;
object-fit: contain;
@@ -458,6 +468,14 @@ dialog.anomalous-gallery-viewer[open] {
box-shadow: 0 0 30px rgba(0, 0, 0, 0.8);
}
/* A video fills the window (keeping its shape), however small it was made. */
.anomalous-gallery-viewer video {
width: 95%;
height: 95%;
object-fit: contain;
background: #000;
}
.anomalous-gallery-viewer-close {
position: absolute;
top: 20px;