Files
Claude 3adebb731f Rename FunPack to FunUI (5.0.3 Classy Chopper): /fun endpoint, wink logo, load-time migration of old names
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
2026-10-25 13:11:03 +00:00

300 lines
14 KiB
Python

"""LoRA training: a training set made from your media and rated results, tagged, then learned on ComfyUI's own trainer.
An add-on (Settings > Modules): it asks for onnxruntime, which only the automatic tagger needs. The window is the
Training feature of the app; this module holds what it stands on: the saved sets, the tagger models, rated results
as training data, the graph a run is queued as, and the training node. The learning itself is ComfyUI's Train LoRA
node, fed latents and conditioning built from the set, with the loaders the pipeline already has.
H3 trains picture and sound together; the repairs its latent and its rotary op need live in the H3 module and are
used when this module asks. UNTESTED on real weights or hardware: proven only on tiny stand-in models on CPU.
"""
from . import captions, jobs, loop, media_io, plan, rated, store, taggers, train
from .nodes import FunUITrainLora
ID = "training_lora"
TITLE = "LoRA training"
STAGE = "post"
CATEGORY = "system"
STATUS = "experimental"
NODES = [FunUITrainLora]
def _bad(web, why, status=400):
return web.json_response({"why": why}, status=status)
def _loras_folders():
from pathlib import Path
try:
import folder_paths
return [Path(f) for f in folder_paths.get_folder_paths("loras")]
except Exception: # noqa: BLE001 -- no ComfyUI folders here
return []
def saved_files(rec) -> list[str]:
"""The files a run left in the loras folder: its finished LoRA, the copies saved along the way (name_stepN) and its preview."""
import re
name = (rec or {}).get("name") or ""
stem = "".join(c for c in name if c.isalnum() or c in "-_ .").strip() or "funui_lora"
pattern = re.compile(rf"{re.escape(stem)}_(step(\d+)|preview)(_\d+)?\.safetensors")
found = set()
for folder in _loras_folders():
try:
found.update(p.name for p in folder.iterdir() if p.is_file() and pattern.fullmatch(p.name))
except OSError:
pass
final = (rec or {}).get("lora") or ""
order = lambda f: (0, 0, f) if f == final else ((2, 0, f) if "_preview" in f else (1, int(re.search(r"_step(\d+)", f).group(1)), f))
return sorted(found | ({final} if final and any((d / final).is_file() for d in _loras_folders()) else set()), key=order)
def routes(table, base, web):
from ..._core import addons, projects, shortcuts
def enabled():
return addons.enabled("lora_training")
@table.get(base + "/state")
async def _state(_req):
return web.json_response({"enabled": enabled(), "uses": train.USES, "algorithms": list(train.ALGORITHMS),
"labels": list(store.LABELS), "rated_labels": rated.LABEL_TITLES})
# --- training sets
@table.get(base + "/sets")
async def _sets(_req):
return web.json_response({"sets": store.summaries()})
@table.post(base + "/sets")
async def _create(req):
try:
body = await req.json()
name = str(body.get("name") or "")
if not store.valid_name(name):
return _bad(web, "A set's name is letters, numbers, spaces, - and _ (up to 60).")
return web.json_response(store.create(name))
except (ValueError, TypeError, AttributeError) as exc:
return _bad(web, str(exc))
@table.get(base + "/sets/{name}")
async def _get(req):
doc = store.get(req.match_info["name"]) if store.valid_name(req.match_info["name"]) else None
return web.json_response(doc) if doc else _bad(web, "There is no such set.", 404)
@table.put(base + "/sets/{name}")
async def _put(req):
name = req.match_info["name"]
if not store.valid_name(name):
return _bad(web, "That is not a set name.")
try:
return web.json_response(store.save(name, await req.json()))
except (ValueError, TypeError) as exc:
return _bad(web, str(exc))
@table.delete(base + "/sets/{name}")
async def _delete(req):
name = req.match_info["name"]
return web.json_response({"deleted": store.valid_name(name) and store.delete(name)})
@table.post(base + "/sets/{name}/add")
async def _add(req):
name = req.match_info["name"]
if not store.valid_name(name):
return _bad(web, "That is not a set name.")
try:
body = await req.json()
entries = body.get("entries")
roots = train.roots()
for entry in entries if isinstance(entries, list) else []: # a file added without words is captioned with the prompt it was made from, read from the file itself
src = store.clean_src(entry.get("src")) if isinstance(entry, dict) else None
if src and not str(entry.get("caption") or "").strip():
path = store.resolve(src, roots, train.bin_path)
entry["caption"] = rated.embedded_prompt(path) if path else ""
doc, added = store.add(name, entries)
except (ValueError, TypeError, AttributeError) as exc:
return _bad(web, str(exc))
return web.json_response({"set": doc, "added": added})
@table.post(base + "/sets/{name}/bulk")
async def _bulk(req):
name = req.match_info["name"]
doc = store.get(name) if store.valid_name(name) else None
if doc is None:
return _bad(web, "There is no such set.", 404)
try:
body = await req.json()
ids = body.get("ids")
changed = captions.bulk(doc["items"], body.get("op"), body.get("tag"), body.get("new"),
set(ids) if isinstance(ids, list) else None)
return web.json_response({"set": store.save(name, doc), "changed": changed})
except (ValueError, TypeError, AttributeError) as exc:
return _bad(web, str(exc))
# --- rated results as training data
@table.get(base + "/rated")
async def _rated(req):
docs = []
for entry in projects.listing():
project = projects.get(entry["id"])
if project is not None:
docs.append({**project.to_dict(), "id": entry["id"]})
doc_of = {d["id"]: d for d in docs}
parts = [p for p in (req.rel_url.query.get("include") or "main").split(",") if p in rated.INCLUDE]
found = rated.scan(docs, lambda t: shortcuts.expand(t, seed=1), shortcuts.resolve_variables, parts)
roots = train.roots()
here = [(r, store.resolve({"kind": "output", **r["media"]}, roots, train.bin_path)) for r in found]
here = [(r, path) for r, path in here if path] # a rating is not a video: only results still on this machine
found = []
for r, path in here:
doubtful = not rated.is_this_video(r, path) # the file may be another video that took the name of one rated on another instance: shown, but marked
made_from = rated.embedded_prompt(path) # only the file's own prompt: what the project kept under this name may belong to another video
r = {**r, "prompt_text": made_from, "caption": rated.prompt_caption(made_from, doc_of.get(r["project"]), parts, lambda t: shortcuts.expand(t, seed=1), shortcuts.resolve_variables)}
found.append({**r, "doubtful": doubtful})
want = req.rel_url.query.get("label")
if want:
found = [r for r in found if r["label"] == want]
counts = {label: sum(1 for r in found if r["label"] == label) for label in rated.LABEL_TITLES}
return web.json_response({"results": found, "counts": counts, "doubtful": sum(1 for r in found if r["doubtful"])})
@table.post(base + "/clips")
async def _clips(req):
"""Frame count and rate of video sources, by source key: what the pickers show beside a clip."""
body = await req.json()
out, roots = {}, train.roots()
for raw in (body.get("srcs") or [])[:500] if isinstance(body, dict) else []:
src = store.clean_src(raw)
if src is None:
continue
path = store.resolve(src, roots, train.bin_path)
info = media_io.clip_info(path) if path and store.kind_of(path.name) == "video" else None
if info:
out[store.src_key(src)] = info
return web.json_response({"clips": out})
# --- automatic tagging
@table.get(base + "/taggers")
async def _taggers(_req):
return web.json_response({"taggers": taggers.listing(), "job": {k: v for k, v in taggers.job.items() if k != "results"}})
@table.post(base + "/taggers/download")
async def _download(req):
body = await req.json()
started = taggers.download(str(body.get("tagger") or ""))
return web.json_response({"started": started})
@table.post(base + "/taggers/remove")
async def _remove(req):
body = await req.json()
return web.json_response({"removed": taggers.remove(str(body.get("tagger") or ""))})
@table.post(base + "/autotag")
async def _autotag(req):
body = await req.json()
name = str(body.get("set") or "")
doc = store.get(name) if store.valid_name(name) else None
if doc is None:
return _bad(web, "There is no such set.", 404)
tagger = str(body.get("tagger") or "")
if not taggers.ready(tagger):
return _bad(web, "Download that tagger first.")
ids = body.get("ids")
chosen = [i for i in doc["items"] if not isinstance(ids, list) or i["id"] in ids]
targets = []
for item in chosen:
path = store.resolve(item["src"], train.roots(), train.bin_path)
if path is not None:
targets.append((item["id"], path))
options = {k: body[k] for k in ("general", "character", "characters", "blocked", "limit") if k in body}
from . import frames
started = taggers.autotag(tagger, targets, options, lambda p: frames.picture(p))
return web.json_response({"started": started, "total": len(targets)})
@table.get(base + "/autotag")
async def _autotag_state(_req):
return web.json_response(dict(taggers.job))
@table.post(base + "/autotag/cancel")
async def _autotag_cancel(_req):
taggers.cancel()
return web.json_response({"ok": True})
# --- a run
@table.post(base + "/plan")
async def _plan(req):
if not enabled():
return _bad(web, "LoRA training is switched off. Turn it on in Settings > Modules.", 409)
body = await req.json()
name = str(body.get("set") or "")
values = body.get("settings") if isinstance(body.get("settings"), dict) else {}
use = str(values.get("use") or "normal")
if not store.valid_name(name):
return _bad(web, "Pick a training set first.")
_items, problems = train.plan_items(name, use) if use in train.USES else ([], [f"use is one of {sorted(train.USES)}"])
if problems:
return web.json_response({"prompt": {}, "problems": problems})
prompt, problems = plan.assemble(body.get("slots"), name, values)
return web.json_response({"prompt": prompt, "problems": problems})
@table.get(base + "/run")
async def _run(req):
try:
since = int(req.rel_url.query.get("since") or 0)
except ValueError:
since = 0
return web.json_response(jobs.snapshot(since))
@table.post(base + "/run/pause")
async def _pause(_req):
return web.json_response({"paused": jobs.request_pause()})
@table.post(base + "/run/stop")
async def _stop(_req):
now = jobs.snapshot()
was_paused = now["state"] == "paused"
stopped = jobs.request_stop()
if stopped and was_paused:
train.discard(now["run"], now["name"])
return web.json_response({"stopped": True})
if stopped:
try:
import comfy.model_management
comfy.model_management.interrupt_current_processing()
except Exception: # noqa: BLE001 -- the next step ends the run anyway
pass
return web.json_response({"stopped": stopped})
@table.get(base + "/runs")
async def _runs(_req):
return web.json_response({"runs": [{**r, "resumable": loop.has_checkpoint(r["run"])} for r in jobs.history()]})
@table.get(base + "/runs/{run}")
async def _record(req):
rec = jobs.record(req.match_info["run"])
return web.json_response({**rec, "resumable": loop.has_checkpoint(rec.get("run")), "files": saved_files(rec)}) if rec else _bad(web, "There is no such run.", 404)
@table.get(base + "/runs/{run}/lora")
async def _download(req):
from pathlib import Path
from urllib.parse import quote
rec = jobs.record(req.match_info["run"])
name = req.query.get("file") or (rec or {}).get("lora") or ""
if not rec or name not in saved_files(rec) or Path(name).name != name:
return _bad(web, "This run has no such saved LoRA file.", 404)
path = next((f / name for f in _loras_folders() if (f / name).is_file()), None)
if path is None:
return _bad(web, f"{name} is no longer in your loras folder.", 404)
return web.FileResponse(path, headers={"Content-Type": "application/octet-stream", "Content-Disposition": f"attachment; filename*=UTF-8''{quote(name)}"})
@table.delete(base + "/runs/{run}")
async def _forget(req):
run = req.match_info["run"]
gone = jobs.forget(run)
if gone:
loop.forget_checkpoint(run)
return web.json_response({"deleted": gone})
PROVIDES = {"routes": routes}