Shortcut revolver: no-repeat cycling through multi-replacement shortcuts
Editor ▸ Settings ▸ Editor gains a 'Shortcut revolver' toggle (+ 'Random' sub-option). When on, each shortcut with several replacements draws from a per-shortcut queue — sequential (first…last, then restart) or shuffled once per cycle — and nothing repeats until the whole set has fired. Off (default) keeps the seeded random pick unchanged. - State is a sidecar (shortcut_revolver.json) next to the shortcut DB: settings + per-shortcut remaining queue + replacement-set fingerprint (editing a shortcut's replacements restarts its cycle; changing either setting resets all cycles). - Generation commits the draw (refine_v2 positive prompt); every preview path (parse_timeline, split mapping, fragments) peeks the same stored queue without saving, so previews show exactly what the next Generate will use. - Batch Training variants each commit a draw: N variants sweep N consecutive chambers instead of seed-rolled repeats. - FunPackStudio.IS_CHANGED returns nan while the revolver is on so fixed-seed re-runs aren't served from ComfyUI's node cache (the next chamber must fire). Verified live on local ComfyUI: settings round-trip, seed-independent stable peeks, sequential cycle REP_ONE→TWO→THREE→ONE across processes, peek-after-commit shows the next chamber. 9 new unit tests; full suite has the same 20 pre-existing failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1112c2dccc
commit
6528acf504
+68
-14
@@ -663,11 +663,13 @@ def _expand_with_map(text):
|
||||
try:
|
||||
from .templates import (load_shortcut_db, shortcut_list, shortcut_key,
|
||||
_shortcut_replacements, _shortcut_trigger_pattern,
|
||||
normalize_refinement_key)
|
||||
normalize_refinement_key, load_revolver,
|
||||
revolver_next_replacement)
|
||||
except ImportError:
|
||||
from templates import (load_shortcut_db, shortcut_list, shortcut_key,
|
||||
_shortcut_replacements, _shortcut_trigger_pattern,
|
||||
normalize_refinement_key)
|
||||
normalize_refinement_key, load_revolver,
|
||||
revolver_next_replacement)
|
||||
import random
|
||||
from hashlib import md5
|
||||
|
||||
@@ -678,7 +680,7 @@ def _expand_with_map(text):
|
||||
except Exception:
|
||||
return text, whole
|
||||
candidates = []
|
||||
for shortcut in (data.get("shortcuts", {}) or {}).values():
|
||||
for db_key, shortcut in (data.get("shortcuts", {}) or {}).items():
|
||||
if not isinstance(shortcut, dict) or not bool(shortcut.get("enabled", True)):
|
||||
continue
|
||||
reps = _shortcut_replacements(shortcut.get("replacements", shortcut.get("replacement", [])))
|
||||
@@ -689,14 +691,23 @@ def _expand_with_map(text):
|
||||
for trig in shortcut_list(shortcut.get("triggers", [])):
|
||||
pat = _shortcut_trigger_pattern(trig)
|
||||
if pat:
|
||||
candidates.append((trig, pat, reps, sc_key, sc_name))
|
||||
candidates.append((trig, pat, reps, sc_key, sc_name, str(db_key)))
|
||||
if not candidates:
|
||||
return text, whole
|
||||
candidates.sort(key=lambda it: len(shortcut_key(it[0])), reverse=True)
|
||||
combined = "|".join(f"(?P<t{i}>{pat})" for i, (_, pat, _, _, _) in enumerate(candidates))
|
||||
combined = "|".join(f"(?P<t{i}>{pat})" for i, (_, pat, _, _, _, _) in enumerate(candidates))
|
||||
if not combined:
|
||||
return text, whole
|
||||
rng = random.Random(int(md5(text.encode("utf-8")).hexdigest()[:12], 16))
|
||||
# Shortcut revolver: peek the same stored cycle generation will draw from (never saved
|
||||
# here) so split boundaries are found in the text generation will actually see.
|
||||
try:
|
||||
_revolver = load_revolver()
|
||||
except Exception:
|
||||
_revolver = {"enabled": False, "random": False, "state": {}}
|
||||
_rev_on = bool(_revolver.get("enabled"))
|
||||
_rev_state = _revolver.get("state") if isinstance(_revolver.get("state"), dict) else {}
|
||||
_rev_random = bool(_revolver.get("random"))
|
||||
try:
|
||||
pattern = re.compile(combined, re.IGNORECASE | re.UNICODE)
|
||||
except re.error:
|
||||
@@ -711,9 +722,13 @@ def _expand_with_map(text):
|
||||
parts.append(lit); exp_pos += len(lit)
|
||||
rep = None
|
||||
pc_key, pc_name = "", ""
|
||||
for i, (_, _, reps, sc_key, sc_name) in enumerate(candidates):
|
||||
for i, (_, _, reps, sc_key, sc_name, db_key) in enumerate(candidates):
|
||||
if m.group(f"t{i}") is not None:
|
||||
rep = rng.choice(reps); pc_key, pc_name = sc_key, sc_name; break
|
||||
if _rev_on and len(reps) > 1:
|
||||
rep = revolver_next_replacement(_rev_state, db_key, reps, _rev_random)
|
||||
else:
|
||||
rep = rng.choice(reps)
|
||||
pc_key, pc_name = sc_key, sc_name; break
|
||||
if rep is None:
|
||||
rep = m.group(0)
|
||||
pieces.append({"exp_start": exp_pos, "exp_end": exp_pos + len(rep),
|
||||
@@ -6661,13 +6676,13 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
|
||||
def _v2_prompt_key(self, prompt):
|
||||
return re.sub(r"\s+", " ", str(prompt or "").strip())
|
||||
|
||||
def _v2_apply_global_shortcuts(self, text, seed=0):
|
||||
def _v2_apply_global_shortcuts(self, text, seed=0, revolver_commit=False):
|
||||
try:
|
||||
try:
|
||||
from .templates import apply_prompt_shortcuts
|
||||
except ImportError:
|
||||
from templates import apply_prompt_shortcuts
|
||||
expanded, applied = apply_prompt_shortcuts(text, seed=seed)
|
||||
expanded, applied = apply_prompt_shortcuts(text, seed=seed, revolver_commit=revolver_commit)
|
||||
except Exception as error:
|
||||
return str(text or ""), f"Shortcuts: unavailable ({error}).", []
|
||||
if not applied:
|
||||
@@ -11729,8 +11744,13 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
|
||||
# seeds to build N variant conditionings (Studio owns conditioning production).
|
||||
_raw_positive_prompt = str(positive_prompt or "")
|
||||
_raw_intent_prompt = str(user_intent_prompt or "")
|
||||
positive_prompt, shortcut_status, shortcut_applied = self._v2_apply_global_shortcuts(positive_prompt, seed=seed)
|
||||
# revolver_commit only on the real positive prompt: the one draw per run that advances
|
||||
# the shortcut revolver's cycle. The intent prompt peeks, and does so FIRST — if it
|
||||
# echoes the positive prompt's triggers, both then resolve to the same chamber (the
|
||||
# same alignment the shared seed used to give them).
|
||||
user_intent_prompt, intent_shortcut_status, intent_shortcut_applied = self._v2_apply_global_shortcuts(user_intent_prompt, seed=seed)
|
||||
positive_prompt, shortcut_status, shortcut_applied = self._v2_apply_global_shortcuts(
|
||||
positive_prompt, seed=seed, revolver_commit=True)
|
||||
if intent_shortcut_applied:
|
||||
shortcut_status = (
|
||||
f"{shortcut_status}\nIntent {intent_shortcut_status}"
|
||||
@@ -12864,14 +12884,31 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
|
||||
return (cond_i, m)
|
||||
|
||||
try:
|
||||
base_expansion = self._v2_apply_global_shortcuts(str(raw_prompt or ""), seed=seed)[0]
|
||||
try:
|
||||
from .templates import revolver_enabled as _revolver_enabled
|
||||
except ImportError:
|
||||
from templates import revolver_enabled as _revolver_enabled
|
||||
revolver_on = bool(_revolver_enabled())
|
||||
except Exception:
|
||||
base_expansion = str(raw_prompt or "")
|
||||
revolver_on = False
|
||||
out = [tagged(base_entry, 0, base_prompt)]
|
||||
cache = {base_expansion: base_entry}
|
||||
if revolver_on:
|
||||
# Shortcut revolver: each variant draw below COMMITS and advances the shared
|
||||
# cycle, so N variants sweep N consecutive chambers (no repeats until the pool
|
||||
# exhausts). Re-expanding the base here would both burn a chamber and collide
|
||||
# with variant 1's draw — skip seeding the dedupe cache with it.
|
||||
base_expansion = str(raw_prompt or "")
|
||||
cache = {}
|
||||
else:
|
||||
try:
|
||||
base_expansion = self._v2_apply_global_shortcuts(str(raw_prompt or ""), seed=seed)[0]
|
||||
except Exception:
|
||||
base_expansion = str(raw_prompt or "")
|
||||
cache = {base_expansion: base_entry}
|
||||
for i in range(1, n):
|
||||
try:
|
||||
exp_i = self._v2_apply_global_shortcuts(str(raw_prompt or ""), seed=seed + i)[0]
|
||||
exp_i = self._v2_apply_global_shortcuts(str(raw_prompt or ""), seed=seed + i,
|
||||
revolver_commit=True)[0]
|
||||
except Exception:
|
||||
exp_i = base_expansion
|
||||
entry = cache.get(exp_i)
|
||||
@@ -14387,6 +14424,23 @@ class FunPackStudio:
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
# Shortcut revolver advances persistent per-shortcut state on every run, so a
|
||||
# fixed-seed re-run must not be served from ComfyUI's cache while it's enabled —
|
||||
# the next Generate is supposed to draw the next replacement. Normal input-hash
|
||||
# caching applies when the revolver is off.
|
||||
try:
|
||||
try:
|
||||
from .templates import revolver_enabled
|
||||
except ImportError:
|
||||
from templates import revolver_enabled
|
||||
if revolver_enabled():
|
||||
return float("nan")
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _is_output_connected(prompt, unique_id, output_index):
|
||||
return FunPackVideoRefiner()._is_output_connected(prompt, unique_id, output_index)
|
||||
|
||||
@@ -285,6 +285,20 @@ def delete_shortcut(name: str) -> dict:
|
||||
return {"shortcuts": si(), "categories": _shortcut_categories()}
|
||||
|
||||
|
||||
def revolver_settings() -> dict:
|
||||
"""Shortcut-revolver settings (enabled + random order). State lives server-side next to
|
||||
the shortcut DB so previews and generation share one cycle."""
|
||||
return _funpack_attr("templates", "revolver_settings")()
|
||||
|
||||
|
||||
def set_revolver_settings(payload: dict) -> dict:
|
||||
fn = _funpack_attr("templates", "set_revolver_settings")
|
||||
return fn(
|
||||
enabled=payload.get("enabled"),
|
||||
random_order=payload.get("random"),
|
||||
)
|
||||
|
||||
|
||||
def save_category(payload: dict) -> dict:
|
||||
"""Add a category (and optionally a sub-category under it) to the managed list."""
|
||||
add = _funpack_attr("templates", "add_shortcut_category")
|
||||
|
||||
@@ -89,6 +89,8 @@
|
||||
exportShortcutsUrl: () => API("/library/shortcuts/export"),
|
||||
importShortcuts: (data, mode) => j("POST", API(`/library/shortcuts/import?mode=${mode || "merge"}`), data),
|
||||
clearShortcuts: () => j("POST", API("/library/shortcuts/clear"), {}),
|
||||
revolverSettings: () => j("GET", API("/library/revolver")),
|
||||
setRevolverSettings: (payload) => j("POST", API("/library/revolver"), payload),
|
||||
|
||||
// FunPack file manager (Composer ▸ Files)
|
||||
listFiles: () => j("GET", API("/files")),
|
||||
|
||||
@@ -141,6 +141,55 @@
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// Shortcut revolver: server-side library setting (not per-browser) — the no-repeat cycle
|
||||
// is shared by previews and generation, so the toggle lives with the shortcut DB.
|
||||
function revolverSection() {
|
||||
const wrap = el("div", "es-section");
|
||||
wrap.append(el("div", "es-section-title", "Shortcut revolver"));
|
||||
wrap.append(el("div", "es-hint",
|
||||
"For shortcuts with several replacements: remember which replacement fired and don’t reuse it "
|
||||
+ "until the WHOLE set has fired. Off = seeded random pick (may repeat). Saved with your shortcut "
|
||||
+ "library — applies to every browser and to generation."));
|
||||
|
||||
const main = el("label", "chk es-toggle");
|
||||
const mainCb = el("input"); mainCb.type = "checkbox"; mainCb.disabled = true;
|
||||
main.append(mainCb, el("span", null, "Shortcut revolver"));
|
||||
wrap.append(main);
|
||||
|
||||
const sub = el("div", "es-subfields");
|
||||
const rand = el("label", "chk es-toggle");
|
||||
const randCb = el("input"); randCb.type = "checkbox"; randCb.disabled = true;
|
||||
rand.append(randCb, el("span", null, "Random"));
|
||||
sub.append(rand);
|
||||
sub.append(el("div", "es-hint",
|
||||
"Unchecked: replacements fire in order — first, second, … last, then the cycle restarts. "
|
||||
+ "Checked: any order, but still no repeats until the set is exhausted. Changing either "
|
||||
+ "option restarts all cycles."));
|
||||
wrap.append(sub);
|
||||
|
||||
const apply = (s) => {
|
||||
mainCb.checked = !!(s && s.enabled);
|
||||
randCb.checked = !!(s && s.random);
|
||||
mainCb.disabled = false;
|
||||
randCb.disabled = !mainCb.checked;
|
||||
sub.style.display = mainCb.checked ? "" : "none";
|
||||
};
|
||||
const save = () => {
|
||||
mainCb.disabled = randCb.disabled = true;
|
||||
API.setRevolverSettings({ enabled: mainCb.checked, random: randCb.checked })
|
||||
.then(apply)
|
||||
.catch(() => { API.revolverSettings().then(apply).catch(() => {}); });
|
||||
};
|
||||
mainCb.onchange = save;
|
||||
randCb.onchange = save;
|
||||
|
||||
sub.style.display = "none";
|
||||
API.revolverSettings().then(apply).catch(() => {
|
||||
wrap.append(el("div", "es-hint", "Could not load revolver settings."));
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function toggleRow(label, hint, key) {
|
||||
const row = el("div", "es-row");
|
||||
const lbl = el("label", "chk es-toggle");
|
||||
@@ -166,6 +215,7 @@
|
||||
"Use anchor",
|
||||
"Text before the first split trigger is a shared anchor prepended to every scene. Turn off to make that leading text Scene 1 instead.",
|
||||
"anchorEnabled"));
|
||||
content.append(revolverSection());
|
||||
content.append(anchorGuideSection());
|
||||
body.append(content);
|
||||
}
|
||||
@@ -173,7 +223,7 @@
|
||||
window.SettingsWindow.register({
|
||||
id: "editor", group: "", title: "Editor",
|
||||
subtitle: "Per-browser preferences — they apply to this browser, not the project.",
|
||||
keywords: "autocomplete anchor prompt shortcuts ideas suggestions i2v bypass guide preferences",
|
||||
keywords: "autocomplete anchor prompt shortcuts ideas suggestions i2v bypass guide preferences revolver random replacements cycle",
|
||||
iconBg: "linear-gradient(180deg,#6aa9ff,#3b6fd9)",
|
||||
icon: '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round"><path d="M2 4.5h12M2 8h12M2 11.5h12"/><circle cx="6" cy="4.5" r="1.7" fill="#fff" stroke="none"/><circle cx="11" cy="8" r="1.7" fill="#fff" stroke="none"/><circle cx="5" cy="11.5" r="1.7" fill="#fff" stroke="none"/></svg>',
|
||||
mount,
|
||||
|
||||
@@ -1338,6 +1338,20 @@ if web is not None and PromptServer is not None:
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise web.HTTPBadRequest(reason=str(e))
|
||||
|
||||
@routes.get(UI_PREFIX + "/api/library/revolver")
|
||||
async def _revolver_get(_req):
|
||||
try:
|
||||
return web.json_response(bridge.revolver_settings())
|
||||
except Exception as e: # noqa: BLE001
|
||||
return web.json_response({"enabled": False, "random": False, "error": str(e)})
|
||||
|
||||
@routes.post(UI_PREFIX + "/api/library/revolver")
|
||||
async def _revolver_set(req):
|
||||
try:
|
||||
return web.json_response(bridge.set_revolver_settings(await req.json()))
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise web.HTTPBadRequest(reason=str(e))
|
||||
|
||||
@routes.post(UI_PREFIX + "/api/library/categories")
|
||||
async def _category_save(req):
|
||||
try:
|
||||
|
||||
+122
-7
@@ -548,6 +548,101 @@ def save_shortcut_db(data):
|
||||
json.dump(data, file, indent=2, sort_keys=True)
|
||||
|
||||
|
||||
REVOLVER_STORE_FILENAME = "shortcut_revolver.json"
|
||||
|
||||
|
||||
def revolver_store_path():
|
||||
return os.path.join(template_store_dir(), REVOLVER_STORE_FILENAME)
|
||||
|
||||
|
||||
def load_revolver():
|
||||
"""Shortcut-revolver settings + per-shortcut cycle state. Sidecar of the shortcut DB —
|
||||
deliberately NOT part of the shortcuts export/import payload. ``state[<shortcut key>]`` =
|
||||
{"fp": <fingerprint of the replacement list>, "queue": [remaining replacement indices,
|
||||
next one first]}."""
|
||||
data = {}
|
||||
path = revolver_store_path()
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
except (json.JSONDecodeError, OSError, ValueError):
|
||||
data = {}
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
state = data.get("state")
|
||||
return {
|
||||
"enabled": bool(data.get("enabled", False)),
|
||||
"random": bool(data.get("random", False)),
|
||||
"state": state if isinstance(state, dict) else {},
|
||||
}
|
||||
|
||||
|
||||
def save_revolver(data):
|
||||
os.makedirs(template_store_dir(), exist_ok=True)
|
||||
with open(revolver_store_path(), "w", encoding="utf-8") as file:
|
||||
json.dump({
|
||||
"enabled": bool(data.get("enabled", False)),
|
||||
"random": bool(data.get("random", False)),
|
||||
"state": data.get("state") if isinstance(data.get("state"), dict) else {},
|
||||
}, file, indent=2)
|
||||
|
||||
|
||||
def revolver_enabled():
|
||||
try:
|
||||
return bool(load_revolver()["enabled"])
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def revolver_settings():
|
||||
data = load_revolver()
|
||||
return {"enabled": data["enabled"], "random": data["random"]}
|
||||
|
||||
|
||||
def set_revolver_settings(enabled=None, random_order=None):
|
||||
"""Update revolver settings. Any actual change resets the cycle state — the old queues'
|
||||
ordering semantics no longer apply once the mode flips."""
|
||||
data = load_revolver()
|
||||
changed = False
|
||||
if enabled is not None and bool(enabled) != data["enabled"]:
|
||||
data["enabled"] = bool(enabled)
|
||||
changed = True
|
||||
if random_order is not None and bool(random_order) != data["random"]:
|
||||
data["random"] = bool(random_order)
|
||||
changed = True
|
||||
if changed:
|
||||
data["state"] = {}
|
||||
save_revolver(data)
|
||||
return {"enabled": data["enabled"], "random": data["random"]}
|
||||
|
||||
|
||||
def _revolver_fingerprint(replacements):
|
||||
payload = json.dumps(list(replacements), ensure_ascii=False)
|
||||
return md5(payload.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
def revolver_next_replacement(state, key, replacements, random_order):
|
||||
"""Draw the next replacement for shortcut ``key`` from its revolver queue, mutating
|
||||
``state`` in place. The queue is rebuilt — a fresh full cycle over every replacement —
|
||||
when it's empty or the replacement set changed (fingerprint mismatch): sequential
|
||||
(first, second, … last) by default, shuffled once per cycle when ``random_order``.
|
||||
Either way nothing repeats until the whole set has fired."""
|
||||
n = len(replacements)
|
||||
fp = _revolver_fingerprint(replacements)
|
||||
entry = state.get(key)
|
||||
queue = None
|
||||
if isinstance(entry, dict) and entry.get("fp") == fp and isinstance(entry.get("queue"), list):
|
||||
queue = [int(i) for i in entry["queue"] if isinstance(i, int) and 0 <= int(i) < n]
|
||||
if not queue:
|
||||
queue = list(range(n))
|
||||
if random_order:
|
||||
random.shuffle(queue)
|
||||
index = queue.pop(0)
|
||||
state[key] = {"fp": fp, "queue": queue}
|
||||
return replacements[index]
|
||||
|
||||
|
||||
def shortcut_items(data=None):
|
||||
data = load_shortcut_db() if data is None else normalize_shortcut_db(data)
|
||||
items = []
|
||||
@@ -827,13 +922,18 @@ def resolve_variables(text, variables):
|
||||
return _expand(str(text or ""), frozenset(), 0), sorted(undefined)
|
||||
|
||||
|
||||
def apply_prompt_shortcuts(text, seed=0, shortcut_db=None):
|
||||
def apply_prompt_shortcuts(text, seed=0, shortcut_db=None, revolver_commit=False):
|
||||
"""Expand shortcut triggers. Multi-replacement picks are seeded-random unless the
|
||||
shortcut revolver is enabled (see load_revolver): then each firing draws the next
|
||||
replacement from the shortcut's no-repeat cycle. ``revolver_commit`` persists the
|
||||
advanced cycle state — True only for real generation; previews peek without saving,
|
||||
so they show exactly what the next generation will draw."""
|
||||
original = str(text or "")
|
||||
if not original:
|
||||
return original, []
|
||||
data = load_shortcut_db() if shortcut_db is None else normalize_shortcut_db(shortcut_db)
|
||||
candidates = []
|
||||
for shortcut in data.get("shortcuts", {}).values():
|
||||
for db_key, shortcut in data.get("shortcuts", {}).items():
|
||||
if not isinstance(shortcut, dict) or not bool(shortcut.get("enabled", True)):
|
||||
continue
|
||||
replacements = _shortcut_replacements(shortcut.get("replacements", shortcut.get("replacement", [])))
|
||||
@@ -843,12 +943,13 @@ def apply_prompt_shortcuts(text, seed=0, shortcut_db=None):
|
||||
for trigger in shortcut_list(shortcut.get("triggers", [])):
|
||||
pattern = _shortcut_trigger_pattern(trigger)
|
||||
if pattern:
|
||||
candidates.append((trigger, pattern, replacements, shortcut.get("name", trigger), sc_key))
|
||||
candidates.append((trigger, pattern, replacements, shortcut.get("name", trigger),
|
||||
sc_key, str(db_key)))
|
||||
if not candidates:
|
||||
return original, []
|
||||
|
||||
candidates.sort(key=lambda item: len(shortcut_key(item[0])), reverse=True)
|
||||
combined = "|".join(f"(?P<t{index}>{pattern})" for index, (_, pattern, _, _, _) in enumerate(candidates))
|
||||
combined = "|".join(f"(?P<t{index}>{pattern})" for index, (_, pattern, _, _, _, _) in enumerate(candidates))
|
||||
if not combined:
|
||||
return original, []
|
||||
try:
|
||||
@@ -858,15 +959,24 @@ def apply_prompt_shortcuts(text, seed=0, shortcut_db=None):
|
||||
if rng_seed == 0:
|
||||
rng_seed = int(md5(original.encode("utf-8")).hexdigest()[:12], 16)
|
||||
rng = random.Random(rng_seed)
|
||||
revolver = load_revolver()
|
||||
revolver_on = bool(revolver.get("enabled"))
|
||||
revolver_state = revolver.get("state")
|
||||
revolver_random = bool(revolver.get("random"))
|
||||
revolver_dirty = False
|
||||
applied = []
|
||||
removals_happened = False
|
||||
|
||||
def replace(match):
|
||||
nonlocal removals_happened
|
||||
for index, (trigger, _, replacements, name, sc_key) in enumerate(candidates):
|
||||
nonlocal removals_happened, revolver_dirty
|
||||
for index, (trigger, _, replacements, name, sc_key, db_key) in enumerate(candidates):
|
||||
if match.group(f"t{index}") is None:
|
||||
continue
|
||||
replacement = rng.choice(replacements)
|
||||
if revolver_on and len(replacements) > 1:
|
||||
replacement = revolver_next_replacement(revolver_state, db_key, replacements, revolver_random)
|
||||
revolver_dirty = True
|
||||
else:
|
||||
replacement = rng.choice(replacements)
|
||||
applied.append({"name": str(name), "trigger": trigger,
|
||||
"replacement": replacement, "refinement_key": sc_key})
|
||||
if not replacement:
|
||||
@@ -877,6 +987,11 @@ def apply_prompt_shortcuts(text, seed=0, shortcut_db=None):
|
||||
expanded = re.sub(combined, replace, original, flags=re.IGNORECASE | re.UNICODE)
|
||||
if removals_happened:
|
||||
expanded = _cleanup_removed_phrases(expanded)
|
||||
if revolver_dirty and revolver_commit:
|
||||
try:
|
||||
save_revolver(revolver)
|
||||
except OSError:
|
||||
pass
|
||||
return expanded, applied
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Shortcut revolver: no-repeat cycling through a shortcut's replacements.
|
||||
|
||||
Covers: sequential cycle order + wrap-around, random mode as a full no-repeat permutation
|
||||
per cycle, peek (previews) vs commit (generation) semantics on apply_prompt_shortcuts,
|
||||
fingerprint reset when the replacement set is edited, settings changes resetting state,
|
||||
and off-by-default behavior staying byte-identical to the seeded random pick.
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
|
||||
class _FakeRoutes:
|
||||
def get(self, _p):
|
||||
return lambda f: f
|
||||
|
||||
def post(self, _p):
|
||||
return lambda f: f
|
||||
|
||||
def put(self, _p):
|
||||
return lambda f: f
|
||||
|
||||
def delete(self, _p):
|
||||
return lambda f: f
|
||||
|
||||
def patch(self, _p):
|
||||
return lambda f: f
|
||||
|
||||
|
||||
sys.modules.setdefault("folder_paths", types.SimpleNamespace(models_dir="", get_user_directory=lambda: ""))
|
||||
sys.modules.setdefault(
|
||||
"server",
|
||||
types.SimpleNamespace(PromptServer=types.SimpleNamespace(instance=types.SimpleNamespace(routes=_FakeRoutes()))),
|
||||
)
|
||||
|
||||
import templates
|
||||
from templates import (
|
||||
apply_prompt_shortcuts,
|
||||
load_revolver,
|
||||
normalize_shortcut_item,
|
||||
revolver_next_replacement,
|
||||
save_revolver,
|
||||
set_revolver_settings,
|
||||
)
|
||||
|
||||
REPS = ["alpha one", "alpha two", "alpha three"]
|
||||
|
||||
|
||||
def _db():
|
||||
item = normalize_shortcut_item({
|
||||
"name": "Alpha", "triggers": ["aa"], "replacements": list(REPS), "refinement_key": "",
|
||||
})
|
||||
single = normalize_shortcut_item({
|
||||
"name": "Solo", "triggers": ["ss"], "replacements": ["only one"], "refinement_key": "",
|
||||
})
|
||||
return {"version": 1, "source": "test",
|
||||
"shortcuts": {templates.shortcut_key("Alpha"): item,
|
||||
templates.shortcut_key("Solo"): single}}
|
||||
|
||||
|
||||
def _use_tmp_store(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(templates, "template_store_dir", lambda: str(tmp_path))
|
||||
|
||||
|
||||
def test_sequential_cycle_then_wraps():
|
||||
state = {}
|
||||
draws = [revolver_next_replacement(state, "k", REPS, False) for _ in range(7)]
|
||||
assert draws == REPS + REPS + [REPS[0]]
|
||||
|
||||
|
||||
def test_random_cycle_is_full_permutation_without_repeats():
|
||||
state = {}
|
||||
for _ in range(20): # many cycles: each must be a permutation of the full set
|
||||
cycle = [revolver_next_replacement(state, "k", REPS, True) for _ in range(len(REPS))]
|
||||
assert sorted(cycle) == sorted(REPS)
|
||||
|
||||
|
||||
def test_replacement_edit_resets_cycle():
|
||||
state = {}
|
||||
revolver_next_replacement(state, "k", REPS, False)
|
||||
edited = REPS + ["alpha four"]
|
||||
assert revolver_next_replacement(state, "k", edited, False) == edited[0]
|
||||
|
||||
|
||||
def test_apply_shortcuts_peek_does_not_advance(monkeypatch, tmp_path):
|
||||
_use_tmp_store(monkeypatch, tmp_path)
|
||||
save_revolver({"enabled": True, "random": False, "state": {}})
|
||||
first, _ = apply_prompt_shortcuts("an aa here", seed=1, shortcut_db=_db())
|
||||
second, _ = apply_prompt_shortcuts("an aa here", seed=2, shortcut_db=_db())
|
||||
assert first == second == f"an {REPS[0]} here"
|
||||
|
||||
|
||||
def test_apply_shortcuts_commit_advances_and_wraps(monkeypatch, tmp_path):
|
||||
_use_tmp_store(monkeypatch, tmp_path)
|
||||
save_revolver({"enabled": True, "random": False, "state": {}})
|
||||
seen = [apply_prompt_shortcuts("an aa here", seed=1, shortcut_db=_db(),
|
||||
revolver_commit=True)[0] for _ in range(4)]
|
||||
assert seen == [f"an {r} here" for r in REPS + [REPS[0]]]
|
||||
|
||||
|
||||
def test_multiple_firings_in_one_prompt_consume_consecutive_chambers(monkeypatch, tmp_path):
|
||||
_use_tmp_store(monkeypatch, tmp_path)
|
||||
save_revolver({"enabled": True, "random": False, "state": {}})
|
||||
expanded, applied = apply_prompt_shortcuts("aa then aa", seed=1, shortcut_db=_db(),
|
||||
revolver_commit=True)
|
||||
assert expanded == f"{REPS[0]} then {REPS[1]}"
|
||||
assert [a["replacement"] for a in applied] == [REPS[0], REPS[1]]
|
||||
|
||||
|
||||
def test_single_replacement_shortcut_untouched_by_revolver(monkeypatch, tmp_path):
|
||||
_use_tmp_store(monkeypatch, tmp_path)
|
||||
save_revolver({"enabled": True, "random": False, "state": {}})
|
||||
expanded, _ = apply_prompt_shortcuts("a ss here", seed=1, shortcut_db=_db(),
|
||||
revolver_commit=True)
|
||||
assert expanded == "a only one here"
|
||||
assert load_revolver()["state"] == {} # nothing multi-replacement fired -> no state written
|
||||
|
||||
|
||||
def test_disabled_matches_seeded_random(monkeypatch, tmp_path):
|
||||
_use_tmp_store(monkeypatch, tmp_path)
|
||||
a, _ = apply_prompt_shortcuts("an aa here", seed=7, shortcut_db=_db())
|
||||
b, _ = apply_prompt_shortcuts("an aa here", seed=7, shortcut_db=_db(), revolver_commit=True)
|
||||
assert a == b # same seed -> same pick; no revolver file involved
|
||||
assert not (tmp_path / templates.REVOLVER_STORE_FILENAME).exists()
|
||||
|
||||
|
||||
def test_settings_change_resets_state(monkeypatch, tmp_path):
|
||||
_use_tmp_store(monkeypatch, tmp_path)
|
||||
save_revolver({"enabled": True, "random": False,
|
||||
"state": {"x": {"fp": "abc", "queue": [2]}}})
|
||||
settings = set_revolver_settings(random_order=True)
|
||||
assert settings == {"enabled": True, "random": True}
|
||||
assert load_revolver()["state"] == {}
|
||||
# No-op update keeps state.
|
||||
save_revolver({"enabled": True, "random": True,
|
||||
"state": {"x": {"fp": "abc", "queue": [2]}}})
|
||||
set_revolver_settings(enabled=True, random_order=True)
|
||||
assert load_revolver()["state"] != {}
|
||||
Reference in New Issue
Block a user