Training captions are plain text boxes; the set has its own prefix and postfix
Tags from the tagger are added after a comma and the caption is never reworded. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MpnF7GwNGMbngU11wjym7P
This commit is contained in:
@@ -1,26 +1,16 @@
|
||||
// A picture or clip with its caption beside it: the preview and the tag editor in one card.
|
||||
//
|
||||
// The card shows what it is given and reports what a person edits. The caption is a comma separated
|
||||
// line; the editor inside owns the chips, and onChange hears the whole line after an edit.
|
||||
// The card shows what it is given and reports what a person edits. The caption is free text in a plain
|
||||
// box, kept exactly as typed; onChange hears the whole text as it changes.
|
||||
|
||||
import { define, lookup } from "../internals/register.js";
|
||||
import { el } from "../internals/el.js";
|
||||
import { setText } from "../internals/text.js";
|
||||
import { clean } from "./tags.js";
|
||||
|
||||
const LAYOUTS = ["cell", "side"];
|
||||
|
||||
const tagsOf = (text) => {
|
||||
const seen = new Set(), out = [];
|
||||
for (const part of String(text ?? "").split(/[,\n]/)) {
|
||||
const tag = clean(part);
|
||||
if (tag && !seen.has(tag.toLowerCase())) { seen.add(tag.toLowerCase()); out.push(tag); }
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
for (const layout of LAYOUTS) {
|
||||
define("captioned", layout, ({ src, kind = "image", title = "", caption = "", badge, tone = "neutral", selected = false, suggestions = [], onChange, onSelect, onOpen, label } = {}) => {
|
||||
define("captioned", layout, ({ src, kind = "image", title = "", caption = "", badge, tone = "neutral", selected = false, onChange, onSelect, onOpen, label } = {}) => {
|
||||
const glyph = () => el("span", { cls: "cx-captioned-glyph", text: kind === "video" ? "▶" : "▨", attrs: { "aria-hidden": "true" } });
|
||||
const view = el("button", { cls: ["cx-captioned-view", "cx-focusable"], attrs: { type: "button", "aria-label": title ? `Open ${title}` : "Open" } });
|
||||
const show = (next, nextKind) => {
|
||||
@@ -41,20 +31,20 @@ for (const layout of LAYOUTS) {
|
||||
const pick = onSelect ? el("input", { cls: ["cx-captioned-pick", "cx-focusable"], attrs: { type: "checkbox", "aria-label": title ? `Select ${title}` : "Select" } }) : null;
|
||||
if (pick) { pick.checked = !!selected; pick.addEventListener("change", () => { node.classList.toggle("cx-on", pick.checked); onSelect(pick.checked); }); }
|
||||
|
||||
const editor = lookup("tags", "editor")({ value: tagsOf(caption), suggestions, placeholder: "Add a tag", label: title ? `Tags for ${title}` : "Tags",
|
||||
onChange: (list) => { if (onChange) onChange(list.join(", ")); } });
|
||||
const editor = lookup("textarea", "md")({ value: String(caption ?? ""), rows: 3, autoGrow: true, placeholder: "Caption", label: title ? `Caption for ${title}` : "Caption",
|
||||
onInput: (text) => { if (onChange) onChange(text); } });
|
||||
|
||||
const head = el("div", { cls: "cx-captioned-head", children: [pick, name, chip].filter(Boolean) });
|
||||
const body = el("div", { cls: "cx-captioned-body", children: [head, editor.node] });
|
||||
const node = el("div", { cls: ["cx-captioned", `cx-captioned-${layout}`, selected ? "cx-on" : ""].filter(Boolean), attrs: { role: "group", "aria-label": label || title || "Captioned media" }, children: [view, body] });
|
||||
return {
|
||||
node,
|
||||
getCaption: () => editor.get().join(", "),
|
||||
setCaption: (text) => editor.setValue(tagsOf(text)),
|
||||
getCaption: () => editor.value,
|
||||
setCaption: (text) => editor.setValue(String(text ?? "")),
|
||||
setSelected(on) { if (pick) pick.checked = !!on; node.classList.toggle("cx-on", !!on); },
|
||||
setBadge(text, nextTone) { setText(chip, text ?? ""); chip.hidden = !text; if (nextTone) chip.className = `cx-captioned-badge cx-captioned-${nextTone}`; },
|
||||
setPicture: show,
|
||||
focus: () => editor.focus(),
|
||||
focus: () => editor.node.focus(),
|
||||
destroy() { editor.destroy(); node.remove(); },
|
||||
};
|
||||
});
|
||||
|
||||
@@ -242,15 +242,17 @@ test("a cloud tag is shown as text, never as markup", () => {
|
||||
|
||||
// --- captioned ---------------------------------------------------------------
|
||||
|
||||
test("a captioned card shows its tags as chips and hands the whole line back after an edit", () => {
|
||||
test("a captioned card shows its caption in a text box as written and hands the whole text back as it is typed", () => {
|
||||
const heard = [];
|
||||
const c = mount(composer.captioned.cell({ title: "a.png", caption: "red, blue, red", onChange: (v) => heard.push(v) }));
|
||||
assert.deepEqual([...c.node.querySelectorAll(".cx-tag-text")].map((n) => n.textContent), ["red", "blue"]);
|
||||
c.node.querySelector(".cx-tag-x").click();
|
||||
assert.deepEqual(heard, ["blue"]);
|
||||
const c = mount(composer.captioned.cell({ title: "a.png", caption: "A fox runs.\nred, blue, red", onChange: (v) => heard.push(v) }));
|
||||
const box = c.node.querySelector("textarea");
|
||||
assert.equal(box.value, "A fox runs.\nred, blue, red");
|
||||
box.value = "A fox sleeps, red";
|
||||
box.dispatchEvent(new window.Event("input", { bubbles: true }));
|
||||
assert.deepEqual(heard, ["A fox sleeps, red"]);
|
||||
c.setCaption("one, two");
|
||||
assert.equal(c.getCaption(), "one, two");
|
||||
assert.deepEqual(heard, ["blue"], "setCaption does not call onChange");
|
||||
assert.deepEqual(heard, ["A fox sleeps, red"], "setCaption does not call onChange");
|
||||
});
|
||||
|
||||
test("a captioned card takes its picture and caption as text, selects, opens and marks a clip", () => {
|
||||
@@ -258,7 +260,7 @@ test("a captioned card takes its picture and caption as text, selects, opens and
|
||||
const c = mount(composer.captioned.side({ title: "<b>x</b>", caption: "<i>t</i>", kind: "video", src: "/thumb.png", badge: "Clip", tone: "good",
|
||||
onSelect: (on) => events.push(["select", on]), onOpen: () => events.push(["open"]) }));
|
||||
assert.equal(c.node.querySelector(".cx-captioned-title").textContent, "<b>x</b>");
|
||||
assert.equal(c.node.querySelector(".cx-tag-text").textContent, "<i>t</i>");
|
||||
assert.equal(c.node.querySelector("textarea").value, "<i>t</i>");
|
||||
assert.ok(c.node.querySelector(".cx-captioned-play"), "a clip is marked");
|
||||
assert.equal(c.node.querySelector(".cx-captioned-badge").textContent, "Clip");
|
||||
c.node.querySelector(".cx-captioned-view").click();
|
||||
|
||||
@@ -10,6 +10,18 @@ export const splitTags = (text) => {
|
||||
};
|
||||
export const joinTags = (tags) => splitTags((tags || []).join(", ")).join(", ").slice(0, 4000);
|
||||
|
||||
const same = (a, b) => String(a).split(/\s+/).filter(Boolean).join(" ").toLowerCase() === String(b).split(/\s+/).filter(Boolean).join(" ").toLowerCase();
|
||||
/** The comma separated pieces of a caption as typed: trimmed, no empties, nothing dropped or reworded. */
|
||||
export const parts = (text) => String(text ?? "").split(",").map((p) => p.trim()).filter(Boolean);
|
||||
|
||||
/** The caption with these tags added at its end after a comma, skipping any it has; the caption itself stays as typed. */
|
||||
export function addTags(caption, tags) {
|
||||
const have = parts(caption);
|
||||
const fresh = splitTags((tags || []).join(", ")).filter((t) => !have.some((h) => same(t, h)));
|
||||
const text = String(caption ?? "").trimEnd().replace(/,+$/, "").trimEnd();
|
||||
return (text ? [text, ...fresh] : fresh).join(", ").slice(0, 4000);
|
||||
}
|
||||
|
||||
/** Every tag with how many captions hold it, most common first. */
|
||||
export function frequency(items) {
|
||||
const counts = new Map();
|
||||
@@ -24,20 +36,19 @@ export function frequency(items) {
|
||||
export function bulk(items, op, tag, to, ids) {
|
||||
tag = String(tag ?? "").split(/\s+/).filter(Boolean).join(" ");
|
||||
if (!tag || !["add", "prepend", "remove", "replace"].includes(op)) return 0;
|
||||
const low = tag.toLowerCase();
|
||||
to = String(to ?? "").split(/\s+/).filter(Boolean).join(" ");
|
||||
if (op === "replace" && !to) op = "remove";
|
||||
let changed = 0;
|
||||
for (const item of items) {
|
||||
if (ids && !ids.has(item.id)) continue;
|
||||
let tags = splitTags(item.caption);
|
||||
const has = tags.some((t) => t.toLowerCase() === low);
|
||||
let tags = parts(item.caption);
|
||||
const has = tags.some((t) => same(t, tag));
|
||||
if (op === "add" && !has) tags.push(tag);
|
||||
else if (op === "prepend") tags = [tag, ...tags.filter((t) => t.toLowerCase() !== low)];
|
||||
else if (op === "remove" && has) tags = tags.filter((t) => t.toLowerCase() !== low);
|
||||
else if (op === "replace" && has) tags = tags.map((t) => (t.toLowerCase() === low ? to : t));
|
||||
else if (op === "prepend") tags = [tag, ...tags.filter((t) => !same(t, tag))];
|
||||
else if (op === "remove" && has) tags = tags.filter((t) => !same(t, tag));
|
||||
else if (op === "replace" && has) tags = tags.map((t) => (same(t, tag) ? to : t));
|
||||
else continue;
|
||||
const text = joinTags(tags);
|
||||
const text = tags.join(", ").slice(0, 4000);
|
||||
if (text !== item.caption) { item.caption = text; changed += 1; }
|
||||
}
|
||||
return changed;
|
||||
|
||||
@@ -101,6 +101,7 @@ export default {
|
||||
await session.flush();
|
||||
const settings = settingsOf(doc);
|
||||
settings.trigger = doc.trigger || "";
|
||||
settings.postfix = doc.postfix || "";
|
||||
setRun({ shown: true, active: true, state: "queued", loss: [], smooth: [], step: 0, total: settings.steps, elapsed: 0, message: "Checking the pipeline", error: "", lora: "" });
|
||||
draw();
|
||||
let before = "";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Step 2, Tags: every item with its caption beside its picture, bulk edits, a tag cloud, and automatic tagging.
|
||||
import { composer as c } from "../../composer/composer.js";
|
||||
import { LABEL_TONES, LABEL_WORDS, stillOf } from "./state.js";
|
||||
import { bulk, frequency, joinTags, splitTags } from "./captions.js";
|
||||
import { addTags, bulk, frequency, joinTags, splitTags } from "./captions.js";
|
||||
|
||||
const PAGE = 48;
|
||||
const FILTERS = [
|
||||
@@ -28,7 +28,7 @@ export function tagsStep({ app, session, go, ui, peek }) {
|
||||
|
||||
const cards = list.slice(0, ui.limit).map((item) => {
|
||||
const card = c.captioned.cell({ title: item.name || item.src.filename || item.src.id, src: stillOf(item), kind: item.media_kind, caption: item.caption,
|
||||
badge: LABEL_WORDS[item.label], tone: LABEL_TONES[item.label], selected: sel.has(item.id), suggestions: cloud.map((x) => x.tag).slice(0, 400),
|
||||
badge: LABEL_WORDS[item.label], tone: LABEL_TONES[item.label], selected: sel.has(item.id),
|
||||
onChange: (text) => session.edit(() => { item.caption = text; }),
|
||||
onSelect: (on) => { if (on) sel.add(item.id); else sel.delete(item.id); sync(); },
|
||||
onOpen: () => peek(item) });
|
||||
@@ -67,12 +67,15 @@ export function tagsStep({ app, session, go, ui, peek }) {
|
||||
ui.redraw();
|
||||
} });
|
||||
|
||||
// --- trigger word
|
||||
const trigger = c.input.md({ label: "Trigger word", placeholder: "none", value: doc.trigger || "",
|
||||
onCommit: (v) => session.edit((d) => { d.trigger = v.split(/\s+/).filter(Boolean).join(" ").slice(0, 80); }) });
|
||||
// --- text put before and after every caption when training (the set's own; the project's prefix and postfix are not used)
|
||||
const phrase = (key, label, hint) => c.field.default({ label, hint, control: c.input.md({ label, placeholder: "none", value: doc[key] || "",
|
||||
onCommit: (v) => session.edit((d) => { d[key] = v.split(/\s+/).filter(Boolean).join(" ").slice(0, 80); }) }) });
|
||||
|
||||
return c.region.stack({ gap: "md", children: [
|
||||
c.field.default({ label: "Trigger word", hint: "Put first in every caption when training, so you can call the LoRA up later by writing it. Optional.", control: trigger }),
|
||||
c.field.row({ fields: [
|
||||
phrase("trigger", "Prefix", "Put before every caption when training, for example a word to call the LoRA up later. Optional."),
|
||||
phrase("postfix", "Postfix", "Put after every caption when training. Optional."),
|
||||
] }),
|
||||
c.group.default({ label: "Automatic tagging", rows: [autoTag({ app, session, ui })] }),
|
||||
c.group.default({ label: "Tags in this set", hint: "Click one to show only the items that have it. The x removes it from all.", rows: [cloudEl] }),
|
||||
c.toolbar.default({ items: [filter, count], trailing: [everyone, none] }),
|
||||
@@ -141,7 +144,7 @@ export function applyTagging(session, job, mode) {
|
||||
for (const item of d.items) {
|
||||
const found = (job.results || {})[item.id];
|
||||
if (!found) continue;
|
||||
const text = joinTags(mode === "replace" ? found : [...splitTags(item.caption), ...found]);
|
||||
const text = mode === "replace" ? joinTags(found) : addTags(item.caption, found);
|
||||
if (text !== item.caption) { item.caption = text; n += 1; }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ export function trainStep({ app, session, go, ui }) {
|
||||
stop.node.hidden = !busy;
|
||||
|
||||
return c.region.stack({ gap: "md", children: [
|
||||
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 tags are learned from with only the trigger word." : ""}`.trim()
|
||||
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()
|
||||
: `Nothing in "${session.name}" fits "${(USES.find((u) => u.value === v.use) || {}).label}". Add media or change the choice below.` }),
|
||||
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." }) : null,
|
||||
c.group.default({ label: "Basics", rows: [name, preset,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
|
||||
import { bulk, frequency, joinTags, splitTags } from "./captions.js";
|
||||
import { addTags, bulk, frequency, joinTags, splitTags } from "./captions.js";
|
||||
|
||||
let feature;
|
||||
test.before(async () => { setupDom(); feature = (await import("./index.js")).default; });
|
||||
@@ -59,6 +59,12 @@ test("captions: tags are trimmed and unique, counts are per caption, bulk edits
|
||||
assert.equal(bulk(items, "prepend", "zq"), 2);
|
||||
assert.equal(items[1].caption, "zq, lion");
|
||||
assert.equal(bulk(items, "add", ""), 0);
|
||||
const prose = [{ id: "p", caption: "A fox runs.\nIt jumps, twice, home" }];
|
||||
assert.equal(bulk(prose, "add", "sky"), 1);
|
||||
assert.equal(prose[0].caption, "A fox runs.\nIt jumps, twice, home, sky", "only commas are touched: the words stay as written");
|
||||
assert.equal(addTags("a fox, running home.", ["A Fox", "grass", "sky"]), "a fox, running home., grass, sky");
|
||||
assert.equal(addTags("", ["x", "y"]), "x, y");
|
||||
assert.equal(addTags("a long prompt,", ["x"]), "a long prompt, x");
|
||||
});
|
||||
|
||||
test("the window is hidden while the add-on is off", async () => {
|
||||
@@ -81,7 +87,7 @@ test("the Data step lists the sets, offers the three sources and can be skipped"
|
||||
off();
|
||||
});
|
||||
|
||||
test("Tags step: a card per item; editing a caption saves the set", async () => {
|
||||
test("Tags step: a text box per item under its picture; typing saves the set and keeps the text as typed", async () => {
|
||||
const f = fake();
|
||||
const { button, off } = await open(f);
|
||||
button.click();
|
||||
@@ -89,10 +95,13 @@ test("Tags step: a card per item; editing a caption saves the set", async () =>
|
||||
click("Next: tags").click();
|
||||
await wait(20);
|
||||
assert.equal(document.querySelectorAll(".cx-captioned").length, 2);
|
||||
document.querySelector(".cx-captioned .cx-tag-x").click(); // takes "cat" off the first item
|
||||
const box = document.querySelector(".cx-captioned textarea");
|
||||
assert.equal(box.value, "cat, grey");
|
||||
box.value = "A cat\nsits, calmly";
|
||||
box.dispatchEvent(new window.Event("input", { bubbles: true }));
|
||||
await wait(700);
|
||||
const saved = f.calls.filter((c) => c[0] === "save").pop();
|
||||
assert.equal(saved[2].items[0].caption, "grey");
|
||||
assert.equal(saved[2].items[0].caption, "A cat\nsits, calmly");
|
||||
off();
|
||||
});
|
||||
|
||||
@@ -131,6 +140,33 @@ test("Train step: More settings stays open while an option in it is changed", as
|
||||
off();
|
||||
});
|
||||
|
||||
test("Tags step: the prefix and postfix are the set's own, saved with it and sent to the run", async () => {
|
||||
const f = fake();
|
||||
const { button, off } = await open(f);
|
||||
button.click();
|
||||
await wait(60);
|
||||
click("Next: tags").click();
|
||||
await wait(20);
|
||||
const field = (label) => [...document.querySelectorAll(".cx-field")].find((x) => x.textContent.startsWith(label)).querySelector("input");
|
||||
for (const [label, text] of [["Prefix", "zq"], ["Postfix", "end"]]) {
|
||||
const input = field(label);
|
||||
input.value = text;
|
||||
input.dispatchEvent(new window.Event("change", { bubbles: true }));
|
||||
}
|
||||
await wait(700);
|
||||
const saved = f.calls.filter((c) => c[0] === "save").pop();
|
||||
assert.equal(saved[2].trigger, "zq");
|
||||
assert.equal(saved[2].postfix, "end");
|
||||
click("Next: train").click();
|
||||
await wait(20);
|
||||
click("Start training").click();
|
||||
await wait(40);
|
||||
const plan = f.calls.find((c) => c[0] === "plan");
|
||||
assert.equal(plan[2].trigger, "zq");
|
||||
assert.equal(plan[2].postfix, "end");
|
||||
off();
|
||||
});
|
||||
|
||||
test("a refused plan shows the reason and starts nothing", async () => {
|
||||
const f = fake();
|
||||
f.api.trainPlan = async () => ({ prompt: {}, problems: ["the pipeline has no clip loader"] });
|
||||
|
||||
@@ -74,7 +74,7 @@ FROZEN = {
|
||||
"model", "clip", "vae", "audio_vae", "set_name", "name", "use", "steps", "learning_rate", "rank", "algorithm",
|
||||
"optimizer", "loss", "batch_size", "grad_accumulation", "resolution", "aspect_buckets", "frames", "fps", "start",
|
||||
"trigger", "seed", "training_dtype", "lora_dtype", "gradient_checkpointing", "offloading", "bypass",
|
||||
"model_name", "weight_dtype", "compute_dtype",
|
||||
"model_name", "weight_dtype", "compute_dtype", "postfix",
|
||||
],
|
||||
"FunPackEmptyLatent": ["model", "width", "height", "length", "batch_size", "audio_vae", "frame_rate"],
|
||||
"FunPackDiffusionModelLoader": [
|
||||
|
||||
@@ -152,7 +152,7 @@ All overlays use the portal, the z-ladder and the dismiss stack.
|
||||
| `chart` | `line` | `series` (`{ name, values }` or `{ name, points: [{x, y}] }`, plus `tone` `accent`/`good`/`warn`/`muted`, `faint` for a raw noisy line under a smoothed one, `dashed`), `xLabel`, `yLabel`, `height` (`sm`, `md`, `lg`), `logY`, `empty`, `label`. Points that are not numbers are skipped; long series are cut to a few hundred drawn points keeping highs and lows. Hover reads the nearest values. Methods `setSeries`, `setEmpty`. |
|
||||
| `tags` | `editor` | `value` (list of strings), `suggestions`, `placeholder`, `label`, `maxTags`, `onChange(list)`. Enter or comma adds, Backspace on an empty box removes the last, chips drag to reorder, Tab takes the first suggestion. Methods `get`, `setValue` (does not call `onChange`), `focus`. |
|
||||
| `tags` | `cloud` | `items` (`{ tag, count }`), `selected`, `onPick(tag)`, `onRemove(tag)` (adds a remove button), `label`, `empty`. Draws the first 300 and says how many more. Methods `setItems`, `setSelected`. |
|
||||
| `captioned` | `cell`, `side` | A picture or clip with its caption: preview, name, a badge and a tag editor in one card. `cell` stacks the picture over the tags (for grids), `side` puts them side by side (for one at a time). `src` (a picture URL; a clip passes its still), `kind` (`image` or `video`, a clip gets a play mark), `title`, `caption` (comma separated), `badge` and `tone` (`neutral`, `good`, `bad`, `warn`), `selected` and `onSelect(on)` (adds a tick box), `suggestions`, `onChange(caption)`, `onOpen()` (makes the picture a button). Methods `getCaption`, `setCaption` (does not call `onChange`), `setSelected`, `setBadge`, `setPicture`, `focus`. |
|
||||
| `captioned` | `cell`, `side` | A picture or clip with its caption: preview, name, a badge and a text box for the caption in one card. `cell` stacks the picture over the caption (for grids), `side` puts them side by side (for one at a time). `src` (a picture URL; a clip passes its still), `kind` (`image` or `video`, a clip gets a play mark), `title`, `caption` (free text, kept as typed), `badge` and `tone` (`neutral`, `good`, `bad`, `warn`), `selected` and `onSelect(on)` (adds a tick box), `onChange(caption)` (as it is typed), `onOpen()` (makes the picture a button). Methods `getCaption`, `setCaption` (does not call `onChange`), `setSelected`, `setBadge`, `setPicture`, `focus`. |
|
||||
|
||||
### Media and time
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ MAX_CAPTION = 4000
|
||||
|
||||
|
||||
def split_tags(text) -> list[str]:
|
||||
""""a, b ,, a" -> ["a", "b"]: trimmed, no empties, first occurrence kept."""
|
||||
""""a, b ,, a" -> ["a", "b"]: split at commas, spaces collapsed, no empties, first occurrence kept. For counting, not for rewriting."""
|
||||
seen, out = set(), []
|
||||
for part in str(text or "").replace("\n", ",").split(","):
|
||||
tag = " ".join(part.split())
|
||||
@@ -20,13 +20,27 @@ def join_tags(tags) -> str:
|
||||
return ", ".join(split_tags(", ".join(str(t) for t in tags)))[:MAX_CAPTION]
|
||||
|
||||
|
||||
def with_trigger(caption, trigger) -> str:
|
||||
"""The trigger word first, once. An empty trigger changes nothing."""
|
||||
trigger = " ".join(str(trigger or "").split())
|
||||
if not trigger:
|
||||
return str(caption or "")
|
||||
rest = [t for t in split_tags(caption) if t.lower() != trigger.lower()]
|
||||
return ", ".join([trigger, *rest])
|
||||
def parts(text) -> list[str]:
|
||||
"""The comma separated pieces of a caption as typed: trimmed, no empties, nothing dropped or reworded."""
|
||||
return [p.strip() for p in str(text or "").split(",") if p.strip()]
|
||||
|
||||
|
||||
def _same(a, b) -> bool:
|
||||
return " ".join(a.split()).lower() == " ".join(b.split()).lower()
|
||||
|
||||
|
||||
def with_parts(caption, prefix="", postfix="") -> str:
|
||||
"""The caption between the prefix and the postfix, joined with commas. Empty parts are left out; the caption is not reworded."""
|
||||
pieces = [" ".join(str(x or "").split()) for x in (prefix,)] + [str(caption or "").strip()] + [" ".join(str(postfix or "").split())]
|
||||
return ", ".join(p.strip(", ") for p in pieces if p.strip(", "))
|
||||
|
||||
|
||||
def add_tags(caption, tags) -> str:
|
||||
"""The caption with these tags added at its end, after a comma, skipping any it already has. The caption itself is kept as typed."""
|
||||
have = parts(caption)
|
||||
new = [t for t in split_tags(", ".join(str(t) for t in tags)) if not any(_same(t, h) for h in have)]
|
||||
text = str(caption or "").rstrip().rstrip(",").rstrip()
|
||||
return (", ".join([text, *new]) if text else ", ".join(new))[:MAX_CAPTION]
|
||||
|
||||
|
||||
def frequency(captions) -> list[tuple[str, int]]:
|
||||
@@ -42,6 +56,7 @@ def bulk(items, op, tag, new=None, ids=None) -> int:
|
||||
"""Change captions in place; returns how many items changed.
|
||||
|
||||
`op` is add (at the end), prepend, remove or replace (tag -> new). `ids` limits it to those items.
|
||||
Only the comma separated piece that matches is touched; the rest of a caption keeps its words.
|
||||
"""
|
||||
changed = 0
|
||||
tag = " ".join(str(tag or "").split())
|
||||
@@ -54,21 +69,20 @@ def bulk(items, op, tag, new=None, ids=None) -> int:
|
||||
for item in items:
|
||||
if ids is not None and item.get("id") not in ids:
|
||||
continue
|
||||
tags = split_tags(item.get("caption"))
|
||||
low = [t.lower() for t in tags]
|
||||
if op == "add" and tag.lower() not in low:
|
||||
tags.append(tag)
|
||||
pieces = parts(item.get("caption"))
|
||||
has = any(_same(p, tag) for p in pieces)
|
||||
if op == "add" and not has:
|
||||
pieces.append(tag)
|
||||
elif op == "prepend":
|
||||
tags = [tag] + [t for t in tags if t.lower() != tag.lower()]
|
||||
elif op == "remove" and tag.lower() in low:
|
||||
tags = [t for t in tags if t.lower() != tag.lower()]
|
||||
elif op == "replace" and tag.lower() in low:
|
||||
tags = [new if t.lower() == tag.lower() else t for t in tags]
|
||||
pieces = [tag] + [p for p in pieces if not _same(p, tag)]
|
||||
elif op == "remove" and has:
|
||||
pieces = [p for p in pieces if not _same(p, tag)]
|
||||
elif op == "replace" and has:
|
||||
pieces = [new if _same(p, tag) else p for p in pieces]
|
||||
else:
|
||||
continue
|
||||
text = join_tags(tags)
|
||||
text = ", ".join(pieces)[:MAX_CAPTION]
|
||||
if text != item.get("caption"):
|
||||
item["caption"] = text
|
||||
changed += 1
|
||||
return changed
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class FunPackTrainLora(io.ComfyNode):
|
||||
io.Int.Input("frames", default=33, min=1, max=1025, tooltip="Frames taken from each clip, for video models. H3 rounds to its own grid (17 x n + 5)."),
|
||||
io.Float.Input("fps", default=0.0, min=0.0, max=120.0, step=0.5, tooltip="Frames per second to read the clips at. 0 uses each clip's own rate (H3: 24)."),
|
||||
io.Combo.Input("start", options=["start", "middle"], default="start", tooltip="Where in a clip to take the frames from."),
|
||||
io.String.Input("trigger", default="", tooltip="A word put at the start of every caption, to call the LoRA up later."),
|
||||
io.String.Input("trigger", default="", tooltip="The prefix: text put at the start of every caption, for example a word to call the LoRA up later."),
|
||||
io.Int.Input("seed", default=0, min=0, max=0xFFFFFFFFFFFFFFFF, tooltip="Seed for the noise and the LoRA's starting weights."),
|
||||
io.Combo.Input("training_dtype", options=["bf16", "fp32", "none"], default="bf16", tooltip="Precision used while training. bf16 is right for nearly everything."),
|
||||
io.Combo.Input("lora_dtype", options=["bf16", "fp32"], default="bf16", tooltip="Precision the LoRA is stored in."),
|
||||
@@ -47,6 +47,7 @@ class FunPackTrainLora(io.ComfyNode):
|
||||
io.String.Input("model_name", default="", tooltip="The diffusion model file to train on, as the loader lists it. The trainer loads it itself, once, with no loader node in front."),
|
||||
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."),
|
||||
],
|
||||
outputs=[io.String.Output(display_name="lora")],
|
||||
)
|
||||
@@ -54,7 +55,7 @@ class FunPackTrainLora(io.ComfyNode):
|
||||
@classmethod
|
||||
def execute(cls, clip, vae, set_name, name, use, steps, learning_rate, rank, algorithm, optimizer, loss, batch_size,
|
||||
grad_accumulation, resolution, aspect_buckets, frames, fps, start, trigger, seed, training_dtype, lora_dtype,
|
||||
gradient_checkpointing, offloading, bypass, model=None, audio_vae=None, model_name="", weight_dtype="default", compute_dtype="default") -> io.NodeOutput:
|
||||
gradient_checkpointing, offloading, bypass, model=None, audio_vae=None, model_name="", weight_dtype="default", compute_dtype="default", postfix="") -> io.NodeOutput:
|
||||
if not addons.enabled("lora_training"):
|
||||
raise RuntimeError("LoRA training is switched off. Turn it on in Settings > Modules.")
|
||||
cfg = {"name": name, "use": use, "steps": steps, "learning_rate": learning_rate, "rank": rank, "algorithm": algorithm,
|
||||
@@ -62,5 +63,5 @@ class FunPackTrainLora(io.ComfyNode):
|
||||
"resolution": resolution, "aspect_buckets": aspect_buckets, "frames": frames, "fps": fps, "start": start,
|
||||
"trigger": trigger, "seed": seed, "training_dtype": training_dtype, "lora_dtype": lora_dtype,
|
||||
"gradient_checkpointing": gradient_checkpointing, "offloading": offloading, "bypass": bypass,
|
||||
"model_name": model_name, "weight_dtype": weight_dtype, "compute_dtype": compute_dtype}
|
||||
"model_name": model_name, "weight_dtype": weight_dtype, "compute_dtype": compute_dtype, "postfix": postfix}
|
||||
return io.NodeOutput(train.run(model, clip, vae, audio_vae, set_name, cfg))
|
||||
|
||||
@@ -102,7 +102,8 @@ def clean(doc, name: str) -> dict:
|
||||
seen.add(src_key(item["src"]))
|
||||
items.append(item)
|
||||
settings = doc.get("settings")
|
||||
return {"name": name, "trigger": " ".join(str(doc.get("trigger") or "").split())[:80], "items": items,
|
||||
return {"name": name, "trigger": " ".join(str(doc.get("trigger") or "").split())[:80],
|
||||
"postfix": " ".join(str(doc.get("postfix") or "").split())[:80], "items": items,
|
||||
"settings": settings if isinstance(settings, dict) else {}, "updated": time.time()}
|
||||
|
||||
|
||||
|
||||
@@ -17,8 +17,11 @@ def out(name, **extra):
|
||||
|
||||
def test_tags_are_trimmed_and_unique():
|
||||
assert captions.split_tags(" a,B ,, a\nc ") == ["a", "B", "c"]
|
||||
assert captions.with_trigger("red, zq, tall", "zq") == "zq, red, tall"
|
||||
assert captions.with_trigger("red", "") == "red"
|
||||
assert captions.with_parts("red, tall", "zq", "end") == "zq, red, tall, end"
|
||||
assert captions.with_parts("red", "", "") == "red" and captions.with_parts("", "zq", "") == "zq"
|
||||
assert captions.with_parts("A fox runs.\nIt jumps, twice.", " zq ", None) == "zq, A fox runs.\nIt jumps, twice."
|
||||
assert captions.add_tags("a fox, running home.", ["A Fox", "grass", "sky"]) == "a fox, running home., grass, sky"
|
||||
assert captions.add_tags("", ["x", "y"]) == "x, y" and captions.add_tags("a long prompt,", ["x"]) == "a long prompt, x"
|
||||
|
||||
|
||||
def test_bulk_edits_only_the_chosen_items():
|
||||
|
||||
@@ -148,8 +148,7 @@ def run(model, clip, vae, audio_vae, set_name: str, cfg: dict, prompt_id: str =
|
||||
raise ValueError(f"{item['name'] or item['id']} cannot be read: {exc}") from exc
|
||||
single = bool(adapt) or not cfg["aspect_buckets"]
|
||||
buckets = media_io.buckets_for([w / h for w, h in sizes], cfg["resolution"], step, single)
|
||||
trigger = cfg["trigger"]
|
||||
texts = [captions.with_trigger(item["caption"], trigger) for item, _ in items]
|
||||
texts = [captions.with_parts(item["caption"], cfg["trigger"], cfg.get("postfix", "")) for item, _ in items]
|
||||
|
||||
if adapt and adapt.get("video"):
|
||||
cfg = _fit_frames(cfg, adapt, items)
|
||||
|
||||
Reference in New Issue
Block a user