Files
JeyzerMC-comfyui-flakes/web/flake-modal.js
T

2586 lines
159 KiB
JavaScript
Raw 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,
makeTextarea, makeLabel, makeNumberInput, attachAutoGrow,
familyFolder, familyFromFolder, makeHoverRemoveWrapper, CN_MODEL_MAP,
} from "./utils.js";
import {
getCoverUrl, getVariantImageUrl, uploadCover, fetchLoras, fetchCnTypes, fetchInputs,
saveFlakeApi, deleteFlakeApi, fetchFlakeMeta, fetchFlake,
fetchLoraSiblingImage, loraSiblingImageUrl, fetchLoraSiblingImagePath,
fetchLoraVariantSiblingImagePath, invalidateList,
} from "./api.js";
import { openFileBrowser, openFileLoadPicker } from "./pickers.js";
// 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.
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 };
}
// Transient menu anchored under a ☰ handle. `items` is [{ label, onClick }].
function openFieldMenu(anchorEl, items) {
const menu = document.createElement("div");
css(menu, "display:flex;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;");
function cleanup() {
document.removeEventListener("click", onOutside, true);
if (menu.parentElement) menu.remove();
}
function onOutside(e) {
if (!menu.contains(e.target) && e.target !== anchorEl) cleanup();
}
for (const o of items) {
const it = document.createElement("button");
it.textContent = o.label;
css(it, "text-align:left;padding:4px 8px;background:#2a2a2a;color:#ddd;border:1px solid #444;border-radius:3px;cursor:pointer;font-size:12px;");
it.addEventListener("click", (e) => { e.stopPropagation(); cleanup(); o.onClick(); });
menu.appendChild(it);
}
document.body.appendChild(menu);
const rect = anchorEl.getBoundingClientRect();
menu.style.left = `${rect.left}px`;
menu.style.top = `${rect.bottom + 4}px`;
// Defer so the opening click doesn't immediately close it.
setTimeout(() => document.addEventListener("click", onOutside, true), 0);
}
// ☰ handle for a schedulable card (LoRA / prompt / flake link, #372).
function makeScheduleHandle(entry, rerender) {
const handle = document.createElement("span");
handle.textContent = "☰";
css(handle, "cursor:pointer;color:#666;font-size:12px;user-select:none;");
handle.title = "Options";
handle.addEventListener("click", (e) => {
e.stopPropagation();
openFieldMenu(handle, [scheduleMenuItem(entry, rerender)]);
});
return handle;
}
function scheduleMenuItem(entry, rerender) {
if (entry.schedule) {
return { label: "Remove Hook Scheduler", onClick: () => { delete entry.schedule; rerender(); } };
}
return {
label: "Add Hook Scheduler",
onClick: () => { entry.schedule = [{ start: 0.0, strength: 1.0 }, { start: 0.5, strength: 0.0 }]; rerender(); },
};
}
// Keyframe editor for `entry.schedule` (#372): a 0-100% timeline showing the
// strength of each segment plus Start % / Strength rows. Start is stored as a
// fraction; strength holds until the next keyframe and is 1.0 before the first.
function makeSchedulePanel(entry) {
const panel = document.createElement("div");
css(panel, "display:flex;flex-direction:column;gap:4px;background:#1e1e1e;border:1px solid #333;border-radius:4px;padding:6px;");
const fmt = (v) => String(Math.round(v * 100) / 100);
function draw() {
panel.replaceChildren();
const kfs = entry.schedule;
kfs.sort((a, b) => a.start - b.start);
const maxStrength = Math.max(1, ...kfs.map(k => k.strength));
const bar = document.createElement("div");
css(bar, "position:relative;height:18px;background:#141414;border:1px solid #333;border-radius:3px;overflow:hidden;");
const segments = [{ start: 0, strength: 1.0 }, ...kfs];
segments.forEach((k, i) => {
const end = i + 1 < segments.length ? segments[i + 1].start : 1;
if (end <= k.start) return;
const seg = document.createElement("div");
const alpha = 0.15 + 0.75 * Math.min(1, k.strength / maxStrength);
css(seg, `position:absolute;top:0;bottom:0;left:${k.start * 100}%;width:${(end - k.start) * 100}%;background:rgba(74,158,255,${k.strength > 0 ? alpha : 0});border-left:1px solid #444;box-sizing:border-box;font-size:10px;color:#ddd;display:flex;align-items:center;justify-content:center;overflow:hidden;white-space:nowrap;`);
seg.textContent = fmt(k.strength);
seg.title = `${fmt(k.start * 100)}% – ${fmt(end * 100)}%: strength ${fmt(k.strength)}`;
bar.appendChild(seg);
});
panel.appendChild(bar);
kfs.forEach((k, i) => {
const row = document.createElement("div");
css(row, "display:flex;gap:6px;align-items:center;");
const startLbl = document.createElement("span");
startLbl.textContent = "Start %";
css(startLbl, "font-size:11px;color:#888;white-space:nowrap;");
row.appendChild(startLbl);
const startIn = makeComfyNumberInput(fmt(k.start * 100), "0", 1);
startIn.min = "0";
startIn.max = "100";
css(startIn, startIn.style.cssText + "flex:1;min-width:0;padding:3px 6px;font-size:12px;");
startIn.addEventListener("change", () => {
const v = parseFloat(startIn.value);
k.start = Number.isFinite(v) ? Math.min(100, Math.max(0, v)) / 100 : k.start;
draw();
});
row.appendChild(startIn);
const strLbl = document.createElement("span");
strLbl.textContent = "Strength";
css(strLbl, "font-size:11px;color:#888;white-space:nowrap;");
row.appendChild(strLbl);
const strIn = makeComfyNumberInput(fmt(k.strength), "1", 0.05);
strIn.min = "0";
css(strIn, strIn.style.cssText + "flex:1;min-width:0;padding:3px 6px;font-size:12px;");
strIn.addEventListener("change", () => {
const v = parseFloat(strIn.value);
k.strength = Number.isFinite(v) ? Math.max(0, v) : k.strength;
draw();
});
row.appendChild(strIn);
const rm = makeSmallButton("✕");
rm.title = "Remove keyframe";
if (kfs.length === 1) rm.style.visibility = "hidden";
rm.addEventListener("click", () => { kfs.splice(i, 1); draw(); });
row.appendChild(rm);
panel.appendChild(row);
});
const addBtn = makeSmallButton("+ Keyframe");
addBtn.style.alignSelf = "flex-start";
addBtn.addEventListener("click", () => {
const last = kfs[kfs.length - 1];
kfs.push({ start: last ? (last.start + 1) / 2 : 0, strength: 1.0 });
draw();
});
panel.appendChild(addBtn);
}
draw();
return panel;
}
export function openEditModal({ mode, name, data, dirs, family = "SDXL/Base" }) {
return new Promise((resolve) => {
let { content, footer, close, handlers, panel } = openOverlay();
handlers.onClose = (v) => resolve(v ?? 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 title = document.createElement("h3");
css(title, "margin:0 0 8px;font-size:16px;color:#fff;font-weight:500;");
title.textContent =
mode === "default" ? "Edit default flake" :
mode === "create" ? "New flake" :
`Edit ${name}`;
content.appendChild(title);
// ---- 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"],
};
// 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)) continue;
activeFields.push(key);
lastAdded = key;
if (key === "lora") fieldState.loras = [{ name: "", url: "", path: "", strength: 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: {} } } : {};
}
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;
function getOutputPrefix() {
const folder = familyFolder(currentFamily);
return folder ? `img/${folder}/` : "img/";
}
function stripOutputPrefix(val) {
if (!val) return val;
const prefixes = ["img/sdxl/", "img/illustrious/", "img/pony/", "img/zib/", "img/zit/", "img/anima/", "img/flux_klein/", "img/common/", "img/"];
for (const p of prefixes) {
if (val.toLowerCase().startsWith(p)) return val.slice(p.length);
}
return val;
}
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 -> tagged LoRA
// names. Serie precedes the display name; tagged LoRA names follow it.
let pathManuallyEdited = mode !== "create";
let stemManuallyEdited = mode !== "create";
function slugPart(s) { return (s || "").trim().replace(/ /g, "_").toLowerCase(); }
function taggedLoraSlugs() {
return (fieldState.loras || [])
.filter(l => l && l.tag_name && (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 = taggedLoraSlugs();
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");
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 coverFile = null;
let coverSourcePath = data?.cover_image || null;
let coverImg = null;
let setCoverFromLora = null;
let setCoverFromCnImage = null;
let trySiblingCover = null;
let currentFamily = 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 < 2) return family;
return familyFromFolder(parts[1]) || family;
}
if (mode === "edit" && name) {
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 = CN_MODEL_MAP[familyFolder(currentFamily)];
const out = mapped ? types.filter(t => t in mapped) : types;
if (keep && !out.includes(keep)) out.push(keep);
return out;
}
function loraBrowserDefaultPath() {
const folder = familyFolder(currentFamily);
return folder ? `img/${folder}` : "";
}
// Open the load picker (search bar + folders + flakes) and return the
// chosen flake name, or null. Hoisted so the add-field UI can auto-open
// it when a Flake Link field is added (#320).
async function pickFlakeTarget() {
try {
const list = await (await fetch(`/flakes/list?family=${encodeURIComponent(currentFamily || "")}`)).json();
const flakes = (list.flakes || []).filter(n => n !== name);
const res = await openFileLoadPicker({
flakes,
directories: list.directories || [],
family: currentFamily || "",
displayNames: list.display_names || {},
tagNames: list.tag_names || {},
});
return res && res.name ? res.name : null;
} catch { return null; }
}
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);
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: any pending upload, the stored source, and the preview.
coverFile = null;
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 */ }
}
});
const coverInput = document.createElement("input");
coverInput.type = "file";
coverInput.accept = ".png,.jpg,.jpeg,.webp,.gif";
coverInput.style.display = "none";
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";
}
}
// Default cover for edit mode
if (mode === "edit" && name) {
updateCoverPreview(getCoverUrl(name));
}
coverBox.addEventListener("click", () => coverInput.click());
coverInput.addEventListener("change", () => {
const file = coverInput.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = () => updateCoverPreview(reader.result);
reader.readAsDataURL(file);
// Manual selection always copies the image beside the flake
// yaml as <name>.<ext> and uses that copy — never an external
// reference (#352). Automatic covers (LoRA / CN siblings) keep
// their reference behaviour via setCoverFrom* below.
coverFile = file;
coverSourcePath = null;
}
});
// 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 (coverFile || coverSourcePath || !loraPath) return false;
try {
const imagePath = await fetchLoraSiblingImagePath(loraPath);
if (imagePath) {
coverSourcePath = imagePath;
updateCoverPreview(loraSiblingImageUrl(loraPath));
return true;
}
} catch {
return false;
}
};
// Auto-cover from a controlnet input image when no cover has been chosen
setCoverFromCnImage = (imagePath) => {
if (coverFile || coverSourcePath || !imagePath) return false;
coverSourcePath = imagePath;
updateCoverPreview(`/view?filename=${encodeURIComponent(imagePath)}&type=input`);
return true;
};
// Probe candidate sibling cover filenames (input dir) in order and set
// the first that actually loads as the flake cover, when no cover is
// set yet (#353). Shared by the top-level and variant-choice CN images.
trySiblingCover = (candidates) => {
if (!Array.isArray(candidates) || !candidates.length) return;
if (coverFile || coverSourcePath || !setCoverFromCnImage) return;
let idx = 0;
const tryNext = () => {
if (idx >= candidates.length || coverFile || coverSourcePath) return;
const cand = candidates[idx++];
const probe = new Image();
probe.onload = () => { if (!coverFile && !coverSourcePath) setCoverFromCnImage(cand); };
probe.onerror = tryNext;
probe.src = `/view?filename=${encodeURIComponent(cand)}&type=input`;
};
tryNext();
};
coverWrap.appendChild(coverBox);
coverWrap.appendChild(coverInput);
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 && !coverFile) {
const prefix = getOutputPrefix();
const fullP = prefix + p.replace(/\.ya?ml$/i, ".png");
updateCoverPreview(getCoverUrl(fullP));
}
});
}
const origClose = close;
close = async (value) => {
if (value && (value.created || value.saved)) {
if (coverFile) {
try {
await uploadCover(value.name, coverFile);
} catch { /* ignore */ }
} else if (coverSourcePath) {
// A cover_image reference supersedes any previously-copied
// cover sitting beside the yaml; remove it so the
// reference isn't shadowed on reload (#259).
try {
await fetch(`/flakes/cover?name=${encodeURIComponent(value.name)}`, { method: "DELETE" });
} catch { /* ignore */ }
}
}
origClose(value);
};
}
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: yaml defaults — list of { target, variant, lora_strengths }.
// Normalize legacy single `flake_link` into the list (#234).
flake_links: (Array.isArray(data.flake_links)
? data.flake_links
: (data.flake_link ? [data.flake_link] : [])
).filter(l => l && l.target).map(l => ({
target: String(l.target || ""),
variant: { ...(l.variant || {}) },
lora_strengths: Array.isArray(l.lora_strengths) ? [...l.lora_strengths] : [],
...(Array.isArray(l.schedule) ? { schedule: l.schedule.map(k => ({ ...k })) } : {}),
})),
};
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" };
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");
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 = [];
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);
function renderLoras() {
loraBox.replaceChildren();
for (let i = 0; i < fieldState.loras.length; i++) {
const lora = fieldState.loras[i];
const card = document.createElement("div");
css(card, "background:#252525;padding:10px;border-radius:6px;display:flex;flex-direction:column;gap:6px;border:1px solid #333;");
const header = document.createElement("div");
css(header, "display:flex;gap:6px;align-items:center;");
header.appendChild(makeScheduleHandle(lora, renderLoras));
const title = document.createElement("span");
const titleText = lora.name ? `LoRA: ${lora.name}` : "LoRA";
title.textContent = titleText;
css(title, "flex:1;font-size:12px;font-weight:500;color:#aaa;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;");
header.appendChild(title);
if (lora.url) {
const linkIcon = document.createElement("a");
linkIcon.textContent = "\uD83D\uDD17";
css(linkIcon, "font-size:12px;text-decoration:none;cursor:pointer;color:#4a9eff;");
linkIcon.addEventListener("click", (e) => {
e.stopPropagation();
window.open(lora.url, "_blank");
});
header.appendChild(linkIcon);
}
const editBtn = document.createElement("button");
editBtn.innerHTML = lora._editing ? "&#9652;" : "&#9662;";
css(editBtn, "background:transparent;color:#888;border:none;padding:0;font-size:14px;cursor:pointer;line-height:1;display:flex;align-items:center;justify-content:center;height:20px;width:20px;");
editBtn.title = "Edit name and URL";
editBtn.addEventListener("click", () => {
lora._editing = !lora._editing;
renderLoras();
});
header.appendChild(editBtn);
const removeBtn = makeSmallButton("\u2715");
removeBtn.addEventListener("click", () => {
fieldState.loras.splice(i, 1);
renderLoras();
});
header.appendChild(removeBtn);
card.appendChild(header);
if (lora._editing) {
const editRow = document.createElement("div");
css(editRow, "display:flex;flex-direction:column;gap:4px;");
const nameInput = makeComfyInput(lora.name || "", "Display name");
nameInput.addEventListener("change", () => {
lora.name = nameInput.value;
if (lora.tag_name) recomputePrefills();
renderLoras();
});
const urlInput = makeComfyInput(lora.url || "", "https://civitai.com/models/...");
urlInput.addEventListener("change", () => {
lora.url = urlInput.value;
renderLoras();
});
editRow.appendChild(makeLabel("Name"));
editRow.appendChild(nameInput);
// Tag-name checkbox (#274): append this LoRA's name to the
// output stem, output path, and grid display name.
const tagRow = document.createElement("div");
css(tagRow, "display:flex;align-items:center;gap:6px;margin-top:2px;");
const tagChk = document.createElement("input");
tagChk.type = "checkbox";
tagChk.checked = !!lora.tag_name;
css(tagChk, "cursor:pointer;margin:0;");
const tagLbl = document.createElement("label");
tagLbl.textContent = "Tag flake with this LoRA's name";
css(tagLbl, "font-size:11px;color:#aaa;cursor:pointer;");
const toggleTag = () => {
lora.tag_name = tagChk.checked;
recomputePrefills();
};
tagChk.addEventListener("change", toggleTag);
tagLbl.addEventListener("click", () => { tagChk.checked = !tagChk.checked; toggleTag(); });
tagRow.appendChild(tagChk);
tagRow.appendChild(tagLbl);
editRow.appendChild(tagRow);
editRow.appendChild(makeLabel("URL"));
editRow.appendChild(urlInput);
card.appendChild(editRow);
}
const pathRow = document.createElement("div");
css(pathRow, "display:flex;gap:4px;align-items:center;");
const loraWrap = makeSearchableDropdown([], lora.path || "", "Select LoRA...");
loraWrap.container.style.display = "none";
(async () => {
try {
const loras = await fetchLoras();
for (const l of loras) loraWrap.datalist.appendChild(Object.assign(document.createElement("option"), { value: l }));
} catch { /* ignore */ }
})();
const pathBox = document.createElement("div");
css(pathBox, "flex:1;background:#1a1a1a;color:#ddd;border:1px solid #333;padding:6px 8px;border-radius:6px;font-size:13px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;height:32px;box-sizing:border-box;display:flex;align-items:center;");
pathBox.textContent = lora.path ? lora.path.replace(/\.safetensors?$/i, "").split(/[\\/]/).pop() : "No LoRA selected";
pathBox.title = lora.path || "";
const pathEditBtn = makeSmallButton("...");
pathEditBtn.title = "Type manually";
pathBox.addEventListener("click", async () => {
const result = await openFileBrowser({ type: "loras", defaultPath: loraBrowserDefaultPath() });
if (result && result.file) {
lora.path = result.file;
pathBox.textContent = lora.path ? lora.path.replace(/\.safetensors?$/i, "").split(/[\\/]/).pop() : "No LoRA selected";
pathBox.title = lora.path || "";
loraWrap.element.value = lora.path;
if (i === 0 && setCoverFromLora) setCoverFromLora(lora.path);
}
});
pathEditBtn.addEventListener("click", (e) => {
e.stopPropagation();
pathBox.style.display = "none";
pathEditBtn.style.display = "none";
loraWrap.container.style.display = "block";
loraWrap.element.focus();
});
loraWrap.element.addEventListener("change", () => {
const val = loraWrap.element.value;
lora.path = val;
pathBox.textContent = val ? val.replace(/\.safetensors?$/i, "").split(/[\\/]/).pop() : "No LoRA selected";
pathBox.title = val;
pathBox.style.display = "block";
pathEditBtn.style.display = "inline-block";
loraWrap.container.style.display = "none";
if (i === 0 && val && setCoverFromLora) setCoverFromLora(val);
});
loraWrap.element.addEventListener("blur", () => {
setTimeout(() => {
const val = loraWrap.element.value;
lora.path = val;
pathBox.textContent = val ? val.replace(/\.safetensors?$/i, "").split(/[\\/]/).pop() : "No LoRA selected";
pathBox.title = val;
pathBox.style.display = "block";
pathEditBtn.style.display = "inline-block";
loraWrap.container.style.display = "none";
if (i === 0 && val && setCoverFromLora) setCoverFromLora(val);
}, 200);
});
pathRow.appendChild(pathBox);
pathRow.appendChild(pathEditBtn);
pathRow.appendChild(loraWrap.container);
if (lora.path) {
const clearBtn = makeSmallButton("\u2715");
css(clearBtn, "color:#f88;");
clearBtn.addEventListener("click", (e) => {
e.stopPropagation();
lora.path = "";
pathBox.textContent = "No LoRA selected";
pathBox.title = "";
loraWrap.element.value = "";
});
pathRow.appendChild(clearBtn);
}
card.appendChild(pathRow);
const strSlider = makeComfyValueSlider(lora.strength ?? 1.0, -10, 10, 0.05, (v) => {
lora.strength = v;
});
card.appendChild(strSlider);
if (lora.schedule) card.appendChild(makeSchedulePanel(lora));
loraBox.appendChild(card);
}
const addBtn = makeSmallButton("+ Add LoRA");
addBtn.addEventListener("click", () => {
// Open new LoRA entries expanded (name + URL visible).
// _editing is runtime-only and stripped on save.
fieldState.loras.push({ name: "", url: "", path: "", strength: 1.0, _editing: true });
renderLoras();
});
loraBox.appendChild(addBtn);
}
renderLoras();
}
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);
const taCss = "background:#1a1a1a;color:#ddd;border:1px solid #333;padding:6px;border-radius:4px;font-size:12px;width:100%;box-sizing:border-box;font-family:inherit;outline:none;";
// Removing the last prompt removes the whole field.
function removePrompt(i) {
fieldState.prompts.splice(i, 1);
if (fieldState.prompts.length) { renderPrompts(); return; }
activeFields.splice(activeFields.indexOf("prompt"), 1);
renderFields();
}
function renderPrompts() {
promptBox.replaceChildren();
if (!fieldState.prompts.length) fieldState.prompts.push({});
fieldState.prompts.forEach((prompt, i) => {
const card = document.createElement("div");
css(card, "background:#252525;padding:8px;border-radius:6px;border:1px solid #333;display:flex;flex-direction:column;gap:6px;");
const header = document.createElement("div");
css(header, "display:flex;gap:6px;align-items:center;");
header.appendChild(makeScheduleHandle(prompt, renderPrompts));
const title = document.createElement("span");
title.textContent = `Prompt ${i + 1}`;
css(title, "flex:1;font-size:12px;font-weight:500;color:#aaa;");
header.appendChild(title);
if (fieldState.prompts.length > 1) {
const removeBtn = makeSmallButton("✕");
removeBtn.title = "Remove prompt";
removeBtn.addEventListener("click", () => removePrompt(i));
header.appendChild(removeBtn);
}
card.appendChild(header);
const row = document.createElement("div");
css(row, "display:flex;gap:8px;align-items:stretch;");
card.appendChild(row);
const posCol = document.createElement("div");
css(posCol, "display:flex;flex-direction:column;gap:4px;min-width:0;");
row.appendChild(posCol);
const negCol = document.createElement("div");
css(negCol, "display:flex;flex-direction:column;gap:4px;min-width:0;");
row.appendChild(negCol);
const hasNeg = prompt.negative != null;
// Positive spans the width when alone; equal split with a
// negative present. The "+ Negative" placeholder stays
// compact so it never steals the positive's space (#264).
posCol.style.flex = hasNeg ? "1 1 0" : "1 1 auto";
negCol.style.flex = hasNeg ? "1 1 0" : "0 0 auto";
// A positive prompt is always present by default (#332) — no
// "+ Positive" button. Seed an empty string when missing.
if (prompt.positive == null) prompt.positive = "";
const posTA = makeTextarea(prompt.positive, "positive prompt", 3);
css(posTA, taCss);
posTA.addEventListener("change", () => { prompt.positive = posTA.value; });
posTA.addEventListener("input", () => { prompt.positive = posTA.value; });
attachAutoGrow(posTA);
posCol.appendChild(makeHoverRemoveWrapper(posTA, () => {
if (prompt.negative == null) removePrompt(i);
else { prompt.positive = null; renderPrompts(); }
}, "Remove positive prompt"));
if (hasNeg) {
const negTA = makeTextarea(prompt.negative, "negative prompt", 2);
css(negTA, taCss);
negTA.addEventListener("change", () => { prompt.negative = negTA.value; });
negTA.addEventListener("input", () => { prompt.negative = negTA.value; });
attachAutoGrow(negTA);
negCol.appendChild(makeHoverRemoveWrapper(negTA, () => {
if (prompt.positive == null) removePrompt(i);
else { prompt.negative = null; renderPrompts(); }
}, "Remove negative prompt"));
} else {
const negBtn = document.createElement("button");
negBtn.textContent = "+ Negative";
css(negBtn, "align-self:flex-start;cursor:pointer;border-radius:4px;font-size:13px;background:#2a2a2a;color:#999;border:1px dashed #555;transition:background 0.15s ease;user-select:none;box-sizing:border-box;white-space:nowrap;padding:6px 10px;");
negBtn.addEventListener("mouseenter", () => { negBtn.style.background = "#333"; });
negBtn.addEventListener("mouseleave", () => { negBtn.style.background = "#2a2a2a"; });
negBtn.addEventListener("click", () => {
prompt.negative = "";
renderPrompts();
});
negCol.appendChild(negBtn);
}
if (prompt.schedule) card.appendChild(makeSchedulePanel(prompt));
promptBox.appendChild(card);
});
const addBtn = makeSmallButton("+ Add prompt");
addBtn.addEventListener("click", () => {
fieldState.prompts.push({});
renderPrompts();
});
promptBox.appendChild(addBtn);
}
renderPrompts();
}
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, "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 imgFileInput = document.createElement("input");
imgFileInput.type = "file";
imgFileInput.accept = ".png,.jpg,.jpeg,.webp,.gif";
imgFileInput.style.display = "none";
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(`/view?filename=${encodeURIComponent(cnImagePath)}&type=input`);
}
imgBox.addEventListener("click", async () => {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".png,.jpg,.jpeg,.webp,.gif,.bmp";
fileInput.style.display = "none";
document.body.appendChild(fileInput);
fileInput.click();
await new Promise((resolve) => {
fileInput.addEventListener("change", async () => {
const file = fileInput.files?.[0];
if (file) {
try {
const form = new FormData();
form.append("image", file);
form.append("type", "input");
form.append("overwrite", "true");
const resp = await fetch("/upload/image", { method: "POST", body: form });
const result = await resp.json();
const fileName = result.name || file.name;
arr[i].image = fileName;
updateCnImgPreview(`/view?filename=${encodeURIComponent(fileName)}&type=input`);
// 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;
// Prefer a sibling "_cover" image as the cover; probe candidates.
if (trySiblingCover) trySiblingCover(siblingCandidates);
renderCNs();
} catch { /* ignore */ }
}
document.body.removeChild(fileInput);
resolve();
});
});
});
imgFileInput.addEventListener("change", () => {
const file = imgFileInput.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = () => {
updateCnImgPreview(reader.result);
};
reader.readAsDataURL(file);
}
});
imageCol.appendChild(imgBox);
imageCol.appendChild(dimLabel);
imageCol.appendChild(imgFileInput);
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 ? CN_MODEL_MAP[familyFolder(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);
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", () => {
arr.push({ type: "", image: "", strength: 1.0, start_percent: 0, end_percent: 1 });
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: "LoRA", onClick: () => addChoiceField("lora") },
{ label: "ControlNet", onClick: () => addChoiceField("controlnets") },
{ label: "Flake Link", onClick: () => addChoiceField("flake_link") },
]);
});
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;
});
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];
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;gap:8px;align-items:flex-start;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 choicePosCol = document.createElement("div");
css(choicePosCol, "display:flex;flex-direction:column;gap:4px;min-width:0;min-height:0;");
promptsWrap.appendChild(choicePosCol);
const choiceNegCol = document.createElement("div");
css(choiceNegCol, "display:flex;flex-direction:column;gap:4px;min-width:0;min-height:0;");
promptsWrap.appendChild(choiceNegCol);
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._uploaded) {
imgPreview.src = `/view?filename=${encodeURIComponent(cur.image)}&type=input`;
} else if (mode !== "create" && name) {
imgPreview.src = getVariantImageUrl(name, groupName, choiceName);
} else {
imgPreview.src = `/view?filename=${encodeURIComponent(cur.image)}&type=input`;
}
imgPreview.style.display = "block";
imgBoxLabel.style.display = "none";
} else {
imgPreview.style.display = "none";
imgBoxLabel.style.display = "block";
}
}
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 fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".png,.jpg,.jpeg,.webp,.gif,.bmp";
fileInput.style.display = "none";
document.body.appendChild(fileInput);
fileInput.click();
await new Promise((resolve) => {
fileInput.addEventListener("change", async () => {
const file = fileInput.files?.[0];
if (!file) { document.body.removeChild(fileInput); resolve(); return; }
try {
const form = new FormData();
form.append("image", file);
form.append("type", "input");
form.append("overwrite", "true");
const resp = await fetch("/upload/image", { method: "POST", body: form });
const result = await resp.json();
const fileName = result.name || file.name;
fieldState.variants[groupName][choiceName] = fieldState.variants[groupName][choiceName] || {};
fieldState.variants[groupName][choiceName].image = fileName;
fieldState.variants[groupName][choiceName]._uploaded = true;
refreshChoiceImage();
} catch { /* ignore */ }
document.body.removeChild(fileInput);
resolve();
});
});
});
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);
function renderChoicePrompts() {
choicePosCol.replaceChildren();
choiceNegCol.replaceChildren();
// Positive spans the width when alone; equal
// split with a compact "+ Negative" otherwise (#264).
const hasNeg = choice.negative != null;
choicePosCol.style.flex = hasNeg ? "1 1 0" : "1 1 auto";
choiceNegCol.style.flex = hasNeg ? "1 1 0" : "0 0 auto";
// Positive override is always present by default (#332)
// — no "+ Positive" button. Empty values are pruned on save.
fieldState.variants[groupName][choiceName] = fieldState.variants[groupName][choiceName] || {};
if (choice.positive == null) choice.positive = "";
{
const cPos = makeTextarea(choice.positive || "", "extra positive", 2);
// Content-sized beside the floated image; autogrows (#336).
css(cPos, "background:#1a1a1a;color:#ddd;border:1px solid #333;padding:6px;border-radius:4px;font-size:12px;width:100%;min-height:64px;box-sizing:border-box;font-family:inherit;resize:vertical;outline:none;");
attachAutoGrow(cPos);
cPos.addEventListener("change", () => {
fieldState.variants[groupName][choiceName] = fieldState.variants[groupName][choiceName] || {};
fieldState.variants[groupName][choiceName].positive = cPos.value;
});
const posWrap = makeHoverRemoveWrapper(cPos, () => {
delete fieldState.variants[groupName][choiceName].positive;
renderChoicePrompts();
}, "Remove positive prompt");
css(posWrap, "min-height:0;");
choicePosCol.appendChild(posWrap);
}
if (choice.negative != null) {
const cNeg = makeTextarea(choice.negative || "", "extra negative", 2);
css(cNeg, "background:#1a1a1a;color:#ddd;border:1px solid #333;padding:6px;border-radius:4px;font-size:12px;width:100%;min-height:64px;box-sizing:border-box;font-family:inherit;resize:vertical;outline:none;");
attachAutoGrow(cNeg);
cNeg.addEventListener("change", () => {
fieldState.variants[groupName][choiceName] = fieldState.variants[groupName][choiceName] || {};
fieldState.variants[groupName][choiceName].negative = cNeg.value;
});
const negWrap = makeHoverRemoveWrapper(cNeg, () => {
delete fieldState.variants[groupName][choiceName].negative;
renderChoicePrompts();
}, "Remove negative prompt");
css(negWrap, "min-height:0;");
choiceNegCol.appendChild(negWrap);
} else {
const negBtn = document.createElement("button");
negBtn.textContent = "+ Negative";
css(negBtn, "align-self:flex-start;cursor:pointer;border-radius:4px;font-size:13px;background:#2a2a2a;color:#999;border:1px dashed #555;transition:background 0.15s ease;user-select:none;box-sizing:border-box;white-space:nowrap;padding:6px 10px;");
negBtn.addEventListener("mouseenter", () => { negBtn.style.background = "#333"; });
negBtn.addEventListener("mouseleave", () => { negBtn.style.background = "#2a2a2a"; });
negBtn.addEventListener("click", () => {
fieldState.variants[groupName][choiceName] = fieldState.variants[groupName][choiceName] || {};
fieldState.variants[groupName][choiceName].negative = fieldState.variants[groupName][choiceName].negative ?? "";
renderChoicePrompts();
});
choiceNegCol.appendChild(negBtn);
}
}
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);
async function pickChoiceTarget() {
try {
const list = await (await fetch(`/flakes/list?family=${encodeURIComponent(currentFamily || "")}`)).json();
const flakes = (list.flakes || []).filter((n) => n !== name);
const res = await openFileLoadPicker({ flakes, directories: list.directories || [], family: currentFamily || "", displayNames: list.display_names || {}, tagNames: list.tag_names || {} });
return res && res.name ? res.name : null;
} catch { return null; }
}
// 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 === "lora") {
if (!Array.isArray(co.loras)) co.loras = [];
co.loras.push({ name: "", path: "", strength: 1.0 });
renderChoiceExtras();
} else if (key === "controlnets") {
if (!Array.isArray(co.controlnets)) co.controlnets = [];
co.controlnets.push({ type: "", image: "", strength: 1.0 });
renderChoiceExtras();
} else if (key === "flake_link") {
const t = await pickChoiceTarget();
if (!t) return;
if (!Array.isArray(co.flake_links)) co.flake_links = [];
co.flake_links.push({ target: t, variant: {}, lora_strengths: [] });
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)
const loras = Array.isArray(co.loras) ? co.loras : [];
if (loras.length) sectionLabel("Variant LoRAs", loraHost);
loras.forEach((lr, i) => {
const row = document.createElement("div");
css(row, "display:flex;gap:6px;align-items:center;margin-top:3px;");
row.appendChild(makeScheduleHandle(lr, renderChoiceExtras));
// Clickable box that opens the same "Select LoRA" file
// popup as the regular flake LoRA section (#310).
const box = document.createElement("div");
css(box, "flex:1;min-width:0;background:#1a1a1a;color:#ddd;border:1px solid #333;padding:5px 8px;border-radius:6px;font-size:12px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;");
const showName = () => { box.textContent = lr.path ? lr.path.replace(/\.safetensors?$/i, "").split(/[\\/]/).pop() : "Select LoRA…"; box.title = lr.path || ""; };
showName();
box.addEventListener("click", async () => {
const result = await openFileBrowser({ type: "loras", defaultPath: "" });
if (result && result.file) { lr.path = result.file; showName(); }
});
row.appendChild(box);
const slider = makeSmallValueSlider(lr.strength ?? 1.0, 0, 2, 0.05, (v) => { lr.strength = v; });
slider.style.flex = "0 0 110px";
row.appendChild(slider);
const rm = makeSmallButton("✕");
rm.addEventListener("click", () => { loras.splice(i, 1); if (!loras.length) delete co.loras; renderChoiceExtras(); });
row.appendChild(rm);
loraHost.appendChild(row);
if (lr.schedule) loraHost.appendChild(makeSchedulePanel(lr));
});
// ---- 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, "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 = `/view?filename=${encodeURIComponent(cn.image)}&type=input`; imgPrev.style.display = "block"; imgPlaceholder.style.display = "none"; }
else { imgPrev.style.display = "none"; imgPlaceholder.style.display = "block"; dimCap.textContent = ""; dimCap.style.display = "none"; }
};
showImg();
imgBox.addEventListener("click", () => {
const fi = document.createElement("input");
fi.type = "file"; fi.accept = ".png,.jpg,.jpeg,.webp,.gif,.bmp"; fi.style.display = "none";
document.body.appendChild(fi); fi.click();
fi.addEventListener("change", async () => {
const file = fi.files?.[0];
if (file) {
try {
const form = new FormData(); form.append("image", file); form.append("type", "input"); form.append("overwrite", "true");
const resp = await fetch("/upload/image", { method: "POST", body: form });
const result = await resp.json();
cn.image = result.name || file.name;
// Infer the type and seed the flake cover from the CN's
// sibling cover when none is set yet (#353).
try {
const types = await familyCnTypes();
const r = inferCnFromImage(cn.image, types);
if (r.inferredType && !cn.type) cn.type = r.inferredType;
if (trySiblingCover) trySiblingCover(r.siblings || []);
} catch { /* ignore */ }
renderChoiceExtras();
} catch { /* ignore */ }
}
document.body.removeChild(fi);
});
});
const imageCol = document.createElement("div");
css(imageCol, "flex:0 0 auto;display:flex;flex-direction:column;align-items:center;gap:3px;");
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 ? CN_MODEL_MAP[familyFolder(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);
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 pickChoiceTarget(); if (t) { link.target = t; link.lora_strengths = []; 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);
const ovr = document.createElement("div");
css(ovr, "display:flex;flex-direction:column;gap:4px;");
card.appendChild(ovr);
if (link.target) {
fetchFlake(link.target).then((ld) => {
const lps = (Array.isArray(ld.loras) && ld.loras.length) ? ld.loras : (ld.path ? [{ name: "", path: ld.path, strength: ld.strength ?? 1.0 }] : []);
if (!Array.isArray(link.lora_strengths)) link.lora_strengths = [];
lps.forEach((lr, i) => {
const r = document.createElement("div");
css(r, "display:flex;gap:6px;align-items:center;");
const nm = document.createElement("span");
nm.textContent = lr.name || lr.path || `LoRA ${i + 1}`;
css(nm, "flex:0 0 120px;font-size:11px;color:#bbb;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;");
r.appendChild(nm);
const cur = link.lora_strengths[i];
const init = (cur === null || cur === undefined) ? (lr.strength ?? 1.0) : cur;
const sl = makeComfyValueSlider(init, -10, 10, 0.05, (v) => { while (link.lora_strengths.length <= i) link.lora_strengths.push(null); link.lora_strengths[i] = v; });
sl.style.flex = "1";
r.appendChild(sl);
ovr.appendChild(r);
});
}).catch(() => {});
}
if (link.schedule) card.appendChild(makeSchedulePanel(link));
extrasHost.appendChild(card);
});
}
renderChoiceExtras();
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 trimmed = choiceNameInput.value.trim();
if (!trimmed) { finish(); return; }
if (fieldState.variants[groupName][trimmed]) {
window.alert(`Choice '${trimmed}' already exists in this group.`);
finish();
return;
}
const choiceData = { positive: "" };
// 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 = [];
// Render one link's variant + lora-strength overrides into box.
function renderOneLinkOverrides(link, box) {
box.replaceChildren();
if (!link.target) return;
(async () => {
let linkedData;
try {
linkedData = await fetchFlake(link.target);
} catch {
const err = document.createElement("div");
err.textContent = `Target not found: ${link.target}`;
css(err, "font-size:11px;color:#f99;");
box.appendChild(err);
return;
}
const variants = linkedData.variants || linkedData.options || {};
if (Object.keys(variants).length > 0) {
const vLabel = document.createElement("div");
vLabel.textContent = "Variant choice override (saved on this flake)";
css(vLabel, "font-size:11px;color:#aaa;font-weight:500;");
box.appendChild(vLabel);
for (const [group, choices] of Object.entries(variants)) {
const row = document.createElement("div");
css(row, "display:flex;gap:8px;align-items:center;");
const gLabel = document.createElement("span");
gLabel.textContent = group;
css(gLabel, "flex:0 0 120px;font-size:12px;color:#bbb;");
row.appendChild(gLabel);
const opts = [{ value: "", label: "— none —" }, ...Object.keys(choices).map(c => ({ value: c, label: c }))];
const dd = makeComfyDropdown(opts, link.variant?.[group] || "");
dd.element.addEventListener("change", () => {
link.variant = link.variant || {};
if (dd.element.value) link.variant[group] = dd.element.value;
else delete link.variant[group];
});
css(dd.container, "flex:1;min-width:0;");
row.appendChild(dd.container);
box.appendChild(row);
}
}
// Linked flake's LoRAs — the modern `loras` list, or a
// legacy single LoRA stored as `path`/`strength` (#300).
// Without the legacy fallback, a single-LoRA target shows
// no override sliders at all.
const loras = (Array.isArray(linkedData.loras) && linkedData.loras.length)
? linkedData.loras
: (linkedData.path ? [{ name: "", path: linkedData.path, strength: linkedData.strength ?? 1.0 }] : []);
if (loras.length > 0) {
const lLabel = document.createElement("div");
lLabel.textContent = "LoRA strength overrides";
css(lLabel, "font-size:11px;color:#aaa;font-weight:500;margin-top:4px;");
box.appendChild(lLabel);
link.lora_strengths = link.lora_strengths || [];
while (link.lora_strengths.length < loras.length) link.lora_strengths.push(null);
loras.forEach((lr, i) => {
const row = document.createElement("div");
css(row, "display:flex;gap:8px;align-items:center;");
const nm = document.createElement("span");
nm.textContent = lr.name || lr.path || `LoRA ${i + 1}`;
css(nm, "flex:0 0 160px;font-size:12px;color:#bbb;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;");
row.appendChild(nm);
const cur = link.lora_strengths[i];
const defaultStrength = lr.strength ?? 1.0;
const initial = (cur === null || cur === undefined) ? defaultStrength : cur;
// makeSmallValueSlider returns a bare element (not {container}).
// The old `.container` access made css() throw, so the
// sliders never rendered — only the label did (#307).
// Range matches the grid override dropdown (-10..10).
// Uses the larger makeComfyValueSlider for readability (#312).
const slider = makeComfyValueSlider(initial, -10, 10, 0.05, (v) => {
link.lora_strengths[i] = v;
});
slider.style.flex = "1";
row.appendChild(slider);
const resetBtn = makeSmallButton("↺");
resetBtn.title = "Use linked flake's default strength";
resetBtn.addEventListener("click", () => {
link.lora_strengths[i] = null;
renderOneLinkOverrides(link, box);
});
row.appendChild(resetBtn);
box.appendChild(row);
});
}
})();
}
function renderLinks() {
linkBox.replaceChildren();
fieldState.flake_links.forEach((link, li) => {
const card = document.createElement("div");
css(card, "background:#252525;padding:8px;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, renderLinks));
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;font-weight:500;color:#cdd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;");
header.appendChild(title);
const changeBtn = makeSmallButton("Change");
changeBtn.addEventListener("click", async () => {
const picked = await pickFlakeTarget();
if (picked && picked !== link.target) {
link.target = picked;
link.variant = {};
link.lora_strengths = [];
renderLinks();
}
});
header.appendChild(changeBtn);
const removeBtn = makeSmallButton("✕");
removeBtn.addEventListener("click", () => {
fieldState.flake_links.splice(li, 1);
renderLinks();
});
header.appendChild(removeBtn);
card.appendChild(header);
const ovrBox = document.createElement("div");
css(ovrBox, "display:flex;flex-direction:column;gap:6px;");
card.appendChild(ovrBox);
renderOneLinkOverrides(link, ovrBox);
if (link.schedule) card.appendChild(makeSchedulePanel(link));
linkBox.appendChild(card);
});
const addBtn = makeSmallButton("+ Add flake link");
addBtn.addEventListener("click", async () => {
const picked = await pickFlakeTarget();
if (picked) {
fieldState.flake_links.push({ target: picked, variant: {}, lora_strengths: [] });
renderLinks();
}
});
linkBox.appendChild(addBtn);
}
renderLinks();
}
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" },
{ key: "controlnets", label: "ControlNets" },
{ key: "flake_link", label: "Flake link" },
];
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._ = [{ type: "", image: "", strength: 1.0 }];
if (ft.key === "variants") fieldState.variants = { Default: { Default: {} } };
if (ft.key === "flake_link") fieldState.flake_links = [];
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();
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();
// 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 = fieldState.loras.map(l => ({
name: l.name || "",
url: l.url || "",
path: l.path || "",
strength: l.strength ?? 1.0,
...(l.tag_name ? { tag_name: true } : {}),
...(l.schedule?.length ? { schedule: l.schedule } : {}),
}));
}
if (ft === "prompt" && fieldState.prompts.length > 0) {
const prompts = fieldState.prompts.map(p => {
const out = {};
if (p.positive != null) out.positive = p.positive;
if (p.negative != null) out.negative = p.negative;
if (p.schedule?.length) out.schedule = p.schedule;
return out;
});
// 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 (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)) {
for (const choice of Object.values(group || {})) {
if (!choice) continue;
if (Array.isArray(choice.loras)) {
choice.loras = 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 = choice.flake_links
.filter(l => l && l.target)
.map(l => {
const out = { target: l.target };
const strengths = (l.lora_strengths || []).slice();
while (strengths.length && (strengths[strengths.length - 1] == null)) strengths.pop();
if (strengths.length) out.lora_strengths = strengths;
if (l.variant && Object.keys(l.variant).length) out.variant = l.variant;
if (l.schedule?.length) out.schedule = l.schedule;
return out;
});
if (choice.flake_links.length === 0) delete choice.flake_links;
}
}
}
ordered.variants = fieldState.variants;
}
if (ft === "flake_link" && Array.isArray(fieldState.flake_links) && fieldState.flake_links.length > 0) {
const outLinks = [];
for (const link of fieldState.flake_links) {
if (!link || !link.target) continue;
const out = { target: link.target };
const variant = link.variant ? Object.fromEntries(Object.entries(link.variant).filter(([, v]) => v)) : {};
if (Object.keys(variant).length > 0) out.variant = variant;
// Drop trailing nulls to keep the yaml clean.
let strengths = (link.lora_strengths || []).slice();
while (strengths.length && (strengths[strengths.length - 1] === null || strengths[strengths.length - 1] === undefined)) {
strengths.pop();
}
if (strengths.length > 0) out.lora_strengths = strengths;
if (link.schedule?.length) out.schedule = link.schedule;
outLinks.push(out);
}
if (outLinks.length > 0) ordered.flake_links = outLinks;
}
}
if (coverSourcePath && !coverFile) {
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,
);
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);
});
}