Files
olivv-cs-ComfyUI-FunPack/tests/test_scene_builder.py
T
digital-garbageandClaude Sonnet 4.6 223e60e832 Add silent transition placement - split without outputting the trigger
New placement option 'silent': the transition phrase marks the scene
boundary but is stripped from the output entirely. Neither the previous
nor the next segment contains it. Available globally and as a per-entry
override on custom transitions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 19:38:29 +03:00

1049 lines
35 KiB
Python

import json
import sys
import types
from pathlib import Path
import torch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
class FakeRoutes:
def get(self, _path):
return lambda func: func
def post(self, _path):
return lambda func: func
sys.modules.setdefault(
"folder_paths",
types.SimpleNamespace(get_user_directory=lambda: ""),
)
sys.modules.setdefault(
"server",
types.SimpleNamespace(PromptServer=types.SimpleNamespace(instance=types.SimpleNamespace(routes=FakeRoutes()))),
)
import templates
from conditioning import FunPackVideoRefinerV2
from templates import (
FunPackSceneBuilder,
apply_prompt_shortcuts,
apply_scene_database_authority,
extract_scene_phrases,
load_shortcut_db,
load_scene_db,
load_transition_db,
load_custom_transition_triggers,
normalize_scene_text_spacing,
normalize_scene_memory_items,
normalize_shortcut_db,
save_shortcut_db,
remember_scene_phrases,
save_shortcut_item,
save_scene_db,
save_transition_item,
delete_transition_item,
transition_items,
)
def use_tmp_scene_store(monkeypatch, tmp_path):
monkeypatch.setattr(templates, "template_store_dir", lambda: str(tmp_path))
monkeypatch.setattr(
templates,
"refinement_state_path",
lambda key, namespace, prefix="refine": str(tmp_path / "refinements" / f"{prefix}_{namespace}_{key}.json"),
)
monkeypatch.setattr(
FunPackVideoRefinerV2,
"_v2_state_path",
lambda self, key: str(tmp_path / "refinements" / f"refine_v2_clip_{key}.json"),
)
def test_scene_builder_collects_inputs_as_memory_but_outputs_selection(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
builder = FunPackSceneBuilder()
payload = {
"positive_phrases": ["red dress", "walking pose"],
"negative_phrases": ["bad hands"],
}
input_lora_stack = {
"version": 2,
"mode": "ltx2",
"per_block": True,
"loras": [{"name": "vampire.safetensors", "model_weight": 0.8}],
}
positive, negative, lora_stack, status = builder.build_scene(
scene="-None-",
scene_name="Vampire Courtyard",
aliases="gothic vampire",
action="save",
mode="Manual",
intent_prompt="",
positive_prompt="woman in courtyard, red dress, moonlight",
negative_prompt="blurry, bad hands",
refinement_key="scene_key",
scene_payload=json.dumps(payload),
lora_stack=input_lora_stack,
)
assert positive == "red dress, walking pose"
assert negative == "bad hands"
assert "moonlight" not in positive
assert lora_stack is input_lora_stack
assert "Manual saved" in status
data = load_scene_db("scene_key")
assert "woman in courtyard" in data["universal_memory"]
assert "blurry" in data["universal_memory"]
assert data["scenes"]["Vampire Courtyard"]["positive_phrases"] == ["red dress", "walking pose"]
def test_scene_builder_public_interface_is_prompt_stack_status_only():
assert FunPackSceneBuilder.RETURN_NAMES == (
"positive_prompt",
"negative_prompt",
"lora_stack",
"status",
)
assert FunPackSceneBuilder.RETURN_TYPES == (
"STRING",
"STRING",
"FUNPACK_LORA_STACK",
"STRING",
)
inputs = FunPackSceneBuilder.INPUT_TYPES()
required = inputs["required"]
optional = inputs["optional"]
assert "sigmas" not in required
assert "sigmas" not in optional
assert "positive_conditioning" not in required
assert "positive_conditioning" not in optional
assert "refinement_key" not in FunPackSceneBuilder.RETURN_NAMES
assert "scene_name" not in FunPackSceneBuilder.RETURN_NAMES
assert required["scene_positive"][1]["multiline"] is False
assert required["scene_negative"][1]["multiline"] is False
def test_scene_builder_manual_outputs_exact_composed_text(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
builder = FunPackSceneBuilder()
outputs = builder.build_scene(
scene="-None-",
scene_name="Editable Scene",
aliases="",
action="save",
mode="Manual",
scene_positive="red dress. walking pose, custom order",
scene_negative="bad hands. blur",
positive_prompt="source memory only, should not output",
negative_prompt="source negative only",
refinement_key="scene_key",
scene_payload=json.dumps({
"positive_phrases": ["ignored payload phrase"],
"negative_phrases": ["ignored payload negative"],
}),
)
assert outputs[0] == "red dress. walking pose, custom order"
assert outputs[1] == "bad hands. blur"
assert "source memory only" not in outputs[0]
data = load_scene_db("scene_key")
scene = data["scenes"]["Editable Scene"]
assert scene["positive_text"] == "red dress. walking pose, custom order"
assert scene["negative_text"] == "bad hands. blur"
assert "source memory only" in data["universal_memory"]
def test_scene_builder_save_load_preserves_raw_prompt_order(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
builder = FunPackSceneBuilder()
positive = "middle detail, first action. close-up camera, last environment, subject returns"
negative = "blur, bad hands, wrong action"
saved = builder.build_scene(
scene="-None-",
scene_name="Order Scene",
aliases="",
action="save",
mode="Manual",
scene_positive=positive,
scene_negative=negative,
positive_prompt="memory source only",
negative_prompt="negative memory source only",
refinement_key="order_key",
)
assert saved[0] == positive
assert saved[1] == negative
data = load_scene_db("order_key")
scene = data["scenes"]["Order Scene"]
assert scene["positive_text"] == positive
assert scene["negative_text"] == negative
assert scene["positive_phrases"] == [
"middle detail",
"first action",
"close-up camera",
"last environment",
"subject returns",
]
loaded = builder.build_scene(
scene="Order Scene",
scene_name="",
aliases="",
action="load",
mode="Manual",
scene_positive="",
scene_negative="",
refinement_key="order_key",
)
assert loaded[0] == positive
assert loaded[1] == negative
assert "Manual loaded" in loaded[3]
def test_scene_builder_empty_saved_text_does_not_rebuild_from_phrase_arrays(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
data = load_scene_db("empty_text_key")
data["scenes"]["Empty Positive"] = {
"name": "Empty Positive",
"aliases": [],
"output_mode": "Manual",
"positive_text": "",
"negative_text": "",
"positive_phrases": ["should not appear"],
"negative_phrases": ["also should not appear"],
}
save_scene_db(data, "empty_text_key")
outputs = FunPackSceneBuilder().build_scene(
scene="Empty Positive",
scene_name="",
aliases="",
action="load",
mode="Manual",
scene_positive="",
scene_negative="",
refinement_key="empty_text_key",
)
assert outputs[0] == ""
assert outputs[1] == ""
def test_scene_builder_auto_exact_and_fuzzy_match_saved_scene(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
builder = FunPackSceneBuilder()
saved_payload = {
"positive_phrases": ["anthro dragon", "mossy scales"],
"negative_phrases": ["extra limbs"],
}
manual_payload = {
"positive_phrases": ["manual fallback"],
"negative_phrases": [],
}
builder.build_scene(
scene="-None-",
scene_name="Forest Dragon",
aliases="woodland beast",
action="save",
mode="Manual",
intent_prompt="",
positive_prompt="anthro dragon, mossy scales",
negative_prompt="extra limbs",
refinement_key="dragon_key",
scene_payload=json.dumps(saved_payload),
)
exact = builder.build_scene(
scene="-None-",
scene_name="",
aliases="",
action="load",
mode="Auto",
intent_prompt="please apply woodland beast now",
positive_prompt="ignored prompt",
negative_prompt="ignored negative",
refinement_key="dragon_key",
scene_payload=json.dumps(manual_payload),
)
assert exact[0] == "anthro dragon, mossy scales"
assert exact[1] == "extra limbs"
assert exact[2] is None
assert "Auto exact" in exact[3]
fuzzy = builder.build_scene(
scene="-None-",
scene_name="",
aliases="",
action="load",
mode="Auto",
intent_prompt="make it forest and dragon",
positive_prompt="ignored prompt",
negative_prompt="",
refinement_key="dragon_key",
scene_payload=json.dumps(manual_payload),
)
assert fuzzy[0] == "anthro dragon, mossy scales"
assert "Auto fuzzy" in fuzzy[3]
def test_scene_builder_auto_no_match_falls_back_to_manual(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
builder = FunPackSceneBuilder()
payload = {
"positive_phrases": ["manual only"],
"negative_phrases": ["manual negative"],
}
outputs = builder.build_scene(
scene="-None-",
scene_name="",
aliases="",
action="load",
mode="Auto",
intent_prompt="no known scene here",
positive_prompt="source memory only",
negative_prompt="bad source memory",
refinement_key="",
scene_payload=json.dumps(payload),
)
assert outputs[0] == "manual only"
assert outputs[1] == "manual negative"
assert "Manual" in outputs[3]
def test_scene_builder_auto_still_collects_provided_scene_knowledge(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
builder = FunPackSceneBuilder()
builder.build_scene(
scene="-None-",
scene_name="",
aliases="",
action="load",
mode="Auto",
intent_prompt="no matching saved scene",
scene_positive="glass rain, neon reflection",
scene_negative="bad fingers",
positive_prompt="",
negative_prompt="",
refinement_key="auto_memory_key",
)
data = load_scene_db("auto_memory_key")
assert "glass rain" in data["universal_memory"]
assert "neon reflection" in data["universal_memory"]
assert "bad fingers" in data["universal_memory"]
def test_scene_builder_learning_mode_collects_memory_and_passes_inputs_through(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
builder = FunPackSceneBuilder()
lora_stack = {"loras": [{"name": "active.safetensors"}]}
positive_prompt = " first prompt,\nsecond prompt "
negative_prompt = "bad anatomy, blur"
outputs = builder.build_scene(
scene="-None-",
scene_name="",
aliases="",
action="load",
mode="Learning",
intent_prompt="ignored scene trigger",
positive_prompt=positive_prompt,
negative_prompt=negative_prompt,
refinement_key="learn_key",
scene_payload=json.dumps({
"positive_phrases": ["manual phrase"],
"negative_phrases": ["manual negative"],
}),
lora_stack=lora_stack,
)
assert outputs[0] == positive_prompt
assert outputs[1] == negative_prompt
assert outputs[2] is lora_stack
assert "Learning" in outputs[3]
data = load_scene_db("learn_key")
assert "first prompt" in data["universal_memory"]
assert "second prompt" in data["universal_memory"]
assert "bad anatomy" in data["universal_memory"]
assert data["scenes"] == {}
def test_scene_builder_learning_mode_ignores_selected_scene_output(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
builder = FunPackSceneBuilder()
builder.build_scene(
scene="-None-",
scene_name="Saved Scene",
aliases="",
action="save",
mode="Manual",
scene_positive="saved positive",
scene_negative="saved negative",
positive_prompt="memory source",
negative_prompt="",
refinement_key="learn_selected_key",
)
outputs = builder.build_scene(
scene="Saved Scene",
scene_name="",
aliases="",
action="load",
mode="Learning",
positive_prompt="live positive",
negative_prompt="live negative",
refinement_key="learn_selected_key",
)
assert outputs[0] == "live positive"
assert outputs[1] == "live negative"
assert "saved positive" not in outputs[0]
assert "Learning" in outputs[3]
def test_scene_builder_auto_mode_ignores_selected_scene_when_intent_matches_other(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
builder = FunPackSceneBuilder()
builder.build_scene(
scene="-None-",
scene_name="Manual Scene",
aliases="manual scene",
action="save",
mode="Manual",
scene_positive="manual selected",
scene_negative="",
refinement_key="auto_selected_key",
)
builder.build_scene(
scene="-None-",
scene_name="Auto Target",
aliases="dragon alley",
action="save",
mode="Manual",
scene_positive="auto matched",
scene_negative="",
refinement_key="auto_selected_key",
)
outputs = builder.build_scene(
scene="Manual Scene",
scene_name="",
aliases="",
action="load",
mode="Auto",
intent_prompt="show dragon alley",
refinement_key="auto_selected_key",
)
assert outputs[0] == "auto matched"
assert "Auto" in outputs[3]
def test_scene_builder_collects_prompt_words_from_sentence_chunks(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
builder = FunPackSceneBuilder()
builder.build_scene(
scene="-None-",
scene_name="",
aliases="",
action="load",
mode="Learning",
positive_prompt="person smoking in the rain",
negative_prompt="",
refinement_key="word_key",
)
data = load_scene_db("word_key")
assert "person smoking in the rain" in data["universal_memory"]
assert "person" in data["universal_memory"]
assert "smoking" in data["universal_memory"]
assert "the" not in data["universal_memory"]
def test_scene_builder_treats_space_before_punctuation_as_normal_punctuation():
assert normalize_scene_text_spacing("red dress . walking pose , smoke") == "red dress. walking pose, smoke"
phrases = extract_scene_phrases("red dress . walking pose , smoke")
assert [item["text"] for item in phrases] == ["red dress", "walking pose", "smoke"]
def test_refiner_reset_preserves_scene_builder_memory_in_refinement_key(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
builder = FunPackSceneBuilder()
builder.build_scene(
scene="-None-",
scene_name="Memory Scene",
aliases="",
action="save",
mode="Manual",
positive_prompt="silver hair, rain street",
negative_prompt="blur",
refinement_key="shared_key",
scene_payload=json.dumps({
"positive_phrases": ["silver hair"],
"negative_phrases": ["blur"],
}),
)
refiner = FunPackVideoRefinerV2()
reset_state, reset_status = refiner._v2_load_state("shared_key", reset_session=True)
assert reset_status == "fresh"
assert reset_state["prompt_histories"] == {}
assert reset_state["global"]["phrase_memory"] == {}
assert reset_state["scene_builder"]["scenes"]["Memory Scene"]["positive_phrases"] == ["silver hair"]
assert "silver hair" in reset_state["scene_builder"]["universal_memory"]
def test_scene_builder_database_items_keep_categories_and_wildcards():
memory = normalize_scene_memory_items([
{"text": "red dress", "category": "appearance", "wildcard": True},
{"text": "blue dress", "category": "appearance", "wildcard": True},
{"text": "bad hands", "category": "negative"},
])
assert memory["red dress"]["category"] == "appearance"
assert memory["red dress"]["wildcard"] is True
assert memory["blue dress"]["wildcard"] is True
assert memory["bad hands"]["wildcard"] is False
assert memory["bad hands"]["source"] == "negative"
def test_shortcuts_are_global_and_independent_from_refinement_keys(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
save_shortcut_item({
"name": "Run",
"triggers": ["running"],
"replacements": ["running freely"],
})
data = load_scene_db("some_key")
assert data["scenes"] == {}
shortcuts = load_shortcut_db()["shortcuts"]
assert "run" in shortcuts
assert shortcuts["run"]["triggers"] == ["running"]
def test_refiner_reset_session_does_not_clear_global_shortcuts(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
save_shortcut_item({
"name": "Run",
"triggers": ["running"],
"replacements": ["running freely"],
})
FunPackVideoRefinerV2()._v2_load_state("reset_key", reset_session=True)
assert "run" in load_shortcut_db()["shortcuts"]
def test_shortcuts_match_boundaries_and_preserve_surrounding_spacing(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
save_shortcut_item({
"name": "Run",
"triggers": ["running"],
"replacements": ["running freely"],
})
expanded, applied = apply_prompt_shortcuts(
"A video of man running in the park, running.",
seed=1,
)
assert expanded == "A video of man running freely in the park, running freely."
assert "manrunning" not in expanded
assert "parkfreely" not in expanded
assert len(applied) == 2
unchanged, applied = apply_prompt_shortcuts("A video of over-running shoes", seed=1)
assert unchanged == "A video of over-running shoes"
assert applied == []
def test_shortcuts_use_longest_trigger_and_seeded_replacement(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
save_shortcut_item({
"name": "Look",
"triggers": ["camera", "looks into camera"],
"replacements": [
"a man is facing the camera and looking into it",
"a man's gaze is directed at the viewer",
],
})
first, first_applied = apply_prompt_shortcuts("He looks into camera.", seed=77)
second, second_applied = apply_prompt_shortcuts("He looks into camera.", seed=77)
assert first == second
assert first_applied == second_applied
assert first_applied[0]["trigger"] == "looks into camera"
assert "He " in first
assert first.endswith(".")
def test_longer_phrase_wins_across_different_shortcuts(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
save_shortcut_item({
"name": "Smoke",
"triggers": ["smoking"],
"replacements": ["pipe smoking"],
})
save_shortcut_item({
"name": "Smoking Fish",
"triggers": ["smoking fish"],
"replacements": ["cold smoked salmon"],
})
save_shortcut_item({
"name": "Chef Smoking Fish",
"triggers": ["chef smoking fish"],
"replacements": ["a chef preparing cold smoked salmon"],
})
# "smoking fish" wins over "smoking" alone
result, applied = apply_prompt_shortcuts("I want smoking fish for dinner.", seed=1)
assert result == "I want cold smoked salmon for dinner."
assert len(applied) == 1
assert applied[0]["trigger"] == "smoking fish"
# "chef smoking fish" wins over both "smoking" and "smoking fish"
result, applied = apply_prompt_shortcuts("chef smoking fish on a plate.", seed=1)
assert result == "a chef preparing cold smoked salmon on a plate."
assert len(applied) == 1
assert applied[0]["trigger"] == "chef smoking fish"
# standalone "smoking" not part of a longer phrase still applies
result, applied = apply_prompt_shortcuts("smoking is shown on screen.", seed=1)
assert result == "pipe smoking is shown on screen."
assert len(applied) == 1
assert applied[0]["trigger"] == "smoking"
# mixed: standalone "smoking" and "smoking fish" each match their own shortcut
result, applied = apply_prompt_shortcuts("smoking and smoking fish.", seed=1)
assert result == "pipe smoking and cold smoked salmon."
assert len(applied) == 2
triggers = {a["trigger"] for a in applied}
assert triggers == {"smoking", "smoking fish"}
def test_empty_replacement_removes_phrase_and_cleans_artifacts(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
save_shortcut_item({
"name": "Strip Game Tag",
"triggers": ["from Zenless Zone Zero"],
"replacements": [""],
})
# trailing removal - space+comma artifact cleaned
result, applied = apply_prompt_shortcuts("Nicole from Zenless Zone Zero, fighting stance.", seed=1)
assert result == "Nicole, fighting stance."
assert len(applied) == 1
assert applied[0]["replacement"] == ""
# leading removal - leading comma+space cleaned
result, applied = apply_prompt_shortcuts("from Zenless Zone Zero, Nicole in fighting stance.", seed=1)
assert result == "Nicole in fighting stance."
# middle removal - double space cleaned
result, applied = apply_prompt_shortcuts("Nicole from Zenless Zone Zero and a sword.", seed=1)
assert result == "Nicole and a sword."
# end of string - trailing space cleaned
result, applied = apply_prompt_shortcuts("Nicole from Zenless Zone Zero", seed=1)
assert result == "Nicole"
def test_disabled_shortcuts_do_not_expand(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
save_shortcut_item({
"name": "Run",
"enabled": False,
"triggers": ["running"],
"replacements": ["running freely"],
})
expanded, applied = apply_prompt_shortcuts("running", seed=1)
assert expanded == "running"
assert applied == []
def test_shortcut_only_import_export_shape_round_trips(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
exported = normalize_shortcut_db({
"shortcuts": {
"look": {
"name": "Look",
"enabled": True,
"triggers": ["looks into camera"],
"replacements": ["a man is facing the camera and looking into it"],
}
}
})
save_shortcut_db(exported)
loaded = load_shortcut_db()
assert loaded["shortcuts"]["look"]["name"] == "Look"
assert loaded["shortcuts"]["look"]["triggers"] == ["looks into camera"]
def test_scene_builder_expands_shortcuts_only_in_positive_prompt(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
save_shortcut_item({
"name": "Run",
"triggers": ["running"],
"replacements": ["running freely"],
})
positive, negative, _, status = FunPackSceneBuilder().build_scene(
scene="-None-",
scene_name="Shortcut Scene",
aliases="",
action="load",
mode="Manual",
scene_positive="A video of man running in the park",
scene_negative="running",
refinement_key="shortcut_key",
)
assert positive == "A video of man running freely in the park"
assert negative == "running"
assert "Shortcuts: expanded" in status
def test_refiner_expands_positive_and_intent_shortcuts_before_encoding(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
save_shortcut_item({
"name": "Run",
"triggers": ["running"],
"replacements": ["running freely"],
})
cond = [(torch.ones(1, 2, 4), {"pooled_output": torch.ones(1, 4)})]
_, status, _, _, encoded_prompts, _, _ = FunPackVideoRefinerV2().refine_v2(
"A video of man running",
clip=None,
rating="Missing action",
refinement_key="refiner_shortcut_key",
positive_conditioning=cond,
user_intent_prompt="running",
reset_session=True,
)
assert "A video of man running freely" in encoded_prompts
assert "Shortcuts: expanded" in status
def test_scene_builder_wildcard_outputs_one_adjacent_matching_phrase(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
builder = FunPackSceneBuilder()
data = load_scene_db("wild_key")
data["universal_memory"] = normalize_scene_memory_items([
{"text": "red dress", "category": "appearance", "wildcard": True},
{"text": "blue dress", "category": "appearance", "wildcard": True},
{"text": "green dress", "category": "appearance", "wildcard": True},
])
save_scene_db(data, "wild_key")
monkeypatch.setattr(templates.random, "choice", lambda choices: "blue dress")
outputs = builder.build_scene(
scene="-None-",
scene_name="Wildcard Scene",
aliases="",
action="load",
mode="Manual",
scene_positive="red dress, blue dress, walking pose, green dress",
scene_negative="",
refinement_key="wild_key",
)
assert outputs[0] == "blue dress, walking pose, green dress"
assert "red dress" not in outputs[0]
def test_scene_builder_legacy_wildcard_group_migrates_to_checkbox():
memory = normalize_scene_memory_items([
{"text": "red dress", "category": "appearance", "wildcard_group": "outfit"},
])
assert memory["red dress"]["wildcard"] is True
def test_scene_builder_database_authority_preserves_unchanged_rows():
previous = normalize_scene_memory_items([
{"text": "red dress", "category": "appearance", "category_source": "auto", "category_locked": False},
])
incoming = normalize_scene_memory_items([
{"text": "red dress", "category": "appearance"},
])
resolved = apply_scene_database_authority(previous, incoming)
assert resolved["red dress"]["category_source"] == "auto"
assert resolved["red dress"]["category_locked"] is False
def test_scene_builder_database_authority_locks_new_and_changed_rows():
previous = normalize_scene_memory_items([
{"text": "red dress", "category": "appearance"},
{"text": "walking pose", "category": "action"},
])
incoming = normalize_scene_memory_items([
{"text": "red dress", "category": "style"},
{"text": "blue dress", "category": "appearance"},
])
resolved = apply_scene_database_authority(previous, incoming)
assert resolved["red dress"]["category_source"] == "user"
assert resolved["red dress"]["category_locked"] is True
assert resolved["blue dress"]["category_source"] == "user"
assert resolved["blue dress"]["category_locked"] is True
def test_scene_builder_prompt_learning_keeps_locked_user_category():
data = {
"universal_memory": normalize_scene_memory_items([
{"text": "running pose", "category": "appearance", "category_source": "user", "category_locked": True},
]),
"scenes": {},
}
remember_scene_phrases(data, positive_prompt="running pose")
item = data["universal_memory"]["running pose"]
assert item["category"] == "appearance"
assert item["category_source"] == "user"
assert item["category_locked"] is True
assert item["count"] == 1
def test_refiner_scene_builder_sync_skips_locked_rows():
refiner = FunPackVideoRefinerV2()
state = {
"scene_builder": {
"universal_memory": normalize_scene_memory_items([
{"text": "running pose", "category": "appearance", "category_source": "user", "category_locked": True},
]),
"scenes": {},
}
}
global_state = {"phrase_memory": {}}
phrase = refiner._v2_classify_phrases(None, [{"text": "running pose", "tokens": ["running", "pose"]}])[0]
last_run = {"prompt": "running pose", "phrases": [phrase]}
refiner._v2_update_phrase_memory(
global_state,
last_run,
{"key": "like", "reward": 1.0, "label": "Perfect"},
1,
{"missing_axes": [], "satisfied_axes": ["action"], "resolved_axes": [], "regressed_axes": [], "wrong_axes": []},
)
refiner._v2_sync_scene_builder_memory(state, global_state, last_run, 1)
item = state["scene_builder"]["universal_memory"]["running pose"]
assert item["category"] == "appearance"
assert item["category_source"] == "user"
assert item["category_locked"] is True
def test_custom_transition_save_load_delete(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
save_transition_item({"name": "Number One", "trigger": "1"})
save_transition_item({"name": "Next Shot", "trigger": "NEXT"})
db = load_transition_db()
assert "number one" in db["transitions"]
assert db["transitions"]["number one"]["trigger"] == "1"
assert "replacement" not in db["transitions"]["number one"]
items = transition_items()
assert any(i["trigger"] == "1" for i in items)
delete_transition_item("Number One")
assert "number one" not in load_transition_db()["transitions"]
def test_custom_transition_triggers_returns_enabled_only(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
save_transition_item({"name": "Cut", "trigger": "CUT", "enabled": True})
save_transition_item({"name": "Disabled", "trigger": "SKIP", "enabled": False})
triggers = load_custom_transition_triggers()
assert "cut" in triggers # keys are lowercased for case-insensitive lookup
assert "skip" not in triggers
def test_split_prompt_recognizes_custom_trigger(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
save_transition_item({"name": "1", "trigger": "1"})
refiner = FunPackVideoRefinerV2()
segments = refiner._v2_split_prompt_by_transitions("beach waves, 1, mountain snow")
assert len(segments) == 2
assert segments[0].strip() == "beach waves"
assert "mountain snow" in segments[1]
def test_split_prompt_custom_trigger_kept_at_segment_start(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
save_transition_item({"name": "NEXT", "trigger": "NEXT"})
refiner = FunPackVideoRefinerV2()
segments = refiner._v2_split_prompt_by_transitions("beach waves, NEXT, mountain snow")
assert len(segments) == 2
assert "beach waves" in segments[0]
assert segments[1].startswith("NEXT")
assert "mountain snow" in segments[1]
def test_split_prompt_drops_dangling_trailing_transition(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
refiner = FunPackVideoRefinerV2()
# transition at the end with no real content after it
segments = refiner._v2_split_prompt_by_transitions(
"female character Kai'Sa, the scene then cuts to"
)
assert len(segments) == 1
assert "Kai'Sa" in segments[0]
# transition + lone article is still dangling - should also be dropped
segments = refiner._v2_split_prompt_by_transitions(
"female character Kai'Sa, the scene then cuts to the"
)
assert len(segments) == 1
assert "Kai'Sa" in segments[0]
# transition + real content after it must NOT be dropped
segments = refiner._v2_split_prompt_by_transitions(
"female character Kai'Sa, the scene then cuts to the beach"
)
assert len(segments) == 2
assert "beach" in segments[1]
def test_split_prompt_no_stray_comma_after_article(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
refiner = FunPackVideoRefinerV2()
# "the scene then cuts to" matches, leaving "the, next view" as following - comma must be gone
segments = refiner._v2_split_prompt_by_transitions(
"Kai'Sa, the scene then cuts to the, next view"
)
assert len(segments) == 2
assert "the, next" not in segments[1]
assert "the next view" in segments[1]
def test_split_prompt_does_not_split_on_scene_as_noun(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
refiner = FunPackVideoRefinerV2()
# "scene proceeds/features/shows" must NOT trigger a split
for phrase in ["scene proceeds", "scene features", "scene shows", "scene begins"]:
text = f"Kai'Sa walking. The {phrase} into the next action."
segments = refiner._v2_split_prompt_by_transitions(text)
assert len(segments) == 1, f"Should not split on '{phrase}', got: {segments}"
# numbered scene labels must still split
segments = refiner._v2_split_prompt_by_transitions("Kai'Sa walking. scene 2 she runs.")
assert len(segments) == 2
def test_split_transition_placement_end(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
refiner = FunPackVideoRefinerV2()
# placement=end: transition phrase closes the previous segment
segments = refiner._v2_split_prompt_by_transitions(
"Kai'Sa walks confidently. cut to the next action",
placement="end",
)
assert len(segments) == 2
assert "cut to" in segments[0]
assert "Kai'Sa walks" in segments[0]
assert "the next action" in segments[1]
assert "cut to" not in segments[1]
def test_split_custom_transition_placement_override(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
# custom transition with explicit "end" placement overrides global "start"
save_transition_item({"name": "CUT", "trigger": "CUT", "placement": "end"})
refiner = FunPackVideoRefinerV2()
segments = refiner._v2_split_prompt_by_transitions(
"Kai'Sa walks. CUT she runs.",
placement="start", # global is start, but custom says end
)
assert len(segments) == 2
assert "CUT" in segments[0]
assert "she runs" in segments[1]
assert "CUT" not in segments[1]
def test_custom_transition_trigger_ending_with_punctuation(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
save_transition_item({"name": "Scene cut", "trigger": "Scene cut."})
refiner = FunPackVideoRefinerV2()
segments = refiner._v2_split_prompt_by_transitions(
"Kai'Sa walks confidently. Scene cut. She looks into camera."
)
assert len(segments) == 2
assert "Kai'Sa walks" in segments[0]
assert "She looks" in segments[1]
def test_split_transition_placement_silent(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
refiner = FunPackVideoRefinerV2()
segments = refiner._v2_split_prompt_by_transitions(
"Kai'Sa walks confidently. cut to she looks into camera.",
placement="silent",
)
assert len(segments) == 2
assert "cut to" not in segments[0]
assert "cut to" not in segments[1]
assert "Kai'Sa walks" in segments[0]
assert "she looks into camera" in segments[1]
def test_custom_transition_silent_overrides_global_start(monkeypatch, tmp_path):
use_tmp_scene_store(monkeypatch, tmp_path)
save_transition_item({"name": "Scene cut", "trigger": "Scene cut.", "placement": "silent"})
refiner = FunPackVideoRefinerV2()
segments = refiner._v2_split_prompt_by_transitions(
"Kai'Sa walks. Scene cut. She looks into camera.",
placement="start",
)
assert len(segments) == 2
assert "Scene cut" not in segments[0]
assert "Scene cut" not in segments[1]
assert "Kai'Sa walks" in segments[0]
assert "She looks" in segments[1]