feat: Flake Animate Stack previews the first active input image (#442)
This commit is contained in:
@@ -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 |
|
||||
|---|---|---|---|---|
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user