From 4f06ed9d890e8917f4526da48485a27da3ab3ec1 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:53:44 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 4 +- app/ui/features/composer/shortcuts.js | 16 +++++--- app/ui/features/composer/shortcuts.test.js | 39 ++++++++++++++++--- app/ui/shell/api.js | 4 +- core/routes.py | 10 +++-- core/shortcuts.py | 31 ++++++++++----- core/tests/test_routes_shortcuts.py | 2 +- core/tests/test_shortcuts_find_replace.py | 25 ++++++++++++ modules/loaders/sla_attention.py | 4 +- modules/output/save/__init__.py | 4 +- modules/sampling/two_stage/stages.py | 38 ++++++++---------- .../two_stage/tests/test_two_stage.py | 25 ++++++++++++ 12 files changed, 148 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e98154c8..f0daaa62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/ui/features/composer/shortcuts.js b/app/ui/features/composer/shortcuts.js index 4dcb6def..810f7437 100644 --- a/app/ui/features/composer/shortcuts.js +++ b/app/ui/features/composer/shortcuts.js @@ -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, ] }) }); diff --git a/app/ui/features/composer/shortcuts.test.js b/app/ui/features/composer/shortcuts.test.js index e17b6559..d7363790 100644 --- a/app/ui/features/composer/shortcuts.test.js +++ b/app/ui/features/composer/shortcuts.test.js @@ -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(); +}); diff --git a/app/ui/shell/api.js b/app/ui/shell/api.js index 066d430f..87e03be8 100644 --- a/app/ui/shell/api.js +++ b/app/ui/shell/api.js @@ -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 }), diff --git a/core/routes.py b/core/routes.py index 02fcdaa0..3d13ff48 100644 --- a/core/routes.py +++ b/core/routes.py @@ -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): diff --git a/core/shortcuts.py b/core/shortcuts.py index ce81d728..7043814b 100644 --- a/core/shortcuts.py +++ b/core/shortcuts.py @@ -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"(? 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 -------------------------------------------------------------- diff --git a/core/tests/test_routes_shortcuts.py b/core/tests/test_routes_shortcuts.py index d3d58b45..9edd6e54 100644 --- a/core/tests/test_routes_shortcuts.py +++ b/core/tests/test_routes_shortcuts.py @@ -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"] diff --git a/core/tests/test_shortcuts_find_replace.py b/core/tests/test_shortcuts_find_replace.py index 78d72c08..e909afa8 100644 --- a/core/tests/test_shortcuts_find_replace.py +++ b/core/tests/test_shortcuts_find_replace.py @@ -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 diff --git a/modules/loaders/sla_attention.py b/modules/loaders/sla_attention.py index 2ea6bc91..65b18812 100644 --- a/modules/loaders/sla_attention.py +++ b/modules/loaders/sla_attention.py @@ -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 diff --git a/modules/output/save/__init__.py b/modules/output/save/__init__.py index 6ebd7b6f..10ccfe0a 100644 --- a/modules/output/save/__init__.py +++ b/modules/output/save/__init__.py @@ -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", diff --git a/modules/sampling/two_stage/stages.py b/modules/sampling/two_stage/stages.py index 29c5969c..a1af755d 100644 --- a/modules/sampling/two_stage/stages.py +++ b/modules/sampling/two_stage/stages.py @@ -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 diff --git a/modules/sampling/two_stage/tests/test_two_stage.py b/modules/sampling/two_stage/tests/test_two_stage.py index ca4a2cb5..e33dcb93 100644 --- a/modules/sampling/two_stage/tests/test_two_stage.py +++ b/modules/sampling/two_stage/tests/test_two_stage.py @@ -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