Reference sheet generator (optional module + dialog); leak report tests start from unwrapped ComfyUI functions
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
000507c5f2
commit
34db2b331d
@@ -4,6 +4,8 @@ What is new in each version, in plain words. Newest first.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Reference sheet** (Assets panel, "Reference sheet..."): pick rows and columns, a picture and a label for each slot, and a title. You get one white sheet with the pictures in a grid, each labelled below and the title in the top left corner, saved to the media bin as a picture. Pictures come from the bin or an upload; each is cropped from the middle to the slot's shape.
|
||||
|
||||
## [5.2.0] "Classy Chopper" - 2026-11-08
|
||||
|
||||
### New: finishing touches for the finished video
|
||||
|
||||
@@ -37,7 +37,7 @@ export function enhance(app, own) {
|
||||
const styles = defaults && defaults.styles ? Object.keys(defaults.styles) : ["default", "dynamic"], style = styles.includes(val("style", "default")) ? val("style", "default") : "default";
|
||||
page.set([
|
||||
c.label.section({ text: "Prompt enhancer" }),
|
||||
c.hint.default({ text: "A language model rewrites the finished prompt before it is encoded. Off, or if it fails, your prompt is used as typed. (word:1.5) and [a@2-4] markup does not survive a rewrite." }),
|
||||
c.hint.default({ text: "A language model rewrites the finished prompt. Off or failed, your prompt is used as typed. (word:1.5) and [a@2-4] markup is lost in a rewrite." }),
|
||||
c.toggle.default({ label: "Enhance prompt", checked: on, onChange: (x) => set("enabled", x) }),
|
||||
...(!on ? readout() : [
|
||||
pick("encoder_file", "Encoder", "The model's own encoder", "The text encoder that writes the rewrite. Models like H3's own encoder cannot write prose: pick a chat model such as Gemma 4 here."),
|
||||
|
||||
@@ -26,7 +26,7 @@ export default {
|
||||
onClick: () => app.maintenance?.cuda13(slow.title) }).node;
|
||||
slow.hidden = true;
|
||||
// Shot camera runs without spaCy, but aims its moves with plain word rules.
|
||||
const rough = c.button.sm({ label: "Camera targets rougher", tone: "ghost", title: "Shot camera is on, but spaCy is not: camera moves aim with simple word rules and pick worse targets. Switch on Automatic camera and focus in Settings ▸ Modules.",
|
||||
const rough = c.button.sm({ label: "Camera targets rougher", tone: "ghost", title: "spaCy is off, so camera moves aim less well. Switch on Automatic camera and focus in Settings ▸ Modules.",
|
||||
onClick: () => app.openSettings?.("modules") }).node;
|
||||
rough.hidden = true;
|
||||
// PTD runs its own four to eight hops and was trained without these: SLA, and any other enhancement that is on.
|
||||
|
||||
@@ -46,7 +46,7 @@ export function sceneRows(p, app) {
|
||||
...(isSubclip(sc) ? [c.hint.default({ text: "This is a cut: its prompt and source belong to the first part." })] : [card(
|
||||
c.field.default({ label: "Prompt", control: c.textarea.md({ label: "Prompt", value: sc.text || "", rows: 4, onInput: (v) => p.setText(sc.id, v) }) }))]),
|
||||
c.label.section({ text: "Pictures" }),
|
||||
card(mediaField(app, p, sc, { label: "Aspect ratio source", hint: "The scene's picture (or a video's first frame). Its shape sets the scene's aspect ratio; the project's Width and Height set the scale. The prompt enhancer can read it.",
|
||||
card(mediaField(app, p, sc, { label: "Aspect ratio source", hint: "The scene's picture. Its shape sets the aspect ratio; the project's Width and Height set the size.",
|
||||
key: "source_image", multiple: false, kinds: ["image", "video"], empty: "— choose aspect ratio source —" }),
|
||||
mediaField(app, p, sc, { label: "References", key: "references", multiple: true, kinds: ["image", "video", "audio"], empty: "+ Add reference" })),
|
||||
c.label.section({ text: "Length" }),
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
|
||||
import { offer, _reset } from "../../shell/mounts.js";
|
||||
import { loadFeatures } from "../../shell/features.js";
|
||||
import { problem, request, resize } from "./sheet.js";
|
||||
|
||||
let hub;
|
||||
test.before(async () => { setupDom(); hub = (await import("./index.js")).default; });
|
||||
test.after(() => teardownDom());
|
||||
|
||||
const slot = (picture = "", label = "") => ({ picture, label });
|
||||
|
||||
test("resizing keeps what is in place (reading order) and the request leaves trailing empty slots out", () => {
|
||||
const two = [slot("a", "front"), slot("", "back")];
|
||||
const six = resize(two, 2, 3);
|
||||
assert.equal(six.length, 6);
|
||||
assert.deepEqual(six.slice(0, 2), two);
|
||||
assert.deepEqual(resize(six, 1, 2), two);
|
||||
const r = request({ title: " Mara ", rows: 2, cols: 3, format: "4:3", width: 512, slots: six });
|
||||
assert.equal(r.title, "Mara");
|
||||
assert.deepEqual(r.slots, [{ picture: "a", label: "front" }, { picture: undefined, label: "back" }]);
|
||||
});
|
||||
|
||||
test("a sheet needs one picture and no more than 36 slots", () => {
|
||||
assert.match(problem({ rows: 2, cols: 2, slots: [slot("", "x")] }), /at least one picture/);
|
||||
assert.equal(problem({ rows: 2, cols: 2, slots: [slot("a")] }), "");
|
||||
assert.match(problem({ rows: 12, cols: 12, slots: [slot("a")] }), /36/);
|
||||
});
|
||||
|
||||
test("it is hidden when the server has no refsheet module, and the button opens a dialog that sends the grid and leaves nothing behind", async () => {
|
||||
_reset();
|
||||
const host = document.createElement("div");
|
||||
offer("assets.actions", host);
|
||||
let sent;
|
||||
const api = { media: async () => ({ media: [{ id: "a", name: "front.png", kind: "image" }, { id: "v", name: "clip.mp4", kind: "video" }] }), uploadMedia: async () => ({ media: [], problems: [] }),
|
||||
refsheetMake: async (b) => { sent = b; return { media: { id: "n", name: "Sheet.png" }, missing: [] }; } };
|
||||
const app = { api, dom: { pickFile: async () => null }, say() {}, hasModule: () => false };
|
||||
const none = await loadFeatures(["x"], { load: async () => ({ default: hub }), app, has: (n) => n in app });
|
||||
assert.equal(none.mounted.length, 0);
|
||||
|
||||
_reset();
|
||||
offer("assets.actions", host);
|
||||
app.hasModule = () => true;
|
||||
const { mounted } = await loadFeatures(["x"], { load: async () => ({ default: hub }), app, has: (n) => n in app });
|
||||
assert.equal(mounted.length, 1);
|
||||
const button = host.querySelector("button");
|
||||
assert.match(button.textContent, /Reference sheet/);
|
||||
button.click();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const picks = [...document.querySelectorAll("select")].filter((s) => /Picture for slot/.test(s.getAttribute("aria-label") || ""));
|
||||
assert.equal(picks.length, 6);
|
||||
assert.deepEqual([...picks[0].options].map((o) => o.textContent), ["Empty", "front.png", "Upload a picture…"]); // a video is not offered
|
||||
picks[0].value = "a"; picks[0].dispatchEvent(new Event("change", { bubbles: true }));
|
||||
const make = [...document.querySelectorAll("button")].find((b) => b.textContent === "Make sheet");
|
||||
make.click();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
assert.deepEqual(sent, { title: "", rows: 2, cols: 3, format: "1:1", width: 512, slots: [{ picture: "a", label: undefined }] });
|
||||
mounted[0].teardown();
|
||||
assert.equal(host.textContent, "");
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// Reference sheet: pick a grid, a picture and a label for each slot, and get one white sheet saved to the media bin.
|
||||
// Pictures come from the bin (or are uploaded into it first); each is cut to the slot's shape on the server.
|
||||
import { composer as c } from "../../composer/composer.js";
|
||||
import { learn } from "../../shell/bin.js";
|
||||
import { DEFAULTS, FORMATS, MAX_SLOTS, problem, request, resize } from "./sheet.js";
|
||||
|
||||
const UPLOAD = "__upload";
|
||||
const thumb = (id) => `/fun/api/media/${encodeURIComponent(id)}/thumb`;
|
||||
|
||||
export default {
|
||||
id: "refsheet",
|
||||
mount: "assets.actions",
|
||||
needs: ["api", "dom"],
|
||||
modules: ["refsheet"],
|
||||
setup({ host, app }) {
|
||||
let win = null;
|
||||
const open = async () => {
|
||||
if (win) return;
|
||||
let pictures = [];
|
||||
const reload = async () => { const all = (await app.api.media()).media || []; learn(all); pictures = all.filter((m) => m.kind === "image"); };
|
||||
try { await reload(); } catch (err) { return c.toast.warn({ text: `Could not read the media bin: ${err.message}` }); }
|
||||
const state = { ...DEFAULTS, slots: resize([], DEFAULTS.rows, DEFAULTS.cols) };
|
||||
const grid = c.region.stack({ gap: "sm" });
|
||||
const note = c.text.sm({ text: "" });
|
||||
let make;
|
||||
|
||||
const choose = async (i, value) => {
|
||||
if (value === UPLOAD) {
|
||||
const file = await app.dom.pickFile("image/*");
|
||||
if (file) {
|
||||
const got = await app.api.uploadMedia([file]);
|
||||
if (got.problems.length) c.toast.warn({ text: got.problems[0] });
|
||||
if (got.media[0]) { await reload(); state.slots[i].picture = got.media[0].id; app.say("bin"); }
|
||||
}
|
||||
} else state.slots[i].picture = value;
|
||||
drawGrid(); status();
|
||||
};
|
||||
const cell = (slot, i) => {
|
||||
const named = pictures.find((m) => m.id === slot.picture);
|
||||
const card = c.captioned.cell({ src: slot.picture ? thumb(slot.picture) : "", title: `Slot ${i + 1}`, caption: slot.label, label: `Slot ${i + 1}`, onChange: (text) => { slot.label = text; } });
|
||||
const pick = c.select.sm({ label: `Picture for slot ${i + 1}`, value: named ? named.id : "", onChange: (v) => choose(i, v),
|
||||
options: [{ value: "", label: "Empty" }, ...pictures.map((m) => ({ value: m.id, label: m.name })), { value: UPLOAD, label: "Upload a picture…" }] });
|
||||
return c.region.stack({ gap: "xs", children: [card, pick] });
|
||||
};
|
||||
const drawGrid = () => grid.set([c.grid.cards({ children: state.slots.map(cell) })]);
|
||||
const status = () => { note.setText(problem(state)); make && make.setDisabled(!!problem(state)); };
|
||||
const regrid = () => { state.slots = resize(state.slots, state.rows, state.cols); drawGrid(); status(); };
|
||||
|
||||
const num = (key, label, max) => c.number.md({ label, value: state[key], min: 1, max, step: 1, precision: 0, onChange: (v) => { state[key] = Math.max(1, Math.min(max, Math.round(v) || 1)); regrid(); } });
|
||||
make = c.button.md({ label: "Make sheet", tone: "primary", onClick: async () => {
|
||||
make.setBusy(true);
|
||||
try {
|
||||
const out = await app.api.refsheetMake(request(state));
|
||||
await reload(); app.say("bin");
|
||||
c.toast.good({ text: `Saved “${out.media.name}” to the media bin.` });
|
||||
if (out.missing.length) c.toast.warn({ text: `Left empty (picture gone or not readable): ${out.missing.join(", ")}.` });
|
||||
win.close("done");
|
||||
} catch (err) { c.toast.warn({ text: `Could not make the sheet: ${err.message}` }); } finally { make.setBusy(false); }
|
||||
} });
|
||||
win = c.modal.generic({ title: "Reference sheet", subtitle: "Pictures in a grid on white, each labelled below. Each picture is cropped from the middle to fit its slot.", size: "xl",
|
||||
onClose: () => { win = null; },
|
||||
body: c.region.stack({ gap: "md", children: [
|
||||
c.field.default({ label: "Title (top left corner, optional)", control: c.input.md({ value: state.title, label: "Title", onCommit: (v) => { state.title = v; } }) }),
|
||||
c.region.stack({ gap: "sm", children: [num("rows", "Rows", 12), num("cols", "Columns", 12),
|
||||
c.select.md({ label: "Slot format", value: state.format, options: FORMATS.map((f) => ({ value: f, label: f })), onChange: (v) => { state.format = v; } })] }),
|
||||
note, grid] }),
|
||||
actions: [make] });
|
||||
drawGrid(); status();
|
||||
};
|
||||
|
||||
const button = c.button.sm({ label: "Reference sheet…", tone: "ghost", title: `A labelled grid of pictures on white, saved to the media bin (up to ${MAX_SLOTS} slots)`, onClick: open });
|
||||
host.append(button.node);
|
||||
return () => { if (win) win.close("gone"); button.destroy(); };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
// The reference sheet dialog's state, apart from the screen: how many slots, what is in each, and the request the server gets.
|
||||
export const FORMATS = ["1:1", "4:3", "3:4", "3:2", "2:3", "16:9", "9:16"];
|
||||
export const DEFAULTS = { title: "", rows: 2, cols: 3, format: "1:1", width: 512 };
|
||||
export const MAX_SLOTS = 36;
|
||||
|
||||
/** The slots for a grid of rows x cols: what was already there stays in its place (reading order), new ones are empty. */
|
||||
export function resize(slots, rows, cols) {
|
||||
return Array.from({ length: rows * cols }, (_, i) => slots[i] || { picture: "", label: "" });
|
||||
}
|
||||
|
||||
/** The server's request. Trailing empty slots are left out; a slot with only a label still keeps its place. */
|
||||
export function request(state) {
|
||||
const slots = state.slots.map((s) => ({ picture: s.picture || undefined, label: s.label.trim() || undefined }));
|
||||
while (slots.length && !slots[slots.length - 1].picture && !slots[slots.length - 1].label) slots.pop();
|
||||
return { title: state.title.trim(), rows: state.rows, cols: state.cols, format: state.format, width: state.width, slots };
|
||||
}
|
||||
|
||||
/** Why the sheet cannot be made yet, or "". */
|
||||
export function problem(state) {
|
||||
if (state.rows * state.cols > MAX_SLOTS) return `${state.rows} x ${state.cols} is more than ${MAX_SLOTS} slots.`;
|
||||
return state.slots.some((s) => s.picture) ? "" : "Pick at least one picture.";
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export default {
|
||||
}
|
||||
app.editorSettings.push(() => c.region.stack({ gap: "md", children: [
|
||||
c.label.section({ text: "Render" }),
|
||||
c.toggle.default({ label: "Keep the clips' own frame rate", hint: "Off: the final video uses the project's frame rate. On: it uses the fastest rate among the clips (for example after Interpolate), so no frames are dropped.",
|
||||
c.toggle.default({ label: "Keep the clips' own frame rate", hint: "Off: the final video uses the project's frame rate. On: the fastest clip's rate, so no frames are dropped.",
|
||||
checked: p.pref("renderRealFps", false) === true, onChange: (v) => p.setPref("renderRealFps", v) })] }));
|
||||
draw();
|
||||
const offered = offer(app, { id: "render-final", label: "Render", icon: "⧉", run: () => { if (!busy && p.project) render(); } });
|
||||
|
||||
@@ -12,7 +12,7 @@ export default {
|
||||
const page = c.region.stack({ gap: "md" });
|
||||
const draw = (s, busy = false) => page.set([
|
||||
c.label.section({ text: "Shortcut revolver" }),
|
||||
c.hint.default({ text: "Off: a seeded random pick that may repeat. On: no replacement is reused until the whole set has fired. A server setting for every browser and for generation; the open project remembers it." }),
|
||||
c.hint.default({ text: "Off: a random pick that may repeat. On: nothing repeats until the whole set has been used." }),
|
||||
c.toggle.default({ label: "Shortcut revolver", checked: s.enabled, disabled: busy, onChange: (v) => save({ enabled: v, random: s.random }) }),
|
||||
s.enabled ? c.toggle.default({ label: "Random", hint: "Off: replacements fire in order, then the cycle restarts. On: any order, still no repeats. Changing either restarts all cycles.", checked: s.random, disabled: busy, onChange: (v) => save({ enabled: s.enabled, random: v }) }) : null,
|
||||
].filter(Boolean));
|
||||
|
||||
@@ -27,7 +27,7 @@ export const engine = (app) => function mount() {
|
||||
hint: "Every guidance module sits out of the run. Your choices are kept, switch it back off to restore them.",
|
||||
checked: gOff, onChange: (on) => ps.setGuidanceOff(on).then(draw) }) : null,
|
||||
category === "conditioning" && ps.modulesById()["conditioning_shot_camera"] ? c.toggle.default({ label: "Disable all automated camera prompt enhancement",
|
||||
hint: "Shot camera stops adding cut times, views, reframes, camera moves and liked details to your prompt. Everything else stays as you set it. Your choices are kept.",
|
||||
hint: "Stops Shot camera adding cuts, views, camera moves and liked details to your prompt. Your choices are kept.",
|
||||
checked: ps.cameraOff(), onChange: (on) => ps.setCameraOff(on).then(draw) }) : null,
|
||||
...(category === "guidance" && gOff ? [c.hint.default({ text: "Guidance enhancements are disabled for this project. Their settings are hidden, not lost." })]
|
||||
: withSettings.filter((m) => (m.category || "") === category).flatMap((m) => [c.label.section({ text: m.title || m.id }),
|
||||
|
||||
@@ -136,7 +136,7 @@ export const models = (app) => function mount() {
|
||||
painted = key;
|
||||
const busy = r.models.some((m) => m.state === "downloading");
|
||||
if (!r.models.some((m) => m.state !== "ready")) return row.set([]); // both are there: nothing to offer
|
||||
row.set([...((r.files || []).length ? [] : [c.banner.info({ text: "Veda needs its predictor file in ComfyUI/models/veda, and none is there. You can fetch it straight from Hugging Face; nothing is downloaded until you click." })]),
|
||||
row.set([...((r.files || []).length ? [] : [c.banner.info({ text: "Veda needs a predictor file in ComfyUI/models/veda. You can fetch it from Hugging Face; nothing downloads until you click." })]),
|
||||
...r.models.filter((m) => m.state !== "ready").map((m) => c.settingsRow.default({ label: `${m.title} · ${mb(m.size)}`, hint: m.state === "error" ? `Failed: ${m.error}` : m.state === "downloading" ? `${Math.floor(100 * m.done / m.size)}%` : m.hint,
|
||||
control: c.button.sm({ label: m.state === "downloading" ? "Downloading…" : m.state === "error" ? "Try again" : "Download", tone: "primary", disabled: busy,
|
||||
onClick: async () => { try { await api.vedaDownload(m.id); fetching = true; } catch (err) { c.toast.warn({ text: err.message }); } show(); } }) })),
|
||||
@@ -163,7 +163,7 @@ export const models = (app) => function mount() {
|
||||
const spec = specs[slot.node];
|
||||
if (spec === undefined) return [c.hint.default({ text: "Loading…" })];
|
||||
const head = c.header.sm({ text: label(slot) });
|
||||
if (spec === null && (ps.skipped && ps.skipped() || {})[slot.id]) return [head, c.banner.warn({ text: `${slot.node} is skipped: it is not available (its module did not load, or the node was rejected), so the run goes without it. The Log says why; when it is back, this node runs again.` })];
|
||||
if (spec === null && (ps.skipped && ps.skipped() || {})[slot.id]) return [head, c.banner.warn({ text: `${slot.node} is skipped because it is not available, so the run goes without it. The Log says why.` })];
|
||||
if (spec === null) return [head, structure(slot), c.banner.warn({ text: `${slot.node} is not installed — this slot can't be edited or run. Install the pack that provides it, swap it for another node, or remove it.` }), installRow(slot.node)];
|
||||
const sockets = (spec.sockets || []).filter((w) => showing(w, slot, spec)).map((s) => c.field.default({ label: s.name, hint: s.required ? `${s.type} · needed` : s.type, control: wiring(slot, s.name, s.type, slot.inputs && slot.inputs[s.name], "Not connected") }));
|
||||
// A field that belongs to a module (the stage a LoRA works in belongs to Two stages) is not drawn while that module is absent or switched off.
|
||||
|
||||
@@ -169,9 +169,9 @@ test("the Veda predictor row appears only for method veda with no file, names th
|
||||
assert.doesNotMatch(page.node.textContent, /Veda needs its predictor/);
|
||||
page.node.remove();
|
||||
page = await open({ sla: true, sla_method: "veda" });
|
||||
assert.match(page.node.textContent, /none is there/);
|
||||
assert.match(page.node.textContent, /needs a predictor file/);
|
||||
const text = page.node.textContent;
|
||||
assert.ok(text.indexOf("veda_predictor") < text.indexOf("none is there") && text.indexOf("none is there") < text.indexOf("after_it"),
|
||||
assert.ok(text.indexOf("veda_predictor") < text.indexOf("needs a predictor file") && text.indexOf("needs a predictor file") < text.indexOf("after_it"),
|
||||
"the download sits right under the predictor selector, before the next field, not at the bottom of the page");
|
||||
assert.match(page.node.textContent, /By downloading you accept the MiniMax H3 Community License/);
|
||||
assert.match(page.node.textContent, /275 MB|263 MB/);
|
||||
@@ -184,13 +184,13 @@ test("the Veda predictor row appears only for method veda with no file, names th
|
||||
page.node.remove();
|
||||
page = await open({ sla: true, sla_method: "veda" });
|
||||
const buttons = () => [...page.node.querySelectorAll("button")].map((b) => b.textContent);
|
||||
assert.doesNotMatch(page.node.textContent, /none is there/);
|
||||
assert.doesNotMatch(page.node.textContent, /needs a predictor file/);
|
||||
assert.deepEqual(buttons().filter((t) => /Installed|Download/.test(t)), ["Download"], "one installed: only the missing one is offered");
|
||||
assert.match(page.node.textContent, /By downloading you accept/, "the licence stays while a button is shown");
|
||||
page.node.remove();
|
||||
state.files = ["a.safetensors", "b.safetensors"]; state.models[1].state = "ready";
|
||||
page = await open({ sla: true, sla_method: "veda" });
|
||||
assert.doesNotMatch(page.node.textContent, /Installed|Download|By downloading|none is there/, "both installed: nothing to offer, so nothing is shown");
|
||||
assert.doesNotMatch(page.node.textContent, /Installed|Download|By downloading|needs a predictor file/, "both installed: nothing to offer, so nothing is shown");
|
||||
page.node.remove();
|
||||
});
|
||||
|
||||
|
||||
@@ -53,11 +53,11 @@ export const modules = (app) => function mount() {
|
||||
const installed = mine.filter((m) => !isOff(m));
|
||||
const waiting = addons.filter((a) => a.state === "pending");
|
||||
page.set([notes.length ? c.banner.warn({ text: notes.join(" ") }) : null,
|
||||
waiting.length ? c.banner.info({ text: `Restart ComfyUI to finish: ${waiting.map((a) => a.title).join(", ")} will be installed (their dependencies downloaded) while ComfyUI starts, and work after that.`, action: { label: "Restart ComfyUI", onClick: () => app.maintenance.restart() } }) : null,
|
||||
waiting.length ? c.banner.info({ text: `Restart ComfyUI to finish installing ${waiting.map((a) => a.title).join(", ")}.`, action: { label: "Restart ComfyUI", onClick: () => app.maintenance.restart() } }) : null,
|
||||
...section("Available modules", offered.map(addonRow)),
|
||||
...section("Installed modules", [...onAddons.map(addonRow), ...CATEGORIES.flatMap(([key]) => installed.filter((m) => (m.category || "") === key).map(row))]),
|
||||
...section("Disabled modules", [...offAddons.map(addonRow), ...mine.filter(isOff).map(row)]),
|
||||
c.hint.default({ text: "A module you turn off here is off for this project only, and disappears from Engine settings. One that failed while generating is off for every project until its code is repaired or you turn it back on." }),
|
||||
c.hint.default({ text: "A module turned off here is off for this project only. One that failed while generating is off everywhere until you turn it back on." }),
|
||||
master].filter(Boolean));
|
||||
}
|
||||
draw();
|
||||
|
||||
@@ -22,7 +22,7 @@ export const shotMemory = (app) => function mount() {
|
||||
const words = data.words || [], views = data.views || [];
|
||||
const details = data.details || [];
|
||||
const infusion = data.infusion || [];
|
||||
if (!words.length && !views.length && !(data.arms || []).length && !details.length && !infusion.length) return page.set([c.emptyState.default({ icon: "◎", title: "Nothing learned yet", hint: "Learns which words, views and details you liked. Needs the Shot camera node with camera moves, shot views or liked details on in Engine, then rate your renders." })]);
|
||||
if (!words.length && !views.length && !(data.arms || []).length && !details.length && !infusion.length) return page.set([c.emptyState.default({ icon: "◎", title: "Nothing learned yet", hint: "Learns which words, views and details you liked. Needs Shot camera with camera moves, views or liked details on; then rate your renders." })]);
|
||||
page.set([c.hint.default({ text: `${data.prompts} prompts seen · ${data.shots} shots reviewed. Forget anything that taught it the wrong thing.` }),
|
||||
...(details.length ? [c.label.section({ text: "Liked details" }), ...details.map((d) => row(d.phrase, `${d.kept ? "will be added" : "dropped"} · liked ${d.good.toFixed(1)} · disliked ${d.bad.toFixed(1)}`, "detail", d.phrase))] : []),
|
||||
...(views.length ? [c.label.section({ text: "Views" }), ...views.map((v) => row(v.view, `liked ${v.good.toFixed(1)} · disliked ${v.bad.toFixed(1)}`, "view", v.view))] : []),
|
||||
|
||||
@@ -9,12 +9,12 @@ const STEPS = [
|
||||
{ at: "zone:1", side: "left", title: "Preview", text: "The monitor plays the cut wherever the playhead is. Save frame puts the current picture in the media bin." },
|
||||
{ at: "zone:2", side: "left", title: "Properties", text: "Project settings (size, length, postfix) and the picked scene's prompt, source, references and length." },
|
||||
{ at: "zone:3", side: "top", title: "Timeline", text: "One clip per scene. Drag the edges to trim, press S to split, Space to play. Lanes below carry separated sound." },
|
||||
{ at: "button:▶ Generate|Generating…", side: "top", title: "Generate", text: "Makes every scene on the timeline, except left-out scenes and video clips; an earlier render of a scene stays as a take. Beside it: Selected makes only the picked scenes, Takes ×N makes the picked scene N times with only the seed changing, and Rate scene… lets FunUI learn your taste (Upscale and Interpolate appear there too once you switch them on). “Generate this scene” in Properties makes just the picked one." },
|
||||
{ at: "button:▶ Generate|Generating…", side: "top", title: "Generate", text: "Makes every scene on the timeline; earlier renders stay as takes. Beside it: Selected, Takes ×N (new seeds) and Rate scene… (teaches FunUI your taste)." },
|
||||
{ at: "button:⏱ Sampler", side: "top", title: "Sampler", text: "Steps, sampler, scheduler, seed and the common switches, one click from the timeline. While a run goes, the step count shows to its left." },
|
||||
{ at: "button:◆ Composer", side: "top", title: "Composer", text: "Write the whole story in one box, cut at the cut word; keep templates, $variables and shortcuts; chat with the prompt enhancer." },
|
||||
{ at: "button:+ Add", side: "top", title: "Add and edit", text: "+ Add opens one menu: a scene, a video clip or audio track from the bin, an effect or transition, a text or picture overlay. Next to it: Split, Remove, Export, Save to media bin and Separate audio." },
|
||||
{ at: "button:+ Add", side: "top", title: "Add and edit", text: "+ Add opens one menu: a scene, a clip, an effect, a transition or an overlay. Next to it: Split, Remove, Export and more." },
|
||||
{ at: "button:⧉ Render|Rendering…", side: "top", title: "Render", text: "Joins the whole timeline into one file, with the separated sound mixed in. Export downloads the picked clips." },
|
||||
{ at: "main", side: "bottom", title: "FAQ", text: "Nothing happens? Check the ComfyUI chip at the top right and the Log window. Wrong look? Settings ▸ Appearance. Lost a layout? View ▸ Reset Layout. Lost a project? File ▸ Open recent. That is the tour: Help ▸ Start tour runs it again." },
|
||||
{ at: "main", side: "bottom", title: "FAQ", text: "Nothing happens? Check the ComfyUI chip at the top right and the Log. Lost a layout? View ▸ Reset Layout. That is the tour; Help ▸ Start tour runs it again." },
|
||||
];
|
||||
|
||||
export default {
|
||||
|
||||
@@ -103,7 +103,7 @@ export function dataStep({ app, session, go, ui }) {
|
||||
ratedNote.setText((n ? `${n} rated result${n > 1 ? "s" : ""} found in your projects. ${p ? `${p} picked: only those are added.` : "None picked: all are added."}` : "None found yet. Rate results in your projects and they appear here.") + left);
|
||||
addRated.node.disabled = !n || !open;
|
||||
}
|
||||
const include = c.field.default({ label: "Captions include", hint: "What goes into each caption from the result's prompt. Shortcuts are expanded; variables are filled in only when ticked, otherwise $name stays as written.",
|
||||
const include = c.field.default({ label: "Captions include", hint: "What goes into each caption from the result's prompt. Shortcuts are expanded; variables only when ticked.",
|
||||
control: c.checklist.default({ label: "Captions include", items: INCLUDE, values: ui.include, onChange: async (v) => {
|
||||
ui.include = v;
|
||||
try { ui.rated = await app.api.trainRated(v.length ? v : ["none"]); } catch { /* the old captions stay */ }
|
||||
|
||||
@@ -90,7 +90,7 @@ export function trainStep({ app, session, go, ui }) {
|
||||
live,
|
||||
c.hint.default({ text: used.length ? `${used.length} item${used.length > 1 ? "s" : ""} will be learned from (${caption} with tags). ${used.length - caption ? "Items without a caption are learned from with only the prefix and postfix." : ""}`.trim()
|
||||
: `"${session.name}" is empty. Add media in the Data step.` }),
|
||||
hasAudioVae ? c.banner.info({ text: "This pipeline has an audio VAE, so it trains picture and sound together (H3). One clip at a time, so training is slow and needs a lot of memory. musubi-tuner's H3 recipe: learning rate 0.0001, rank 16, batch 1, bf16, gradient checkpointing, plus block swap. Its plain flow-matching loss undoes the few-step quality of a distilled (turbo) model; musubi adds a training adapter, guidance loss or teacher matching for that, which this trainer does not have." }) : null,
|
||||
hasAudioVae ? c.banner.info({ text: "Trains picture and sound together, one clip at a time: slow, and needs a lot of memory. Start with learning rate 0.0001, rank 16, batch 1. A turbo model can lose its few-step quality." }) : null,
|
||||
c.group.default({ label: "Basics", rows: [name, preset,
|
||||
num("steps", "Steps", "More steps learn more and can overdo it.", { min: 1, max: 100000, step: 10 }),
|
||||
num("learning_rate", "Learning rate", "How big each step is. Lower is safer and slower.", { min: 0.0000001, max: 1, step: 0.0001, precision: 7 }),
|
||||
|
||||
@@ -18,6 +18,7 @@ export default [
|
||||
"./features/takes/index.js",
|
||||
"./features/rating/index.js",
|
||||
"./features/subjects/index.js",
|
||||
"./features/refsheet/index.js",
|
||||
"./features/upscale/index.js",
|
||||
"./features/interpolate/index.js",
|
||||
"./features/revolver/index.js",
|
||||
|
||||
@@ -147,6 +147,7 @@ export const api = {
|
||||
/** The Stop button for Upscale and Interpolate jobs (Upscale's route; Interpolate has its own below). -> {stopped} */
|
||||
stopJob: (id) => call("POST", `/api/m/render/jobs/${encodeURIComponent(id)}/stop`),
|
||||
stopInterpolateJob: (id) => call("POST", `/api/m/interpolate/jobs/${encodeURIComponent(id)}/stop`),
|
||||
refsheetMake: (body) => call("POST", "/api/m/refsheet/make", body),
|
||||
frameGeneration: () => call("GET", "/api/m/interpolate/frame_generation"),
|
||||
/** Interpolation is a background job of FunUI, not a ComfyUI prompt (that would make ComfyUI drop the workflow's loaded models). -> {job_id}. */
|
||||
interpolate: (media, options) => call("POST", "/api/m/interpolate/run", { media: { filename: media.filename, subfolder: media.subfolder || "", type: media.type === "temp" ? "temp" : "output" }, options }),
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
"""The leak report names the holder of a model whose patcher is gone."""
|
||||
import gc
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from core import leak_report
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _unwrapped():
|
||||
"""Another test may already have installed the report on the shared ComfyUI module; each test here starts from the plain functions."""
|
||||
try:
|
||||
import comfy.model_management as mm
|
||||
except Exception: # noqa: BLE001 -- ComfyUI absent: the tests that need it skip themselves
|
||||
yield
|
||||
return
|
||||
cleanup, switch = mm.cleanup_models_gc, mm.LoadedModel._switch_parent
|
||||
mm.cleanup_models_gc = lambda: None if getattr(cleanup, leak_report.TAG, False) else cleanup()
|
||||
mm.LoadedModel._switch_parent = (lambda self: None) if getattr(switch, leak_report.TAG, False) else switch
|
||||
yield
|
||||
mm.cleanup_models_gc, mm.LoadedModel._switch_parent = cleanup, switch
|
||||
|
||||
|
||||
class Holder:
|
||||
"""Stands for the closure or cache that keeps a model alive."""
|
||||
def __init__(self, model):
|
||||
|
||||
@@ -48,7 +48,7 @@ def test_no_hint_or_tooltip_is_an_essay():
|
||||
import ast
|
||||
import pathlib
|
||||
root = pathlib.Path(__file__).resolve().parents[2]
|
||||
limit, long = 200, []
|
||||
limit, long = 160, []
|
||||
for path in sorted(root.glob("modules/**/*.py")) + sorted(root.glob("core/*.py")):
|
||||
if "tests" in path.parts:
|
||||
continue
|
||||
@@ -67,3 +67,18 @@ def test_no_hint_or_tooltip_is_an_essay():
|
||||
if isinstance(text, str) and len(text) > limit:
|
||||
long.append(f"{path.relative_to(root)}:{v.lineno} ({len(text)})")
|
||||
assert not long, f"hints over {limit} characters: {long}"
|
||||
|
||||
|
||||
def test_no_ui_hint_or_banner_is_an_essay():
|
||||
"""The same for the text the browser UI writes itself: hints, tooltips, banners, tour steps."""
|
||||
import pathlib
|
||||
import re
|
||||
root = pathlib.Path(__file__).resolve().parents[2] / "app" / "ui"
|
||||
pat = re.compile(r"""\b(hint|tooltip|title|text)\b["']?\s*[:=(]\s*(["'`])((?:(?!\2).){201,}?)\2""")
|
||||
long = []
|
||||
for path in sorted(root.rglob("*.js")):
|
||||
if ".test." in path.name:
|
||||
continue
|
||||
for number, line in enumerate(path.read_text().splitlines(), 1):
|
||||
long += [f"{path.relative_to(root)}:{number} ({len(m.group(3))})" for m in pat.finditer(line) if "${" not in m.group(3)]
|
||||
assert not long, f"UI text over 200 characters: {long}"
|
||||
|
||||
@@ -33,7 +33,7 @@ SETTINGS = {
|
||||
"options": [{"value": "off", "label": "Off"},
|
||||
{"value": "middle", "label": "Middle frame of the clip before"},
|
||||
{"value": "memory", "label": "Memory from the last clips"}],
|
||||
"hint": "Uses pictures from the previous clip as weak guides. Middle frame keeps the room and side characters; Memory uses one picture from each of the last clips and needs the JoyAI-Echo LoRA.",
|
||||
"hint": "Uses pictures from the previous clip as weak guides. Middle frame keeps the room and side characters; Memory needs the JoyAI-Echo LoRA.",
|
||||
},
|
||||
"strength": {
|
||||
"type": "float", "default": 0.25, "min": 0.25, "max": 1.0, "step": 0.05,
|
||||
|
||||
@@ -17,7 +17,7 @@ class FunUINegativeErase(io.ComfyNode):
|
||||
io.Float.Input("strength", default=0.0, min=0.0, max=2.0, step=0.05,
|
||||
tooltip="0 = off. How much of the negative prompt is taken out of the positive prompt's words (1 = all of it)."),
|
||||
io.Combo.Input("mode", options=list(erase.MODES), default="project",
|
||||
tooltip="project: removes the part of each word that lies along the negative's direction. subtract: pulls every word away from the negative by a share of its own size."),
|
||||
tooltip="project: removes what the negative describes from each word. subtract: pushes each word away from the negative."),
|
||||
io.Boolean.Input("keep_size", default=True,
|
||||
tooltip="Put each word back on its original size, changing its direction only.")],
|
||||
outputs=[io.Conditioning.Output(display_name="positive"), io.String.Output(display_name="status")],
|
||||
|
||||
@@ -86,7 +86,7 @@ class FunUIEnhancePrompt(io.ComfyNode):
|
||||
io.String.Input("chat", multiline=True, default="", optional=True,
|
||||
tooltip="Your comments on earlier rewrites (Composer > Chat)."),
|
||||
io.Combo.Input("style", options=list(en.STYLES), default="default", optional=True,
|
||||
tooltip="default: the built-in instructions. dynamic: the same rules, but every shot gets a concrete action beat, a change over time and secondary motion. Typed Instructions override both."),
|
||||
tooltip="default: the built-in instructions. dynamic: adds a concrete action, a change over time and secondary motion to every shot. Typed Instructions override both."),
|
||||
],
|
||||
outputs=[io.String.Output(display_name="enhanced"),
|
||||
io.String.Output(display_name="status")],
|
||||
|
||||
@@ -64,7 +64,7 @@ class FunUIDiffusionModelLoader(io.ComfyNode):
|
||||
io.Boolean.Input("fp16_accumulation", default=False, optional=True,
|
||||
tooltip="Speeds up fp16 maths slightly, with a small quality trade-off. Needs a recent PyTorch and a GPU that supports it."),
|
||||
io.Boolean.Input("sla", default=False, optional=True, extra_dict={"funui_requires": ["sparse_attention"]},
|
||||
tooltip="Faster attention for MiniMax H3, with a quality trade-off. Needed for the SLA turbo LoRA to give any speedup. Requires Triton and an H3 model; otherwise it is skipped and the status says why."),
|
||||
tooltip="Speeds up MiniMax H3 with a quality trade-off, and is what the SLA turbo LoRA needs. Requires Triton; skipped on other models."),
|
||||
# SLA settings. Every one is validated at its default (see sla_attention);
|
||||
# they are here because a knob whose value was measured is still a knob.
|
||||
io.Float.Input("sla_sparsity", extra_dict=shows(("sla", [True]), ("sla_method", ["sla", "vsa"])), default=sla_attention.SLA_DEFAULTS["sparsity_ratio"],
|
||||
@@ -87,11 +87,11 @@ class FunUIDiffusionModelLoader(io.ComfyNode):
|
||||
min=0, max=8, optional=True,
|
||||
tooltip="Runs the last N steps with normal attention for a cleaner finish. Costs extra time; 0 is fine for most runs."),
|
||||
io.Boolean.Input("int8_convrot", default=False, optional=True,
|
||||
tooltip="MiniMax H3 only: stores the big layers as int8 for about half the memory, with slightly different pictures. Skipped for files that are already quantized; the status says so."),
|
||||
tooltip="MiniMax H3 only: about half the memory, with slightly different pictures. Skipped for files that are already quantized."),
|
||||
# Added later, so after everything: saved workflows can store widget values by position.
|
||||
io.Combo.Input("sla_engine", extra_dict=shows(("sla", [True]), ("sla_method", ["sla"])), options=list(sla_attention.ENGINES),
|
||||
default=sla_attention.SLA_DEFAULTS["engine"], optional=True,
|
||||
tooltip="What runs the faster attention. comfy_kitchen is ComfyUI's own and needs comfy-kitchen 0.2.32+; triton is FunUI's and has more options. Falls back to triton if missing."),
|
||||
tooltip="What runs the faster attention. comfy_kitchen is ComfyUI's own and needs comfy-kitchen 0.2.32+; triton is FunUI's, with more options."),
|
||||
io.String.Input("sla_dense_steps", extra_dict=shows(("sla", [True]), ("sla_method", ["sla", "sol-attn", "veda"])), default=sla_attention.SLA_DEFAULTS["dense_steps"],
|
||||
optional=True,
|
||||
tooltip="Steps that use normal attention, counted from 1 ('1', '1-2', '1,6'). The first step sets the layout, so it is exact by default. Blank or 0 means none."),
|
||||
@@ -105,7 +105,7 @@ class FunUIDiffusionModelLoader(io.ComfyNode):
|
||||
tooltip="Triton engine: removes a faint double image on fast motion. Uses a little more memory."),
|
||||
io.Combo.Input("sla_method", extra_dict=shows(("sla", [True])), options=list(sla_attention.METHODS),
|
||||
default=sla_attention.SLA_DEFAULTS["method"], optional=True,
|
||||
tooltip="How the parts to skip are chosen. sla: a fixed share, what turbo LoRAs expect. sol-attn: adapts per block. vsa: only for models trained for it. veda: experimental, needs CUDA."),
|
||||
tooltip="How the parts to skip are chosen. sla: a fixed share. sol-attn: adapts per block. vsa: needs a model trained for it. veda: experimental, needs CUDA."),
|
||||
io.Float.Input("sla_tau", extra_dict=shows(("sla", [True]), ("sla_method", ["sol-attn"])), default=sla_attention.SLA_DEFAULTS["tau"], min=0.0, max=4.0,
|
||||
step=0.05, optional=True,
|
||||
tooltip="sol-attn only: higher skips more attention. About 1.0 is gentle, 2.0 is aggressive."),
|
||||
|
||||
@@ -51,12 +51,10 @@ class FunUILoraLoader(io.ComfyNode):
|
||||
if first: # the original order: clip sits between slot 1's two strengths
|
||||
inputs.append(io.Clip.Input("clip", optional=True, tooltip="Leave unwired for model-only LoRAs."))
|
||||
inputs.append(io.Float.Input(f"strength_clip{sfx}", default=1.0, min=-100.0, max=100.0, step=0.01, optional=True, extra_dict=mine,
|
||||
tooltip="How strongly the LoRA changes the text encoder. Only used when a clip is wired, and not for a LoRA set to the First or Second stage (the text is encoded before sampling starts)."))
|
||||
tooltip="How strongly the LoRA changes the text encoder. Needs a wired clip; not used for First or Second stage LoRAs."))
|
||||
inputs.append(io.Combo.Input(f"apply_to_stage{sfx}", options=list(dit_hooks.STAGES), default="Both", optional=True,
|
||||
extra_dict={**mine, "funui_needs_module": ["two_stage"]},
|
||||
tooltip="Which part of a Two stages run this LoRA works in. First: the smaller picture. "
|
||||
"Second: the full-size steps. Only the model is changed for First or Second; "
|
||||
"the text encoder strength is not used."))
|
||||
tooltip="Which part of a Two stages run this LoRA applies to. First: the smaller picture. Second: the full-size steps. Both only change the model."))
|
||||
return io.Schema(
|
||||
node_id="FunUILoraLoader",
|
||||
display_name="FunUI LoRA Loader",
|
||||
|
||||
@@ -45,7 +45,7 @@ class FunUIVAELoader(io.ComfyNode):
|
||||
tooltip="The precision the VAE works in. bfloat16 is the default; MiniMax H3 needs it."),
|
||||
io.Combo.Input("decode_vae_name", options=["None", *vae_files()], default="None", optional=True,
|
||||
display_name="Decode-only VAE (Optional)",
|
||||
tooltip="A separate VAE used only to turn the result into pictures; this one still encodes. Pick MiniMax H3's Sol-H3-VAE for about 2.5 times faster decoding, or an X2 Detail VAE. None uses this VAE for both."),
|
||||
tooltip="A separate VAE used only for the final decode. H3's Sol-H3-VAE decodes about 2.5 times faster. None uses this VAE for both."),
|
||||
],
|
||||
outputs=[
|
||||
io.Vae.Output(display_name="vae"),
|
||||
|
||||
@@ -104,7 +104,7 @@ class FunUILoadMedia(io.ComfyNode):
|
||||
tooltip="Read the whole video and its sound. Off: only the first frame, which is all a picture input needs."),
|
||||
io.String.Input(
|
||||
"prepare", default="", optional=True,
|
||||
tooltip="With full on: how to get the video ready, as JSON (fps, skip_first, cut_last, every_nth, frame_cap, width, height, fit, crop_*, multiple_of). Empty keeps all frames at 24 fps."),
|
||||
tooltip="With full on: how to prepare the video, as JSON (fps, width, height, fit, crop_*, ...). Empty keeps every frame at 24 fps."),
|
||||
],
|
||||
outputs=[io.Image.Output(display_name="image"), io.Image.Output(display_name="frames"),
|
||||
io.Audio.Output(display_name="audio")],
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Reference sheet: pictures from the media bin in a labelled grid on white, saved back to the bin as a picture.
|
||||
|
||||
An optional helper for building a reference picture (a character from several angles, outfits, props) that is then used like any
|
||||
other picture. Remove the folder and nothing else changes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from . import sheet
|
||||
from ..._core import media
|
||||
|
||||
ID = "refsheet"
|
||||
TITLE = "Reference sheet"
|
||||
STAGE = "load"
|
||||
CATEGORY = "system"
|
||||
STATUS = "experimental"
|
||||
NODES = []
|
||||
|
||||
|
||||
def _open(media_id):
|
||||
"""A bin picture as a PIL image, or None when it is gone or is not a picture."""
|
||||
from PIL import Image
|
||||
path = media.path_for(media_id) if isinstance(media_id, str) else None
|
||||
item = media.get(media_id) if path else None
|
||||
if not item or item.get("kind") != "image":
|
||||
return None
|
||||
try:
|
||||
with Image.open(path) as im:
|
||||
im.load()
|
||||
return im.copy()
|
||||
except Exception: # noqa: BLE001 -- a file Pillow cannot read is an empty slot, said in "missing"
|
||||
return None
|
||||
|
||||
|
||||
def build(body):
|
||||
"""-> (bin entry, [labels of slots whose picture could not be read]). Raises ValueError naming what to fix."""
|
||||
if not isinstance(body, dict):
|
||||
raise ValueError("Send {rows, cols, format, width, title, slots: [{picture, label}]}.")
|
||||
slots = body.get("slots") or []
|
||||
missing = [str(s.get("label") or i + 1) for i, s in enumerate(slots) if isinstance(s, dict) and s.get("picture") and _open(s["picture"]) is None]
|
||||
png = sheet.make(body.get("rows", 2), body.get("cols", 3), body.get("format", "1:1"), body.get("width", 512), body.get("title"), slots, _open)
|
||||
name = " ".join(str(body.get("title") or "").split())[:60] or "Reference sheet"
|
||||
return media.save_upload(f"{name}.png", png, limit=None), missing
|
||||
|
||||
|
||||
def routes(table, base, web):
|
||||
@table.post(base + "/make")
|
||||
async def _make(req):
|
||||
try:
|
||||
body = await req.json()
|
||||
except Exception: # noqa: BLE001
|
||||
body = None
|
||||
try:
|
||||
entry, missing = await asyncio.to_thread(build, body)
|
||||
except ValueError as exc:
|
||||
return web.json_response({"detail": str(exc)}, status=400)
|
||||
return web.json_response({"media": entry, "missing": missing})
|
||||
|
||||
@table.get(base + "/formats")
|
||||
async def _formats(_req):
|
||||
return web.json_response({"formats": list(sheet.FORMATS), "max_slots": sheet.MAX_SLOTS})
|
||||
|
||||
|
||||
PROVIDES = {"routes": routes}
|
||||
@@ -0,0 +1,84 @@
|
||||
"""The sheet itself: pictures in a grid on white, each labelled below, the title in the top left corner.
|
||||
|
||||
Pure Pillow, no ComfyUI. Every picture is cut to the slot's shape (centre crop, like CSS `cover`) and resized to the slot, so
|
||||
all cells are the same size whatever went in. An empty slot keeps its label and shows a plain frame.
|
||||
"""
|
||||
|
||||
import io
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
||||
|
||||
FORMATS = {"1:1": (1, 1), "4:3": (4, 3), "3:4": (3, 4), "3:2": (3, 2), "2:3": (2, 3), "16:9": (16, 9), "9:16": (9, 16)}
|
||||
MAX_SLOTS, MIN_SLOT, MAX_SLOT, MAX_SIDE = 36, 128, 1024, 8192
|
||||
LABEL_CHARS = 80
|
||||
INK, PAPER, FRAME = (20, 20, 20), (255, 255, 255), (222, 222, 222)
|
||||
|
||||
|
||||
def _font(px):
|
||||
# Pillow's built-in scalable font: no font file to ship. ponytail: one typeface, a font picker if anyone asks.
|
||||
return ImageFont.load_default(size=px)
|
||||
|
||||
|
||||
def _fit(draw, text, font_px, width):
|
||||
"""The text cut with an ellipsis until it fits `width` on one line."""
|
||||
font = _font(font_px)
|
||||
text = " ".join(text.split())[:LABEL_CHARS]
|
||||
if draw.textlength(text, font=font) <= width:
|
||||
return text, font
|
||||
while text and draw.textlength(text + "…", font=font) > width:
|
||||
text = text[:-1]
|
||||
return text.rstrip() + "…", font
|
||||
|
||||
|
||||
def cover(img, size):
|
||||
"""`img` cut to the shape of `size` from the middle, then resized to it."""
|
||||
return ImageOps.fit(ImageOps.exif_transpose(img).convert("RGB"), size, Image.LANCZOS, centering=(0.5, 0.5))
|
||||
|
||||
|
||||
def check(rows, cols, shape, width, slots):
|
||||
"""Refuse a bad request by name; -> (rows, cols, (w, h) of one slot)."""
|
||||
for name, v, lo, hi in (("rows", rows, 1, 12), ("columns", cols, 1, 12), ("slot width", width, MIN_SLOT, MAX_SLOT)):
|
||||
if isinstance(v, bool) or not isinstance(v, int) or not lo <= v <= hi:
|
||||
raise ValueError(f"{name} must be a whole number from {lo} to {hi}")
|
||||
if rows * cols > MAX_SLOTS:
|
||||
raise ValueError(f"{rows} x {cols} is {rows * cols} slots; the most is {MAX_SLOTS}")
|
||||
if shape not in FORMATS:
|
||||
raise ValueError(f"slot format must be one of {', '.join(FORMATS)}")
|
||||
if not isinstance(slots, list) or len(slots) > rows * cols:
|
||||
raise ValueError(f"send at most {rows * cols} slots")
|
||||
a, b = FORMATS[shape]
|
||||
return rows, cols, (width, round(width * b / a))
|
||||
|
||||
|
||||
def make(rows, cols, shape, width, title, slots, open_picture):
|
||||
"""-> PNG bytes. `slots` is a list of {picture, label} in reading order (left to right, top to bottom); `picture` is whatever
|
||||
`open_picture` turns into a PIL image (None or missing = an empty slot)."""
|
||||
rows, cols, (w, h) = check(rows, cols, shape, width, slots)
|
||||
gap, margin = round(w * 0.06), round(w * 0.08)
|
||||
label_px, title_px = max(12, round(w * 0.055)), max(16, round(w * 0.09))
|
||||
label_h = round(label_px * 1.9)
|
||||
title_h = round(title_px * 2.0) if str(title or "").strip() else 0
|
||||
W, H = margin * 2 + cols * w + (cols - 1) * gap, margin * 2 + title_h + rows * (h + label_h) + (rows - 1) * gap
|
||||
if max(W, H) > MAX_SIDE:
|
||||
raise ValueError(f"the sheet would be {W} x {H} pixels; the most is {MAX_SIDE} on a side. Use fewer slots or a smaller slot width")
|
||||
sheet = Image.new("RGB", (W, H), PAPER)
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
if title_h:
|
||||
text, font = _fit(draw, str(title), title_px, W - 2 * margin)
|
||||
draw.text((margin, margin), text, fill=INK, font=font)
|
||||
for i in range(rows * cols):
|
||||
slot = slots[i] if i < len(slots) and isinstance(slots[i], dict) else {}
|
||||
x = margin + (i % cols) * (w + gap)
|
||||
y = margin + title_h + (i // cols) * (h + label_h + gap)
|
||||
picture = open_picture(slot.get("picture")) if slot.get("picture") else None
|
||||
if picture is not None:
|
||||
sheet.paste(cover(picture, (w, h)), (x, y))
|
||||
else:
|
||||
draw.rectangle([x, y, x + w - 1, y + h - 1], outline=FRAME, width=2)
|
||||
label = str(slot.get("label") or "").strip()
|
||||
if label:
|
||||
text, font = _fit(draw, label, label_px, w)
|
||||
draw.text((x, y + h + round(label_px * 0.45)), text, fill=INK, font=font)
|
||||
out = io.BytesIO()
|
||||
sheet.save(out, "PNG")
|
||||
return out.getvalue()
|
||||
@@ -0,0 +1,114 @@
|
||||
import asyncio
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from core import config, media
|
||||
from modules.media import refsheet
|
||||
from modules.media.refsheet import sheet
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "MEDIA_DIR", tmp_path / "media")
|
||||
|
||||
|
||||
def _png(size, color):
|
||||
out = io.BytesIO()
|
||||
Image.new("RGB", size, color).save(out, "PNG")
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def _add(size, color, name="p.png"):
|
||||
return media.save_upload(name, _png(size, color))["id"]
|
||||
|
||||
|
||||
def _decode(png):
|
||||
return Image.open(io.BytesIO(png)).convert("RGB")
|
||||
|
||||
|
||||
def test_every_picture_is_cut_to_the_slot_and_the_rest_is_white(store):
|
||||
wide, tall = _add((400, 100), (255, 0, 0)), _add((100, 400), (0, 0, 255))
|
||||
png = sheet.make(1, 2, "1:1", 256, "", [{"picture": wide}, {"picture": tall}], refsheet._open)
|
||||
im = _decode(png)
|
||||
w = 256
|
||||
gap, margin = round(w * 0.06), round(w * 0.08)
|
||||
assert im.size == (margin * 2 + 2 * w + gap, margin * 2 + w + round(max(12, round(w * 0.055)) * 1.9))
|
||||
assert im.getpixel((0, 0)) == (255, 255, 255)
|
||||
assert im.getpixel((margin + w // 2, margin + w // 2)) == (255, 0, 0) # the middle of a wide picture fills the whole square
|
||||
assert im.getpixel((margin + 2, margin + 2)) == (255, 0, 0) and im.getpixel((margin + w - 3, margin + w - 3)) == (255, 0, 0)
|
||||
assert im.getpixel((margin + w + gap + w // 2, margin + w // 2)) == (0, 0, 255)
|
||||
|
||||
|
||||
def _ink(im, box):
|
||||
return any(sum(im.getpixel((x, y))) < 200 for x in range(box[0], box[2]) for y in range(box[1], box[3]))
|
||||
|
||||
|
||||
def test_a_label_sits_below_its_slot_even_when_the_slot_is_empty(store):
|
||||
pic = _add((64, 64), (0, 200, 0))
|
||||
plain = _decode(sheet.make(1, 2, "1:1", 256, "", [{"picture": pic}, {}], refsheet._open))
|
||||
drawn = _decode(sheet.make(1, 2, "1:1", 256, "", [{"picture": pic, "label": "front"}, {"label": "back"}], refsheet._open))
|
||||
margin, w, gap = 20, 256, 15
|
||||
for x in (margin, margin + w + gap): # both slots' label strips
|
||||
strip = (x, margin + w + 2, x + w, drawn.size[1])
|
||||
assert not _ink(plain, strip) and _ink(drawn, strip)
|
||||
|
||||
|
||||
def test_the_title_is_in_the_top_left_corner(store):
|
||||
im = _decode(sheet.make(1, 1, "1:1", 256, "Hero", [], refsheet._open))
|
||||
assert _ink(im, (20, 20, 200, 60)) and not _ink(im, (im.size[0] // 2, 0, im.size[0], 60))
|
||||
|
||||
|
||||
def test_a_long_label_is_cut_to_the_slot_width(store):
|
||||
from PIL import ImageDraw
|
||||
d = ImageDraw.Draw(Image.new("RGB", (10, 10)))
|
||||
text, font = sheet._fit(d, "word " * 60, 28, 200)
|
||||
assert text.endswith("…") and d.textlength(text, font=font) <= 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
dict(rows=0), dict(cols=13), dict(rows=True), dict(width=64), dict(width=2000), dict(shape="5:4"), dict(rows=12, cols=12),
|
||||
])
|
||||
def test_a_bad_request_is_refused_by_name(bad):
|
||||
args = dict(rows=2, cols=2, shape="1:1", width=512, slots=[]) | bad
|
||||
with pytest.raises(ValueError):
|
||||
sheet.check(args["rows"], args["cols"], args["shape"], args["width"], args["slots"])
|
||||
|
||||
|
||||
def test_too_many_slots_and_a_huge_sheet_are_refused():
|
||||
with pytest.raises(ValueError, match="at most 4"):
|
||||
sheet.check(2, 2, "1:1", 512, [{}] * 5)
|
||||
with pytest.raises(ValueError, match="pixels"):
|
||||
sheet.make(6, 6, "9:16", 1024, "", [], lambda m: None)
|
||||
|
||||
|
||||
def test_the_route_saves_the_sheet_to_the_bin_and_names_pictures_it_could_not_read(store):
|
||||
ok = _add((50, 50), (9, 9, 9))
|
||||
table = {}
|
||||
|
||||
class T:
|
||||
def post(self, path): return lambda fn: table.setdefault(path, fn)
|
||||
def get(self, path): return lambda fn: table.setdefault(path, fn)
|
||||
|
||||
class W:
|
||||
@staticmethod
|
||||
def json_response(d, status=200): return {"status": status, **d}
|
||||
refsheet.routes(T(), "/m", W)
|
||||
|
||||
def ask(body):
|
||||
class R:
|
||||
async def json(self): return body
|
||||
return asyncio.run(table["/m/make"](R()))
|
||||
|
||||
got = ask({"rows": 1, "cols": 2, "format": "4:3", "width": 256, "title": "Mara", "slots": [{"picture": ok, "label": "front"}, {"picture": "ffffffffffff", "label": "gone"}]})
|
||||
assert got["status"] == 200 and got["missing"] == ["gone"]
|
||||
item = media.get(got["media"]["id"])
|
||||
assert item["kind"] == "image" and item["name"] == "Mara.png" and _decode(media.path_for(item["id"]).read_bytes()).size[0] > 512
|
||||
assert ask({"rows": 99})["status"] == 400 and ask("nope")["status"] == 400
|
||||
assert [m["id"] for m in media.listing()].count(item["id"]) == 1
|
||||
|
||||
|
||||
def test_a_video_or_sound_in_a_slot_is_an_empty_slot(store):
|
||||
vid = media.save_upload("clip.mp4", b"not really a video")["id"]
|
||||
assert refsheet._open(vid) is None
|
||||
@@ -43,7 +43,7 @@ SETTINGS = {
|
||||
"hint": "Frames neighbouring windows share. More overlap joins smoother but runs slower; it must be shorter than the window.",
|
||||
"when": {"enabled": True, "schedule": [v for v in SCHEDULES if v != "batched"]}},
|
||||
"schedule": {"type": "enum", "default": "standard_uniform", "label": "Window schedule",
|
||||
"hint": "Window layout. standard_uniform: shifts a little each step. standard_static: same every step. looped_uniform: the end wraps to the start. batched: no overlap.",
|
||||
"hint": "Window layout. standard_uniform: shifts each step. standard_static: fixed. looped_uniform: the end wraps to the start. batched: no overlap.",
|
||||
"options": [{"value": v, "label": v} for v in SCHEDULES], "when": {"enabled": True}},
|
||||
"fuse": {"type": "enum", "default": "pyramid", "label": "Window blend",
|
||||
"hint": "How windows are blended. pyramid: trusts each window's middle most. overlap-linear: fades across the overlap. flat: plain average.",
|
||||
|
||||
@@ -51,7 +51,7 @@ SETTINGS = {
|
||||
{"value": "noise", "label": "Starting noise"},
|
||||
],
|
||||
"label": "Merge", "ui": "segmented",
|
||||
"hint": "Blends in a second seed. Step answers: asks the model twice at chosen steps. Second run: blends a whole second video (slower). Starting noise: mixes the second seed's noise in at no extra cost.",
|
||||
"hint": "Blends in a second seed. Step answers: at chosen steps. Second run: a whole second video (slower). Starting noise: free.",
|
||||
"when": {"enabled": True},
|
||||
},
|
||||
"steps": {
|
||||
|
||||
@@ -53,7 +53,7 @@ SETTINGS = {
|
||||
"enabled": {
|
||||
"type": "bool", "default": False,
|
||||
"label": "Few-step PTD",
|
||||
"hint": "Renders in the adapter file's own 4 to 8 steps, without CFG. Use the base the file was trained on, with SLA, turbo LoRAs and other enhancements off. Cannot start from a picture or mask.",
|
||||
"hint": "Renders in the adapter's own 4 to 8 steps without CFG. Use the base it was trained on, with SLA and turbo LoRAs off. No picture or mask start.",
|
||||
},
|
||||
"adapter": {
|
||||
"type": "enum", "default": _FOUND[0] if _FOUND else "",
|
||||
|
||||
@@ -34,7 +34,7 @@ SETTINGS = {
|
||||
"enabled": {
|
||||
"type": "bool", "default": False,
|
||||
"label": "Measure the run",
|
||||
"hint": "Logs how grainy the picture is and how much it changes each step, and compares with the previous run of the same seed, steps and size. No extra model calls.",
|
||||
"hint": "Logs how grainy the picture is and how much it changes each step, and compares with the last run of the same seed, steps and size.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -116,10 +116,7 @@ class FunUISampler(io.ComfyNode):
|
||||
tooltip="Below 1 starts partway through the schedule."),
|
||||
io.Custom("FUNUI_SETTINGS").Input("settings", optional=True),
|
||||
io.Sigmas.Input("sigmas", optional=True,
|
||||
tooltip="A schedule to run exactly as written. When wired, "
|
||||
"steps, scheduler and denoise are not used. For a "
|
||||
"second pass its first value is the strength: 0.4 "
|
||||
"polishes, 0.8 reworks the shot."),
|
||||
tooltip="A schedule to run exactly as written; steps, scheduler and denoise are then unused. In a second pass its first value is the strength."),
|
||||
],
|
||||
outputs=[
|
||||
io.Latent.Output(display_name="latent"),
|
||||
|
||||
@@ -70,7 +70,7 @@ SETTINGS = {
|
||||
"polish": {
|
||||
"type": "float", "default": 0.25, "min": 0.0, "max": 0.6, "step": 0.01,
|
||||
"label": "Polish step sigma", "ui": "slider",
|
||||
"hint": "0: off. Otherwise one extra full-size pass after the last step, starting from this noise level, as a short polish. From about 0.4 the sound is redrawn too.",
|
||||
"hint": "0: off. Otherwise adds one extra full-size polish pass from this noise level. From about 0.4 the sound is redrawn too.",
|
||||
"when": {"enabled": True},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ SETTINGS = {
|
||||
"back_up": {
|
||||
"type": "float", "default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05, "ui": "slider",
|
||||
"label": "Noise added at the hand-over",
|
||||
"hint": "How far back the full-size stage starts. 0: where the first stage stopped. Higher: more fresh noise, so soft or doubled outlines can be redrawn. Maximum 0.95.",
|
||||
"hint": "How far back the full-size stage starts. 0: where stage one stopped. Higher: more fresh noise, which can redraw soft or doubled outlines.",
|
||||
"when": {"enabled": True},
|
||||
},
|
||||
"resize": {
|
||||
@@ -72,7 +72,7 @@ SETTINGS = {
|
||||
{"value": "upscaler", "label": "Latent upscaler"}],
|
||||
"renamed_options": {"trilinear": "bicubic"}, # removed: it was Bilinear here (time is never resampled)
|
||||
"label": "How the hand-over is enlarged",
|
||||
"hint": "Plain methods are free. Latent upscaler uses H3's learned upscaler (models/latent_upscale_models) for learned detail instead of blur; one small pass per run.",
|
||||
"hint": "Plain methods are free. Latent upscaler adds learned detail instead of blur, for one small extra pass.",
|
||||
"when": {"enabled": True},
|
||||
},
|
||||
"upscaler": {
|
||||
|
||||
@@ -48,7 +48,7 @@ class FunUITrainLora(io.ComfyNode):
|
||||
io.Combo.Input("weight_dtype", options=["default", "fp8_e4m3fn", "fp8_e5m2", "fp16", "bf16", "fp32"], default="default", tooltip="How the model file's weights are stored when it is loaded here."),
|
||||
io.Combo.Input("compute_dtype", options=["default", "fp16", "bf16", "fp32"], default="default", tooltip="What the maths runs in when the model is loaded here."),
|
||||
io.String.Input("postfix", default="", tooltip="Text put at the end of every caption."),
|
||||
io.Int.Input("swap_blocks", default=0, min=0, max=128, tooltip="How many transformer blocks keep their weights in system memory, fetched one layer at a time: slower, and each one frees about a block's weight on the card. 0 keeps the whole model on the card."),
|
||||
io.Int.Input("swap_blocks", default=0, min=0, max=128, tooltip="How many blocks wait in system memory to save VRAM, at the cost of speed. 0 keeps the whole model on the card."),
|
||||
io.Int.Input("preview_every", default=0, min=0, max=100000, tooltip="Pause every this many steps so the pipeline can make a preview with the LoRA so far. 0 never pauses."),
|
||||
io.Int.Input("save_every", default=0, min=0, max=100000, tooltip="Save a copy of the LoRA so far to the loras folder every this many steps, named <name>_step<N>. 0 saves only the finished one."),
|
||||
io.String.Input("resume", default="", tooltip="The name of a paused run to carry on. Empty starts a new run."),
|
||||
|
||||
Reference in New Issue
Block a user