feat: Flake Animate Stack previews the first active input image (#442)

This commit is contained in:
Mehdi Chaid
2026-10-21 17:53:05 -04:00
parent 959862c1d0
commit 0dc0cc77a4
4 changed files with 130 additions and 78 deletions
+2 -1
View File
@@ -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/<family>/`. 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 |
|---|---|---|---|---|
+93
View File
@@ -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;
+6 -75
View File
@@ -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).
+29 -2
View File
@@ -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();