Shortcuts find/replace: whole words, tidy removals, phrase total, fresh count; Two stages: restore the guider on any error between stages; honest hints
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
3dbcc8a4e8
commit
4f06ed9d89
+2
-2
@@ -20,12 +20,12 @@
|
||||
### Added
|
||||
|
||||
- **Prompt enhancer: Dynamic style.** A Style choice in the Enhance panel (default: unchanged). Dynamic keeps every rule of the default but asks for a concrete action beat, a change over time and secondary motion in each shot. Instructions you type still override it.
|
||||
- **Shortcuts: find, replace and common phrases.** The ⋯ menu in Shortcuts has Find and replace (search the text of every shortcut, preview each change, match case or not, then change them all in one go, or remove the phrase) and Common phrases (the phrases used in more than one shortcut, with counts; pick one to correct it everywhere). Triggers are never touched.
|
||||
- **Shortcuts: find, replace and common phrases.** The ⋯ menu in Shortcuts has Find and replace (search the text of every shortcut, preview each change, match case or not, whole words only or inside words too, then change them all in one go, or remove the phrase) and Common phrases (phrases of two or more words used in more than one shortcut, with counts; pick one to correct it everywhere). Triggers are never touched.
|
||||
|
||||
- **Sparse attention method "veda"** (H3 loader, SLA on, method veda; Experimental, UNTESTED on a GPU). A small learned predictor (Veda, arXiv 2605.30325) picks which 128-token tiles of the attention map matter and an INT8 Triton kernel computes only those, at the predictor's own trained budget (90% for text-to-video). It needs CUDA and Triton and a predictor file in ComfyUI/models/veda, chosen in "veda_predictor" (auto takes the text-to-video file, else the first). Works with LoRA (it sees q/k/v after the LoRA), MXFP8 (weights only: q/k/v stay bf16), Two stages (a size the predictor was not trained for uses the nearest tile plan, and the log says so), dense steps and the attention backend, which keeps the text refiner, masked calls and short sequences. sla_sparsity, the rising share and the references choice are not read in this first version. When Veda cannot run, the status says why and the chosen backend is installed on its own. One log line per run in the SLA line's wording (calls, tile pairs kept, asked vs real sparsity, share of full attention computed, steps at full attention). The Triton kernel is checked against an exact reference on the GPU before first use; the torch half (tiles, predictor, selection, the override on a real tiny H3) is tested on CPU.
|
||||
- **Veda predictor download** (Settings > Models, shown only for method veda): a button per official file that is not in models/veda yet, directly under the predictor selector; nothing once both are there (text-to-video, reference-to-video) fetches it straight from Hugging Face on your click, with the MiniMax H3 Community License named and linked beside it. FunUI does not host or bundle the file. The download streams to a `.part` file and is kept only when its size and checksum match; taggers and this share one downloader (`core/fetch.py`).
|
||||
- **Run meter** (Settings > Engine > Sampling, off by default). Logs how grainy the picture is, across it and over time, and how much it still changes at each step, then compares with the previous run of the same seed. It adds no model call, so flipping one setting and rendering again shows what that setting did.
|
||||
- **Post settings for the finished video** (Settings > Engine > Post, all off or neutral by default): Smooth jagged edges (Light or Strong; FXAA-style, only strongly contrasting edge pixels are softened), Auto contrast, Auto brightness and Colour strength. Contrast and brightness are measured once on the whole clip and applied as one fixed curve, so nothing flickers from frame to frame. They run on the frames before encoding, so NVENC, its CPU fallback and every codec get the same picture. Save Video reads them from the pipeline's settings object (older saved pipelines are linked to it automatically). Tried on the CPU only.
|
||||
- **Post settings for the finished video** (Settings > Engine > Post, all off or neutral by default): Smooth jagged edges (Light or Strong; FXAA-style, only strongly contrasting edge pixels are softened), Auto contrast, Auto brightness and Colour strength. Contrast and brightness are measured once from 16 frames spread over the clip and applied as one fixed curve, so nothing flickers from frame to frame. They run on the frames before encoding, so NVENC, its CPU fallback and every codec get the same picture. Save Video reads them from the pipeline's settings object (older saved pipelines are linked to it automatically). Tried on the CPU only.
|
||||
- **LoRA Loader: "Apply to stage"** (First, Second or Both, per slot). Shown only while the Two stages module is present and not switched off for the project; the feature itself may stay off in the sampler settings. A LoRA set to First works only in the smaller first stage, one set to Second only in the full-size steps; Two stages hands each stage the model with its own LoRAs, and ComfyUI re-patches the weights between the stages. Only the model changes for First or Second (the text encoder is encoded before sampling, so its strength is not used). When Two stages is off, or a run cannot be cut in two, those LoRAs are not applied and the Log says so. Both is the old behaviour. Tried on a small model on the CPU with every feature switched on; not on a GPU yet.
|
||||
- **LoRA Loader reads LoRAs written for the Diffusers layout of H3** (separate q, k and v, `ff.net`, `transformer_blocks`), such as NVIDIA's Open-H3-Regenerate-2K refiner, which used to report "matched nothing". The names are translated, q, k and v are aimed at their rows of H3's fused attention weight, and the halves of the gated MLP are put in H3's order (its layout stores them the other way round, so without that the LoRA loads and quietly does the wrong thing). Strength 1.0, as the author says it is already folded in.
|
||||
- **Attention explorer: "Change the sound too"** (Settings > Engine > Sampling). Turn it off and only the picture's attention changes: the sound's rows keep the model's own attention and the sound is no longer one of the clip regions. On models that mix picture and sound in one sequence, Prompt focus stays off while the sound is kept as is. On by default, so nothing changes until you switch it. Untested on a GPU.
|
||||
|
||||
@@ -31,9 +31,9 @@ export function shortcuts(app, own) {
|
||||
|
||||
/** Phrases that repeat across shortcuts; picking one hands it to `onPick`. */
|
||||
async function phrasesWindow(onPick) {
|
||||
let phrases;
|
||||
try { phrases = (await app.api.shortcutPhrases()).phrases || []; } catch (err) { return tell(err.message); }
|
||||
const win = c.modal.stacked({ title: "Common phrases", subtitle: "Phrases used in more than one shortcut. Pick one to look at it or change it everywhere.", size: "md", body: c.filterList.md({
|
||||
let phrases, total = 0;
|
||||
try { const got = await app.api.shortcutPhrases(); phrases = got.phrases || []; total = got.total || phrases.length; } catch (err) { return tell(err.message); }
|
||||
const win = c.modal.stacked({ title: "Common phrases", subtitle: `Phrases of two or more words used in more than one shortcut.${total > phrases.length ? ` Showing the ${phrases.length} most shared of ${total}.` : ""} Pick one to look at it or change it everywhere.`, size: "md", body: c.filterList.md({
|
||||
items: phrases.map((p) => ({ id: p.phrase, label: p.phrase, hint: `${p.shortcuts} shortcuts, ${p.uses} ${p.uses === 1 ? "line" : "lines"}` })),
|
||||
placeholder: "Search phrases", empty: "No phrase is used in more than one shortcut.",
|
||||
onChange: (phrase) => { win.close("picked"); onPick(phrase); } }) });
|
||||
@@ -41,7 +41,7 @@ export function shortcuts(app, own) {
|
||||
|
||||
/** Find a phrase in the text of every shortcut, or replace it in all of them at once. */
|
||||
function findWindow(start = "", mode = "find") {
|
||||
const state = { find: start, replace: "", matchCase: false, mode };
|
||||
const state = { find: start, replace: "", matchCase: false, wholeWords: true, mode };
|
||||
const results = c.region.stack({ gap: "sm" });
|
||||
let ticket = 0, timer = null, apply = null;
|
||||
const findBox = c.input.md({ label: "Find", value: start, placeholder: "Find", onInput: (v) => { state.find = v; soon(); } });
|
||||
@@ -57,7 +57,7 @@ export function shortcuts(app, own) {
|
||||
const mine = ++ticket;
|
||||
if (!state.find.trim()) return note("Type the phrase to look for. It is matched exactly as typed.");
|
||||
try {
|
||||
const asked = state.mode === "replace" ? await app.api.replaceShortcuts(state.find, state.replace, state.matchCase, false) : await app.api.findShortcuts(state.find, state.matchCase);
|
||||
const asked = state.mode === "replace" ? await app.api.replaceShortcuts(state.find, state.replace, state.matchCase, false, state.wholeWords) : await app.api.findShortcuts(state.find, state.matchCase, state.wholeWords);
|
||||
if (mine !== ticket || !alive) return;
|
||||
const rows = asked.matches || [], names = new Set(rows.map((m) => m.name));
|
||||
if (!rows.length) return note("No shortcut contains that phrase.");
|
||||
@@ -74,11 +74,14 @@ export function shortcuts(app, own) {
|
||||
own(() => clearTimeout(timer));
|
||||
|
||||
async function commit() {
|
||||
clearTimeout(timer);
|
||||
await look(); // the count asked about is the one for what is typed now
|
||||
if (!found) return;
|
||||
const ok = await c.modal.dialogue({ title: state.replace === "" ? "Remove everywhere" : "Replace everywhere", tone: "danger",
|
||||
message: `Change ${found} ${found === 1 ? "line" : "lines"} in your saved shortcuts? This cannot be undone.`, confirmLabel: state.replace === "" ? "Remove" : "Replace" }).result;
|
||||
if (!ok) return;
|
||||
try {
|
||||
const done = await app.api.replaceShortcuts(state.find, state.replace, state.matchCase, true);
|
||||
const done = await app.api.replaceShortcuts(state.find, state.replace, state.matchCase, true, state.wholeWords);
|
||||
draw(done);
|
||||
c.toast.good({ text: `Changed ${done.changed} ${done.changed === 1 ? "shortcut" : "shortcuts"}.` });
|
||||
win.close("done");
|
||||
@@ -89,6 +92,7 @@ export function shortcuts(app, own) {
|
||||
c.segmented.md({ label: "Mode", value: state.mode, options: [{ value: "find", label: "Find" }, { value: "replace", label: "Replace" }], onChange: (v) => { state.mode = v; fields.set(v === "replace" ? [findField, replaceField] : [findField]); look(); } }),
|
||||
fields,
|
||||
c.toggle.default({ label: "Match upper and lower case", checked: false, onChange: (v) => { state.matchCase = v; look(); } }),
|
||||
c.toggle.default({ label: "Whole words only", hint: "Off also finds the phrase inside longer words, such as “cat” in “scatter”.", checked: true, onChange: (v) => { state.wholeWords = v; look(); } }),
|
||||
c.button.sm({ label: "Common phrases…", onClick: () => phrasesWindow((phrase) => { findBox.setValue(phrase); state.find = phrase; look(); }) }),
|
||||
results,
|
||||
] }) });
|
||||
|
||||
@@ -44,12 +44,12 @@ const closeAll = () => { for (const m of document.querySelectorAll(".cx-modal"))
|
||||
test("Find lists the matches with their counts and sends the text and the case choice as typed", async () => {
|
||||
const asked = [];
|
||||
const lib = { shortcuts: [one("a")] };
|
||||
const page = await mount({ shortcuts: async () => lib, findShortcuts: async (f, mc) => { asked.push([f, mc]); return { matches: [hit("a", "red fox red", null, 2), hit("b", "a red hat", null)], skipped: [] }; } });
|
||||
const page = await mount({ shortcuts: async () => lib, findShortcuts: async (f, mc, ww) => { asked.push([f, mc, ww]); return { matches: [hit("a", "red fox red", null, 2), hit("b", "a red hat", null)], skipped: [] }; } });
|
||||
page.node.querySelector("button[title^='Export']").click();
|
||||
press("Find and replace…");
|
||||
typeInto("Find", "a.b (red)");
|
||||
await later(260);
|
||||
assert.deepEqual(asked.at(-1), ["a.b (red)", false]);
|
||||
assert.deepEqual(asked.at(-1), ["a.b (red)", false, true]);
|
||||
assert.match(document.querySelector(".cx-modal").textContent, /3 matches in 2 shortcuts/);
|
||||
assert.deepEqual(rows(), ["a" + "red fox red", "b" + "a red hat"]);
|
||||
assert.ok([...document.querySelectorAll("button")].find((b) => /everywhere/.test(b.textContent)).disabled, "Find alone can change nothing");
|
||||
@@ -60,21 +60,21 @@ test("Replace shows before and after, asks first, then saves and redraws with th
|
||||
const calls = [];
|
||||
let lib = { shortcuts: [one("a")] };
|
||||
const api = { shortcuts: async () => lib,
|
||||
replaceShortcuts: async (f, r, mc, apply) => { calls.push([f, r, mc, apply]); return { ...(apply ? { shortcuts: [one("renamed")] } : {}), matches: [hit("a", "red fox", "blue fox")], changed: 1, skipped: [] }; } };
|
||||
replaceShortcuts: async (f, r, mc, apply, ww) => { calls.push([f, r, mc, apply, ww]); return { ...(apply ? { shortcuts: [one("renamed")] } : {}), matches: [hit("a", "red fox", "blue fox")], changed: 1, skipped: [] }; } };
|
||||
const page = await mount(api);
|
||||
page.node.querySelector("button[title^='Export']").click();
|
||||
press("Find and replace…");
|
||||
press("Replace");
|
||||
typeInto("Find", "red"); typeInto("Replace with", "blue");
|
||||
await later(260);
|
||||
assert.deepEqual(calls.at(-1), ["red", "blue", false, false], "a preview writes nothing");
|
||||
assert.deepEqual(calls.at(-1), ["red", "blue", false, false, true], "a preview writes nothing");
|
||||
assert.deepEqual(rows(), ["ared fox → blue fox"]);
|
||||
press("Replace everywhere");
|
||||
await wait();
|
||||
assert.equal(calls.filter((c) => c[3]).length, 0, "nothing is written before the person agrees");
|
||||
press("Replace", true); // the confirmation
|
||||
await later(30);
|
||||
assert.deepEqual(calls.at(-1), ["red", "blue", false, true]);
|
||||
assert.deepEqual(calls.at(-1), ["red", "blue", false, true, true]);
|
||||
assert.deepEqual([...page.node.querySelectorAll(".cx-filter-label")].map((n) => n.textContent), ["renamed"]);
|
||||
closeAll(); page.node.remove();
|
||||
});
|
||||
@@ -107,3 +107,32 @@ test("Common phrases lists them with counts, and picking one opens Replace with
|
||||
assert.deepEqual(asked, ["golden light"]);
|
||||
closeAll(); page.node.remove();
|
||||
});
|
||||
|
||||
test("the count in the confirmation is asked again for what is typed at that moment", async () => {
|
||||
let latest = "";
|
||||
const api = { shortcuts: async () => ({ shortcuts: [one("a")] }),
|
||||
replaceShortcuts: async (f, _r, _m, apply) => { latest = f; return { matches: f === "red" ? [hit("a", "red", "blue"), hit("b", "red", "blue")] : [hit("a", "r", "b")], skipped: [], changed: 1, shortcuts: apply ? [one("a")] : undefined }; } };
|
||||
const page = await mount(api);
|
||||
page.node.querySelector("button[title^='Export']").click();
|
||||
press("Find and replace…");
|
||||
press("Replace");
|
||||
typeInto("Find", "r"); await later(260);
|
||||
typeInto("Find", "red"); // typed, and Replace pressed before the pause ends
|
||||
press("Remove everywhere");
|
||||
await later(40);
|
||||
assert.equal(latest, "red");
|
||||
assert.match(document.body.textContent, /Change 2 lines/);
|
||||
closeAll(); page.node.remove();
|
||||
});
|
||||
|
||||
test("Whole words only is on to begin with and is sent as chosen", async () => {
|
||||
const sent = [];
|
||||
const page = await mount({ shortcuts: async () => ({ shortcuts: [one("a")] }), findShortcuts: async (f, mc, ww) => { sent.push(ww); return { matches: [], skipped: [] }; } });
|
||||
page.node.querySelector("button[title^='Export']").click();
|
||||
press("Find and replace…");
|
||||
typeInto("Find", "cat"); await later(260);
|
||||
const box = document.querySelectorAll(".cx-modal input[type=checkbox]")[1];
|
||||
box.click(); await later(260);
|
||||
assert.deepEqual(sent, [true, false]);
|
||||
closeAll(); page.node.remove();
|
||||
});
|
||||
|
||||
+2
-2
@@ -66,8 +66,8 @@ export const api = {
|
||||
importShortcuts: (data, mode) => call("POST", "/api/shortcuts/import", { data, mode }),
|
||||
addShortcutCategory: (category, sub_category) => call("POST", "/api/shortcuts/category", { category, sub_category }),
|
||||
deleteShortcut: (name) => call("DELETE", `/api/shortcuts/${encodeURIComponent(name)}`),
|
||||
findShortcuts: (find, match_case) => call("POST", "/api/shortcuts/find", { find, match_case }),
|
||||
replaceShortcuts: (find, replace, match_case, apply) => call("POST", "/api/shortcuts/replace", { find, replace, match_case, apply }),
|
||||
findShortcuts: (find, match_case, whole_words) => call("POST", "/api/shortcuts/find", { find, match_case, whole_words }),
|
||||
replaceShortcuts: (find, replace, match_case, apply, whole_words) => call("POST", "/api/shortcuts/replace", { find, replace, match_case, apply, whole_words }),
|
||||
shortcutPhrases: () => call("GET", "/api/shortcuts/phrases"),
|
||||
revolver: () => call("GET", "/api/shortcuts/revolver"),
|
||||
setRevolver: (enabled, random) => call("POST", "/api/shortcuts/revolver", { enabled, random }),
|
||||
|
||||
+7
-3
@@ -1139,7 +1139,8 @@ def register(routes, prefix=None):
|
||||
if body is None:
|
||||
return web.json_response({"problems": ["send {find, match_case}"]}, status=400)
|
||||
try:
|
||||
return web.json_response(shortcuts_mod.find_replace(body.get("find"), None, body.get("match_case") is True))
|
||||
return web.json_response(shortcuts_mod.find_replace(body.get("find"), None, body.get("match_case") is True,
|
||||
whole_words=body.get("whole_words") is True))
|
||||
except ValueError as exc:
|
||||
return web.json_response({"problems": [str(exc)]}, status=400)
|
||||
|
||||
@@ -1151,14 +1152,17 @@ def register(routes, prefix=None):
|
||||
return web.json_response({"problems": ["send {find, replace, match_case, apply}"]}, status=400)
|
||||
try:
|
||||
done = shortcuts_mod.find_replace(body.get("find"), body["replace"], body.get("match_case") is True,
|
||||
body.get("apply") is True)
|
||||
body.get("apply") is True, whole_words=body.get("whole_words") is True)
|
||||
except ValueError as exc:
|
||||
return web.json_response({"problems": [str(exc)]}, status=400)
|
||||
return web.json_response(dict(json.loads(_library().text), **done))
|
||||
|
||||
PHRASES_SHOWN = 200
|
||||
|
||||
@routes.get(P + "/api/shortcuts/phrases")
|
||||
async def _shortcuts_phrases(_req):
|
||||
return web.json_response({"phrases": shortcuts_mod.common_phrases()})
|
||||
rows = await asyncio.to_thread(shortcuts_mod.common_phrases) # a big library takes seconds: not on the server's loop
|
||||
return web.json_response({"phrases": rows[:PHRASES_SHOWN], "total": len(rows)})
|
||||
|
||||
@routes.get(P + "/api/shortcuts/revolver")
|
||||
async def _revolver_get(_req):
|
||||
|
||||
+21
-10
@@ -175,20 +175,29 @@ def clear() -> list[Shortcut]:
|
||||
# These work on the replacement text only: that is the prose. A trigger is a key, and rewriting
|
||||
# triggers in bulk could merge two of them or break the picker's order.
|
||||
|
||||
def _finder(query, match_case: bool = False):
|
||||
"""The query as a literal (never a pattern) regex; runs of whitespace match the single space the library keeps."""
|
||||
_WORD_EDGE = r"[\w'’-]" # what a trigger treats as part of a word (see _trigger_pattern)
|
||||
|
||||
|
||||
def _finder(query, match_case: bool = False, whole_words: bool = False):
|
||||
"""The query as a literal (never a pattern) regex; runs of whitespace match the single space the library keeps.
|
||||
`whole_words` refuses a hit inside a longer word ("cat" in "scatter"), but only at an end of the query that is
|
||||
itself a word character, so a query like ", big" still matches after a word."""
|
||||
text = re.sub(r"\s+", " ", str(query if query is not None else ""))
|
||||
if not text.strip():
|
||||
raise ValueError("type the phrase to look for")
|
||||
return re.compile(re.escape(text), 0 if match_case else re.IGNORECASE)
|
||||
body = re.escape(text)
|
||||
if whole_words:
|
||||
body = (rf"(?<!{_WORD_EDGE})" if re.match(_WORD_EDGE, text[0]) else "") + body + \
|
||||
(rf"(?!{_WORD_EDGE})" if re.match(_WORD_EDGE, text[-1]) else "")
|
||||
return re.compile(body, 0 if match_case else re.IGNORECASE)
|
||||
|
||||
|
||||
def find_replace(find, replace=None, match_case: bool = False, apply: bool = False, items=None) -> dict:
|
||||
def find_replace(find, replace=None, match_case: bool = False, apply: bool = False, items=None, whole_words: bool = False) -> dict:
|
||||
"""-> {matches, changed, skipped}. Finds `find` in every replacement of every shortcut (enabled or not);
|
||||
`replace=None` only looks. Matches are taken left to right and never overlap ("aa" in "aaa" is one).
|
||||
`apply` writes the library. A replacement that would end up empty or longer than the limit is left
|
||||
`apply` writes the library. A replacement that would end up with no word in it or longer than the limit is left
|
||||
as it was and named in `skipped`: an empty replacement means "remove this phrase", not "was blanked by accident"."""
|
||||
rx = _finder(find, match_case)
|
||||
rx = _finder(find, match_case, whole_words)
|
||||
new = None if replace is None else re.sub(r"\s+", " ", str(replace))
|
||||
with _LOCK:
|
||||
base = listing() if items is None else items
|
||||
@@ -203,9 +212,11 @@ def find_replace(find, replace=None, match_case: bool = False, apply: bool = Fal
|
||||
after = None if new is None else rx.sub(lambda _m: new, text)
|
||||
entry = {"name": sc.name, "index": i, "count": count, "before": text, "after": after}
|
||||
if after is not None:
|
||||
if new == "": # a removal: tidy the commas it leaves, as removing a trigger's phrase does
|
||||
after = _cleanup_removed_phrases(after)
|
||||
after = after.strip()
|
||||
if not after or len(after) > MAX_ITEM:
|
||||
skipped.append({"name": sc.name, "index": i, "reason": "empty" if not after else "too long"})
|
||||
if not re.search(r"\w", after) or len(after) > MAX_ITEM: # nothing but punctuation is as empty as nothing
|
||||
skipped.append({"name": sc.name, "index": i, "reason": "too long" if len(after) > MAX_ITEM else "empty"})
|
||||
continue
|
||||
lines[i], touched, entry["after"] = after, True, after
|
||||
matches.append(entry)
|
||||
@@ -219,7 +230,7 @@ def find_replace(find, replace=None, match_case: bool = False, apply: bool = Fal
|
||||
_SEGMENT = re.compile(r"[^\w\s'’-]+|\n") # punctuation ends a phrase: "red fox, big hat" never makes "fox big"
|
||||
|
||||
|
||||
def common_phrases(items=None, min_words: int = 2, min_shortcuts: int = 2, max_words: int = 8, limit: int = 200) -> list[dict]:
|
||||
def common_phrases(items=None, min_words: int = 2, min_shortcuts: int = 2, max_words: int = 6, limit: int | None = None) -> list[dict]:
|
||||
"""Runs of words that appear in the replacements of at least `min_shortcuts` different shortcuts:
|
||||
[{phrase, shortcuts, uses, words}], most shared first. `uses` counts replacement lines (a phrase twice in one
|
||||
line is one use). A phrase is dropped when a longer one around it is in exactly the same places, so
|
||||
@@ -244,7 +255,7 @@ def common_phrases(items=None, min_words: int = 2, min_shortcuts: int = 2, max_w
|
||||
del kept[child]
|
||||
rows = [{"phrase": e[0], "shortcuts": len(e[1]), "uses": len(e[2]), "words": len(k.split())} for k, e in kept.items()]
|
||||
rows.sort(key=lambda r: (-r["shortcuts"], -r["words"], -r["uses"], r["phrase"].casefold()))
|
||||
return rows[:limit]
|
||||
return rows if limit is None else rows[:limit]
|
||||
|
||||
|
||||
# --- categories --------------------------------------------------------------
|
||||
|
||||
@@ -169,7 +169,7 @@ def test_find_replace_and_phrases_over_http(server):
|
||||
assert [m["name"] for m in found["matches"]] == ["A", "B"]
|
||||
assert _request(server, "POST", "/fun/api/shortcuts/find", {"find": ""})[0] == 400
|
||||
_, phrases = _request(server, "GET", "/fun/api/shortcuts/phrases")
|
||||
assert phrases["phrases"][0]["phrase"] == "golden light"
|
||||
assert phrases["phrases"][0]["phrase"] == "golden light" and phrases["total"] == 1
|
||||
status, preview = _request(server, "POST", "/fun/api/shortcuts/replace", {"find": "golden", "replace": "warm", "apply": False})
|
||||
assert status == 200 and preview["changed"] == 2
|
||||
assert [s["replacements"] for s in _request(server, "GET", "/fun/api/shortcuts")[1]["shortcuts"]][0] == ["soft golden light"]
|
||||
|
||||
@@ -103,6 +103,25 @@ def test_unicode_case_insensitive(store):
|
||||
assert shortcuts.find_replace("éclair")["matches"][0]["count"] == 2
|
||||
|
||||
|
||||
def test_whole_words_refuses_a_hit_inside_a_longer_word_and_still_matches_punctuation_edges(store):
|
||||
put("a", "a bored fox, the scatter cat", "red fox, big hat")
|
||||
assert [m["count"] for m in shortcuts.find_replace("red fox", whole_words=False)["matches"]] == [1, 1]
|
||||
assert [m["index"] for m in shortcuts.find_replace("red fox", whole_words=True)["matches"]] == [1]
|
||||
shortcuts.find_replace("cat", "dog", apply=True, whole_words=True)
|
||||
assert texts()["a"][0] == "a bored fox, the scatter dog"
|
||||
shortcuts.find_replace(", big", " small", apply=True, whole_words=True) # ends that are not word characters are free
|
||||
assert texts()["a"][1] == "red fox small hat"
|
||||
shortcuts.find_replace("cat", "dog", apply=True) # off: inside a word too
|
||||
assert texts()["a"][0] == "a bored fox, the sdogter dog"
|
||||
|
||||
|
||||
def test_removing_a_phrase_tidies_the_commas_and_a_line_left_with_only_punctuation_is_skipped(store):
|
||||
put("a", "red fox, big hat", "a red fox, big hat", "big hat, red fox", "red fox.")
|
||||
done = shortcuts.find_replace("red fox", "", apply=True)
|
||||
assert texts()["a"] == ["big hat", "a, big hat", "red fox."] # the two that became "big hat" are one
|
||||
assert done["skipped"] == [{"name": "a", "index": 3, "reason": "empty"}]
|
||||
|
||||
|
||||
# ── common phrases ────────────────────────────────────────────────────────
|
||||
|
||||
def phrases(**kw):
|
||||
@@ -150,3 +169,9 @@ def test_every_listed_phrase_can_be_found_and_the_counts_agree(store):
|
||||
|
||||
def test_nothing_in_an_empty_library(store):
|
||||
assert shortcuts.common_phrases() == []
|
||||
|
||||
|
||||
def test_the_phrase_limit_can_be_lifted_so_a_caller_can_say_how_many_there_are(store):
|
||||
for i in range(3):
|
||||
put(f"s{i}", " ".join(f"w{j}" for j in range(8)))
|
||||
assert len(shortcuts.common_phrases(limit=2)) == 2 and len(shortcuts.common_phrases()) > 2
|
||||
|
||||
@@ -517,7 +517,9 @@ def make_run_start(state):
|
||||
counter above resets itself from the schedule, but a run cancelled during its first step leaves the next run
|
||||
looking like the same step, so its stabilize_motion memory (the cancelled run's block choices) and a kernel
|
||||
failure would be read as its own. Only these two are cleared: a Two stages run enters this once per stage and
|
||||
the stage's own bookkeeping has to carry over."""
|
||||
the stage's own bookkeeping has to carry over. So the call and fall-through counts of a run cancelled in its first
|
||||
step still reach the next run's summary line (log only, the picture is unaffected): a retry cannot be told apart
|
||||
from a second stage."""
|
||||
def outer(executor, *args, **kwargs):
|
||||
state["history"].clear()
|
||||
state["failed"] = None
|
||||
|
||||
@@ -26,12 +26,12 @@ SETTINGS = {
|
||||
"auto_contrast": {
|
||||
"type": "bool", "default": False,
|
||||
"label": "Auto contrast",
|
||||
"hint": "Stretches the clip's darkest and brightest levels (by at most 15% each) toward black and white. Measured once on the whole clip and applied as one fixed curve, so it never flickers.",
|
||||
"hint": "Stretches the clip's darkest and brightest levels (by at most 15% each) toward black and white. Measured once from 16 frames spread over the clip and applied as one fixed curve, so it never flickers.",
|
||||
},
|
||||
"auto_brightness": {
|
||||
"type": "bool", "default": False,
|
||||
"label": "Auto brightness",
|
||||
"hint": "Lifts or lowers the clip's midtones toward a middle level (within a mild range). Measured once on the whole clip, never per frame.",
|
||||
"hint": "Lifts or lowers the clip's midtones toward a middle level (within a mild range). Measured once from 16 frames spread over the clip, never per frame.",
|
||||
},
|
||||
"saturation": {
|
||||
"type": "float", "default": 1.0, "min": 0.5, "max": 1.5, "step": 0.05, "ui": "slider",
|
||||
|
||||
@@ -250,32 +250,26 @@ def _staged(original, guider, size, steps, resize, method, back_up, *args, **kwa
|
||||
setattr(guider, FULL, whole)
|
||||
setattr(guider, dit_hooks.STAGE, 1)
|
||||
guider.model_patcher = first_model
|
||||
try:
|
||||
try: # everything from here to the end: an error between the stages (enlarging) must not leave the stage-1 model on a guider ComfyUI may cache
|
||||
run(small_noise, small_image, sampler, first, None, first_stage_callback, disable_pbar, seed,
|
||||
latent_shapes=shapes1)
|
||||
except BaseException:
|
||||
guider.model_patcher = base_model
|
||||
setattr(guider, FULL, None)
|
||||
setattr(guider, dit_hooks.STAGE, None)
|
||||
raise
|
||||
if "x0" not in seen:
|
||||
raise RuntimeError("the first stage gave no clean guess to hand over")
|
||||
if "x0" not in seen:
|
||||
raise RuntimeError("the first stage gave no clean guess to hand over")
|
||||
|
||||
video, audio = comfy.utils.unpack_latents(seen["x0"], shapes1)
|
||||
done = guider.model_patcher.model.process_latent_out(comfy.nested_tensor.NestedTensor((video, audio)).cpu())
|
||||
video, audio = done.unbind()
|
||||
enlarged = enlarge(video, h, w, upscaler, method)
|
||||
handed, _ = comfy.utils.pack_latents(
|
||||
(enlarged.to(latent_image.device, latent_image.dtype), audio.to(latent_image.device, latent_image.dtype)))
|
||||
video, audio = comfy.utils.unpack_latents(seen["x0"], shapes1)
|
||||
done = guider.model_patcher.model.process_latent_out(comfy.nested_tensor.NestedTensor((video, audio)).cpu())
|
||||
video, audio = done.unbind()
|
||||
enlarged = enlarge(video, h, w, upscaler, method)
|
||||
handed, _ = comfy.utils.pack_latents(
|
||||
(enlarged.to(latent_image.device, latent_image.dtype), audio.to(latent_image.device, latent_image.dtype)))
|
||||
|
||||
guider.conds = raw_conds # the first stage encoded the originals for its own size
|
||||
shifted = None
|
||||
if callback is not None:
|
||||
def shifted(step, x0, x, total):
|
||||
return callback(step + steps, x0, x, total + steps)
|
||||
setattr(guider, dit_hooks.STAGE, 2)
|
||||
guider.model_patcher = second_model
|
||||
try:
|
||||
guider.conds = raw_conds # the first stage encoded the originals for its own size
|
||||
shifted = None
|
||||
if callback is not None:
|
||||
def shifted(step, x0, x, total):
|
||||
return callback(step + steps, x0, x, total + steps)
|
||||
setattr(guider, dit_hooks.STAGE, 2)
|
||||
guider.model_patcher = second_model
|
||||
return run(noise, handed, sampler, second, None, shifted, disable_pbar, seed, latent_shapes=latent_shapes)
|
||||
finally:
|
||||
guider.model_patcher = base_model
|
||||
|
||||
@@ -299,3 +299,28 @@ def test_a_stage_lora_is_not_applied_when_two_stages_is_off_and_it_is_said(model
|
||||
monkeypatch.setattr(two.log, "once", lambda key, level, source, message: said.append(message))
|
||||
seen = _stage_lora_run(model, monkeypatch, "Second", values={"enabled": False})
|
||||
assert all(not applied for _g, applied in seen) and any("not applied" in m for m in said)
|
||||
|
||||
|
||||
def test_an_error_between_the_stages_leaves_the_guider_as_it_was_found(model, monkeypatch):
|
||||
"""The guider can be cached by ComfyUI across runs: a failed enlarge must not leave it on the stage-1 model."""
|
||||
import comfy.samplers
|
||||
from comfy.nested_tensor import NestedTensor
|
||||
from modules.sampling.two_stage import install, stages
|
||||
patched = model.clone()
|
||||
install(patched, {"enabled": True, "size": 0.5, "steps": 2}, "funui.two_stage")
|
||||
guider = comfy.samplers.CFGGuider(patched)
|
||||
cond = [[torch.randn(1, 5, 48), {}]]
|
||||
guider.set_conds(cond, cond)
|
||||
guider.set_cfg(1.0)
|
||||
before = guider.model_patcher
|
||||
|
||||
def boom(*_a, **_k):
|
||||
raise RuntimeError("enlarge failed")
|
||||
monkeypatch.setattr(stages, "enlarge", boom)
|
||||
noise = NestedTensor((torch.randn(1, 24, 2, 8, 8), torch.randn(1, 32, 2, 3)))
|
||||
latent = NestedTensor((torch.zeros(1, 24, 2, 8, 8), torch.zeros(1, 32, 2, 3)))
|
||||
with pytest.raises(RuntimeError, match="enlarge failed"):
|
||||
guider.sample(noise, latent, comfy.samplers.sampler_object("euler"), torch.tensor((1.0, 0.9, 0.7, 0.3, 0.0)),
|
||||
seed=1, disable_pbar=True)
|
||||
assert guider.model_patcher is before
|
||||
assert getattr(guider, stages.FULL, None) is None and getattr(guider, "funui_stage", None) is None
|
||||
|
||||
Reference in New Issue
Block a user