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
300 lines
14 KiB
Python
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}
|