From 0dc0cc77a4e0d31e8b2e183a04dca1d1d99ddcb3 Mon Sep 17 00:00:00 2001 From: Mehdi Chaid Date: Wed, 21 Oct 2026 17:52:48 -0400 Subject: [PATCH] feat: Flake Animate Stack previews the first active input image (#442) --- README.md | 3 +- web/utils.js | 93 +++++++++++++++++++++++++++++++++++ web/widgets/flake-generate.js | 81 +++--------------------------- web/widgets/flake-stack.js | 31 +++++++++++- 4 files changed, 130 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 0394c11..d0b338f 100644 --- a/README.md +++ b/README.md @@ -290,7 +290,8 @@ Image-to-video with its own presets and nodes: **Flake Animate Preset** / **Preset Combo** load an animate preset into a `flake_anim_data` pin, **Flake Animate Stack** / **Combo** add flakes, and **Flake Generate Animation** samples it and saves an MP4 to `output/anim//`. Start images belong to the grid -items (bottom-left `+` / count button); each image queues its own video. +items (bottom-left `+` / count button); each image queues its own video. Flake +Animate Stack previews the first active item's first image under its grid. | Family | Folder | Models | Text encoder type | VAEs | |---|---|---|---|---| diff --git a/web/utils.js b/web/utils.js index 07d835b..b5b104e 100644 --- a/web/utils.js +++ b/web/utils.js @@ -358,6 +358,99 @@ export function makeAccentButton(label, { icon = "" } = {}) { return { btn, lbl }; } +// Image (or video) with its filename (full path in the tooltip) and `w × h` +// under it: Flake Generate's output (#329) and Flake Animate Stack's first +// input image (#442). `onReveal` handles a double-click on the media. +export function makeImageSection({ video = false, emptyText, onReveal = null }) { + const el = document.createElement("div"); + css(el, "display:flex;flex-direction:column;align-items:center;gap:4px;margin-top:4px;"); + + const imageEl = document.createElement("img"); + css(imageEl, "max-width:100%;border-radius:4px;display:none;"); + el.appendChild(imageEl); + + const videoEl = document.createElement("video"); + // No native controls: their double-click goes fullscreen instead of revealing (#439). + Object.assign(videoEl, { loop: true, muted: true, autoplay: true, title: "Click to play/pause, double-click to reveal in file explorer" }); + css(videoEl, "max-width:100%;border-radius:4px;display:none;cursor:pointer;"); + videoEl.addEventListener("click", () => (videoEl.paused ? videoEl.play() : videoEl.pause())); + el.appendChild(videoEl); + + if (onReveal) { + imageEl.title = "Double-click to reveal in file explorer"; + imageEl.style.cursor = "pointer"; + imageEl.addEventListener("dblclick", onReveal); + videoEl.addEventListener("dblclick", onReveal); + } + + const noImageLabel = document.createElement("div"); + css(noImageLabel, "font-size:11px;opacity:0.4;text-align:center;padding:8px 0;"); + noImageLabel.textContent = emptyText; + el.appendChild(noImageLabel); + + // Read-only filename label (filename only, full path in tooltip) + dimensions + const filenameLabel = document.createElement("div"); + css(filenameLabel, "width:100%;box-sizing:border-box;color:#ccc;padding:0 4px;font-size:12px;font-family:inherit;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:none;cursor:default;"); + el.appendChild(filenameLabel); + + const dimensionsLabel = document.createElement("div"); + css(dimensionsLabel, "width:100%;box-sizing:border-box;color:#888;padding:0 4px;font-size:11px;font-family:inherit;text-align:center;display:none;"); + el.appendChild(dimensionsLabel); + + function showDimensions(w, h) { + dimensionsLabel.textContent = `${w} × ${h}`; + dimensionsLabel.style.display = "block"; + } + videoEl.addEventListener("loadedmetadata", () => showDimensions(videoEl.videoWidth, videoEl.videoHeight)); + + // `name` (with `path` as its tooltip) hides the filename label when empty. + function show(url, name, path) { + noImageLabel.style.display = "none"; + imageEl.style.display = video ? "none" : "block"; + videoEl.style.display = video ? "block" : "none"; + if (video) { + videoEl.src = url; + } else { + imageEl.src = url; + } + if (name) { + filenameLabel.textContent = name; + filenameLabel.title = path; + filenameLabel.style.display = "block"; + } else { + filenameLabel.style.display = "none"; + } + if (video) return; + imageEl.onload = () => { + if (imageEl.naturalWidth && imageEl.naturalHeight) showDimensions(imageEl.naturalWidth, imageEl.naturalHeight); + }; + if (imageEl.complete && imageEl.naturalWidth) showDimensions(imageEl.naturalWidth, imageEl.naturalHeight); + } + + function clear() { + imageEl.style.display = "none"; + videoEl.style.display = "none"; + videoEl.pause(); + noImageLabel.style.display = "block"; + filenameLabel.style.display = "none"; + dimensionsLabel.style.display = "none"; + } + + // A transient image (e.g. a live sampling preview, #374): no labels. + function showPreview(url) { + imageEl.onload = null; + imageEl.src = url; + imageEl.style.display = "block"; + videoEl.style.display = "none"; + videoEl.pause(); + noImageLabel.style.display = "none"; + filenameLabel.style.display = "none"; + dimensionsLabel.style.display = "none"; + } + + return { el, imageEl, show, clear, showPreview }; +} + export function makeIconBtn(text, title, onClick) { const b = document.createElement("button"); b.textContent = text; diff --git a/web/widgets/flake-generate.js b/web/widgets/flake-generate.js index 6d404ea..713329b 100644 --- a/web/widgets/flake-generate.js +++ b/web/widgets/flake-generate.js @@ -1,4 +1,4 @@ -import { css, setWidgetHidden } from "../utils.js"; +import { css, setWidgetHidden, makeImageSection } from "../utils.js"; import { computeJobCount, getBatchImages, getBatchProgress, getLivePreview } from "../queue.js"; import { buildModel, openGenerationDataOverlay } from "./generation-data.js"; @@ -122,21 +122,6 @@ export function setupFlakeGenerateWidget(node, { video = false } = {}) { container.appendChild(topRow); // ── Image Display ── - const imageContainer = document.createElement("div"); - css(imageContainer, "display:flex;flex-direction:column;align-items:center;gap:4px;margin-top:4px;"); - - const imageEl = document.createElement("img"); - css(imageEl, "max-width:100%;border-radius:4px;display:none;cursor:pointer;"); - imageEl.title = "Double-click to reveal in file explorer"; - imageContainer.appendChild(imageEl); - - const videoEl = document.createElement("video"); - // No native controls: their double-click goes fullscreen instead of revealing (#439). - Object.assign(videoEl, { loop: true, muted: true, autoplay: true, title: "Click to play/pause, double-click to reveal in file explorer" }); - css(videoEl, "max-width:100%;border-radius:4px;display:none;cursor:pointer;"); - videoEl.addEventListener("click", () => (videoEl.paused ? videoEl.play() : videoEl.pause())); - imageContainer.appendChild(videoEl); - // Double-click the output image to reveal it in the OS file explorer (#329). let currentImage = null; let previewUrl = null; // live sampling preview shown instead of currentImage (#374) @@ -152,22 +137,7 @@ export function setupFlakeGenerateWidget(node, { video = false } = {}) { }), }).catch((e) => console.error("[flakes] reveal failed", e)); }; - imageEl.addEventListener("dblclick", reveal); - videoEl.addEventListener("dblclick", reveal); - - const noImageLabel = document.createElement("div"); - css(noImageLabel, "font-size:11px;opacity:0.4;text-align:center;padding:8px 0;"); - noImageLabel.textContent = video ? "No video generated yet" : "No image generated yet"; - imageContainer.appendChild(noImageLabel); - - // Read-only filename label (filename only, full path in tooltip) + dimensions - const filenameLabel = document.createElement("div"); - css(filenameLabel, "width:100%;box-sizing:border-box;color:#ccc;padding:0 4px;font-size:12px;font-family:inherit;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:none;cursor:default;"); - imageContainer.appendChild(filenameLabel); - - const dimensionsLabel = document.createElement("div"); - css(dimensionsLabel, "width:100%;box-sizing:border-box;color:#888;padding:0 4px;font-size:11px;font-family:inherit;text-align:center;display:none;"); - imageContainer.appendChild(dimensionsLabel); + const section = makeImageSection({ video, emptyText: video ? "No video generated yet" : "No image generated yet", onReveal: reveal }); function outputRelPath(img) { if (!img || (img.type && img.type !== "output")) return ""; @@ -175,48 +145,16 @@ export function setupFlakeGenerateWidget(node, { video = false } = {}) { return sub ? `${sub}/${img.filename}` : img.filename; } - function showDimensions(w, h) { - dimensionsLabel.textContent = `${w} × ${h}`; - dimensionsLabel.style.display = "block"; - } - videoEl.addEventListener("loadedmetadata", () => showDimensions(videoEl.videoWidth, videoEl.videoHeight)); - function showImage(img) { currentImage = img; previewUrl = null; - const url = `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || "output"}&subfolder=${encodeURIComponent(img.subfolder || "")}`; - noImageLabel.style.display = "none"; - imageEl.style.display = video ? "none" : "block"; - videoEl.style.display = video ? "block" : "none"; - if (video) { - videoEl.src = url; - } else { - imageEl.src = url; - } const rel = outputRelPath(img); - if (rel) { - const fileName = img.filename; - filenameLabel.textContent = fileName; - filenameLabel.title = rel; - filenameLabel.style.display = "block"; - } else { - filenameLabel.style.display = "none"; - } - if (video) return; - imageEl.onload = () => { - if (imageEl.naturalWidth && imageEl.naturalHeight) showDimensions(imageEl.naturalWidth, imageEl.naturalHeight); - }; - if (imageEl.complete && imageEl.naturalWidth) showDimensions(imageEl.naturalWidth, imageEl.naturalHeight); + section.show(`/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || "output"}&subfolder=${encodeURIComponent(img.subfolder || "")}`, rel && img.filename, rel); } function clearImage() { previewUrl = null; - imageEl.style.display = "none"; - videoEl.style.display = "none"; - videoEl.pause(); - noImageLabel.style.display = "block"; - filenameLabel.style.display = "none"; - dimensionsLabel.style.display = "none"; + section.clear(); } // Live sampling preview (#374), shown until the result replaces it. @@ -224,17 +162,10 @@ export function setupFlakeGenerateWidget(node, { video = false } = {}) { if (url === previewUrl) return; previewUrl = url; currentImage = null; - imageEl.onload = null; - imageEl.src = url; - imageEl.style.display = "block"; - videoEl.style.display = "none"; - videoEl.pause(); - noImageLabel.style.display = "none"; - filenameLabel.style.display = "none"; - dimensionsLabel.style.display = "none"; + section.showPreview(url); } - container.appendChild(imageContainer); + container.appendChild(section.el); // Per-combo results: the live batch state when this workflow ran a batch, // else what the saved workflow carried (#373). diff --git a/web/widgets/flake-stack.js b/web/widgets/flake-stack.js index 6395a5b..b3ad168 100644 --- a/web/widgets/flake-stack.js +++ b/web/widgets/flake-stack.js @@ -2,11 +2,12 @@ import { css, ensureDefault, makeSmallButton, makeAccentButton, svgIcon, makeGridItemOverlay, makeHoverButton, makeTypeRibbon, makeBypassStrike, makeFavoriteStar, TYPE_COLORS, _showDropIndicator, _hideDropIndicator, _hideAllDropIndicators, makeAddBlock, flakeDrag, clearFlakeDrag, makePanelDropdown, makeSmallValueSlider, renderVariantLoraSliders, variantInitials, renderFlakeLabel, splitNameAndTags, - _registerOpenPanel, _unregisterOpenPanel, setWidgetHidden, effectiveVariant, variantDropdownOptions, variantDefaultsOf, choiceFlakeLinks, + _registerOpenPanel, _unregisterOpenPanel, setWidgetHidden, effectiveVariant, variantDropdownOptions, variantDefaultsOf, choiceFlakeLinks, makeImageSection, } from "../utils.js"; import { fetchList, fetchFlake, getCoverUrl, getVariantImageUrl, fetchFlakeMeta, cachedVariantDefaults, invalidateList, importAnimFlakeApi, pickedImageUrl } from "../api.js"; import { openEditModal } from "../flake-modal.js"; import { openFileLoadPicker, makeItemImagesButton } from "../pickers.js"; +import { stackImageJobs } from "../queue.js"; // Append the names of any tag-name LoRAs (#274) to a flake's grid display name, // e.g. "Askeladd" + a tagged "Some Author Name" -> "Askeladd — Some Author Name". @@ -711,6 +712,7 @@ export function setupFlakeWidget(node, { kind = "img" } = {}) { } if (grid._addBlock) grid.appendChild(grid._addBlock); refreshToggleAll(); + refreshPreview(); } // Cross-node drag & drop with other Flake Stack / Flake Combo grids (#415). @@ -989,6 +991,31 @@ export function setupFlakeWidget(node, { kind = "img" } = {}) { }; } + // Flake Animate Stack: the first job's input image under the grid (#442). + const preview = kind === "anim" ? makeImageSection({ emptyText: "No input image" }) : null; + let previewPath; + if (preview) { + container.appendChild(preview.el); + preview.imageEl.addEventListener("load", () => { + node.setSize([node.size[0], Math.max(node.size[1], node.computeSize()[1])]); + node.setDirtyCanvas(true, true); + }); + } + function refreshPreview() { + const path = stackImageJobs(node)[0]?.image ?? null; + if (!preview || path === previewPath) return; + previewPath = path; + if (path) preview.show(pickedImageUrl(path), path.split(/[\\/]/).pop(), path); + else preview.clear(); + } + // Image scaled to the widget width (6px padding + 4px margin a side), plus + // the filename and dimension labels; the empty label otherwise. + function previewHeight() { + if (!preview) return 0; + const { naturalWidth: w, naturalHeight: h } = preview.imageEl; + return previewPath && w ? Math.min(w, node.size[0] - 20) * h / w + 46 : 36; + } + // ---- Widget registration ---- node._flakes_render = render; const flakeWidget = node.addDOMWidget("flakes_ui", "div", container, { serialize: false, margin: 4 }); @@ -997,7 +1024,7 @@ export function setupFlakeWidget(node, { kind = "img" } = {}) { // wide node doesn't over-count rows and pad itself with empty space (#317). const cols = Math.max(1, Math.floor((node.size[0] - 16) / (itemW + 4))); const rows = Math.max(1, Math.ceil((readEntries().length + 1) / cols)); - return [node.size[0], rows * (itemH + 4) + 55]; // +24 for the toggle-all button (#315) + return [node.size[0], rows * (itemH + 4) + 55 + previewHeight()]; // +24 for the toggle-all button (#315) }; writeEntries(readEntries()); render();