editor: prompt $variables + global-prompt templates (Composer)
Project-scoped $name variables resolved DEAD LAST in refine_v2 — after shortcut-expand and the transition split — so a value containing a comma or trigger word can never move a scene cut (order: shortcuts -> splits -> vars). Recursive (a value may reference other vars), cycle-safe (self-reference left literal, no infinite loop), undefined -> literal $name. - templates.resolve_variables: recursive, depth-guarded, cycle-safe resolver - refine_v2: resolve base prompt (single-scene) + per-scene texts (post-split); variables threaded via studio_settings.refiner (same channel as scenes) - builder/server/timeline: carry project.variables into the build; persist variables + prompt_templates on the project - Composer: templates dropdown + Save above the prompt; collapsible Variables editor (Add/Remove rows) below it, with live undeclared/cycle hint Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7e053e9d60
commit
e11a4e109f
+23
-1
@@ -11718,8 +11718,16 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
|
||||
steer_mode="relative", absolute_strength=0.6,
|
||||
_seed=None, _seed_source="fresh seed", _scene_seeds=None, _velocity_keys=None,
|
||||
batch_variants=1, guess_mode=False, guess_direction="up", guess_range=1.0,
|
||||
guess_freeze_seed=True, movie_editor_scene_ratings=None, scene_segments=None):
|
||||
guess_freeze_seed=True, movie_editor_scene_ratings=None, scene_segments=None,
|
||||
variables=None):
|
||||
seed = int(_seed) if _seed is not None else random.randint(1, 0xffffffffffffffff)
|
||||
# Project-scoped `$name` variables, resolved DEAD LAST (after shortcut-expand + split)
|
||||
# so they can never affect scene-cut detection. Empty/None = no-op.
|
||||
try:
|
||||
from .templates import resolve_variables as _resolve_variables
|
||||
except ImportError:
|
||||
from templates import resolve_variables as _resolve_variables
|
||||
_prompt_variables = variables or None
|
||||
encode_cache = {}
|
||||
linked_refinement_key = str(refinement_key_input or "").strip()
|
||||
if linked_refinement_key:
|
||||
@@ -12084,6 +12092,12 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
|
||||
prompt_to_encode = f"{base}, {', '.join(additions)}" if base else ", ".join(additions)
|
||||
repair_status = repair_status.replace("suggestion(s)", "phrase(s)").replace("suggested", "added")
|
||||
|
||||
# Resolve $name variables into the base prompt before encoding (single-scene path +
|
||||
# the modified_positive output). The multi-scene per-scene texts are resolved after the
|
||||
# split below; this keeps the order shortcuts -> split -> variables intact.
|
||||
if _prompt_variables:
|
||||
prompt_to_encode = _resolve_variables(prompt_to_encode, _prompt_variables)[0]
|
||||
|
||||
cond, meta, encode_status, conditioning_owner = self._v2_conditioning_source(
|
||||
clip,
|
||||
prompt_to_encode,
|
||||
@@ -12301,6 +12315,13 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
|
||||
segments = [(anchor_exp, None)] + \
|
||||
[(s.get("expanded", ""), s.get("effect")) for s in canon_scenes]
|
||||
split_scene_texts, split_scene_effects = self._v2_transition_scene_texts(segments)
|
||||
# Variables resolve here — AFTER shortcut-expand and the transition split —
|
||||
# so a variable whose value contains a comma or a trigger word can never
|
||||
# change the scene count. Per-scene texts are what the chain sampler encodes.
|
||||
if _prompt_variables and split_scene_texts:
|
||||
split_scene_texts = [
|
||||
_resolve_variables(t, _prompt_variables)[0] for t in split_scene_texts
|
||||
]
|
||||
scene_refinement_keys = [set(s.get("keys") or set()) for s in canon_scenes]
|
||||
current_scene_seeds = self._v2_scene_seed_values(seed, len(split_scene_texts), _scene_seeds)
|
||||
current_scene_seed_source = (
|
||||
@@ -14448,6 +14469,7 @@ class FunPackStudio:
|
||||
_scene_seeds=scene_seed_memory,
|
||||
movie_editor_scene_ratings=_me_scene_ratings,
|
||||
scene_segments=(rf.get("scenes") if isinstance(rf.get("scenes"), dict) else None),
|
||||
variables=(rf.get("variables") if isinstance(rf.get("variables"), (list, dict)) else None),
|
||||
)
|
||||
|
||||
# --- Conditioning Adjust ---
|
||||
|
||||
@@ -319,6 +319,10 @@ def build(object_info: dict, models_config: dict, params: dict, media: dict | No
|
||||
# (first run after the user clicks "Reset Studio session"); explicit so it's never
|
||||
# left on from a previous run.
|
||||
_rf["reset_session"] = bool(params.get("reset_session"))
|
||||
# Project `$name` variables — Studio resolves them dead-last (after split), per scene.
|
||||
_vars = params.get("variables")
|
||||
if isinstance(_vars, (list, dict)) and _vars:
|
||||
_rf["variables"] = _vars
|
||||
if _me_scene_ratings:
|
||||
_rf["movie_editor_scene_ratings"] = _me_scene_ratings
|
||||
_ss["refiner"] = _rf
|
||||
|
||||
@@ -566,6 +566,13 @@ class Project:
|
||||
# corresponding slot == "funpack"). Keys match ComfyUI widget/input names exactly.
|
||||
studio_inputs: dict = field(default_factory=dict)
|
||||
sampler_inputs: dict = field(default_factory=dict)
|
||||
# Prompt `$name` variables — a project-scoped find/replace layer resolved at generation AFTER
|
||||
# shortcut-expand and the transition split (so they never affect scene cuts). Ordered list of
|
||||
# {"name": str, "value": str}. Shortcuts may reference variables; resolution is recursive.
|
||||
variables: list = field(default_factory=list)
|
||||
# Saved global-prompt templates: [{"name": str, "prompt": str, "variables": [...]}]. Selecting
|
||||
# one in the Composer applies its prompt + variables; loaded with the project (no Load button).
|
||||
prompt_templates: list = field(default_factory=list)
|
||||
# Refinement key for this project's runs. Feeds the FunPackRefinementKeyLoader (Studio /
|
||||
# Chain Sampler / SaveRefinementLatent). "default" = the keyless/default store. Shortcuts
|
||||
# bound to a non-default key layer their own per-scene training on top of this.
|
||||
@@ -629,6 +636,8 @@ class Project:
|
||||
sampler_slot=str(d.get("sampler_slot", "funpack")),
|
||||
studio_inputs=dict(d.get("studio_inputs") or {}),
|
||||
sampler_inputs=dict(d.get("sampler_inputs") or {}),
|
||||
variables=list(d.get("variables") or []),
|
||||
prompt_templates=list(d.get("prompt_templates") or []),
|
||||
refinement_key=str(d.get("refinement_key") or "default"),
|
||||
keep_original_audio=bool(d.get("keep_original_audio", True)),
|
||||
audio_tracks=list(d.get("audio_tracks") or []),
|
||||
|
||||
@@ -187,6 +187,9 @@
|
||||
|| (pv && (pv.display_prompt != null ? pv.display_prompt : pv.combined_prompt)) || "";
|
||||
const val = gpDraft != null ? gpDraft : live;
|
||||
|
||||
// Templates: pick one to apply (prompt + variables), Save to snapshot the current state.
|
||||
wrap.append(composeTemplatesBar());
|
||||
|
||||
const titleRow = el("div", "compose-head");
|
||||
titleRow.append(el("div", "lib-form-title", "Global prompt"));
|
||||
const addSc = el("button", "btn ghost tiny", "+ Add shortcut");
|
||||
@@ -197,15 +200,140 @@
|
||||
|
||||
const ta = el("textarea", "lib-in compose-ta"); ta.rows = 14; ta.value = val;
|
||||
ta.placeholder = "Anchor, scene texts, and split markers — one combined montage prompt for generation.";
|
||||
ta.oninput = () => { gpDraft = ta.value; S.scheduleGlobalPromptApply(ta.value); };
|
||||
ta.oninput = () => { gpDraft = ta.value; S.scheduleGlobalPromptApply(ta.value); updateVarHint(); };
|
||||
wrap.append(ta);
|
||||
wrap.append(el("div", "insp-hint",
|
||||
"Edits apply automatically and stay in sync with the timeline's per-scene prompts. Shortcuts expand at generation time."));
|
||||
composeTextarea = ta;
|
||||
if (window.ShortcutAutocomplete) window.ShortcutAutocomplete.attach(ta);
|
||||
|
||||
// Variables ($name) — collapsible, same toggle pattern as Settings ▸ Built-in pipeline.
|
||||
wrap.append(composeVariables());
|
||||
return wrap;
|
||||
}
|
||||
let composeTextarea = null;
|
||||
let varsOpen = false; // Variables panel expanded?
|
||||
let varHintEl = null; // undeclared / cycle hint line under the prompt
|
||||
|
||||
// ── templates bar (above the global prompt) ─────────────────────────────────────
|
||||
function composeTemplatesBar() {
|
||||
const bar = el("div", "compose-templates");
|
||||
const sel = el("select", "lib-in compose-tpl-select");
|
||||
const ph = new Option("Templates…", ""); ph.disabled = true; ph.selected = true; sel.append(ph);
|
||||
(S.promptTemplates() || []).forEach((t) => {
|
||||
const preview = String(t.prompt || "").replace(/\s+/g, " ").slice(0, 48);
|
||||
const o = new Option(`${t.name}${preview ? " — " + preview : ""}`, t.name);
|
||||
sel.append(o);
|
||||
});
|
||||
sel.title = "Apply a saved global prompt + its variables";
|
||||
sel.onchange = () => { const n = sel.value; if (n) S.applyPromptTemplate(n).then(render); };
|
||||
bar.append(sel);
|
||||
const save = el("button", "btn ghost tiny", "Save");
|
||||
save.title = "Save the current global prompt + variables as a template";
|
||||
save.onclick = () => {
|
||||
const name = (window.prompt("Template name:") || "").trim();
|
||||
if (!name) return;
|
||||
const txt = composeTextarea ? composeTextarea.value : (S.get().project.global_prompt || "");
|
||||
S.savePromptTemplate(name, txt);
|
||||
render();
|
||||
};
|
||||
bar.append(save);
|
||||
return bar;
|
||||
}
|
||||
|
||||
// ── variables panel (below the global prompt) ───────────────────────────────────
|
||||
// The list IS the editor: editable name/value rows + Add/Remove. Resolution happens at
|
||||
// generation, after shortcuts + split (Studio), so nothing here touches the timeline.
|
||||
function composeVariables() {
|
||||
const box = el("div", "compose-vars");
|
||||
const toggle = el("button", "btn ghost tiny composer-var-toggle",
|
||||
(varsOpen ? "▾ " : "▸ ") + "Variables");
|
||||
toggle.title = "Project $name variables — substituted into the prompt at generation";
|
||||
toggle.onclick = () => { varsOpen = !varsOpen; render(); };
|
||||
box.append(toggle);
|
||||
|
||||
varHintEl = el("div", "insp-hint compose-var-hint");
|
||||
box.append(varHintEl);
|
||||
|
||||
if (varsOpen) {
|
||||
const list = el("div", "compose-var-list");
|
||||
// Local working copy; committed (persisted) on every edit, re-rendered only on add/remove.
|
||||
const vars = (S.projectVariables() || []).map((v) => ({
|
||||
name: String((v && v.name) || ""), value: String((v && v.value != null) ? v.value : ""),
|
||||
}));
|
||||
const commit = () => S.setProjectVariables(vars);
|
||||
vars.forEach((v, i) => {
|
||||
const row = el("div", "compose-var-row");
|
||||
row.append(el("span", "compose-var-dollar", "$"));
|
||||
const nm = el("input", "lib-in compose-var-name");
|
||||
nm.value = v.name; nm.placeholder = "name";
|
||||
nm.oninput = () => { v.name = nm.value.replace(/^\$+/, ""); commit(); updateVarHint(); };
|
||||
row.append(nm);
|
||||
row.append(el("span", "compose-var-eq", "="));
|
||||
const vv = el("input", "lib-in compose-var-val");
|
||||
vv.value = v.value; vv.placeholder = "value (may reference $other)";
|
||||
vv.oninput = () => { v.value = vv.value; commit(); updateVarHint(); };
|
||||
row.append(vv);
|
||||
const rm = el("button", "btn ghost tiny", "✕");
|
||||
rm.title = "Remove variable";
|
||||
rm.onclick = () => { vars.splice(i, 1); commit(); render(); };
|
||||
row.append(rm);
|
||||
list.append(row);
|
||||
});
|
||||
const add = el("button", "btn ghost tiny", "+ Add variable");
|
||||
add.onclick = () => { vars.push({ name: "", value: "" }); commit(); render(); };
|
||||
list.append(add);
|
||||
box.append(list);
|
||||
}
|
||||
// Defer so composeTextarea is assigned before the first scan.
|
||||
setTimeout(updateVarHint, 0);
|
||||
return box;
|
||||
}
|
||||
|
||||
// Detect $name cycles among the declared variables (the "yell"); mirrors the safe-degrade
|
||||
// guard in templates.resolve_variables but reports the loop instead of silently leaving it.
|
||||
function detectVarCycles() {
|
||||
const map = {};
|
||||
(S.projectVariables() || []).forEach((v) => {
|
||||
const k = String((v && v.name) || "").replace(/^\$+/, "").trim();
|
||||
if (k) map[k] = String((v && v.value) || "");
|
||||
});
|
||||
const refs = (n) => (String(map[n] || "").match(/\$([A-Za-z_][A-Za-z0-9_]*)/g) || [])
|
||||
.map((m) => m.slice(1)).filter((r) => r in map);
|
||||
const color = {}, path = [], cycles = [];
|
||||
const dfs = (n) => {
|
||||
color[n] = 1; path.push(n);
|
||||
for (const r of refs(n)) {
|
||||
if (color[r] === 1) cycles.push(path.slice(path.indexOf(r)).concat(r));
|
||||
else if (!color[r]) dfs(r);
|
||||
}
|
||||
path.pop(); color[n] = 2;
|
||||
};
|
||||
Object.keys(map).forEach((n) => { if (!color[n]) dfs(n); });
|
||||
return cycles;
|
||||
}
|
||||
|
||||
// Live hint under the prompt: cycle warning takes priority, else list $vars used but not declared.
|
||||
function updateVarHint() {
|
||||
if (!varHintEl) return;
|
||||
const declared = new Set((S.projectVariables() || [])
|
||||
.map((v) => String((v && v.name) || "").replace(/^\$+/, "").trim()).filter(Boolean));
|
||||
const txt = composeTextarea ? composeTextarea.value : "";
|
||||
const used = new Set((txt.match(/\$([A-Za-z_][A-Za-z0-9_]*)/g) || []).map((m) => m.slice(1)));
|
||||
const undeclared = [...used].filter((n) => !declared.has(n));
|
||||
const cycles = detectVarCycles();
|
||||
let msg = "";
|
||||
if (cycles.length) {
|
||||
msg = "⚠ Variable loop: " + cycles[0].map((n) => "$" + n).join(" → ")
|
||||
+ " — references itself. Rework it; it won't expand.";
|
||||
} else if (undeclared.length) {
|
||||
msg = "Referencing " + undeclared.map((n) => "$" + n).join(", ")
|
||||
+ " — never declared, passed as plain text.";
|
||||
}
|
||||
varHintEl.textContent = msg;
|
||||
varHintEl.classList.toggle("compose-var-warn", !!cycles.length);
|
||||
varHintEl.style.display = msg ? "" : "none";
|
||||
}
|
||||
|
||||
// Insert text at the compose textarea's caret (or append), then re-sync the timeline.
|
||||
function insertIntoCompose(text) {
|
||||
|
||||
@@ -898,6 +898,43 @@
|
||||
scheduleSaveSilent();
|
||||
}
|
||||
|
||||
// ── prompt variables ($name) + global-prompt templates ──────────────────────
|
||||
function projectVariables() { return (state.project && state.project.variables) || []; }
|
||||
function setProjectVariables(list) {
|
||||
if (!state.project) return;
|
||||
// Normalize: strip a leading $, drop fully-empty rows on persist (kept while editing in the UI).
|
||||
patchProjectQuiet({ variables: (Array.isArray(list) ? list : []).map((v) => ({
|
||||
name: String((v && v.name) || "").replace(/^\$+/, "").trim(),
|
||||
value: String((v && v.value != null) ? v.value : ""),
|
||||
})) });
|
||||
}
|
||||
function promptTemplates() { return (state.project && state.project.prompt_templates) || []; }
|
||||
function savePromptTemplate(name, promptText) {
|
||||
if (!state.project) return;
|
||||
const nm = String(name || "").trim();
|
||||
if (!nm) return;
|
||||
const snapshot = {
|
||||
name: nm,
|
||||
prompt: String(promptText != null ? promptText : (state.project.global_prompt || "")),
|
||||
variables: JSON.parse(JSON.stringify(state.project.variables || [])),
|
||||
};
|
||||
const tpls = (state.project.prompt_templates || []).filter((t) => t && t.name !== nm);
|
||||
tpls.push(snapshot);
|
||||
patchProject({ prompt_templates: tpls });
|
||||
}
|
||||
function deletePromptTemplate(name) {
|
||||
if (!state.project) return;
|
||||
patchProject({ prompt_templates: (state.project.prompt_templates || []).filter((t) => t && t.name !== name) });
|
||||
}
|
||||
async function applyPromptTemplate(name) {
|
||||
if (!state.project) return;
|
||||
const tpl = (state.project.prompt_templates || []).find((t) => t && t.name === name);
|
||||
if (!tpl) return;
|
||||
// Restore the saved variables first, then distribute the prompt (re-splits the timeline).
|
||||
patchProjectQuiet({ variables: JSON.parse(JSON.stringify(tpl.variables || [])) });
|
||||
await applyGlobalPromptQuiet(tpl.prompt || "");
|
||||
}
|
||||
|
||||
function scene(id) { return state.project?.scenes.find((s) => s.id === id) || null; }
|
||||
|
||||
function genUnitId(sc) { return (sc && sc.gen_unit_id) || (sc && sc.id) || ""; }
|
||||
@@ -3867,6 +3904,7 @@
|
||||
resizeScene, setSceneGapAfter, splitScene, snapFrames, snapFramesFloor, snapFramesCeil, sceneEffFrames, sceneEffFps, setSourceTrim, trimSceneLeft, slipScene,
|
||||
applyEnginePreset, ENGINE_PRESETS, undo, redo,
|
||||
refreshPreview, syncFromPreview, applyGlobalPromptQuiet, scheduleGlobalPromptApply, buildGlobalPromptFromTimeline, syncGlobalPromptFromTimeline, generate, generateMontage, generateSelected, selectedSceneCount, renderFinal, exportSelected, saveSelectedToMediaBin, clipSaveableToMediaBin, interrupt, loadModels, loadImageTargets, setModelInput, setModelLink, clearNotice,
|
||||
projectVariables, setProjectVariables, promptTemplates, savePromptTemplate, deletePromptTemplate, applyPromptTemplate,
|
||||
setConditioningSlot, setSamplerSlot, setSamplerInput, setSamplerInputNow, unsetSamplerInput, setStudioInput, setStudioInputNow,
|
||||
loadMedia, uploadMedia, deleteMedia, deleteMediaMany, renameMedia, previewMedia, clearMediaPreview, assignMediaToScene, exportMediaAsset,
|
||||
loadShortcuts, saveShortcut, deleteShortcut, importShortcuts, clearShortcuts, addCategory,
|
||||
|
||||
@@ -1160,6 +1160,24 @@ body.tl-spacing { cursor: grabbing; user-select: none; }
|
||||
.composer-preview { margin-top: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; }
|
||||
.composer-preview:empty { display: none; }
|
||||
|
||||
/* Global-prompt templates bar (above the prompt) */
|
||||
.compose-templates { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
.compose-templates .compose-tpl-select { flex: 1; min-width: 0; }
|
||||
.compose-templates .btn { flex: 0 0 auto; }
|
||||
|
||||
/* Variables panel (below the prompt) */
|
||||
.compose-vars { margin-top: 8px; }
|
||||
.composer-var-toggle { padding-left: 2px; }
|
||||
.compose-var-hint { margin-top: 4px; }
|
||||
.compose-var-hint.compose-var-warn { color: var(--amber); }
|
||||
.compose-var-list { margin-top: 6px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.compose-var-row { display: flex; gap: 6px; align-items: center; }
|
||||
.compose-var-dollar { color: var(--amber); font-family: var(--font-mono); opacity: 0.8; }
|
||||
.compose-var-eq { color: var(--muted); }
|
||||
.compose-var-row .compose-var-name { flex: 0 0 30%; min-width: 0; }
|
||||
.compose-var-row .compose-var-val { flex: 1; min-width: 0; }
|
||||
.compose-var-row .btn { flex: 0 0 auto; }
|
||||
|
||||
/* config validation banner */
|
||||
/* Pipeline requirements panel */
|
||||
.req-panel { margin-bottom: 18px; }
|
||||
|
||||
@@ -1523,6 +1523,7 @@ if web is not None and PromptServer is not None:
|
||||
# generation prompt stays clean — no injected `scene N` delimiters.
|
||||
"scene_segments": build_generation_scene_segments(target),
|
||||
"sampler_inputs": sampler_inputs,
|
||||
"variables": list(target.variables or []),
|
||||
"reset_session": reset_session,
|
||||
"refinement_key": (target.refinement_key or "default"),
|
||||
}, media=(media_pack or {}).get("primary") if media_pack else None)
|
||||
|
||||
@@ -770,6 +770,63 @@ def _cleanup_removed_phrases(text):
|
||||
return text
|
||||
|
||||
|
||||
# ── prompt variables ($name) ────────────────────────────────────────────────────
|
||||
# Variables are a project-scoped find/replace layer resolved DEAD LAST — after shortcut
|
||||
# expansion AND after the transition split — so they can never create or move a scene cut.
|
||||
# A `$name` token is replaced with the variable's text; the text may itself reference other
|
||||
# variables (recursive), undefined names are left as literal `$name`, and a variable that
|
||||
# references itself anywhere in its own chain is a cycle (left literal, never expanded).
|
||||
_VARIABLE_TOKEN = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")
|
||||
_VARIABLE_MAX_DEPTH = 64
|
||||
|
||||
|
||||
def _normalize_variables(variables):
|
||||
"""Accept either {name: text} or [{"name":, "value":}] and return an ordered-safe dict.
|
||||
Names are stripped of a leading `$` and surrounding whitespace; later entries win."""
|
||||
out = {}
|
||||
if isinstance(variables, dict):
|
||||
items = list(variables.items())
|
||||
elif isinstance(variables, list):
|
||||
items = [
|
||||
(v.get("name"), v.get("value", v.get("content", v.get("text", ""))))
|
||||
for v in variables if isinstance(v, dict)
|
||||
]
|
||||
else:
|
||||
items = []
|
||||
for name, val in items:
|
||||
key = str(name if name is not None else "").lstrip("$").strip()
|
||||
if key:
|
||||
out[key] = str(val if val is not None else "")
|
||||
return out
|
||||
|
||||
|
||||
def resolve_variables(text, variables):
|
||||
"""Substitute `$name` tokens in `text` from `variables`. Recursive, cycle-safe (a name in
|
||||
its own expansion chain is left literal), depth-guarded, undefined -> literal `$name`.
|
||||
Returns (resolved_text, sorted list of undefined names referenced)."""
|
||||
var_map = _normalize_variables(variables)
|
||||
if not var_map:
|
||||
return str(text or ""), []
|
||||
undefined = set()
|
||||
|
||||
def _expand(s, stack, depth):
|
||||
if depth > _VARIABLE_MAX_DEPTH:
|
||||
return s
|
||||
|
||||
def _repl(m):
|
||||
name = m.group(1)
|
||||
if name not in var_map:
|
||||
undefined.add(name)
|
||||
return m.group(0) # undefined -> leave literal
|
||||
if name in stack:
|
||||
return m.group(0) # cycle -> leave literal, do not recurse
|
||||
return _expand(var_map[name], stack | {name}, depth + 1)
|
||||
|
||||
return _VARIABLE_TOKEN.sub(_repl, s)
|
||||
|
||||
return _expand(str(text or ""), frozenset(), 0), sorted(undefined)
|
||||
|
||||
|
||||
def apply_prompt_shortcuts(text, seed=0, shortcut_db=None):
|
||||
original = str(text or "")
|
||||
if not original:
|
||||
|
||||
Reference in New Issue
Block a user