Old project/workflow names and browser settings migrate on load; /funpack redirects to /fun. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016acRss69eH1235eRdFTZHY
306 lines
20 KiB
JavaScript
306 lines
20 KiB
JavaScript
// "Training": make a LoRA from your own media and rated results. An add-on (Settings > Modules); the window is absent until it is on.
|
|
// Four steps in one window: Data (optional), Tags, Train, Results. A training set is saved, so each step can be left and come back to.
|
|
import { composer as c } from "../../composer/composer.js";
|
|
import { dataStep } from "./data.js";
|
|
import { applyTagging, tagsStep } from "./tags.js";
|
|
import { footNote, monitor, notice, settingsOf, trainStep } from "./train.js";
|
|
import { resultsStep } from "./results.js";
|
|
import { makeSession, srcKey } from "./state.js";
|
|
|
|
const VIDEO = /\.(mp4|webm|mov|mkv)$/i;
|
|
|
|
const TABS = [{ value: "data" }, { value: "tags" }, { value: "train" }, { value: "results" }]; // the steps, in order: Back and Next walk them
|
|
const STEPS = {
|
|
data: { title: "Pick what to learn from", sub: "Drop pictures and clips, choose from your Bin, or take results you rated. A set you filled earlier can go straight on; an empty one cannot be trained." },
|
|
tags: { title: "Describe each one", sub: "The words under each picture are what the LoRA learns to connect with it. Fix them by hand, or let the tagger suggest." },
|
|
train: { title: "Train the LoRA", sub: "Choose how thorough it should be and press Start. Progress and previews appear in Results." },
|
|
results: { title: "Results", sub: "Watch the training, look at the previews and download a LoRA, the final one or any step." },
|
|
};
|
|
const POLL = 1000;
|
|
const QUEUE_WAIT = 60000;
|
|
|
|
const smooth = (values) => { // a gentle moving average, so the trend shows through a noisy loss
|
|
const w = Math.max(3, Math.round(values.length / 40)), out = [];
|
|
let sum = 0;
|
|
for (let i = 0; i < values.length; i += 1) { sum += values[i]; if (i >= w) sum -= values[i - w]; out.push(sum / Math.min(i + 1, w)); }
|
|
return out;
|
|
};
|
|
|
|
export default {
|
|
id: "training",
|
|
mount: "timeline.status",
|
|
needs: ["api", "dom", "pipeline", "project", "generate"],
|
|
setup({ host, app }) {
|
|
const api = app.api;
|
|
const session = makeSession(api);
|
|
const ui = {
|
|
tab: "data", openBin: false, openRated: false, clips: {}, picked: new Set(), ratedPicked: new Set(), bin: [], rated: { results: [], counts: {} }, ratedKind: "liked", include: ["main"], selected: new Set(), filter: "all", tag: "", limit: 48,
|
|
tagger: { id: "wd_vit_v3", list: [], job: null, general: 0.35, limit: 0, characters: false, mode: "add", scope: "untagged" },
|
|
advanced: false, runs: [], chosenRun: "", record: null,
|
|
run: { shown: false, active: false, state: "idle", loss: [], times: [], stepSeconds: null, lastStep: null, smooth: [], step: 0, total: 0, elapsed: 0, message: "", error: "", lora: "", before: "", runId: "", paused: null },
|
|
previews: [], holdPause: false, awaitStart: false, peek: (item) => peek(item),
|
|
pause: () => pause(), resume: () => launch(ui.run.runId), restore: (id) => { ui.run.loss = []; ui.run.times = []; ui.run.runId = id; ui.record = null; return launch(id); }, previewNow: () => previewNow(),
|
|
monitorBox: null, redraw: () => draw(), pollTaggers: () => pollTaggers(true), loadRecord: (id) => loadRecord(id), loadRuns: () => loadRuns(),
|
|
start: () => start(), stop: () => stop(),
|
|
/** Ask for the frame counts of clips not known yet; `then` runs when new numbers arrived. */
|
|
async needClips(srcs, then) {
|
|
const fresh = srcs.filter((x) => !(srcKey(x) in ui.clips));
|
|
if (!fresh.length) return;
|
|
fresh.forEach((x) => { ui.clips[srcKey(x)] = null; });
|
|
try { Object.assign(ui.clips, (await api.trainClips(fresh)).clips || {}); } catch { fresh.forEach((x) => { delete ui.clips[srcKey(x)]; }); return; }
|
|
if (then) then();
|
|
},
|
|
};
|
|
let foot = () => {};
|
|
let win = null, body = null, timers = [], offSession = null, tagPoll = null, runPoll = null;
|
|
|
|
const button = c.button.sm({ label: "🎓 Training", tone: "neutral", title: "Make a LoRA from your media and rated results", onClick: () => (win ? win.close() : open()) });
|
|
button.node.hidden = true;
|
|
host.append(button.node);
|
|
api.trainState().then((s) => { button.node.hidden = !(s && s.enabled); }).catch(() => {});
|
|
|
|
const peek = (item) => {
|
|
const s = item.src;
|
|
const src = s.kind === "bin" ? `/fun/api/media/${encodeURIComponent(s.id)}/file` : `/view?filename=${encodeURIComponent(s.filename)}&subfolder=${encodeURIComponent(s.subfolder || "")}&type=${encodeURIComponent(s.type || "output")}`;
|
|
const viewer = c.viewer.media({ src, kind: item.media_kind === "video" ? "video" : "image" });
|
|
const m = c.modal.generic({ title: item.name || "Preview", body: viewer, onClose: () => viewer.destroy && viewer.destroy() });
|
|
return m;
|
|
};
|
|
|
|
// --- the steps
|
|
const steps = {
|
|
data: () => dataStep({ app, session, go, ui }),
|
|
tags: () => tagsStep({ app, session, go, ui, peek }),
|
|
train: () => trainStep({ app, session, go, ui }),
|
|
results: () => resultsStep({ app, ui }),
|
|
};
|
|
function go(tab) { if (tab !== "data" && tab !== "results" && !((session.doc || {}).items || []).length) tab = "data"; ui.tab = tab; if (tab === "results") loadRuns(); draw(); }
|
|
function draw() {
|
|
if (!win) return;
|
|
const note = session.error ? c.inlineError.default({ text: session.error }) : null;
|
|
const info = STEPS[ui.tab], order = TABS.map((t) => t.value), at = order.indexOf(ui.tab);
|
|
const ready = ((session.doc || {}).items || []).length > 0; // nothing to learn from: no way on to training
|
|
body.set([c.label.section({ text: `${at + 1} / ${order.length} · ${info.title}` }), c.hint.default({ text: info.sub }), note, steps[ui.tab]()].filter(Boolean));
|
|
const results = (ui.tab === "data" || ui.tab === "train") ? c.button.sm({ label: "See results", tone: "ghost", title: "Earlier runs, the live graph and downloads", onClick: () => go("results") }) : null;
|
|
const back = ui.tab === "results" ? c.button.sm({ label: "Back to training", tone: "ghost", onClick: () => go("train") }) // a run carries on while another set is made or tags are fixed
|
|
: at ? c.button.sm({ label: "Back", tone: "ghost", onClick: () => go(order[at - 1]) }) : null;
|
|
const skip = ui.tab === "data" ? c.button.sm({ label: "Skip to training", tone: "ghost", disabled: !ready, title: ready ? "" : "Add pictures or clips to the set first", onClick: () => go("train") }) : null;
|
|
const next = ui.tab === "data" ? c.button.sm({ label: "Next: tags", tone: "primary", disabled: !ready, title: ready ? "" : "Add pictures or clips to the set first", onClick: () => go("tags") })
|
|
: ui.tab === "tags" ? c.button.sm({ label: "Next: train", tone: "primary", disabled: !ready, onClick: () => go("train") }) : null;
|
|
const elsewhere = ui.tab === "results" ? [c.button.sm({ label: "Prepare a set", tone: "ghost", title: "Make or fill a set; a training going on carries on", onClick: () => go("data") }),
|
|
c.button.sm({ label: "Fix tags", tone: "ghost", title: "Correct the captions; a training going on carries on", onClick: () => go("tags") })] : [];
|
|
const actions = [results, ...elsewhere, back, skip, next];
|
|
foot = () => win && win.setFooter({ actions: [...actions, ...(ui.tab === "train" ? ui.controls || [] : [])].filter(Boolean), note: ui.tab === "train" ? footNote(ui) : "" });
|
|
foot();
|
|
}
|
|
|
|
// --- the Bin and rated results, read when the window opens
|
|
async function loadSources() {
|
|
try { ui.bin = (await api.media()).media || []; } catch { ui.bin = []; }
|
|
try { ui.rated = await api.trainRated(ui.include); } catch { ui.rated = { results: [], counts: {} }; }
|
|
draw();
|
|
}
|
|
async function loadRuns() { try { ui.runs = await api.trainRuns(); } catch { ui.runs = []; } if (ui.runs.length) loadRecord(ui.chosenRun || ui.runs[0].run); }
|
|
async function loadRecord(id) { try { ui.record = await api.trainRunRecord(id); } catch { ui.record = null; } if (ui.tab === "results") draw(); }
|
|
|
|
// --- tagger downloads and the tagging job
|
|
async function pollTaggers(fast) {
|
|
clearTimeout(tagPoll);
|
|
let again = false;
|
|
try {
|
|
const d = await api.trainTaggers();
|
|
ui.tagger.list = d.taggers || [];
|
|
const was = ui.tagger.job && ui.tagger.job.state;
|
|
const job = await api.trainAutotagState();
|
|
ui.tagger.job = job;
|
|
if (was === "running" && job.state === "done") {
|
|
const n = applyTagging(session, job, ui.tagger.mode);
|
|
c.toast.info({ text: `Tagged ${n} item${n === 1 ? "" : "s"}.` });
|
|
await session.flush();
|
|
} else if (was === "running" && job.state === "error") c.toast.warn({ text: job.error });
|
|
again = job.state === "running" || ui.tagger.list.some((t) => t.state === "downloading");
|
|
} catch { /* the page shows what it had */ }
|
|
if (win && ui.tab === "tags") draw();
|
|
if (win && (again || fast === true)) tagPoll = setTimeout(() => pollTaggers(again ? undefined : false), again ? POLL : POLL * 2);
|
|
}
|
|
|
|
// --- a run
|
|
const setRun = (patch) => Object.assign(ui.run, patch);
|
|
const start = () => launch("");
|
|
async function launch(resumeId) {
|
|
const doc = session.doc;
|
|
if (!doc) return;
|
|
await session.flush();
|
|
const settings = settingsOf(doc);
|
|
delete settings.preset; // a starting point the person picked, not a setting of the trainer
|
|
delete settings.preview_strength; // used by the previews only
|
|
settings.trigger = doc.trigger || "";
|
|
settings.postfix = doc.postfix || "";
|
|
if (resumeId) settings.resume = resumeId;
|
|
else { ui.previews = []; settings.resume = ""; }
|
|
ui.holdPause = false;
|
|
setRun(resumeId ? { shown: true, active: true, state: "queued", message: "Carrying on. The models load again first.", error: "", paused: null }
|
|
: { shown: true, active: true, state: "queued", loss: [], times: [], stepSeconds: null, lastStep: null, smooth: [], step: 0, total: settings.steps, elapsed: 0, message: "Checking the pipeline", error: "", lora: "", runId: "", paused: null });
|
|
draw();
|
|
let before = "";
|
|
ui.staleStarted = 0;
|
|
try {
|
|
const last = await api.trainRun(0);
|
|
if (resumeId) ui.staleStarted = last.started || 0; // a failed or stopped run being restored still shows as the current record until the new one begins
|
|
else before = last.run || "";
|
|
} catch { /* first run */ }
|
|
try {
|
|
const plan = await api.trainPlan(session.name, app.pipeline.slots(), settings);
|
|
if (plan.problems && plan.problems.length) throw new Error(plan.problems.join("; "));
|
|
setRun({ message: resumeId ? "Sent to ComfyUI. It loads the models again, which can take a while." : "Sent to ComfyUI. It loads the models first, which can take a while." });
|
|
draw();
|
|
await api.queueGraph(plan.prompt);
|
|
} catch (err) { setRun({ active: false, state: resumeId ? "paused" : "error", error: err.message, message: "" }); draw(); return; }
|
|
ui.before = before;
|
|
ui.awaitStart = Boolean(resumeId); // until the new run begins, the record still shows the pause it is carrying on from
|
|
watch();
|
|
}
|
|
function refreshLive() { // the graph lives in Results, a short notice in Train
|
|
if (!ui.monitorBox) return;
|
|
if (ui.tab === "results") ui.monitorBox.set([monitor(ui)].filter(Boolean));
|
|
else if (ui.tab === "train") { ui.monitorBox.set([notice(ui, go)].filter(Boolean)); foot(); }
|
|
}
|
|
function watch() {
|
|
clearTimeout(runPoll);
|
|
const queuedAt = Date.now(), mine = (ui.watching = (ui.watching || 0) + 1); // one loop at a time: an older one still waiting on the server stops when it hears back
|
|
const tick = async () => {
|
|
if (!ui.run.active || mine !== ui.watching) return;
|
|
if (ui.run.state === "queued" && Date.now() - queuedAt > QUEUE_WAIT && !ui.run.message.includes("log")) {
|
|
setRun({ message: "ComfyUI has not started the run yet. It may be busy with another job or loading models; if this stays, look at ComfyUI's log." });
|
|
refreshLive();
|
|
}
|
|
let snap = null;
|
|
try { snap = await api.trainRun(ui.run.loss.length); } catch { /* try again */ }
|
|
if (mine !== ui.watching) return;
|
|
if (snap && ui.run.state === "previewing") { runPoll = setTimeout(tick, POLL); return; } // the pause is being dealt with: its snapshot must not set the state back to paused
|
|
if (snap && snap.run && snap.run !== ui.before && !(ui.awaitStart && (ui.staleStarted ? snap.started === ui.staleStarted : snap.state === "paused"))) {
|
|
ui.awaitStart = false;
|
|
const times = snap.loss_from === ui.run.loss.length ? ui.run.times.concat(snap.times || []) : ui.run.times;
|
|
const loss = snap.loss_from === ui.run.loss.length ? ui.run.loss.concat(snap.loss) : ui.run.loss;
|
|
setRun({ times, stepSeconds: snap.step_seconds, lastStep: snap.last_step_seconds, state: snap.state, step: snap.step, total: snap.total || ui.run.total, elapsed: snap.elapsed, message: snap.message, loss, smooth: smooth(loss), error: snap.error || "", lora: snap.lora || "", runId: snap.run, paused: snap.paused || null });
|
|
if (snap.state === "paused") {
|
|
setRun({ active: false });
|
|
draw();
|
|
if (snap.paused && snap.paused.reason === "preview" && !ui.holdPause) previewNow(true);
|
|
return;
|
|
}
|
|
if (["done", "error", "stopped"].includes(snap.state)) {
|
|
setRun({ active: false });
|
|
if (snap.state === "done") c.toast.info({ text: `Done: ${snap.lora}` });
|
|
draw();
|
|
loadRuns();
|
|
loadRecord(snap.run); // a failure offers Restore and continue again
|
|
return;
|
|
}
|
|
refreshLive();
|
|
}
|
|
runPoll = setTimeout(tick, POLL);
|
|
};
|
|
runPoll = setTimeout(tick, POLL);
|
|
}
|
|
// The server is the truth. A window that sits idle on a pause (a tab left in the background, a missed poll) asks now and then and takes over what it finds.
|
|
let syncPoll = 0;
|
|
async function reconcile() {
|
|
clearTimeout(syncPoll);
|
|
if (!win) return;
|
|
syncPoll = setTimeout(reconcile, POLL * 5);
|
|
if (ui.run.active || ui.run.state !== "paused") return;
|
|
let snap = null;
|
|
try { snap = await api.trainRun(0); } catch { return; }
|
|
if (!win || ui.run.active || ui.run.state !== "paused" || !snap || !snap.run) return;
|
|
const moved = snap.state === "training" || (snap.state === "paused" && (snap.run !== ui.run.runId || snap.step !== ui.run.step));
|
|
if (!moved) return;
|
|
ui.before = "";
|
|
if (snap.state === "training") {
|
|
setRun({ shown: true, active: true, state: "training", runId: snap.run, loss: snap.loss, times: snap.times || [], stepSeconds: snap.step_seconds, lastStep: snap.last_step_seconds, smooth: smooth(snap.loss), step: snap.step, total: snap.total, elapsed: snap.elapsed, message: snap.message, error: "", paused: null });
|
|
draw();
|
|
watch();
|
|
} else {
|
|
setRun({ shown: true, active: false, state: "paused", runId: snap.run, loss: snap.loss, times: snap.times || [], stepSeconds: snap.step_seconds, lastStep: snap.last_step_seconds, smooth: smooth(snap.loss), step: snap.step, total: snap.total, elapsed: snap.elapsed, message: "", paused: snap.paused });
|
|
draw();
|
|
if (snap.paused && snap.paused.reason === "preview" && !ui.holdPause) previewNow(true);
|
|
}
|
|
}
|
|
async function stop() {
|
|
const waiting = ui.run.state === "paused" || ui.run.state === "previewing";
|
|
setRun({ message: "Stopping" });
|
|
refreshLive();
|
|
if (ui.run.state === "previewing") { ui.stopAfterPreview = true; try { app.generate.cancel(); } catch { /* it was over */ } }
|
|
try { await api.trainStop(); } catch { /* it ends anyway */ }
|
|
if (!waiting) { try { await fetch("/interrupt", { method: "POST" }); } catch { /* the training step check ends it */ } }
|
|
if (ui.run.state === "queued" || waiting) { setRun({ active: false, state: "stopped", message: "" }); draw(); }
|
|
}
|
|
async function pause() {
|
|
ui.holdPause = true; // a pause the person asked for stays: no preview step then carries on by itself
|
|
setRun({ message: ui.run.state === "previewing" ? "It will wait after this preview" : "Pausing at the end of this step" });
|
|
if (ui.run.state === "training") { try { await api.trainPause(); } catch { /* the run goes on */ } }
|
|
refreshLive();
|
|
}
|
|
/** A picture or clip made by the pipeline with the LoRA so far; when `auto`, training carries on afterwards. */
|
|
const previewStrength = () => { const v = Number(settingsOf(session.doc).preview_strength); return Number.isFinite(v) ? v : 1; };
|
|
async function previewNow(auto) {
|
|
const paused = ui.run.paused;
|
|
if (!paused || !paused.preview || ui.run.state === "previewing") return; // one preview at a time
|
|
ui.stopAfterPreview = false;
|
|
setRun({ state: "previewing", message: `Making a preview with the LoRA at step ${paused.step}` });
|
|
draw();
|
|
let out;
|
|
try { out = app.runner && app.runner.preview ? await app.runner.preview({ lora: paused.preview, strength: previewStrength() }) : { error: "Previews need the app's Generate." }; } catch (err) { out = { error: err.message }; }
|
|
if (out.image) {
|
|
const im = out.image;
|
|
ui.previews.push({ id: `p${ui.previews.length + 1}`, step: paused.step, src: { kind: "output", type: im.type || "output", subfolder: im.subfolder || "", filename: im.filename }, media_kind: VIDEO.test(im.filename) ? "video" : "image" });
|
|
} else if (!ui.stopAfterPreview) c.toast.warn({ text: out.error || "The preview could not be made." });
|
|
if (ui.stopAfterPreview) { draw(); return; }
|
|
setRun({ state: "paused", message: "" });
|
|
if (auto && !ui.holdPause) launch(ui.run.runId); else draw();
|
|
}
|
|
// A run already going when the window opens (page reloaded): pick it up.
|
|
async function resume() {
|
|
try {
|
|
const snap = await api.trainRun(0);
|
|
if (snap.state === "paused") {
|
|
ui.before = "";
|
|
setRun({ shown: true, active: false, state: "paused", loss: snap.loss, times: snap.times || [], stepSeconds: snap.step_seconds, lastStep: snap.last_step_seconds, elapsed: snap.elapsed, smooth: smooth(snap.loss), step: snap.step, total: snap.total, message: "", runId: snap.run, paused: snap.paused });
|
|
ui.holdPause = !(snap.paused && snap.paused.reason === "preview"); // a pause the person asked for waits for them; one for a preview step was never theirs, so the preview and the carrying on go ahead
|
|
ui.tab = "train";
|
|
if (!ui.holdPause) previewNow(true);
|
|
} else if (snap.state === "training") {
|
|
ui.before = "";
|
|
setRun({ shown: true, active: true, state: "training", runId: snap.run, loss: snap.loss, times: snap.times || [], stepSeconds: snap.step_seconds, lastStep: snap.last_step_seconds, smooth: smooth(snap.loss), step: snap.step, total: snap.total, elapsed: snap.elapsed, message: snap.message });
|
|
ui.tab = "train";
|
|
watch();
|
|
}
|
|
} catch { /* nothing to resume */ }
|
|
}
|
|
|
|
function open() {
|
|
if (win) return;
|
|
body = c.region.stack({ gap: "lg" });
|
|
win = c.modal.generic({ title: "Make a LoRA", subtitle: "teach the model your style, in four steps", size: "xl", closeOnOutside: false, body, origin: () => button.node,
|
|
onClose: () => { win = null; session.flush(); clearTimeout(tagPoll); clearTimeout(syncPoll); } });
|
|
syncPoll = setTimeout(reconcile, POLL * 5);
|
|
offSession = session.on(draw);
|
|
session.refresh().then(async () => {
|
|
if (!session.name) {
|
|
const remembered = (() => { try { return localStorage.getItem("fp.training.set") || ""; } catch { return ""; } })();
|
|
const pick = session.sets.find((x) => x.name === remembered) || session.sets[0];
|
|
if (pick) await session.open(pick.name);
|
|
}
|
|
draw();
|
|
});
|
|
loadSources();
|
|
pollTaggers();
|
|
resume();
|
|
draw();
|
|
}
|
|
const offOpen = session.on(() => { try { if (session.name) localStorage.setItem("fp.training.set", session.name); } catch { /* private mode */ } });
|
|
|
|
return () => { clearTimeout(tagPoll); clearTimeout(runPoll); clearTimeout(syncPoll); timers.forEach(clearTimeout); if (offSession) offSession(); offOpen(); if (win) win.close(); };
|
|
},
|
|
};
|