editor: pass scene boundaries structurally — remove 'scene N' injection entirely
Root cause the user pushed on: the editor flattened all scenes into ONE prompt string and Studio re-derived boundaries by parsing text, so a carry scene with no transition needed an injected 'scene N' delimiter. The editor already KNOWS every boundary (each generative unit is a scene) — it never needed to smuggle markers into the prompt. - timeline.build_combined_prompt: no longer injects anything (verbatim anchor + scene texts). New build_generation_scene_segments(project) lists the boundaries the editor already has (each unit's raw text, prefixed with its REAL transition so effects survive; carry = plain text). - conditioning.split_scenes_from_segments: canonical split from an explicit scene list (reuses split_scenes per item for expansion/keys/effect, folds the anchor), same output shape as split_scenes. refine_v2 gains scene_segments and uses it when present (count-preserving: a text-less i2v scene keeps its slot, no anchor shift); falls back to text-splitting only for raw node-pack use. - server/builder: editor sends scene_segments -> studio_settings.refiner.scenes. Net: the generation prompt Studio encodes is now exactly what the user typed — no 'scene N' anywhere, in the prompt or the conditioning. Tests updated to assert the verbatim prompt + structural segments; new tests for split_scenes_from_segments (boundaries, empty-scene preservation, anchor/key fold). Fallback path unchanged (stash-verified zero regression). NOTE: montage generation needs GPU verification. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8dc163bca6
commit
2c9755f6c8
+77
-7
@@ -960,6 +960,47 @@ def split_scenes(prompt, placement="start"):
|
||||
return {"anchor": anchor["raw"].strip(), "anchor_expanded": anchor["exp"].strip(), "scenes": scenes}
|
||||
|
||||
|
||||
def split_scenes_from_segments(anchor, segments, placement="start"):
|
||||
"""Canonical scene structure from EXPLICIT per-scene boundaries supplied by the caller (the
|
||||
Movie Editor's own scene list) instead of re-deriving boundaries from in-text delimiters.
|
||||
|
||||
The editor already KNOWS where each scene starts (they are separate Scene objects), so it
|
||||
hands us that list directly — no `scene N` marker ever has to be injected into the prompt.
|
||||
`anchor` is the shared prefix text; `segments` is one raw text per editor scene (each may
|
||||
carry its own leading transition trigger, which supplies that scene's effect). Each segment
|
||||
becomes EXACTLY one scene; its shortcut expansion + refinement keys + effect come from
|
||||
split_scenes (reused per item, where there is no boundary ambiguity), and the anchor keys
|
||||
fold into every scene — identical semantics to split_scenes(), same output shape."""
|
||||
def _flatten(split_out):
|
||||
# Collapse a split_scenes() result to one scene: any leading anchor text + every
|
||||
# sub-scene joined; first non-None effect wins; keys unioned.
|
||||
raw_parts, exp_parts, keys, effect = [], [], set(), None
|
||||
ar = (split_out.get("anchor") or "").strip()
|
||||
ae = (split_out.get("anchor_expanded") or "").strip()
|
||||
if ar:
|
||||
raw_parts.append(ar)
|
||||
if ae:
|
||||
exp_parts.append(ae)
|
||||
for s in (split_out.get("scenes") or []):
|
||||
r = (s.get("raw") or "").strip()
|
||||
e = (s.get("expanded") or "").strip()
|
||||
if r:
|
||||
raw_parts.append(r)
|
||||
if e:
|
||||
exp_parts.append(e)
|
||||
keys |= set(s.get("keys") or set())
|
||||
if effect is None and s.get("effect"):
|
||||
effect = s.get("effect")
|
||||
return " ".join(raw_parts).strip(), " ".join(exp_parts).strip(), keys, effect
|
||||
|
||||
a_raw, a_exp, anchor_keys, _ = _flatten(split_scenes(str(anchor or ""), placement=placement))
|
||||
scenes = []
|
||||
for i, seg in enumerate(segments or []):
|
||||
r, e, k, eff = _flatten(split_scenes(str(seg or ""), placement=placement))
|
||||
scenes.append({"index": i, "raw": r, "expanded": e, "keys": anchor_keys | k, "effect": eff})
|
||||
return {"anchor": a_raw, "anchor_expanded": a_exp, "scenes": scenes}
|
||||
|
||||
|
||||
def refinement_key_pool_for(prompt):
|
||||
"""Every non-default refinement key the prompt activates (anchor + all scenes) — the exact
|
||||
set a session reset wipes. Derived from the one canonical split."""
|
||||
@@ -11672,7 +11713,7 @@ 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):
|
||||
guess_freeze_seed=True, movie_editor_scene_ratings=None, scene_segments=None):
|
||||
seed = int(_seed) if _seed is not None else random.randint(1, 0xffffffffffffffff)
|
||||
encode_cache = {}
|
||||
linked_refinement_key = str(refinement_key_input or "").strip()
|
||||
@@ -12211,13 +12252,41 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
|
||||
current_scene_seed_source = str(_seed_source or "fresh seed")
|
||||
if split_by_transitions:
|
||||
try:
|
||||
canonical = split_scenes(_raw_positive_prompt, placement=split_transition_placement)
|
||||
canon_scenes = [s for s in (canonical.get("scenes", []) or [])
|
||||
if (s.get("expanded") or "").strip()]
|
||||
# Prefer EXPLICIT scene boundaries handed in by the caller (the Movie Editor
|
||||
# passes its own scene list). Falls back to text-splitting the prompt only for
|
||||
# raw node-pack use where there is no structured scene list. Either way no
|
||||
# `scene N` delimiter is ever needed in the prompt.
|
||||
structural = isinstance(scene_segments, dict) and scene_segments.get("scenes") is not None
|
||||
if structural:
|
||||
canonical = split_scenes_from_segments(
|
||||
scene_segments.get("anchor", ""), scene_segments.get("scenes", []),
|
||||
placement=split_transition_placement)
|
||||
# The editor's scene list is authoritative on COUNT — one conditioning scene
|
||||
# per editor scene. Keep every scene, including a text-less i2v scene (it stays
|
||||
# at its index as an anchor-only conditioning); dropping it would shift anchors.
|
||||
canon_scenes = list(canonical.get("scenes", []) or [])
|
||||
else:
|
||||
canonical = split_scenes(_raw_positive_prompt, placement=split_transition_placement)
|
||||
canon_scenes = [s for s in (canonical.get("scenes", []) or [])
|
||||
if (s.get("expanded") or "").strip()]
|
||||
if len(canon_scenes) >= 1:
|
||||
segments = [(canonical.get("anchor_expanded", ""), None)] + \
|
||||
[(s.get("expanded", ""), s.get("effect")) for s in canon_scenes]
|
||||
split_scene_texts, split_scene_effects = self._v2_transition_scene_texts(segments)
|
||||
anchor_exp = (canonical.get("anchor_expanded", "") or "").strip()
|
||||
if structural:
|
||||
# Fold the anchor into each scene WITHOUT dropping empties (mirrors
|
||||
# _v2_transition_scene_texts but count-preserving for the structural path).
|
||||
split_scene_texts, split_scene_effects = [], []
|
||||
for s in canon_scenes:
|
||||
seg = (s.get("expanded") or "").strip()
|
||||
if anchor_exp and seg:
|
||||
sep = " " if anchor_exp[-1] in ".!?," else ", "
|
||||
split_scene_texts.append(anchor_exp + sep + seg)
|
||||
else:
|
||||
split_scene_texts.append(anchor_exp or seg)
|
||||
split_scene_effects.append(s.get("effect"))
|
||||
else:
|
||||
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)
|
||||
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 = (
|
||||
@@ -14364,6 +14433,7 @@ class FunPackStudio:
|
||||
_seed_source=seed_source,
|
||||
_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),
|
||||
)
|
||||
|
||||
# --- Conditioning Adjust ---
|
||||
|
||||
@@ -299,6 +299,11 @@ def build(object_info: dict, models_config: dict, params: dict, media: dict | No
|
||||
_ss = {}
|
||||
_rf = _ss.get("refiner") if isinstance(_ss.get("refiner"), dict) else {}
|
||||
_rf["split_by_transitions"] = True
|
||||
# Explicit scene boundaries from the editor (no `scene N` markers in the prompt).
|
||||
# Studio (split_scenes_from_segments) uses this list directly to split scenes.
|
||||
_seg = params.get("scene_segments")
|
||||
if isinstance(_seg, dict) and _seg.get("scenes"):
|
||||
_rf["scenes"] = _seg
|
||||
# reset_session also lives in studio_settings.refiner — armed per-run by the editor
|
||||
# (first run after the user clicks "Reset Studio session"); explicit so it's never
|
||||
# left on from a previous run.
|
||||
|
||||
@@ -694,42 +694,54 @@ def _leading_trigger_re():
|
||||
|
||||
def build_combined_prompt(project: Project, include_excluded: bool = False,
|
||||
for_generation: bool = False) -> str:
|
||||
"""The master prompt = anchor + scene texts.
|
||||
"""The master prompt = anchor + scene texts, joined VERBATIM.
|
||||
|
||||
Display (for_generation=False): a verbatim join — transition triggers/shortcuts stay
|
||||
in-text exactly as typed, so it round-trips with the global prompt.
|
||||
Transition triggers/shortcuts stay in-text exactly as typed, so it round-trips with the
|
||||
global prompt. NOTHING is injected — not even for generation. Scene boundaries for
|
||||
generation are passed STRUCTURALLY via build_generation_scene_segments() (Studio uses that
|
||||
list directly), so we never have to smuggle a `scene N` delimiter into the prompt. The
|
||||
`for_generation` flag is kept for call-site compatibility but no longer changes the output.
|
||||
"""
|
||||
scenes = [
|
||||
s for s in project.scenes
|
||||
if (include_excluded or not s.excluded) and is_generative_scene(s)
|
||||
]
|
||||
parts: list[str] = []
|
||||
anchor = effective_anchor(project)
|
||||
if anchor:
|
||||
parts.append(anchor)
|
||||
for _uid, group in group_generative_units(scenes):
|
||||
text = (gen_unit_root(group).text or "").strip()
|
||||
if text:
|
||||
parts.append(text)
|
||||
return " ".join(p for p in parts if p).strip()
|
||||
|
||||
Generation (for_generation=True): Studio must split into EXACTLY one scene per timeline
|
||||
scene, but a `carry` scene's text has no leading transition, so Studio would merge it
|
||||
with the previous one (only the first scene gets generated). So before any scene whose
|
||||
text doesn't already begin with a transition trigger we inject a separator (the seam's
|
||||
transition_to_next, else the generic 'scene N' label) to force the split. This affects
|
||||
only what's sent to Studio, never the displayed global prompt.
|
||||
|
||||
def build_generation_scene_segments(project: Project, include_excluded: bool = False) -> dict:
|
||||
"""The run's scene boundaries, handed to Studio structurally so no `scene N` marker is ever
|
||||
injected into the prompt. The editor already knows every boundary (each generative unit is a
|
||||
scene), so we just list them.
|
||||
|
||||
Returns {"anchor": <shared prefix text>, "scenes": [<raw unit text>, ...]} where each unit's
|
||||
text is prefixed with its REAL leading transition (intro_transition for the first unit, the
|
||||
previous unit's transition_to_next otherwise) when one exists — so Studio's per-scene split
|
||||
still picks up the transition's visual effect. A `carry` unit with no transition is just its
|
||||
text (effect = none). Studio (split_scenes_from_segments) expands shortcuts + extracts keys
|
||||
per item; one unit = exactly one scene.
|
||||
"""
|
||||
scenes = [
|
||||
s for s in project.scenes
|
||||
if (include_excluded or not s.excluded) and is_generative_scene(s)
|
||||
]
|
||||
units = group_generative_units(scenes)
|
||||
parts: list[str] = []
|
||||
anchor = effective_anchor(project)
|
||||
if anchor:
|
||||
parts.append(anchor)
|
||||
# Only inject separators for a MULTI-unit run: editorial subclips count as one unit.
|
||||
inject = for_generation and len(units) > 1
|
||||
trig_re = _leading_trigger_re() if inject else None
|
||||
out: list[str] = []
|
||||
for i, (_uid, group) in enumerate(units):
|
||||
root = gen_unit_root(group)
|
||||
text = (root.text or "").strip()
|
||||
if inject and not (text and trig_re and trig_re.match(text)):
|
||||
prev_root = gen_unit_root(units[i - 1][1]) if i > 0 else None
|
||||
marker = (project.intro_transition or "").strip() if i == 0 else ((prev_root.transition_to_next if prev_root else "") or "").strip()
|
||||
if not marker:
|
||||
marker = f"scene {i + 1}"
|
||||
parts.append(marker)
|
||||
if text:
|
||||
parts.append(text)
|
||||
return " ".join(p for p in parts if p).strip()
|
||||
text = (gen_unit_root(group).text or "").strip()
|
||||
prev_root = gen_unit_root(units[i - 1][1]) if i > 0 else None
|
||||
trans = ((project.intro_transition or "").strip() if i == 0
|
||||
else ((prev_root.transition_to_next if prev_root else "") or "").strip())
|
||||
out.append((trans + " " + text).strip() if trans else text)
|
||||
return {"anchor": effective_anchor(project), "scenes": out}
|
||||
|
||||
|
||||
def _scene_run_fingerprint(sc: Scene) -> str:
|
||||
|
||||
@@ -25,6 +25,7 @@ from .backend.nle_overlays import build_overlay_video_filter, prepare_overlay_ex
|
||||
from .backend.timeline import (
|
||||
Project,
|
||||
build_combined_prompt,
|
||||
build_generation_scene_segments,
|
||||
collapse_generative_units,
|
||||
continuity_media_refs,
|
||||
effective_negative_prompt,
|
||||
@@ -1507,6 +1508,9 @@ if web is not None and PromptServer is not None:
|
||||
"studio_inputs": _run_studio_inputs(
|
||||
target, active_scenes, prompt_changed=prompt_changed, models=models_cfg,
|
||||
),
|
||||
# Scene boundaries handed to Studio structurally (the editor knows them) so the
|
||||
# generation prompt stays clean — no injected `scene N` delimiters.
|
||||
"scene_segments": build_generation_scene_segments(target),
|
||||
"sampler_inputs": sampler_inputs,
|
||||
"reset_session": reset_session,
|
||||
"refinement_key": (target.refinement_key or "default"),
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
from pathlib import Path
|
||||
|
||||
from movie_editor.backend import bridge
|
||||
from movie_editor.backend.timeline import Project, Scene
|
||||
from movie_editor.backend.timeline import (
|
||||
Project, Scene, build_combined_prompt, build_generation_scene_segments,
|
||||
)
|
||||
from movie_editor.server import (
|
||||
_build_render_filter,
|
||||
_clip_bytes_for_media,
|
||||
@@ -185,6 +187,24 @@ def test_studio_inputs_skips_custom_conditioning():
|
||||
assert _run_studio_inputs(p, p.scenes) == {}
|
||||
|
||||
|
||||
def test_generation_prompt_has_no_injected_scene_markers():
|
||||
# The generation prompt must be the verbatim text the user wrote — never an injected
|
||||
# 'scene N' delimiter. Boundaries travel structurally via build_generation_scene_segments.
|
||||
p = _project(scenes=[
|
||||
{"id": "s1", "text": "a red car drives down a street", "source": {"type": "image", "media_ref": "img1"}},
|
||||
{"id": "s2", "text": "the car stops at a cafe", "source": {"type": "carry"}},
|
||||
{"id": "s3", "text": "a woman exits the cafe", "source": {"type": "carry"}},
|
||||
])
|
||||
gen = build_combined_prompt(p, for_generation=True)
|
||||
assert "scene 1" not in gen.lower()
|
||||
assert "scene 2" not in gen.lower()
|
||||
assert gen == "a red car drives down a street the car stops at a cafe a woman exits the cafe"
|
||||
# boundaries are carried structurally instead — one entry per generative scene
|
||||
seg = build_generation_scene_segments(p)
|
||||
assert seg["scenes"] == [
|
||||
"a red car drives down a street", "the car stops at a cafe", "a woman exits the cafe"]
|
||||
|
||||
|
||||
def test_score_slider_sampler_inputs_pass_through():
|
||||
# FreeSliders score_slider is a plain sampler knob (like embed_guidance): the editor
|
||||
# sets it in sampler_inputs and _run_sampler_inputs must carry it through untouched
|
||||
|
||||
@@ -16,6 +16,7 @@ from movie_editor.backend.timeline import ( # noqa: E402
|
||||
Scene,
|
||||
STUDIO_DEFAULT_GUIDE,
|
||||
build_combined_prompt,
|
||||
build_generation_scene_segments,
|
||||
build_auto_continuity_guides,
|
||||
build_mixed_solo_guides_payload,
|
||||
build_scene_anchors_payload,
|
||||
@@ -40,7 +41,7 @@ def _project(**kw):
|
||||
return p
|
||||
|
||||
|
||||
def test_anchor_plus_two_scenes_emits_separators():
|
||||
def test_generation_prompt_is_verbatim_no_injected_separators():
|
||||
p = _project(
|
||||
anchor="red-haired heroine",
|
||||
intro_transition="cut to",
|
||||
@@ -50,15 +51,16 @@ def test_anchor_plus_two_scenes_emits_separators():
|
||||
],
|
||||
)
|
||||
prompt = build_combined_prompt(p, for_generation=True)
|
||||
# anchor first, intro marker before scene 0, scene marker before scene 1
|
||||
assert prompt.startswith("red-haired heroine")
|
||||
assert "cut to" in prompt
|
||||
assert "blur" in prompt
|
||||
assert prompt.index("cut to") < prompt.index("flying")
|
||||
assert prompt.index("blur") < prompt.index("landing")
|
||||
# anchor + scene texts, VERBATIM — nothing injected.
|
||||
assert prompt == "red-haired heroine flying over the city landing on a rooftop"
|
||||
assert "scene 1" not in prompt and "scene 2" not in prompt
|
||||
# boundaries + the real transitions travel STRUCTURALLY instead of in the prompt
|
||||
seg = build_generation_scene_segments(p)
|
||||
assert seg["anchor"] == "red-haired heroine"
|
||||
assert seg["scenes"] == ["cut to flying over the city", "blur landing on a rooftop"]
|
||||
|
||||
|
||||
def test_generation_prompt_fingerprint_includes_split_markers():
|
||||
def test_generation_prompt_fingerprint_has_no_split_markers():
|
||||
p = _project(
|
||||
anchor="red-haired heroine",
|
||||
intro_transition="cut to",
|
||||
@@ -68,9 +70,9 @@ def test_generation_prompt_fingerprint_includes_split_markers():
|
||||
],
|
||||
)
|
||||
fp = generation_prompt_fingerprint(p, p)
|
||||
assert "cut to" in fp["generation_prompt"]
|
||||
assert "blur" in fp["generation_prompt"]
|
||||
assert fp["display_prompt"] != fp["generation_prompt"]
|
||||
# generation prompt is now verbatim (boundaries are structural) — no injected separators
|
||||
assert "scene 1" not in fp["generation_prompt"]
|
||||
assert fp["generation_prompt"] == "red-haired heroine flying over the city landing on a rooftop"
|
||||
|
||||
|
||||
def test_generation_prompt_hash_changes_when_text_changes():
|
||||
@@ -90,15 +92,18 @@ def test_generation_run_hash_changes_when_anchor_changes():
|
||||
assert r1["run_hash"] != r2["run_hash"]
|
||||
|
||||
|
||||
def test_missing_markers_fall_back_to_scene_labels():
|
||||
def test_carry_scenes_have_no_injected_labels():
|
||||
p = _project(
|
||||
anchor="a knight",
|
||||
scenes=[{"text": "in a forest"}, {"text": "at a castle"}],
|
||||
)
|
||||
prompt = build_combined_prompt(p, for_generation=True)
|
||||
# No explicit markers -> generic "scene N" boundaries so the split still separates.
|
||||
assert "scene 1" in prompt
|
||||
assert "scene 2" in prompt
|
||||
# carry scenes (no transition) used to get an injected 'scene N' label — no longer.
|
||||
assert "scene 1" not in prompt and "scene 2" not in prompt
|
||||
assert prompt == "a knight in a forest at a castle"
|
||||
# the two boundaries are still there, structurally
|
||||
seg = build_generation_scene_segments(p)
|
||||
assert seg["scenes"] == ["in a forest", "at a castle"]
|
||||
|
||||
|
||||
def test_video_clips_excluded_from_combined_prompt():
|
||||
|
||||
@@ -288,6 +288,42 @@ def test_generic_scene_label_splits_but_is_never_encoded(monkeypatch):
|
||||
assert "cut" in real["scenes"][0]["expanded"]
|
||||
|
||||
|
||||
def test_split_scenes_from_segments_uses_explicit_boundaries(monkeypatch):
|
||||
"""The editor hands Studio its OWN scene list, so boundaries never need an in-text delimiter.
|
||||
One segment = exactly one scene; anchor keys fold into every scene; no 'scene N' anywhere."""
|
||||
monkeypatch.setattr(templates, "load_shortcut_db", lambda: _db({}))
|
||||
monkeypatch.setattr(templates, "load_custom_transition_triggers", lambda: {})
|
||||
out = conditioning.split_scenes_from_segments(
|
||||
"", ["a red car drives down a street", "the car stops at a cafe", "a woman exits"])
|
||||
assert [s["expanded"] for s in out["scenes"]] == [
|
||||
"a red car drives down a street", "the car stops at a cafe", "a woman exits"]
|
||||
for s in out["scenes"]:
|
||||
assert "scene 1" not in s["expanded"].lower() and "scene 2" not in s["expanded"].lower()
|
||||
|
||||
|
||||
def test_split_scenes_from_segments_preserves_empty_scene(monkeypatch):
|
||||
"""A text-less i2v scene must KEEP its slot (count = editor scene count), not be dropped —
|
||||
otherwise per-scene anchors/seeds shift by one. It becomes an empty (anchor-only) scene."""
|
||||
monkeypatch.setattr(templates, "load_shortcut_db", lambda: _db({}))
|
||||
monkeypatch.setattr(templates, "load_custom_transition_triggers", lambda: {})
|
||||
out = conditioning.split_scenes_from_segments("", ["", "a red car", "the cafe"])
|
||||
assert len(out["scenes"]) == 3
|
||||
assert out["scenes"][0]["expanded"] == ""
|
||||
assert [s["expanded"] for s in out["scenes"][1:]] == ["a red car", "the cafe"]
|
||||
|
||||
|
||||
def test_split_scenes_from_segments_folds_anchor_and_keys(monkeypatch):
|
||||
"""Anchor text + anchor keys fold into every scene, exactly like split_scenes()."""
|
||||
db = _db({"anc": (["zeta"], ["ZZZ"], "anchorKey"), "a": (["alpha"], ["AAA"], "keyA")})
|
||||
monkeypatch.setattr(templates, "load_shortcut_db", lambda: db)
|
||||
monkeypatch.setattr(templates, "load_custom_transition_triggers", lambda: {})
|
||||
out = conditioning.split_scenes_from_segments("zeta", ["alpha here", "plain scene"])
|
||||
assert out["anchor_expanded"] == "ZZZ"
|
||||
assert out["scenes"][0]["keys"] == {"anchorKey", "keyA"}
|
||||
assert out["scenes"][1]["keys"] == {"anchorKey"}
|
||||
assert "AAA" in out["scenes"][0]["expanded"]
|
||||
|
||||
|
||||
def test_split_scenes_single_scene(monkeypatch):
|
||||
db = _db({"hero": (["hero"], ["a knight"], "mykey")})
|
||||
monkeypatch.setattr(templates, "load_shortcut_db", lambda: db)
|
||||
|
||||
Reference in New Issue
Block a user