Files

2051 lines
124 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { openOverlay } from "./modal.js";
import {
css, makeButton, makeSmallButton, makeComfyLabel, makeComfyInput,
makeComfyDropdown, makePanelDropdown, makeSearchableDropdown,
makeComfyNumberInput, makeComfyValueSlider, makeSmallValueSlider,
makeNumberInput, addImageZoom,
familyFolder, familyFromFolder, familyCnModels, ANIM_DIR,
} from "./utils.js";
import {
getCoverUrl, getVariantImageUrl, flakeAssetUrl, pickedImageUrl, fetchCnTypes, fetchInputs,
saveFlakeApi, deleteFlakeApi, fetchFlakeMeta,
fetchLoraSiblingImage, loraSiblingImageUrl, fetchLoraSiblingImagePath,
fetchLoraVariantSiblingImagePath, invalidateList,
fetchCnModels, fetchAnimFamilies,
} from "./api.js";
import { pickImage, makeImageDropTarget } from "./pickers.js";
import {
openFieldMenu, makeScheduleHandle, makeSchedulePanel, pickFlakeTarget,
renderLoraList, serializeLoras, normalizeLinks, renderLinkList, renderOneLinkOverrides, serializeLinks,
renderOptionList, serializeOptions, makeTogglerInput, renderPromptList, serializePrompts,
} from "./list-editors.js";
// Editor list of a variant choice's prompts (#468), kept on the choice as
// `_prompts` while editing. The yaml holds one unscheduled prompt as flat
// `positive` / `negative` keys and several as a `prompts:` list. An empty
// negative counts as none, so it stays behind "+ Negative" (#469).
function choicePrompts(co) {
if (!co._prompts) {
const src = Array.isArray(co.prompts) ? co.prompts : [{ positive: co.positive, negative: co.negative }];
co._prompts = src
.filter(p => p && (p.positive != null || p.negative != null))
.map(p => ({ ...JSON.parse(JSON.stringify(p)), positive: p.positive ?? null, negative: p.negative || null }));
}
return co._prompts;
}
function storeChoicePrompts(co) {
const prompts = serializePrompts(choicePrompts(co).map(p => ({ ...p, negative: p.negative || null })))
.filter(p => Object.keys(p).length);
delete co._prompts;
if (prompts.length === 1 && !prompts[0].schedule) {
for (const key of ["positive", "negative"]) {
if (key in prompts[0]) co[key] = prompts[0][key];
else delete co[key];
}
delete co.prompts;
return;
}
delete co.positive;
delete co.negative;
if (prompts.length) co.prompts = prompts;
else delete co.prompts;
}
// Infer a ControlNet type + sibling cover name from a CN image filename (#306, #318).
// Old naming: "char_depthanythingv2_001.png" -> sibling "char_cover_001.png".
// JIP naming: "pose_classic_3_depthanythingv2_001.png" -> sibling
// "pose_classic_0_cover_001.png" (the "_3_<preproc>_" run becomes "_0_cover_").
// `inferredType` is matched against the available CN `types` so an unknown token
// is ignored.
const slugStem = (name) => name.trim().toLowerCase().replace(/\s+/g, "_");
function inferCnFromImage(filename, types = []) {
const ext = filename.match(/\.[^.]+$/)?.[0] || "";
const base = filename.slice(0, filename.length - ext.length);
const lower = base.toLowerCase();
// [filename token substring, canonical type fragment]
const ALIASES = [
["depthanything", "depth"], ["midas", "depth"], ["zoe", "depth"], ["leres", "depth"], ["depth", "depth"],
["dwpose", "openpose"], ["densepose", "openpose"], ["openpose", "openpose"],
["canny", "canny"],
["lineartanime", "lineart"], ["lineart", "lineart"],
["softedge", "softedge"], ["pidinet", "softedge"], ["hed", "softedge"],
["scribble", "scribble"], ["mlsd", "mlsd"],
["normalbae", "normal"], ["normal", "normal"],
["segmentation", "seg"], ["ade20k", "seg"], ["seg", "seg"], ["tile", "tile"],
];
let token = "", frag = "";
for (const [tk, fr] of ALIASES) {
if (lower.includes(tk)) { token = tk; frag = fr; break; }
}
const lowerTypes = types.map((t) => t.toLowerCase());
let inferredType = "";
if (frag) {
const idx = lowerTypes.findIndex((t) => t.includes(frag));
if (idx >= 0) inferredType = types[idx];
}
if (!inferredType) {
const idx = lowerTypes.findIndex((t) => lower.includes(t));
if (idx >= 0) inferredType = types[idx];
}
let sibling = "";
const siblings = [];
if (token) {
const segs = base.split("_");
const ti = segs.findIndex((s) => s.toLowerCase().includes(token));
if (ti >= 0) {
// Candidate 1 (#353): swap only the preproc token for "cover", keeping
// the numeric order marker — "001_Test_3_depthanythingv2" ->
// "001_Test_3_cover". This also covers the legacy non-numeric naming
// ("char_depthanythingv2_001" -> "char_cover_001").
const keepNum = [...segs];
keepNum[ti] = "cover";
siblings.push(keepNum.join("_") + ext);
// Candidate 2 (#318): JIP Save writes the cover as order "0", so when
// the token is preceded by a numeric marker also try "_0_cover".
if (ti > 0 && /^\d+$/.test(segs[ti - 1])) {
const zero = [...segs];
zero[ti - 1] = "0";
zero[ti] = "cover";
siblings.push(zero.join("_") + ext);
}
sibling = siblings[0];
}
}
return { inferredType, sibling, siblings };
}
// `kind` "anim" edits an animate flake (#431): `family` is an animate family
// label (an edited flake's comes from its video/<folder>/ name), the flake has
// no ControlNets and Wan 2.2 LoRAs take a LOW file too.
export async function openEditModal({ mode, name, data, dirs, family = "SDXL/Base", kind = "img" }) {
const animFamily = kind === "anim"
? (await fetchAnimFamilies()).families.find(f => mode === "edit" && name ? f.folder === name.split("/")[1] : f.label === family)
: null;
const lowFile = animFamily?.folder === "wan22";
return new Promise((resolve) => {
let { content, footer, close, handlers, panel } = openOverlay();
handlers.onClose = (v) => resolve(v ?? null);
// Preview URL of a picked or saved image value: `./` (owned by the
// flake), absolute (picked outside input/), else input-relative.
const imageUrl = (v) => v.startsWith("./") ? flakeAssetUrl(name, v) : pickedImageUrl(v);
const pickFlakeImage = () => pickImage(mode !== "create" ? name : "");
// Cover candidates for a picked ControlNet image: its batch's `_0_cover`
// (`001_name_3_depth.png` → `001_name_0_cover.png`, #397), then the
// older sibling names from inferCnFromImage (#353).
function cnCoverCandidates(value, siblings = []) {
const cut = Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")) + 1;
const m = value.slice(cut).match(/^(.+)_\d+_[^_]+(\.[^.]+)$/);
const batch = m ? [`${value.slice(0, cut)}${m[1]}_0_cover${m[2]}`, `${value.slice(0, cut)}${m[1]}_0_cover.png`] : [];
return [...new Set([...batch, ...siblings])];
}
// First candidate that loads as an image, or null.
async function firstExistingImage(candidates) {
for (const cand of candidates) {
const ok = await new Promise((res) => {
const probe = new Image();
probe.onload = () => res(true);
probe.onerror = () => res(false);
probe.src = imageUrl(cand);
});
if (ok) return cand;
}
return null;
}
// Dirty-state tracking — flipped on the first interaction inside the
// modal so a stray outside-click can't silently lose progress.
let dirty = false;
const markDirty = () => { dirty = true; };
panel.addEventListener("input", markDirty, true);
panel.addEventListener("change", markDirty, true);
handlers.confirmCancel = async () => {
if (!dirty) return true;
return window.confirm("Are you sure you want to cancel? Unsaved changes will be lost.");
};
const titleRow = document.createElement("div");
css(titleRow, "display:flex;align-items:center;gap:8px;margin:0 0 8px;");
const title = document.createElement("h3");
css(title, "margin:0;font-size:16px;color:#fff;font-weight:500;");
title.textContent =
mode === "default" ? "Edit default flake" :
mode === "create" ? "New flake" :
`Edit ${name}`;
titleRow.appendChild(title);
// Favorite toggle (#402), saved as `favorite: true`.
let favorite = !!data.favorite;
if (mode !== "default") {
const favBtn = document.createElement("button");
const paintFav = () => {
favBtn.textContent = favorite ? "★" : "☆";
favBtn.title = favorite ? "Favorite (click to unfavorite)" : "Mark as favorite";
favBtn.style.color = favorite ? "#f5c518" : "#888";
};
css(favBtn, "padding:0;border:none;background:transparent;font-size:18px;line-height:1;cursor:pointer;");
favBtn.addEventListener("click", () => { favorite = !favorite; paintFav(); markDirty(); });
paintFav();
titleRow.appendChild(favBtn);
}
// Animate flakes: the preset's caption model writes the video prompt
// from each start image, carrying out this flake's action.
let autoCaptionChk = null;
if (animFamily) {
const lbl = document.createElement("label");
lbl.title = "Write the video prompt for each start image with the preset's caption model";
css(lbl, "margin-left:auto;display:flex;align-items:center;gap:6px;font-size:12px;color:#aaa;cursor:pointer;");
autoCaptionChk = document.createElement("input");
autoCaptionChk.type = "checkbox";
autoCaptionChk.checked = !!data.auto_caption;
css(autoCaptionChk, "cursor:pointer;margin:0;");
lbl.append(autoCaptionChk, "Auto-caption");
titleRow.appendChild(lbl);
}
content.appendChild(titleRow);
// ---- Top section: name, path, cover image ----
const topSection = document.createElement("div");
css(topSection, "display:flex;gap:12px;align-items:flex-start;");
const leftCol = document.createElement("div");
css(leftCol, "flex:1;min-width:0;display:flex;flex-direction:column;gap:4px;");
let displayNameInput = null;
let pathInput = null;
let outputStemInput = null;
let selectedType = data.flake_type || "";
const FLAKE_TYPES = ["Style", "Slider", "Character", "Pose", "Concept", "Other"];
// Optional fields seeded when a type is chosen while creating a flake.
// The user can remove any of them afterwards.
const DEFAULT_FIELDS_BY_TYPE = {
Character: ["lora", "prompt", "variants"],
Slider: ["lora"],
Style: ["lora", "prompt"],
Pose: ["prompt", "controlnets"],
};
const defaultLoraStrength = () => selectedType === "Character" ? 0.8 : 1.0;
// Seed a type's default optional fields, skipping any already present so
// user-entered fields are never clobbered. Hoisted so the type dropdown
// handler (defined above the field state) can call it at event time.
function applyTypeDefaults(type) {
const keys = DEFAULT_FIELDS_BY_TYPE[type];
if (!keys) return;
let lastAdded = null;
for (const key of keys) {
if (activeFields.includes(key) || (key === "controlnets" && animFamily)) continue;
activeFields.push(key);
lastAdded = key;
if (key === "lora") fieldState.loras = [{ name: "", url: "", path: "", strength: type === "Character" ? 0.8 : 1.0, _editing: true }];
else if (key === "prompt") fieldState.prompts = [{}];
else if (key === "controlnets") fieldState.controlnets._ = [];
else if (key === "variants") fieldState.variants = type === "Character" ? { outfit: { classic: { default: true } } } : {};
}
if (lastAdded) { renderFields(); scrollToFieldKey(lastAdded); }
}
let typeDropdown = null;
let baseRootSelectRef = null;
// The Save button is gated on `rootsLoaded` so create/edit can't save
// before the base-directory roots resolve. Default-flake mode loads no
// roots, so treat it as ready immediately — otherwise the inline
// Default flake can never be saved (#253).
let rootsLoaded = mode === "default";
// ---- Resolved-path helpers (shared across the field rows below) ----
let currentRootPath = "";
let rootsCache = [];
let resolvedPathLabel = null;
let serieInput = null;
// Folder of the flake being edited (e.g. img/anima/characters), where
// its LoRAs usually live under models/loras/ too.
function flakeDir() {
const p = pathInput ? getOutputPrefix() + pathInput.value.trim() : (name || "");
return p.replace(/\.ya?ml$/i, "").split("/").slice(0, -1).join("/");
}
function getOutputPrefix() {
if (animFamily) return `${ANIM_DIR}/${animFamily.folder}/`;
const folder = familyFolder(currentFamily);
return folder ? `img/${folder}/` : "img/";
}
function stripOutputPrefix(val) {
if (!val) return val;
if (animFamily) return val.split("/").slice(2).join("/");
// Any img/<folder>/ is a family folder, registered or not (#470).
const parts = val.split("/");
if (parts[0].toLowerCase() !== "img") return val;
return parts.slice(parts.length > 2 ? 2 : 1).join("/");
}
function updateResolvedPath() {
if (!resolvedPathLabel) return;
const raw = (pathInput?.value || "").trim();
if (!raw) {
resolvedPathLabel.textContent = "";
return;
}
const rootPart = currentRootPath ? currentRootPath : "C:/<comfyui_path>/models/flakes/";
const prefix = getOutputPrefix();
const fullPath = `${rootPart}${prefix}${raw}.yaml`.replace(/\/\//g, "/");
resolvedPathLabel.textContent = fullPath;
}
// ---- Prefill chain (#272/#273/#274) ----
// In create mode, while a field hasn't been manually edited, compose
// Output Path and Output Stem from Serie -> Display Name -> LoRA names
// toggled "Add to output path" (#458). Serie precedes the display name;
// LoRA names follow it.
let pathManuallyEdited = mode !== "create";
let stemManuallyEdited = mode !== "create";
function slugPart(s) { return (s || "").trim().replace(/ /g, "_").toLowerCase(); }
function outputLoraSlugs() {
return (fieldState.loras || [])
.filter(l => l && l._tag_output && (l.name || "").trim())
.map(l => slugPart(l.name));
}
function recomputePrefills() {
if (mode !== "create") return;
const dn = slugPart(displayNameInput?.value);
const serie = slugPart(serieInput?.value);
const tags = outputLoraSlugs();
if (!pathManuallyEdited && pathInput) {
const typeFolder = selectedType ? selectedType.toLowerCase() + "s" : "";
pathInput.value = [typeFolder, serie, dn, ...tags].filter(Boolean).join("/");
updateResolvedPath();
}
if (!stemManuallyEdited && outputStemInput) {
// Display name contributes to the stem only for Character flakes (#273).
const stemDn = selectedType === "Character" ? dn : "";
const stemParts = [serie, stemDn, ...tags].filter(Boolean);
outputStemInput.value = stemParts.length ? stemParts.join("/") + "/" : "";
fieldState.output_stem = outputStemInput.value || null;
}
}
if (mode !== "default") {
// ---- Row 1: Display Name + Flake Type ----
const row1 = document.createElement("div");
css(row1, "display:flex;gap:8px;align-items:flex-start;");
const nameWrap = document.createElement("div");
css(nameWrap, "flex:1;min-width:0;display:flex;flex-direction:column;gap:4px;");
nameWrap.appendChild(makeComfyLabel("Display name"));
displayNameInput = makeComfyInput(data.name || "", "e.g. My Flake");
nameWrap.appendChild(displayNameInput);
row1.appendChild(nameWrap);
const typeWrap = document.createElement("div");
css(typeWrap, "flex:0 0 140px;min-width:0;display:flex;flex-direction:column;gap:4px;");
typeWrap.appendChild(makeComfyLabel("Flake type"));
typeDropdown = makeComfyDropdown(
[{ value: "", label: "\u2014" }, ...FLAKE_TYPES.map(t => ({ value: t, label: t }))],
selectedType,
);
typeDropdown.element.addEventListener("change", () => {
selectedType = typeDropdown.element.value;
recomputePrefills();
if (mode === "create") applyTypeDefaults(selectedType);
});
typeWrap.appendChild(typeDropdown.container);
row1.appendChild(typeWrap);
leftCol.appendChild(row1);
// ---- Row 2: Serie + Output Stem (50/50) ----
const row2 = document.createElement("div");
css(row2, "display:flex;gap:8px;align-items:flex-start;");
const serieWrap = document.createElement("div");
css(serieWrap, "flex:1;min-width:0;display:flex;flex-direction:column;gap:4px;");
serieWrap.appendChild(makeComfyLabel("Serie"));
const serieDD = makeSearchableDropdown([], data.serie || "", "e.g. Vinland Saga");
serieInput = serieDD.element;
serieWrap.appendChild(serieDD.container);
row2.appendChild(serieWrap);
// Populate the serie suggestions from existing flakes' series.
(async () => {
try {
const r = await fetch(`/flakes/series?kind=${kind}`);
const d = await r.json();
for (const s of (d.series || [])) {
serieDD.datalist.appendChild(Object.assign(document.createElement("option"), { value: s }));
}
} catch { /* ignore */ }
})();
serieInput.addEventListener("input", recomputePrefills);
serieInput.addEventListener("change", () => {
fieldState.serie = serieInput.value.trim() || null;
recomputePrefills();
});
const stemWrap = document.createElement("div");
css(stemWrap, "flex:1;min-width:0;display:flex;flex-direction:column;gap:4px;");
stemWrap.appendChild(makeComfyLabel("Output Stem"));
outputStemInput = makeComfyInput(data.output_stem ?? "", "e.g. musashi/");
outputStemInput.addEventListener("change", () => {
fieldState.output_stem = outputStemInput.value || null;
});
outputStemInput.addEventListener("input", () => { stemManuallyEdited = true; });
stemWrap.appendChild(outputStemInput);
row2.appendChild(stemWrap);
leftCol.appendChild(row2);
// ---- Row 3: Base Directory + Output Path (50/50) ----
const row3 = document.createElement("div");
css(row3, "display:flex;gap:8px;align-items:flex-start;");
const baseWrap = document.createElement("div");
css(baseWrap, "flex:1;min-width:0;display:flex;flex-direction:column;gap:4px;");
baseWrap.appendChild(makeComfyLabel("Base Directory"));
const baseRootSelect = document.createElement("select");
css(baseRootSelect, "width:100%;background:#1a1a1a;color:#ddd;border:1px solid #333;padding:6px 8px;border-radius:6px;font-size:13px;box-sizing:border-box;");
baseWrap.appendChild(baseRootSelect);
baseRootSelectRef = baseRootSelect;
row3.appendChild(baseWrap);
const pathWrap = document.createElement("div");
css(pathWrap, "flex:1;min-width:0;display:flex;flex-direction:column;gap:4px;");
pathWrap.appendChild(makeComfyLabel("Output path"));
pathInput = makeComfyInput("", "characters/musashi");
pathWrap.appendChild(pathInput);
row3.appendChild(pathWrap);
leftCol.appendChild(row3);
(async () => {
try {
const r = await fetch("/flakes/roots?type=flakes");
const d = await r.json();
const roots = d.roots || [];
baseRootSelect.replaceChildren();
for (const root of roots) {
const opt = document.createElement("option");
opt.value = String(root.index);
const normalizedPath = (root.path || "").replace(/\\/g, "/").replace(/\/+$/, "");
// Abbreviate long paths: keep drive + first two segments + .../models
const pathParts = normalizedPath.split("/").filter(Boolean);
let shortPath = normalizedPath;
if (pathParts.length > 4) {
shortPath = `${pathParts[0]}/<...>/${pathParts.slice(-2).join("/")}`;
}
opt.textContent = `${root.label}: ${shortPath}`;
opt.title = `${root.label}: ${root.path}`;
baseRootSelect.appendChild(opt);
}
// Default to the extra model path (index > 0) when available
if (roots.length > 1 && mode === "create") {
baseRootSelect.value = String(roots[roots.length - 1].index);
} else if (mode === "edit" && name) {
const rr = await fetch(`/flakes/get?name=${encodeURIComponent(name)}`);
if (rr.ok) {
const rd = await rr.json();
const savedRootIndex = rd.base_root_index;
if (savedRootIndex != null && savedRootIndex < roots.length) {
baseRootSelect.value = String(savedRootIndex);
}
}
}
const rootIdx = parseInt(baseRootSelect.value, 10);
const root = (rootsCache.find(r => r.index === rootIdx)) || rootsCache[0];
currentRootPath = (root?.path || "").replace(/\\/g, "/").replace(/\/+$/, "") + "/";
updateResolvedPath();
if (!roots.length) {
const opt = document.createElement("option");
opt.textContent = "(no roots configured)";
opt.value = "0";
baseRootSelect.appendChild(opt);
}
} catch { /* ignore */ }
rootsLoaded = true;
saveBtn.disabled = false;
})();
(async () => {
try {
const r = await fetch("/flakes/roots?type=flakes");
const d = await r.json();
rootsCache = d.roots || [];
const rootIdx = parseInt(baseRootSelect?.value || "0", 10);
const root = (rootsCache.find(r => r.index === rootIdx)) || rootsCache[0];
currentRootPath = (root?.path || "").replace(/\\/g, "/");
if (!currentRootPath.endsWith("/")) currentRootPath += "/";
updateResolvedPath();
} catch { /* ignore */ }
})();
baseRootSelect.addEventListener("change", () => {
const rootIdx = parseInt(baseRootSelect.value, 10);
const root = (rootsCache.find(r => r.index === rootIdx)) || rootsCache[0];
currentRootPath = (root?.path || "").replace(/\\/g, "/").replace(/\/+$/, "") + "/";
updateResolvedPath();
});
if (mode === "edit" && name) {
const stripped = stripOutputPrefix(name);
pathInput.value = stripped;
}
// Auto-fill path/stem from serie + display name + tagged loras
// (create mode only; a manual edit disables that field's prefill).
pathInput.addEventListener("input", () => { pathManuallyEdited = true; updateResolvedPath(); });
displayNameInput?.addEventListener("input", recomputePrefills);
}
topSection.appendChild(leftCol);
let coverSourcePath = data?.cover_image || null;
let coverImg = null;
let setCoverFromLora = null;
let setCoverFromCn = null;
let currentFamily = animFamily?.label ?? family;
function inferFamilyFromPath(path) {
// Derives the family from an `img/<folder>/` prefix using the shared
// registry (#355/#360) rather than a local copy of the family list.
if (!path) return family;
const parts = path.replace(/\\/g, "/").toLowerCase().split("/");
if (parts[0] !== "img" || parts.length < 3) return family;
// An unregistered folder keeps the node's family when it names it (#470).
return familyFromFolder(parts[1]) || (familyFolder(family) === parts[1] ? family : parts[1]);
}
if (mode === "edit" && name && !animFamily) {
currentFamily = inferFamilyFromPath(name);
}
// ControlNet types the family has files for (#366): Anima LLLite and
// the Z-Image union only cover a subset of the SDXL types. `keep` stays
// listed so an existing entry's type remains visible.
async function familyCnTypes(keep = "") {
const types = await fetchCnTypes();
const mapped = familyCnModels(currentFamily);
const out = mapped ? types.filter(t => t in mapped) : types;
if (keep && !out.includes(keep)) out.push(keep);
return out;
}
// New ControlNet entry, sized from its image (img res) by default (#400).
// Anima defaults to depth through its VACE model at 0.7 strength until
// 30% of the steps (#398).
async function newCn() {
if (familyFolder(currentFamily) !== "anima") return { type: "", image: "", strength: 1.0, start_percent: 0, end_percent: 1, resolution_from_image: true };
const vace = (await fetchCnModels(currentFamily).catch(() => [])).find(f => f.kind === "vace");
return { type: "depth", image: "", strength: 0.7, start_percent: 0, end_percent: 0.3, resolution_from_image: true, ...(vace ? { model: vace.name } : {}) };
}
// Families that list pickable files (Anima: LLLite patches + VACE, #381)
// swap the read-only model label for a "Model" dropdown. Empty = Auto:
// the type picks the file.
function attachCnModelPicker(cn, label) {
fetchCnModels(currentFamily).then((files) => {
if (!files.length) return;
const opts = [{ value: "", label: "Auto (by type)" }];
for (const f of files) {
const base = f.name.split(/[\\/]/).pop();
opts.push({ value: f.name, label: f.kind === "vace" ? `${base} (VACE)` : base });
}
if (cn.model && !files.some(f => f.name === cn.model)) opts.push({ value: cn.model, label: `${cn.model} (not found)` });
const dd = makeComfyDropdown(opts, cn.model || "");
dd.element.title = "ControlNet model file. Auto uses the LLLite file for the type.";
dd.element.addEventListener("change", () => {
if (dd.element.value) cn.model = dd.element.value;
else delete cn.model;
});
css(dd.container, "flex:1;min-width:0;");
label.replaceWith(dd.container);
}).catch(() => {});
}
if (mode === "edit" || mode === "create") {
const coverWrap = document.createElement("div");
css(coverWrap, "display:flex;flex-direction:column;align-items:center;gap:4px;");
const coverBox = document.createElement("div");
css(coverBox, "position:relative;width:160px;height:200px;border-radius:6px;background:#1a1a1a;border:1px solid #333;display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;");
coverImg = document.createElement("img");
css(coverImg, "width:100%;height:100%;object-fit:cover;display:none;");
coverBox.appendChild(coverImg);
const coverLabel = document.createElement("span");
coverLabel.textContent = "image";
css(coverLabel, "font-size:10px;color:#666;pointer-events:none;");
coverBox.appendChild(coverLabel);
// Hover-only ✕ to remove the cover image (#239). Only visible when
// a cover is set AND the user is hovering the box.
const coverRemoveBtn = document.createElement("button");
coverRemoveBtn.type = "button";
coverRemoveBtn.textContent = "✕";
coverRemoveBtn.title = "Remove cover image";
css(coverRemoveBtn, "position:absolute;top:6px;right:6px;z-index:2;width:22px;height:22px;padding:0;border-radius:4px;background:rgba(20,20,20,0.85);color:#ddd;border:1px solid #555;cursor:pointer;font-size:13px;line-height:1;display:none;align-items:center;justify-content:center;opacity:0;transition:opacity 0.12s ease;");
coverBox.appendChild(coverRemoveBtn);
addImageZoom(coverBox, coverImg);
coverBox.addEventListener("mouseenter", () => {
if (coverImg.style.display === "block") coverRemoveBtn.style.opacity = "1";
});
coverBox.addEventListener("mouseleave", () => { coverRemoveBtn.style.opacity = "0"; });
coverRemoveBtn.addEventListener("click", async (e) => {
e.stopPropagation();
// Reset all cover state: the stored source and the preview.
coverSourcePath = null;
updateCoverPreview(null);
// In edit mode, also delete the file on disk so it doesn't
// re-appear on next open (the cover image lives next to the
// yaml, not in the yaml itself).
if (mode === "edit" && name) {
try {
await fetch(`/flakes/cover?name=${encodeURIComponent(name)}`, { method: "DELETE" });
} catch { /* ignore — preview is already cleared */ }
}
});
function updateCoverPreview(src) {
if (src) {
coverImg.src = src;
coverImg.style.display = "block";
coverLabel.style.display = "none";
coverRemoveBtn.style.display = "flex";
} else {
coverImg.style.display = "none";
coverLabel.style.display = "block";
coverRemoveBtn.style.display = "none";
coverRemoveBtn.style.opacity = "0";
}
}
// A flake without a cover answers 404: show the empty box, so an
// automatic cover (#397) knows there is none.
coverImg.addEventListener("error", () => updateCoverPreview(null));
// Default cover for edit mode
if (mode === "edit" && name) {
updateCoverPreview(getCoverUrl(name));
}
// The save moves the picked image to <name>/<base><ext>, or keeps
// it in place when it already lives in the flake's folder (#396).
function applyCover(path) {
coverSourcePath = path;
updateCoverPreview(imageUrl(path));
}
coverBox.addEventListener("click", async () => {
const picked = await pickFlakeImage();
if (picked) applyCover(picked);
});
makeImageDropTarget(coverBox, (paths) => applyCover(paths[0]));
// Auto-cover from a LoRA's sibling image when no cover has been
// explicitly chosen yet. Stores the resolved image path (e.g. .png)
// so the saved YAML never references a .safetensors file.
setCoverFromLora = async (loraPath) => {
if (coverSourcePath || !loraPath) return false;
try {
const imagePath = await fetchLoraSiblingImagePath(loraPath);
if (imagePath) {
coverSourcePath = imagePath;
updateCoverPreview(loraSiblingImageUrl(loraPath));
return true;
}
} catch {
return false;
}
};
// A ControlNet's batch cover becomes the flake cover when the flake
// has none yet, neither picked nor saved (#353, #397). The save moves
// it into <flake>/cnets/ with the batch.
setCoverFromCn = (imagePath) => {
if (!imagePath || coverSourcePath || coverImg.style.display === "block") return;
coverSourcePath = imagePath;
updateCoverPreview(imageUrl(imagePath));
};
coverWrap.appendChild(coverBox);
topSection.appendChild(coverWrap);
// Default cover when path changes in create mode
if (mode === "create" && pathInput) {
pathInput.addEventListener("input", () => {
const p = pathInput.value.trim();
if (p && !coverSourcePath) {
const prefix = getOutputPrefix();
const fullP = prefix + p.replace(/\.ya?ml$/i, ".png");
updateCoverPreview(getCoverUrl(fullP));
}
});
}
}
content.appendChild(topSection);
const resolvedPathRow = document.createElement("div");
resolvedPathLabel = document.createElement("div");
css(resolvedPathLabel, "font-size:11px;color:#666;word-break:break-all;min-height:0;");
resolvedPathRow.appendChild(resolvedPathLabel);
if (mode !== "default") {
content.appendChild(resolvedPathRow);
}
// ---- Separator ----
const sep = document.createElement("div");
css(sep, "border-top:1px solid #333;margin:12px 0;");
content.appendChild(sep);
// ---- Optional fields state ----
const fieldState = {
loras: Array.isArray(data.loras)
? JSON.parse(JSON.stringify(data.loras))
: (data.path ? [{ name: "", url: "", path: data.path, strength: data.strength ?? 1.0 }] : []),
// Legacy single `prompt:` or the `prompts:` list (#371).
prompts: (Array.isArray(data.prompts) ? data.prompts : (data.prompt ? [data.prompt] : []))
.filter(p => p && (p.positive != null || p.negative != null))
.map(p => ({ ...JSON.parse(JSON.stringify(p)), positive: p.positive ?? null, negative: p.negative ?? null })),
resolution: data.resolution ? [...data.resolution] : null,
controlnets: JSON.parse(JSON.stringify(data.controlnets || [])),
variants: JSON.parse(JSON.stringify(data.variants || data.options || {})),
output_stem: data.output_stem ?? null,
serie: data.serie ?? null,
flake_links: normalizeLinks(data),
prompt_options: JSON.parse(JSON.stringify(data.prompt_options || [])),
toggler: data.toggler ?? "",
};
if (!Array.isArray(fieldState.controlnets._)) {
const arr = Array.isArray(fieldState.controlnets) ? [...fieldState.controlnets] : [];
fieldState.controlnets = { _: arr };
}
// Derive field order from YAML key order (Python preserves insertion order)
const activeFields = [];
const knownFieldKeys = { loras: "lora", path: "lora", prompt: "prompt", prompts: "prompt", resolution: "resolution", controlnets: "controlnets", variants: "variants", options: "variants", flake_links: "flake_link", flake_link: "flake_link", prompt_options: "prompt_options", toggler: "toggler" };
for (const key of Object.keys(data)) {
const ft = knownFieldKeys[key];
if (ft && !activeFields.includes(ft)) activeFields.push(ft);
}
// Fallback: append any fields that exist but weren't in the key order
if (!activeFields.includes("lora") && (Array.isArray(data.loras) || data.path)) activeFields.push("lora");
if (!activeFields.includes("prompt") && fieldState.prompts.length) activeFields.push("prompt");
if (!activeFields.includes("resolution") && fieldState.resolution) activeFields.push("resolution");
if (!activeFields.includes("controlnets") && fieldState.controlnets._.length > 0) activeFields.push("controlnets");
if (!activeFields.includes("variants") && Object.keys(fieldState.variants).length > 0) activeFields.push("variants");
if (!activeFields.includes("flake_link") && fieldState.flake_links && fieldState.flake_links.length) activeFields.push("flake_link");
if (!activeFields.includes("prompt_options") && fieldState.prompt_options.length) activeFields.push("prompt_options");
if (!activeFields.includes("toggler") && fieldState.toggler) activeFields.push("toggler");
const optionalBox = document.createElement("div");
css(optionalBox, "display:flex;flex-direction:column;gap:8px;");
content.appendChild(optionalBox);
let dragFieldIdx = null;
// Scroll a just-added field into view (#271) once it has rendered, so a
// field that spawns below the fold is revealed instead of staying hidden.
function scrollToFieldKey(key) {
if (!key) return;
requestAnimationFrame(() => {
const el = optionalBox.querySelector(`[data-field-key="${key}"]`);
if (!el || typeof el.scrollIntoView !== "function") return;
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
el.scrollIntoView({ block: "nearest", behavior: reduceMotion ? "auto" : "smooth" });
});
}
function renderFields() {
optionalBox.replaceChildren();
for (let fi = 0; fi < activeFields.length; fi++) {
const fieldType = activeFields[fi];
const fieldWrap = document.createElement("div");
css(fieldWrap, "background:#1a1a1a;padding:10px;border-radius:6px;border:1px solid #2a2a2a;display:flex;flex-direction:column;gap:6px;");
fieldWrap.dataset.fieldIdx = String(fi);
fieldWrap.dataset.fieldKey = fieldType;
const header = document.createElement("div");
css(header, "display:flex;gap:6px;align-items:center;");
const dragIcon = document.createElement("span");
dragIcon.textContent = "\u2630";
css(dragIcon, "cursor:grab;color:#666;font-size:12px;");
dragIcon.draggable = true;
dragIcon.addEventListener("dragstart", (e) => {
dragFieldIdx = fi;
fieldWrap.style.opacity = "0.4";
e.dataTransfer.effectAllowed = "move";
});
dragIcon.addEventListener("dragend", () => {
dragFieldIdx = null;
fieldWrap.style.opacity = "";
for (const ind of optionalBox.querySelectorAll(".field-drop-indicator")) {
ind.remove();
}
});
header.appendChild(dragIcon);
const fieldTitle = document.createElement("span");
// Title-case and replace underscores so e.g. "flake_link" reads
// "Flake Link" instead of "Flake_link" (#307).
fieldTitle.textContent = fieldType.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
css(fieldTitle, "flex:1;font-size:12px;font-weight:500;color:#aaa;");
header.appendChild(fieldTitle);
const moveUpBtn = makeSmallButton("\u2191");
moveUpBtn.title = "Move field up";
if (fi === 0) {
moveUpBtn.disabled = true;
moveUpBtn.style.opacity = "0.35";
moveUpBtn.style.cursor = "default";
}
moveUpBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
if (fi === 0) return;
const tmp = activeFields[fi - 1];
activeFields[fi - 1] = activeFields[fi];
activeFields[fi] = tmp;
renderFields();
});
header.appendChild(moveUpBtn);
const moveDownBtn = makeSmallButton("\u2193");
moveDownBtn.title = "Move field down";
if (fi === activeFields.length - 1) {
moveDownBtn.disabled = true;
moveDownBtn.style.opacity = "0.35";
moveDownBtn.style.cursor = "default";
}
moveDownBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
if (fi === activeFields.length - 1) return;
const tmp = activeFields[fi + 1];
activeFields[fi + 1] = activeFields[fi];
activeFields[fi] = tmp;
renderFields();
});
header.appendChild(moveDownBtn);
const delFieldBtn = makeSmallButton("\u2715");
delFieldBtn.addEventListener("click", () => {
const idx = activeFields.indexOf(fieldType);
if (idx !== -1) activeFields.splice(idx, 1);
if (fieldType === "lora") fieldState.loras = [];
if (fieldType === "prompt") fieldState.prompts = [];
if (fieldType === "resolution") fieldState.resolution = null;
if (fieldType === "controlnets") fieldState.controlnets._ = [];
if (fieldType === "variants") {
for (const k of Object.keys(fieldState.variants)) delete fieldState.variants[k];
}
if (fieldType === "flake_link") fieldState.flake_links = [];
if (fieldType === "prompt_options") fieldState.prompt_options = [];
if (fieldType === "toggler") fieldState.toggler = "";
renderFields();
});
header.appendChild(delFieldBtn);
fieldWrap.appendChild(header);
fieldWrap.addEventListener("dragover", (e) => {
if (dragFieldIdx === null || dragFieldIdx === fi) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
for (const ind of optionalBox.querySelectorAll(".field-drop-indicator")) {
ind.remove();
}
const indicator = document.createElement("div");
indicator.className = "field-drop-indicator";
css(indicator, "height:2px;background:#2a6acf;border-radius:1px;margin:2px 0;");
const rect = fieldWrap.getBoundingClientRect();
const above = (e.clientY - rect.top) < rect.height / 2;
fieldWrap.parentNode.insertBefore(
indicator,
above ? fieldWrap : fieldWrap.nextSibling,
);
});
fieldWrap.addEventListener("dragleave", (e) => {
// Only clear if leaving the field bounds entirely
const rect = fieldWrap.getBoundingClientRect();
if (
e.clientX < rect.left || e.clientX > rect.right ||
e.clientY < rect.top || e.clientY > rect.bottom
) {
for (const ind of optionalBox.querySelectorAll(".field-drop-indicator")) {
ind.remove();
}
}
});
fieldWrap.addEventListener("drop", (e) => {
e.preventDefault();
for (const ind of optionalBox.querySelectorAll(".field-drop-indicator")) {
ind.remove();
}
if (dragFieldIdx === null || dragFieldIdx === fi) return;
const rect = fieldWrap.getBoundingClientRect();
const above = (e.clientY - rect.top) < rect.height / 2;
const [movedField] = activeFields.splice(dragFieldIdx, 1);
let insertIdx = fi;
if (dragFieldIdx < fi) insertIdx--;
if (!above) insertIdx++;
activeFields.splice(insertIdx, 0, movedField);
dragFieldIdx = null;
renderFields();
});
if (fieldType === "lora") {
const loraBox = document.createElement("div");
css(loraBox, "display:flex;flex-direction:column;gap:6px;");
fieldWrap.appendChild(loraBox);
renderLoraList(loraBox, fieldState.loras, {
family: currentFamily,
tagName: true,
onOutputTag: mode === "create" ? recomputePrefills : null,
onFirstPath: (path) => setCoverFromLora?.(path),
loraDir: flakeDir,
defaultStrength: defaultLoraStrength(),
lowFile,
});
}
if (fieldType === "prompt") {
// One card per prompt (#371): positive + optional negative.
const promptBox = document.createElement("div");
css(promptBox, "display:flex;flex-direction:column;gap:6px;");
fieldWrap.appendChild(promptBox);
// Removing the last prompt removes the whole field.
renderPromptList(promptBox, fieldState.prompts, () => {
activeFields.splice(activeFields.indexOf("prompt"), 1);
renderFields();
});
}
if (fieldType === "resolution") {
const row = document.createElement("div");
css(row, "display:flex;gap:8px;align-items:center;");
const wInput = makeComfyNumberInput(fieldState.resolution?.[0] || "", "width", 64);
const hInput = makeComfyNumberInput(fieldState.resolution?.[1] || "", "height", 64);
const syncResolution = () => {
const rw = parseInt(wInput.value);
const rh = parseInt(hInput.value);
if (Number.isFinite(rw) && Number.isFinite(rh)) {
fieldState.resolution = [rw, rh];
} else if (!fieldState.resolution) {
fieldState.resolution = [1024, 1024];
}
};
// Listen on both input (real-time) and change (blur) so save
// sees the latest typed values even without losing focus first.
wInput.addEventListener("input", syncResolution);
wInput.addEventListener("change", syncResolution);
hInput.addEventListener("input", syncResolution);
hInput.addEventListener("change", syncResolution);
// Expose the inputs on fieldState so the save handler can read
// them directly as a belt-and-braces fallback.
fieldState._resolutionInputs = { w: wInput, h: hInput };
const xLabel = document.createElement("span");
xLabel.textContent = "\u00d7";
css(xLabel, "color:#888;font-size:13px;");
row.appendChild(wInput);
row.appendChild(xLabel);
row.appendChild(hInput);
fieldWrap.appendChild(row);
}
if (fieldType === "controlnets") {
const cnsBox = document.createElement("div");
css(cnsBox, "display:flex;flex-direction:column;gap:6px;");
fieldWrap.appendChild(cnsBox);
function renderCNs() {
cnsBox.replaceChildren();
const arr = fieldState.controlnets._ || [];
for (let i = 0; i < arr.length; i++) {
const cn = arr[i];
const card = document.createElement("div");
css(card, "background:#252525;padding:10px;border-radius:6px;display:flex;flex-direction:row;gap:10px;border:1px solid #333;");
// Left: input image with file picker
const imageCol = document.createElement("div");
css(imageCol, "display:flex;flex-direction:column;align-items:center;gap:4px;flex:0 0 auto;");
const imgBox = document.createElement("div");
imgBox.dataset.cnMainImg = "1";
css(imgBox, "position:relative;width:80px;height:80px;border-radius:4px;background:#1a1a1a;border:1px solid #333;display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;");
const imgPreview = document.createElement("img");
css(imgPreview, "width:100%;height:100%;object-fit:cover;display:none;");
imgBox.appendChild(imgPreview);
const imgLabel = document.createElement("span");
imgLabel.textContent = "image";
css(imgLabel, "font-size:10px;color:#666;pointer-events:none;");
imgBox.appendChild(imgLabel);
// Caption showing the controlnet image's intrinsic
// pixel dimensions (#256). Filled from the preview
// <img> once it loads; hidden when there's no image.
const dimLabel = document.createElement("span");
css(dimLabel, "font-size:10px;color:#666;pointer-events:none;display:none;");
imgPreview.addEventListener("load", () => {
if (imgPreview.naturalWidth && imgPreview.naturalHeight) {
dimLabel.textContent = `${imgPreview.naturalWidth} × ${imgPreview.naturalHeight}`;
dimLabel.style.display = "block";
}
});
const cnImagePath = cn.image || cn.image_name || "";
function updateCnImgPreview(src) {
if (src) {
imgPreview.src = src;
imgPreview.style.display = "block";
imgLabel.style.display = "none";
} else {
imgPreview.style.display = "none";
imgLabel.style.display = "block";
dimLabel.textContent = "";
dimLabel.style.display = "none";
}
}
if (cnImagePath) {
updateCnImgPreview(imageUrl(cnImagePath));
}
async function applyCnImage(fileName) {
arr[i].image = fileName;
updateCnImgPreview(imageUrl(fileName));
// Auto-set type + cover from the filename (#306, #353).
let inferredType = "";
let siblingCandidates = [];
try {
const types = await familyCnTypes();
const res = inferCnFromImage(fileName, types);
inferredType = res.inferredType;
siblingCandidates = res.siblings || [];
} catch { /* ignore */ }
if (inferredType && !arr[i].type) arr[i].type = inferredType;
renderCNs();
setCoverFromCn?.(await firstExistingImage(cnCoverCandidates(fileName, siblingCandidates)));
}
imgBox.addEventListener("click", async () => {
const fileName = await pickFlakeImage();
if (fileName) applyCnImage(fileName);
});
makeImageDropTarget(imgBox, (paths) => applyCnImage(paths[0]));
addImageZoom(imgBox, imgPreview);
imageCol.appendChild(imgBox);
imageCol.appendChild(dimLabel);
card.appendChild(imageCol);
// Right column (#311): Row 1 = model + Img res + delete;
// Row 2 = type (narrow) + Str/Start/End.
const rightCol = document.createElement("div");
css(rightCol, "flex:1;min-width:0;display:flex;flex-direction:column;gap:6px;");
// Row 1: model label, Img res checkbox, delete button.
const typeRow = document.createElement("div");
css(typeRow, "display:flex;gap:8px;align-items:center;");
const modelLabel = document.createElement("span");
const inferred = cn.model || cn.model_name
|| (cn.type ? familyCnModels(currentFamily)?.[cn.type] || "" : "");
modelLabel.textContent = inferred ? `model: ${inferred}` : "model: \u2014";
css(modelLabel, "font-size:11px;color:#888;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;");
typeRow.appendChild(modelLabel);
attachCnModelPicker(cn, modelLabel);
const resWrap = document.createElement("label");
css(resWrap, "display:flex;align-items:center;gap:3px;font-size:10px;color:#888;white-space:nowrap;cursor:pointer;");
const resChk = document.createElement("input");
resChk.type = "checkbox";
resChk.checked = !!cn.resolution_from_image;
resChk.title = "Use this ControlNet image's dimensions as the generation resolution";
css(resChk, "width:14px;height:14px;cursor:pointer;margin:0;");
resChk.addEventListener("change", () => { arr[i].resolution_from_image = resChk.checked; });
resWrap.appendChild(resChk);
resWrap.appendChild(document.createTextNode("Img res"));
typeRow.appendChild(resWrap);
const removeBtn = makeSmallButton("\u2715");
removeBtn.addEventListener("click", () => { arr.splice(i, 1); renderCNs(); });
typeRow.appendChild(removeBtn);
rightCol.appendChild(typeRow);
// Row 2: type dropdown (narrow) + Str / Start / End.
const controlsRow = document.createElement("div");
css(controlsRow, "display:flex;gap:6px;align-items:flex-end;");
const makeCtlCol = (labelText, el, basis) => {
const col = document.createElement("div");
css(col, `${basis};min-width:0;display:flex;flex-direction:column;gap:2px;`);
const lbl = document.createElement("span");
lbl.textContent = labelText;
css(lbl, "font-size:10px;color:#888;");
el.style.width = "100%";
col.appendChild(lbl);
col.appendChild(el);
return col;
};
const typeDropdown = makeComfyDropdown(
[{ value: "", label: "\u2014 type \u2014" }],
cn.type || "",
);
(async () => {
try {
const types = await familyCnTypes(cn.type);
for (const t of types) {
const o = document.createElement("option");
o.value = t;
o.textContent = t;
if (t === cn.type) o.selected = true;
typeDropdown.element.appendChild(o);
}
} catch { /* ignore */ }
})();
typeDropdown.element.addEventListener("change", () => { arr[i].type = typeDropdown.element.value; renderCNs(); });
controlsRow.appendChild(makeCtlCol("Type", typeDropdown.container, "flex:0 0 96px"));
const strSlider = makeComfyValueSlider(cn.strength ?? 1.0, 0, 2, 0.05, (v) => { arr[i].strength = v; });
controlsRow.appendChild(makeCtlCol("Str", strSlider, "flex:1"));
const startSlider = makeComfyValueSlider(cn.start_percent ?? 0, 0, 1, 0.05, (v) => { arr[i].start_percent = v; });
controlsRow.appendChild(makeCtlCol("Start", startSlider, "flex:1"));
const endSlider = makeComfyValueSlider(cn.end_percent ?? 1, 0, 1, 0.05, (v) => { arr[i].end_percent = v; });
controlsRow.appendChild(makeCtlCol("End", endSlider, "flex:1"));
rightCol.appendChild(controlsRow);
card.appendChild(rightCol);
cnsBox.appendChild(card);
}
const addBtn = makeSmallButton("+ controlnet");
addBtn.addEventListener("click", async () => {
arr.push(await newCn());
renderCNs();
});
cnsBox.appendChild(addBtn);
}
renderCNs();
}
if (fieldType === "variants") {
const optsBox = document.createElement("div");
css(optsBox, "display:flex;flex-direction:column;gap:8px;");
fieldWrap.appendChild(optsBox);
function renderOpts() {
optsBox.replaceChildren();
for (const groupName of Object.keys(fieldState.variants)) {
const groupCard = document.createElement("div");
css(groupCard, "background:#252525;padding:10px;border-radius:6px;display:flex;flex-direction:column;gap:6px;border:1px solid #333;");
const headerRow = document.createElement("div");
css(headerRow, "display:flex;gap:6px;align-items:center;");
const groupNameInput = makeComfyInput(groupName, "group name");
groupNameInput.style.flex = "1";
const removeGroupBtn = makeSmallButton("\u2715 group");
groupNameInput.addEventListener("change", () => {
const newName = groupNameInput.value.trim();
if (!newName || newName === groupName) return;
if (fieldState.variants[newName]) { groupNameInput.value = groupName; return; }
fieldState.variants[newName] = fieldState.variants[groupName];
delete fieldState.variants[groupName];
renderOpts();
});
removeGroupBtn.addEventListener("click", () => {
delete fieldState.variants[groupName];
renderOpts();
});
headerRow.appendChild(groupNameInput);
headerRow.appendChild(removeGroupBtn);
groupCard.appendChild(headerRow);
let dragChoiceName = null;
const choiceNames = Object.keys(fieldState.variants[groupName] || {});
for (const choiceName of choiceNames) {
const choice = fieldState.variants[groupName][choiceName] || {};
const choiceCard = document.createElement("div");
css(choiceCard, "background:#1a1a1a;padding:8px;border-radius:4px;display:flex;flex-direction:column;gap:4px;");
// Header: drag handle + name (50%) + stem (50%)
const cRow = document.createElement("div");
css(cRow, "display:flex;gap:4px;align-items:center;");
// Hamburger handle (#331): drag to reorder the choice AND
// click to open the "Add field" menu (LoRA / ControlNet /
// Flake Link) for this choice \u2014 replaces the per-section
// "+ LoRA / + ControlNet / + Flake Link" buttons.
const dragHandle = document.createElement("span");
dragHandle.textContent = "\u2630";
css(dragHandle, "cursor:grab;color:#666;font-size:12px;padding:0 4px;user-select:none;");
dragHandle.title = "Drag to reorder \u2022 click to add a field";
dragHandle.draggable = true;
dragHandle.addEventListener("dragstart", (e) => {
dragChoiceName = choiceName;
choiceCard.style.opacity = "0.4";
e.dataTransfer.effectAllowed = "move";
});
dragHandle.addEventListener("dragend", () => {
dragChoiceName = null;
choiceCard.style.opacity = "";
for (const ind of optsBox.querySelectorAll(".choice-drop-indicator")) ind.remove();
});
dragHandle.addEventListener("click", (e) => {
e.stopPropagation();
openFieldMenu(dragHandle, [
{ label: "Prompt", onClick: () => addChoiceField("prompt") },
{ label: "LoRA", onClick: () => addChoiceField("lora") },
...(animFamily ? [] : [{ label: "ControlNet", onClick: () => addChoiceField("controlnets") }]),
{ label: "Flake Link", onClick: () => addChoiceField("flake_link") },
{ label: "Options", onClick: () => addChoiceField("prompt_options") },
{ label: "Toggler", onClick: () => addChoiceField("toggler") },
]);
});
cRow.appendChild(dragHandle);
const cNameInput = makeComfyInput(choiceName, "choice name");
cNameInput.style.flex = "1 1 50%";
cNameInput.style.minWidth = "0";
cNameInput.addEventListener("change", () => {
const newCName = cNameInput.value.trim();
if (!newCName || newCName === choiceName) return;
if (fieldState.variants[groupName][newCName]) { cNameInput.value = choiceName; return; }
const newObj = {};
for (const [k, v] of Object.entries(fieldState.variants[groupName])) {
newObj[k === choiceName ? newCName : k] = v;
}
fieldState.variants[groupName] = newObj;
renderOpts();
});
cRow.appendChild(cNameInput);
// Output stem on the same row (per #212).
const stemInput = makeComfyInput(choice.output_stem ?? "", "output stem");
stemInput.style.flex = "1 1 50%";
stemInput.style.minWidth = "0";
stemInput.title = "Output stem (appended to the flake's output path for this variant choice)";
stemInput.addEventListener("change", () => {
fieldState.variants[groupName][choiceName] = fieldState.variants[groupName][choiceName] || {};
fieldState.variants[groupName][choiceName].output_stem = stemInput.value || null;
});
const stemFromNameBtn = makeSmallButton("→");
stemFromNameBtn.title = "Use the choice name as output stem";
stemFromNameBtn.addEventListener("click", () => {
stemInput.value = slugStem(choiceName);
stemInput.dispatchEvent(new Event("change"));
});
cRow.appendChild(stemFromNameBtn);
cRow.appendChild(stemInput);
choiceCard.appendChild(cRow);
// Drop indicator wiring on the card itself.
choiceCard.addEventListener("dragover", (e) => {
if (dragChoiceName === null || dragChoiceName === choiceName) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
for (const ind of optsBox.querySelectorAll(".choice-drop-indicator")) ind.remove();
const indicator = document.createElement("div");
indicator.className = "choice-drop-indicator";
css(indicator, "height:2px;background:#2a6acf;border-radius:1px;margin:2px 0;");
const rect = choiceCard.getBoundingClientRect();
const above = (e.clientY - rect.top) < rect.height / 2;
choiceCard.parentNode.insertBefore(indicator, above ? choiceCard : choiceCard.nextSibling);
});
choiceCard.addEventListener("dragleave", (e) => {
const rect = choiceCard.getBoundingClientRect();
if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) {
for (const ind of optsBox.querySelectorAll(".choice-drop-indicator")) ind.remove();
}
});
choiceCard.addEventListener("drop", (e) => {
e.preventDefault();
for (const ind of optsBox.querySelectorAll(".choice-drop-indicator")) ind.remove();
if (dragChoiceName === null || dragChoiceName === choiceName) return;
const rect = choiceCard.getBoundingClientRect();
const above = (e.clientY - rect.top) < rect.height / 2;
const keys = Object.keys(fieldState.variants[groupName]);
const srcIdx = keys.indexOf(dragChoiceName);
let dstIdx = keys.indexOf(choiceName);
if (srcIdx < 0 || dstIdx < 0) return;
keys.splice(srcIdx, 1);
if (srcIdx < dstIdx) dstIdx--;
if (!above) dstIdx++;
keys.splice(dstIdx, 0, dragChoiceName);
const newObj = {};
for (const k of keys) newObj[k] = fieldState.variants[groupName][k];
// "Applies by default" stays on the first choice (#388).
const oldFirst = choiceNames[0];
if (keys[0] !== oldFirst && newObj[oldFirst]?.default) {
delete newObj[oldFirst].default;
newObj[keys[0]] = newObj[keys[0]] || {};
newObj[keys[0]].default = true;
}
fieldState.variants[groupName] = newObj;
dragChoiceName = null;
renderOpts();
});
// Per #238: card body is one row with name+stem header + tall prompts on
// the left and a portrait (832x1216) image with a hover delete dropdown
// on the right.
if (cRow.parentNode === choiceCard) choiceCard.removeChild(cRow);
// #336: the cover image floats right so the header, prompts,
// and Variant LoRAs flow beside it; the LoRAs spill to full
// width once they extend past the bottom of the image.
const bodyRow = document.createElement("div");
css(bodyRow, "display:flow-root;");
const promptsWrap = document.createElement("div");
css(promptsWrap, "display:flex;flex-direction:column;gap:6px;margin-top:6px;");
// Host for the Variant LoRAs section, kept in flow beside the
// floated image (populated by renderChoiceExtras). A plain block
// (not a BFC) so its rows wrap to full width past the image.
const loraHost = document.createElement("div");
const rightCol = document.createElement("div");
css(rightCol, "float:right;margin:0 0 8px 10px;display:flex;flex-direction:column;align-items:center;gap:4px;");
// 832x1216 aspect (~0.684). At 160px wide -> 234px tall.
const IMG_W = 160, IMG_H = 234;
const imgBox = document.createElement("div");
css(imgBox, `position:relative;width:${IMG_W}px;height:${IMG_H}px;border-radius:4px;background:#1a1a1a;border:1px solid #333;display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;flex-shrink:0;`);
const imgPreview = document.createElement("img");
css(imgPreview, "width:100%;height:100%;object-fit:cover;display:none;");
imgBox.appendChild(imgPreview);
const imgBoxLabel = document.createElement("span");
imgBoxLabel.textContent = "image";
css(imgBoxLabel, "font-size:11px;color:#666;pointer-events:none;");
imgBox.appendChild(imgBoxLabel);
// Hover-only delete button (top-right of image) -> dropdown menu (#238).
let _dropdownOpen = false;
const deleteBtn = document.createElement("button");
deleteBtn.type = "button";
deleteBtn.textContent = "✕";
deleteBtn.title = "Delete options";
css(deleteBtn, "position:absolute;top:6px;right:6px;z-index:2;width:22px;height:22px;padding:0;border-radius:4px;background:rgba(20,20,20,0.85);color:#ddd;border:1px solid #555;cursor:pointer;font-size:13px;line-height:1;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity 0.12s ease;");
imgBox.addEventListener("mouseenter", () => { deleteBtn.style.opacity = "1"; });
imgBox.addEventListener("mouseleave", () => { if (!_dropdownOpen) deleteBtn.style.opacity = "0"; });
imgBox.appendChild(deleteBtn);
const dropdown = document.createElement("div");
css(dropdown, "position:absolute;top:32px;right:6px;z-index:3;display:none;flex-direction:column;min-width:160px;background:#1f1f1f;border:1px solid #555;border-radius:4px;box-shadow:0 4px 12px rgba(0,0,0,0.5);overflow:hidden;");
function makeDropdownItem(label, onClick, danger) {
const item = document.createElement("button");
item.type = "button";
item.textContent = label;
css(item, `background:transparent;border:none;color:${danger ? "#f99" : "#ddd"};padding:8px 12px;text-align:left;font-size:12px;cursor:pointer;`);
item.addEventListener("mouseenter", () => { item.style.background = "rgba(255,255,255,0.05)"; });
item.addEventListener("mouseleave", () => { item.style.background = "transparent"; });
item.addEventListener("click", (e) => { e.stopPropagation(); closeDropdown(); onClick(); });
return item;
}
function refreshDropdown() {
dropdown.replaceChildren();
const cur = fieldState.variants[groupName][choiceName] || {};
if (cur.image) {
dropdown.appendChild(makeDropdownItem("Remove image", () => {
delete cur.image;
refreshChoiceImage();
}, false));
}
dropdown.appendChild(makeDropdownItem("Delete choice", () => {
delete fieldState.variants[groupName][choiceName];
renderOpts();
}, true));
}
function openDropdown() {
refreshDropdown();
dropdown.style.display = "flex";
_dropdownOpen = true;
setTimeout(() => document.addEventListener("mousedown", outsideClick), 0);
}
function closeDropdown() {
dropdown.style.display = "none";
_dropdownOpen = false;
deleteBtn.style.opacity = "0";
document.removeEventListener("mousedown", outsideClick);
}
function outsideClick(e) {
if (!dropdown.contains(e.target) && e.target !== deleteBtn) closeDropdown();
}
deleteBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (_dropdownOpen) closeDropdown(); else openDropdown();
});
imgBox.appendChild(dropdown);
function refreshChoiceImage() {
const cur = fieldState.variants[groupName][choiceName] || {};
if (cur.image) {
if (cur.image.startsWith("./") || cur._uploaded) {
imgPreview.src = imageUrl(cur.image);
} else if (mode !== "create" && name) {
imgPreview.src = getVariantImageUrl(name, groupName, choiceName);
} else {
imgPreview.src = imageUrl(cur.image);
}
imgPreview.style.display = "block";
imgBoxLabel.style.display = "none";
} else {
imgPreview.style.display = "none";
imgBoxLabel.style.display = "block";
}
}
function applyChoiceImage(fileName) {
fieldState.variants[groupName][choiceName] = fieldState.variants[groupName][choiceName] || {};
fieldState.variants[groupName][choiceName].image = fileName;
fieldState.variants[groupName][choiceName]._uploaded = true;
refreshChoiceImage();
}
imgBox.addEventListener("click", async (e) => {
// Skip the file picker when the user clicks the delete button or its
// dropdown — those have their own handlers.
if (e.target === deleteBtn || dropdown.contains(e.target)) return;
const fileName = await pickFlakeImage();
if (fileName) applyChoiceImage(fileName);
});
makeImageDropTarget(imgBox, (paths) => applyChoiceImage(paths[0]));
addImageZoom(imgBox, imgPreview);
rightCol.appendChild(imgBox);
refreshChoiceImage();
// Float (image) first in source order so the header, prompts,
// and LoRAs that follow wrap to its left (#336).
bodyRow.appendChild(rightCol);
bodyRow.appendChild(cRow);
bodyRow.appendChild(promptsWrap);
bodyRow.appendChild(loraHost);
choiceCard.appendChild(bodyRow);
// Prompts are an optional choice field (#468); removing
// the last one removes the field.
function renderChoicePrompts() {
promptsWrap.replaceChildren();
const co = fieldState.variants[groupName][choiceName] || (fieldState.variants[groupName][choiceName] = {});
const prompts = choicePrompts(co);
if (prompts.length) renderPromptList(promptsWrap, prompts, renderChoicePrompts);
}
renderChoicePrompts();
// Per-choice LoRAs / ControlNets / Flake Links (#299):
// a variant choice carries its own, merged on top of the
// flake at generation. Persisted under variants[g][c].* and
// saved automatically via ordered.variants (empties pruned).
const extrasHost = document.createElement("div");
css(extrasHost, "display:flex;flex-direction:column;gap:3px;margin-top:6px;");
choiceCard.appendChild(extrasHost);
// Add a field to this variant choice (#331). Mirrors the
// old per-section "+ X" button actions, now reached from the
// hamburger handle's dropdown.
async function addChoiceField(key) {
const co = fieldState.variants[groupName][choiceName] || (fieldState.variants[groupName][choiceName] = {});
if (key === "prompt") {
choicePrompts(co).push({ positive: "" });
renderChoicePrompts();
} else if (key === "lora") {
if (!Array.isArray(co.loras)) co.loras = [];
co.loras.push({ name: "", url: "", path: "", strength: defaultLoraStrength(), _editing: true });
renderChoiceExtras();
} else if (key === "controlnets") {
if (!Array.isArray(co.controlnets)) co.controlnets = [];
co.controlnets.push(await newCn());
renderChoiceExtras();
} else if (key === "flake_link") {
const t = await pickFlakeTarget(currentFamily, name, kind);
if (!t) return;
if (!Array.isArray(co.flake_links)) co.flake_links = [];
co.flake_links.push({ target: t, variant: {}, lora_strengths: [] });
renderChoiceExtras();
} else if (key === "prompt_options") {
if (!Array.isArray(co.prompt_options)) co.prompt_options = [];
co.prompt_options.push({ name: "", positive: "" });
renderChoiceExtras();
} else if (key === "toggler") {
co.toggler ??= "";
renderChoiceExtras();
}
}
function renderChoiceExtras() {
extrasHost.replaceChildren();
loraHost.replaceChildren();
const co = fieldState.variants[groupName][choiceName] || (fieldState.variants[groupName][choiceName] = {});
const sectionLabel = (text, host = extrasHost) => {
const l = document.createElement("div");
l.textContent = text;
css(l, "font-size:11px;color:#aaa;font-weight:500;margin-top:6px;");
host.appendChild(l);
};
// ---- LoRAs ---- (rendered into loraHost, beside the floated
// cover image, wrapping to full width when tall — #336)
if (co.loras?.length) {
sectionLabel("Variant LoRAs", loraHost);
const loraList = document.createElement("div");
css(loraList, "display:flex;flex-direction:column;gap:6px;margin-top:3px;");
loraHost.appendChild(loraList);
renderLoraList(loraList, co.loras, { family: currentFamily, tagName: true, loraDir: flakeDir, showAdd: false, lowFile });
}
// ---- ControlNets ----
const cnets = Array.isArray(co.controlnets) ? co.controlnets : [];
if (cnets.length) sectionLabel("Variant ControlNets");
cnets.forEach((cn, i) => {
// Image-left + fields-right layout matching the
// regular CN section (#311).
const card = document.createElement("div");
css(card, "display:flex;gap:8px;align-items:flex-start;background:#252525;padding:6px;border-radius:6px;border:1px solid #333;");
const imgBox = document.createElement("div");
css(imgBox, "position:relative;flex:0 0 72px;width:72px;height:72px;border:1px solid #333;border-radius:6px;background:#1a1a1a;cursor:pointer;overflow:hidden;display:flex;align-items:center;justify-content:center;");
const imgPrev = document.createElement("img");
css(imgPrev, "width:100%;height:100%;object-fit:cover;display:none;");
const imgPlaceholder = document.createElement("span");
imgPlaceholder.textContent = "+ img";
css(imgPlaceholder, "font-size:10px;color:#888;");
imgBox.appendChild(imgPrev);
imgBox.appendChild(imgPlaceholder);
// Caption with the ControlNet image's intrinsic dimensions,
// shown below the image like the top-level CN section (#256/#351).
const dimCap = document.createElement("span");
css(dimCap, "font-size:10px;color:#666;display:none;");
imgPrev.addEventListener("load", () => {
if (imgPrev.naturalWidth && imgPrev.naturalHeight) {
dimCap.textContent = `${imgPrev.naturalWidth} × ${imgPrev.naturalHeight}`;
dimCap.style.display = "block";
}
});
const showImg = () => {
if (cn.image) { imgPrev.src = imageUrl(cn.image); imgPrev.style.display = "block"; imgPlaceholder.style.display = "none"; }
else { imgPrev.style.display = "none"; imgPlaceholder.style.display = "block"; dimCap.textContent = ""; dimCap.style.display = "none"; }
};
showImg();
async function applyCnImage(fileName) {
cn.image = fileName;
let siblings = [];
try {
const types = await familyCnTypes();
const r = inferCnFromImage(cn.image, types);
if (r.inferredType && !cn.type) cn.type = r.inferredType;
siblings = r.siblings || [];
} catch { /* ignore */ }
renderChoiceExtras();
// The batch cover becomes this choice's image, replacing
// the previous one, and the flake cover if unset (#397).
const cover = await firstExistingImage(cnCoverCandidates(fileName, siblings));
if (cover) {
co.image = cover;
co._uploaded = true;
refreshChoiceImage();
}
setCoverFromCn?.(cover);
}
imgBox.addEventListener("click", async () => {
const fileName = await pickFlakeImage();
if (fileName) applyCnImage(fileName);
});
makeImageDropTarget(imgBox, (paths) => applyCnImage(paths[0]));
const imageCol = document.createElement("div");
css(imageCol, "flex:0 0 auto;display:flex;flex-direction:column;align-items:center;gap:3px;");
addImageZoom(imgBox, imgPrev);
imageCol.appendChild(imgBox);
imageCol.appendChild(dimCap);
card.appendChild(imageCol);
const right = document.createElement("div");
css(right, "flex:1;min-width:0;display:flex;flex-direction:column;gap:6px;");
const r1 = document.createElement("div");
css(r1, "display:flex;gap:8px;align-items:center;");
const modelLbl = document.createElement("span");
const inferredModel = cn.model || cn.model_name || (cn.type ? familyCnModels(currentFamily)?.[cn.type] || "" : "");
modelLbl.textContent = inferredModel ? `model: ${inferredModel}` : "model: —";
css(modelLbl, "font-size:11px;color:#888;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;");
r1.appendChild(modelLbl);
attachCnModelPicker(cn, modelLbl);
const resW = document.createElement("label");
css(resW, "display:flex;align-items:center;gap:3px;font-size:10px;color:#888;white-space:nowrap;cursor:pointer;");
const resC = document.createElement("input"); resC.type = "checkbox"; resC.checked = !!cn.resolution_from_image; css(resC, "width:14px;height:14px;cursor:pointer;margin:0;");
resC.addEventListener("change", () => { cn.resolution_from_image = resC.checked; });
resW.appendChild(resC); resW.appendChild(document.createTextNode("Img res"));
r1.appendChild(resW);
const rmBtn = makeSmallButton("✕");
rmBtn.addEventListener("click", () => { cnets.splice(i, 1); if (!cnets.length) delete co.controlnets; renderChoiceExtras(); });
r1.appendChild(rmBtn);
right.appendChild(r1);
const r2 = document.createElement("div");
css(r2, "display:flex;gap:6px;align-items:flex-end;");
const ctlCol = (labelText, el, basis) => { const c = document.createElement("div"); css(c, `${basis};min-width:0;display:flex;flex-direction:column;gap:2px;`); const l = document.createElement("span"); l.textContent = labelText; css(l, "font-size:10px;color:#888;"); el.style.width = "100%"; c.appendChild(l); c.appendChild(el); return c; };
const typeDD = makeComfyDropdown([{ value: "", label: "— type —" }], cn.type || "");
familyCnTypes(cn.type).then((types) => { for (const t of types) { const o = document.createElement("option"); o.value = t; o.textContent = t; if (t === cn.type) o.selected = true; typeDD.element.appendChild(o); } }).catch(() => {});
typeDD.element.addEventListener("change", () => { cn.type = typeDD.element.value; renderChoiceExtras(); });
r2.appendChild(ctlCol("Type", typeDD.container, "flex:0 0 96px"));
const strS = makeComfyValueSlider(cn.strength ?? 1.0, 0, 2, 0.05, (v) => { cn.strength = v; });
r2.appendChild(ctlCol("Str", strS, "flex:1"));
const startS = makeComfyValueSlider(cn.start_percent ?? 0, 0, 1, 0.05, (v) => { cn.start_percent = v; });
r2.appendChild(ctlCol("Start", startS, "flex:1"));
const endS = makeComfyValueSlider(cn.end_percent ?? 1, 0, 1, 0.05, (v) => { cn.end_percent = v; });
r2.appendChild(ctlCol("End", endS, "flex:1"));
right.appendChild(r2);
card.appendChild(right);
extrasHost.appendChild(card);
});
// ---- Flake Links ----
const links = Array.isArray(co.flake_links) ? co.flake_links : [];
if (links.length) sectionLabel("Variant Flake Links");
links.forEach((link, li) => {
const card = document.createElement("div");
css(card, "background:#252525;padding:6px;border-radius:6px;border:1px solid #333;display:flex;flex-direction:column;gap:6px;");
const header = document.createElement("div");
css(header, "display:flex;gap:8px;align-items:center;");
header.appendChild(makeScheduleHandle(link, renderChoiceExtras));
const title = document.createElement("span");
title.textContent = link.target ? link.target.split("/").pop() : "(no target)";
title.title = link.target || "";
css(title, "flex:1;font-size:12px;color:#cdd;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;");
header.appendChild(title);
const chg = makeSmallButton("Change");
chg.addEventListener("click", async () => { const t = await pickFlakeTarget(currentFamily, name, kind); if (t) { link.target = t; link.variant = {}; link.lora_strengths = []; link.variant_loras = {}; renderChoiceExtras(); } });
header.appendChild(chg);
const rm = makeSmallButton("✕");
rm.addEventListener("click", () => { links.splice(li, 1); if (!links.length) delete co.flake_links; renderChoiceExtras(); });
header.appendChild(rm);
card.appendChild(header);
// Same variant dropdowns and LoRA sliders as the flake's own links (#420).
const ovr = document.createElement("div");
css(ovr, "display:flex;flex-direction:column;gap:6px;");
card.appendChild(ovr);
renderOneLinkOverrides(link, ovr);
if (link.schedule) card.appendChild(makeSchedulePanel(link));
extrasHost.appendChild(card);
});
// ---- Options / Toggler (#464) ----
if (Array.isArray(co.prompt_options)) {
sectionLabel("Variant Options");
const optBox = document.createElement("div");
css(optBox, "display:flex;flex-direction:column;gap:6px;");
extrasHost.appendChild(optBox);
renderOptionList(optBox, co.prompt_options);
}
if (typeof co.toggler === "string") {
sectionLabel("Variant Toggler");
const row = document.createElement("div");
css(row, "display:flex;gap:6px;align-items:center;");
const rm = makeSmallButton("✕");
rm.addEventListener("click", () => { delete co.toggler; renderChoiceExtras(); });
row.append(makeTogglerInput(co), rm);
extrasHost.appendChild(row);
}
}
renderChoiceExtras();
// The group's first choice can apply when a flake
// instance selects nothing in this group (#388).
if (choiceName === choiceNames[0]) {
const defWrap = document.createElement("label");
css(defWrap, "clear:both;display:flex;align-items:center;gap:6px;font-size:11px;color:#aaa;cursor:pointer;margin-top:4px;");
const defChk = document.createElement("input");
defChk.type = "checkbox";
defChk.checked = !!choice.default;
css(defChk, "width:14px;height:14px;cursor:pointer;margin:0;");
defChk.addEventListener("change", () => {
const co = fieldState.variants[groupName][choiceName] || (fieldState.variants[groupName][choiceName] = {});
if (defChk.checked) co.default = true;
else delete co.default;
});
defWrap.appendChild(defChk);
defWrap.appendChild(document.createTextNode("Applies by default"));
choiceCard.appendChild(defWrap);
}
groupCard.appendChild(choiceCard);
}
const addChoiceBtn = makeSmallButton("+ choice");
addChoiceBtn.addEventListener("click", (e) => {
e.stopPropagation();
e.preventDefault();
const inlineWrap = document.createElement("div");
css(inlineWrap, "display:flex;gap:6px;align-items:center;padding-top:4px;");
const choiceNameInput = makeComfyInput("", "e.g. red dress");
choiceNameInput.style.flex = "1";
const addBtn = makeSmallButton("Add");
const cancelBtn = makeSmallButton("Cancel");
inlineWrap.appendChild(choiceNameInput);
inlineWrap.appendChild(addBtn);
inlineWrap.appendChild(cancelBtn);
addChoiceBtn.replaceWith(inlineWrap);
choiceNameInput.focus();
function finish() {
inlineWrap.replaceWith(addChoiceBtn);
}
addBtn.addEventListener("click", async () => {
const base = choiceNameInput.value.trim();
if (!base) { finish(); return; }
const group = fieldState.variants[groupName];
// Taken names get the first free suffix: "Pose B" .. "Pose Z", then "Pose 27" ...
let trimmed = base;
for (let i = 1; group[trimmed]; i++) {
trimmed = `${base} ${i < 26 ? String.fromCharCode(65 + i) : i + 1}`;
}
// Inherit the fields the group's choices have (#401).
const siblings = Object.values(group).map(c => c || {});
const choiceData = { positive: "" };
if (selectedType === "Character" && !siblings.length) choiceData.default = true;
const cnSrc = siblings.find(c => c.controlnets?.length)?.controlnets[0];
if (cnSrc) {
const { image, ...cnSettings } = cnSrc;
choiceData.controlnets = [{ ...cnSettings, image: "" }];
}
if (siblings.some(c => c.loras)) {
const strength = siblings.find(c => c.loras?.length)?.loras[0].strength ?? defaultLoraStrength();
choiceData.loras = [{ name: "", path: "", strength }];
}
const stemmed = Object.entries(group).filter(([, c]) => c?.output_stem);
if (stemmed.length && stemmed.every(([n, c]) => c.output_stem === slugStem(n))) {
choiceData.output_stem = slugStem(trimmed);
}
// Auto-detect variant sibling image from first LoRA
const firstLora = Array.isArray(data.loras) ? data.loras[0] : null;
const loraPath = firstLora?.path || firstLora?.name || null;
if (loraPath) {
try {
const siblingPath = await fetchLoraVariantSiblingImagePath(loraPath, groupName, trimmed);
if (siblingPath) {
choiceData.image = siblingPath;
}
} catch { /* ignore */ }
}
fieldState.variants[groupName][trimmed] = choiceData;
finish();
renderOpts();
});
cancelBtn.addEventListener("click", finish);
choiceNameInput.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
addBtn.click();
}
if (ev.key === "Escape") {
ev.preventDefault();
finish();
}
});
});
groupCard.appendChild(addChoiceBtn);
optsBox.appendChild(groupCard);
}
const addGroupBtn = makeSmallButton("+ variant group");
addGroupBtn.addEventListener("click", (e) => {
e.stopPropagation();
e.preventDefault();
// Replace the button with an inline input + add/cancel controls
const inlineWrap = document.createElement("div");
css(inlineWrap, "display:flex;gap:6px;align-items:center;");
const groupNameInput = makeComfyInput("", "e.g. outfit");
groupNameInput.style.flex = "1";
const addBtn = makeSmallButton("Add");
const cancelBtn = makeSmallButton("Cancel");
inlineWrap.appendChild(groupNameInput);
inlineWrap.appendChild(addBtn);
inlineWrap.appendChild(cancelBtn);
addGroupBtn.replaceWith(inlineWrap);
groupNameInput.focus();
function finish() {
inlineWrap.replaceWith(addGroupBtn);
}
addBtn.addEventListener("click", () => {
const trimmed = groupNameInput.value.trim();
if (!trimmed) { finish(); return; }
if (fieldState.variants[trimmed]) {
window.alert(`Variant group '${trimmed}' already exists.`);
finish();
return;
}
fieldState.variants[trimmed] = {};
finish();
renderOpts();
});
cancelBtn.addEventListener("click", finish);
groupNameInput.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
addBtn.click();
}
if (ev.key === "Escape") {
ev.preventDefault();
finish();
}
});
});
optsBox.appendChild(addGroupBtn);
}
renderOpts();
}
if (fieldType === "flake_link") {
// Flake links field (#234): one or more linked flakes, each
// picked via an overlay (search + folders), with optional
// default overrides for the target's variant choices and lora
// strengths. Persisted as `flake_links:` (list).
const linkBox = document.createElement("div");
css(linkBox, "display:flex;flex-direction:column;gap:8px;");
fieldWrap.appendChild(linkBox);
if (!Array.isArray(fieldState.flake_links)) fieldState.flake_links = [];
renderLinkList(linkBox, fieldState.flake_links, () => pickFlakeTarget(currentFamily, name, kind));
}
// Options (#464): optional prompts toggled per grid item, or by
// the toggler of a flake further down the chain.
if (fieldType === "prompt_options") {
const optBox = document.createElement("div");
css(optBox, "display:flex;flex-direction:column;gap:6px;");
fieldWrap.appendChild(optBox);
renderOptionList(optBox, fieldState.prompt_options);
}
if (fieldType === "toggler") fieldWrap.appendChild(makeTogglerInput(fieldState));
optionalBox.appendChild(fieldWrap);
}
}
renderFields();
// Add field UI: a single "+ Add field" button that opens a dropdown with
// every field type. The standalone +LoRA / +ControlNet / +Flake Link quick
// buttons were removed (#330); those types live in the dropdown instead.
const addFieldWrap = document.createElement("div");
css(addFieldWrap, "display:flex;flex-direction:column;gap:2px;");
const btnRow = document.createElement("div");
css(btnRow, "display:flex;gap:8px;align-items:center;flex-wrap:wrap;");
// "+ Add field" dropdown for every field type.
const addFieldBtn = makeSmallButton("+ Add field");
btnRow.appendChild(addFieldBtn);
const fieldMenu = document.createElement("div");
css(fieldMenu, "display:none;flex-direction:column;gap:2px;background:#1e1e1e;border:1px solid #444;border-radius:4px;padding:4px;box-shadow:0 4px 12px rgba(0,0,0,0.5);position:fixed;z-index:10000;");
const fieldTypes = [
{ key: "prompt", label: "Prompts" },
{ key: "resolution", label: "Resolution override" },
{ key: "variants", label: "Variants" },
{ key: "lora", label: "LoRA" },
...(animFamily ? [] : [{ key: "controlnets", label: "ControlNets" }]),
{ key: "flake_link", label: "Flake link" },
{ key: "prompt_options", label: "Options" },
{ key: "toggler", label: "Toggler" },
];
for (const ft of fieldTypes) {
const item = document.createElement("button");
item.textContent = ft.label;
css(item, "text-align:left;padding:4px 8px;background:#2a2a2a;color:#ddd;border:1px solid #444;border-radius:3px;cursor:pointer;font-size:12px;");
item.addEventListener("click", async () => {
fieldMenu.style.display = "none";
if (activeFields.includes(ft.key)) {
scrollToFieldKey(ft.key);
return;
}
activeFields.push(ft.key);
if (ft.key === "lora") fieldState.loras = [{ name: "", url: "", path: "", strength: 1.0, _editing: true }];
if (ft.key === "prompt") fieldState.prompts = [{}];
if (ft.key === "resolution") fieldState.resolution = [1024, 1024];
if (ft.key === "controlnets") fieldState.controlnets._ = [await newCn()];
if (ft.key === "variants") fieldState.variants = { Default: { Default: {} } };
if (ft.key === "flake_link") fieldState.flake_links = [];
if (ft.key === "prompt_options") fieldState.prompt_options = [{ name: "", positive: "" }];
renderFields();
scrollToFieldKey(ft.key);
// Preserve the old quick-add conveniences now that the standalone
// buttons are gone (#330): auto-open the CN file picker (#319) and
// the Load-existing-flake popup (#320).
if (ft.key === "controlnets") {
const imgBox = optionalBox.querySelector('[data-field-key="controlnets"] [data-cn-main-img]');
if (imgBox) imgBox.click();
} else if (ft.key === "flake_link") {
const target = await pickFlakeTarget(currentFamily, name, kind);
if (target) {
fieldState.flake_links.push({ target, variant: {}, lora_strengths: [] });
renderFields();
scrollToFieldKey("flake_link");
}
}
});
fieldMenu.appendChild(item);
}
document.body.appendChild(fieldMenu);
addFieldBtn.addEventListener("mousedown", (e) => e.stopPropagation());
addFieldBtn.addEventListener("dblclick", (e) => e.stopPropagation());
addFieldBtn.addEventListener("click", (e) => {
e.stopPropagation();
e.preventDefault();
const showing = fieldMenu.style.display === "flex";
if (showing) {
fieldMenu.style.display = "none";
} else {
const rect = addFieldBtn.getBoundingClientRect();
fieldMenu.style.left = `${rect.left}px`;
fieldMenu.style.top = `${rect.bottom + 4}px`;
fieldMenu.style.display = "flex";
}
});
// Hide menu on click outside
function onDocClick(e) {
if (e.target !== addFieldBtn && !fieldMenu.contains(e.target)) {
fieldMenu.style.display = "none";
}
}
document.addEventListener("click", onDocClick);
handlers.onClose = ((prev) => (v) => {
document.removeEventListener("click", onDocClick);
if (fieldMenu.parentElement) fieldMenu.remove();
prev?.(v);
})(handlers.onClose);
addFieldWrap.appendChild(btnRow);
content.appendChild(addFieldWrap);
// ---- Footer ----
if (mode === "edit") {
const deleteBtn = makeButton("Delete");
css(deleteBtn, deleteBtn.style.cssText + "background:#5a2a2a;border-color:#7a3a3a;color:#fdd;margin-right:auto;");
deleteBtn.addEventListener("click", async () => {
if (!window.confirm(`Delete '${name}'? This cannot be undone.`)) return;
try {
await deleteFlakeApi(name);
close({ deleted: true, name });
} catch (err) {
window.alert(`Delete failed: ${err.message || err}`);
}
});
footer.appendChild(deleteBtn);
}
const cancelBtn = makeButton("Cancel");
cancelBtn.addEventListener("click", async () => {
if (dirty && !window.confirm("Are you sure you want to cancel? Unsaved changes will be lost.")) return;
close(undefined);
});
footer.appendChild(cancelBtn);
const saveBtn = makeButton("Save", true);
if (!rootsLoaded) saveBtn.disabled = true;
saveBtn.addEventListener("click", async () => {
const ordered = {};
if (displayNameInput && displayNameInput.value) ordered.name = displayNameInput.value.trim();
if (selectedType) ordered.flake_type = selectedType;
if (serieInput && serieInput.value.trim()) ordered.serie = serieInput.value.trim();
if (outputStemInput && outputStemInput.value.trim()) ordered.output_stem = outputStemInput.value.trim();
if (favorite) ordered.favorite = true;
if (autoCaptionChk?.checked) ordered.auto_caption = true;
// Save fields in the user-selected order so YAML key order is preserved
for (const ft of activeFields) {
if (ft === "lora" && fieldState.loras && fieldState.loras.length > 0) {
ordered.loras = serializeLoras(fieldState.loras);
}
if (ft === "prompt" && fieldState.prompts.length > 0) {
const prompts = serializePrompts(fieldState.prompts);
// A single unscheduled prompt keeps the legacy `prompt:` key (#371).
if (prompts.length === 1 && !prompts[0].schedule) ordered.prompt = prompts[0];
else ordered.prompts = prompts;
}
if (ft === "resolution") {
// Prefer reading directly from the DOM inputs so we capture
// values typed but not yet committed via change/blur.
const inputs = fieldState._resolutionInputs;
let rw = inputs ? parseInt(inputs.w.value) : NaN;
let rh = inputs ? parseInt(inputs.h.value) : NaN;
if (!Number.isFinite(rw) && fieldState.resolution) rw = parseInt(fieldState.resolution[0]);
if (!Number.isFinite(rh) && fieldState.resolution) rh = parseInt(fieldState.resolution[1]);
if (Number.isFinite(rw) && Number.isFinite(rh)) ordered.resolution = [rw, rh];
}
if (ft === "controlnets") {
const cnArr = fieldState.controlnets._ || [];
if (cnArr.length > 0) {
ordered.controlnets = cnArr.map(cn => {
const { model, model_name, resolution_from_image, ...rest } = cn;
if (model) rest.model = model;
if (resolution_from_image) rest.resolution_from_image = true;
return rest;
});
}
}
if (ft === "variants" && Object.keys(fieldState.variants).length > 0) {
// Prune incomplete per-choice LoRAs / ControlNets / Flake Links
// (#299): drop rows missing their key field and remove now-empty
// arrays so the yaml stays clean.
for (const group of Object.values(fieldState.variants)) {
// Only the group's first choice applies by default (#388).
for (const choice of Object.values(group || {}).slice(1)) {
if (choice) delete choice.default;
}
for (const choice of Object.values(group || {})) {
if (!choice) continue;
delete choice._uploaded;
storeChoicePrompts(choice);
if (Array.isArray(choice.loras)) {
choice.loras = serializeLoras(choice.loras.filter(l => l && (l.path || l.name)));
if (choice.loras.length === 0) delete choice.loras;
}
if (Array.isArray(choice.controlnets)) {
choice.controlnets = choice.controlnets.filter(cn => cn && (cn.image || cn.type));
if (choice.controlnets.length === 0) delete choice.controlnets;
}
if (Array.isArray(choice.flake_links)) {
choice.flake_links = serializeLinks(choice.flake_links);
if (choice.flake_links.length === 0) delete choice.flake_links;
}
if (Array.isArray(choice.prompt_options)) {
choice.prompt_options = serializeOptions(choice.prompt_options);
if (choice.prompt_options.length === 0) delete choice.prompt_options;
}
if (typeof choice.toggler === "string") {
choice.toggler = choice.toggler.trim();
if (!choice.toggler) delete choice.toggler;
}
}
}
ordered.variants = fieldState.variants;
}
if (ft === "prompt_options") {
const opts = serializeOptions(fieldState.prompt_options);
if (opts.length) ordered.prompt_options = opts;
}
if (ft === "toggler" && fieldState.toggler.trim()) ordered.toggler = fieldState.toggler.trim();
if (ft === "flake_link" && Array.isArray(fieldState.flake_links) && fieldState.flake_links.length > 0) {
const outLinks = serializeLinks(fieldState.flake_links);
if (outLinks.length > 0) ordered.flake_links = outLinks;
}
}
if (coverSourcePath) {
ordered.cover_image = coverSourcePath;
}
try {
if (mode === "default") {
await close({ defaultUpdated: true, data: ordered });
return;
}
const outputPath = (pathInput?.value || "").trim();
if (!outputPath) { window.alert("Path is required"); return; }
const fullOutputPath = getOutputPrefix() + outputPath;
const baseRootIndex = baseRootSelectRef ? parseInt(baseRootSelectRef.value, 10) : 0;
const oldName = mode !== "create" ? name : null;
const savedName = await saveFlakeApi(
fullOutputPath, ordered,
currentFamily || undefined,
Number.isFinite(baseRootIndex) ? baseRootIndex : 0,
fullOutputPath,
oldName,
kind,
);
if (mode === "create") {
await close({ created: true, name: savedName, data: ordered });
} else {
await close({ saved: true, name: savedName, data: ordered, oldName: name !== savedName ? name : undefined });
}
} catch (err) {
window.alert(`Save failed: ${err.message || err}`);
}
});
footer.appendChild(saveBtn);
setTimeout(() => { (pathInput || displayNameInput)?.focus(); }, 0);
});
}