Merge branch 'cutting_edge' into dev

This commit is contained in:
digital-garbage
2026-06-16 00:15:52 +03:00
17 changed files with 260 additions and 11525 deletions
+7
View File
@@ -2,6 +2,13 @@
## [Unreleased]
### Removed
- Removed the `FunPack Scene Builder` node and the Studio Scene Builder feature (the per-key
scene database, saved scenes, the Studio **Scene** tab, and scene-database wildcard cleanup).
It was redundant — helpful on paper but effectively unused, with no way to drive it from the
Cutting Room, and it risked interfering with shortcuts. Shortcuts, transition splitting, and
refinement keys are unchanged. Studio now always uses the connected `positive_prompt`.
### Fixed
- "Reset Studio session" silently wiped keys it never listed. The confirmation read the per-scene
`scene_refinement_keys` (which drops keys via its divergence fallback), while the backend reset
-1
View File
@@ -72,7 +72,6 @@ Start with:
- [`docs/FunPackVideoRefinerV2QuickGuide.md`](docs/FunPackVideoRefinerV2QuickGuide.md) for a short Discord-friendly Refiner V2 guide
- [`docs/FunPackLTXAVSceneChainSampler.md`](docs/FunPackLTXAVSceneChainSampler.md) for split-scene LTXV/LTXAV continuation
- [`docs/FunPackLoraWorkflow.md`](docs/FunPackLoraWorkflow.md) for the LoRA/refiner helper workflow
- [`docs/FunPackSceneBuilder.md`](docs/FunPackSceneBuilder.md) for scene preset workflows
Version history is available in [CHANGELOG.md](CHANGELOG.md).
+2 -5
View File
@@ -21,7 +21,7 @@ if __package__:
)
from .model_management import FunPackApplyLoraWeights, FunPackLoraLoader
from .samplers import FunPackHybridEuler2SSampler, FunPackDistilledFlowSampler, FunPackNormalizingSampler, FunPackLTXAVSceneChainSampler
from .templates import FunPackRefinementKeyLoader, FunPackSceneBuilder
from .templates import FunPackRefinementKeyLoader
try:
from . import batch_training # noqa: F401 registers /funpack/batch/* routes
except Exception as _e:
@@ -70,10 +70,9 @@ else:
FunPackNormalizingSampler = None
FunPackLTXAVSceneChainSampler = None
try:
from templates import FunPackRefinementKeyLoader, FunPackSceneBuilder
from templates import FunPackRefinementKeyLoader
except Exception:
FunPackRefinementKeyLoader = None
FunPackSceneBuilder = None
WEB_DIRECTORY = "./web"
@@ -99,7 +98,6 @@ NODE_CLASS_MAPPINGS = {
"FunPackApplyLoraWeights": FunPackApplyLoraWeights,
"FunPackLoraLoader": FunPackLoraLoader,
"FunPackRefinementKeyLoader": FunPackRefinementKeyLoader,
"FunPackSceneBuilder": FunPackSceneBuilder,
}
NODE_CLASS_MAPPINGS = {name: cls for name, cls in NODE_CLASS_MAPPINGS.items() if cls is not None}
@@ -125,7 +123,6 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"FunPackApplyLoraWeights": "FunPack Apply LoRA Weights",
"FunPackLoraLoader": "FunPack LoRA Loader",
"FunPackRefinementKeyLoader": "FunPack Refinement Key Loader",
"FunPackSceneBuilder": "FunPack Scene Builder",
}
__all__ = [
+92 -746
View File
File diff suppressed because it is too large Load Diff
-70
View File
@@ -1,70 +0,0 @@
# FunPack Scene Builder
`FunPack Scene Builder` stores named scene presets inside the selected refinement key, separate from Refiner's conditioning-delta learning. Connected positive and negative prompts are memory sources only: every queue run collects their phrases and useful words into universal memory, but the node outputs composed scene text or pass-through text in Learning mode.
## Visible Controls
The node surface is a compact launcher. Scene name, mode, aliases, prompt editors, saved scenes, and database controls live in the centered custom editor UI.
If no scene exists yet, opening the editor, prompt editor, or database first asks for a new scene name.
## Editor Menus
**Positive prompt**: Opens a combined text and phrase-button composer. Typed text, commas, periods, and manual ordering are preserved exactly. Database chips can be clicked or dragged into the text field, filtered by category, and chips already present in the prompt appear as removable buttons inside the same prompt field.
**Preconstruct**: In the positive prompt editor, this button adds matching database phrases for the current intent into the editor text. It only runs when you press the button; queueing the node never preconstructs automatically.
**Negative prompt**: Same composer behavior for the negative prompt.
**Database**: Add, edit, delete, search, categorize, and mark universal words and phrases as wildcards. Double-click a word or phrase to edit it inline, then use **OK** or **Cancel**.
**Shortcuts**: Add global activation phrases that expand into longer positive prompt text. Shortcuts are shared across refinement keys, can be imported/exported separately, and are not cleared by Refiner reset. Activation phrases replace only the matched word or phrase and preserve surrounding spaces and punctuation.
The editor refreshes the selected scene database before opening. When `refinement_key_input` is connected to a Refinement Key Loader, the editor reads that linked key so prompt memory appears in the same database where queue runs stored it.
Each editor menu has **Back**, **Cancel**, and **Confirm** controls. Back always returns to the main editor. Cancel restores the menu snapshot; if the prompt/database editor was opened directly from the node, Cancel closes the dialog instead.
## Modes
**Manual** outputs the current composed positive and negative scene text, plus the connected LoRA stack passed through unchanged.
**Auto** scans `intent_prompt` for a saved scene name or alias. Exact matches are preferred, then a conservative word-match fallback is used. If nothing matches, the node falls back to Manual output.
**Learning** saves connected positive and negative prompt phrases and useful words into Scene Builder universal memory, then passes the connected positive prompt, negative prompt, and LoRA stack through unchanged.
Shortcuts apply to positive prompt output in all modes, including Learning pass-through. Negative prompts are never expanded.
## Connection-Only Inputs
**intent_prompt**: Text used for Auto scene detection. This can be the same intent prompt that feeds Refiner V2.
**positive_prompt** and **negative_prompt**: Universal memory sources. They do not override scene output except in Learning pass-through mode.
**refinement_key_input**: Optional key that owns this Scene Builder memory and saved scenes.
**lora_stack**: Optional current LoRA stack. Scene Builder passes it through unchanged so Refiner can use the active LoRAs for suggestions.
Scene Builder memory is stored in the selected refinement key under its own `scene_builder` section. Refiner reset clears conditioning-delta learning and prompt histories, but preserves Scene Builder universal memory and saved scenes.
## Outputs
**positive_prompt**: Composed positive scene text.
**negative_prompt**: Composed negative scene text.
**lora_stack**: The connected `FUNPACK_LORA_STACK`, passed through unchanged.
**status**: Summary of selected mode, matched scene, phrase counts, and pass-through LoRA stack count.
## Wildcards
Wildcard is a checkbox on each database word or phrase. If two or more wildcard-enabled entries appear next to each other in the runtime composed prompt, Scene Builder outputs one randomly selected entry from that adjacent run and omits the others. The saved prompt text is not rewritten.
## Workflow
1. Connect positive and negative prompt text, then queue once to collect phrase and word memory.
2. Open **Database** to clean up phrases, assign categories, or enable wildcard random choice.
3. Open **Positive prompt** or **Negative prompt** to type freely and insert chips from the database.
4. Enter a scene name and confirm or save the scene.
5. Use **Auto** with a scene name or alias in `intent_prompt` to apply a saved scene automatically.
6. Use **Learning** when generating changing prompts and you want Scene Builder to collect memory while leaving the generation prompt untouched.
+3 -11
View File
@@ -1,6 +1,6 @@
# FunPack Studio
`FunPack Studio` is a single node that combines all core FunPack refinement tools under one interface. It replaces the typical chain of `FunPack Refinement Key Loader - FunPack Scene Builder - FunPack Apply LoRA Weights - FunPack LoRA Loader - FunPack Video Refiner V2 - FunPack Conditioning Adjust` with a single node and a tabbed popup editor.
`FunPack Studio` is a single node that combines all core FunPack refinement tools under one interface. It replaces the typical chain of `FunPack Refinement Key Loader - FunPack Apply LoRA Weights - FunPack LoRA Loader - FunPack Video Refiner V2 - FunPack Conditioning Adjust` with a single node and a tabbed popup editor.
The standalone nodes remain fully functional. Studio is an alternative for workflows where you want everything in one place.
@@ -22,7 +22,7 @@ The `rating` widget opens a **rating picker** popup. Alongside the quality ratin
| `clip_vision_output` | CLIP_VISION_OUTPUT | Advisory image context stored in session state. Not blended into conditioning. |
| `source_image` | IMAGE | Source image or frame batch. V2 stores size, aspect ratio, and a fingerprint to detect changes. |
| `lora_stack` | FUNPACK_LORA_STACK | External LoRA stack. Bypasses Studio's internal LoRA management entirely when connected. |
| `positive_prompt` | STRING | Positive prompt text. Ignored when Scene Builder mode is not Pass-through. |
| `positive_prompt` | STRING | Positive prompt text. |
| `negative_prompt` | STRING | Negative prompt text. Encoded via CLIP and output as negative conditioning. Skipped when `negative_conditioning` is connected. |
| `user_intent_prompt` | STRING | Raw user intent for repair and alignment anchoring. |
| `feedback_prompt` | STRING | Feedback describing what was wrong with the previous output. Highest priority in the advisor. |
@@ -53,17 +53,9 @@ Set the refinement key for the current session. This key links all FunPack memor
When a `refinement_key_input` is connected to the node, that value takes precedence at runtime unless the **Override** toggle is enabled.
**Scene Builder mode** is also controlled here. Changing from Pass-through to Manual/Auto/Learning activates the scene builder, which then constructs the positive prompt from the Scene tab instead of from the `positive_prompt` input.
### Scene
Available when Scene Builder mode is not Pass-through. Save and load named scene presets. The phrase bank shows all phrases from the session's universal memory. Clicking a chip appends it to the positive prompt composer.
When Scene Builder is active the Refiner tab shows a notice and the intent override field is disabled, since intent derives from the scene prompt.
### Shortcuts
Shortcuts are global positive-prompt expansions shared by Scene Builder, Studio, and Refiner V2. Each shortcut has one or more activation phrases and one or more replacement phrases. Matching uses exact word or phrase boundaries, preserves surrounding spaces and punctuation, and chooses among replacements with the run seed.
Shortcuts are global positive-prompt expansions shared by Studio and Refiner V2. Each shortcut has one or more activation phrases and one or more replacement phrases. Matching uses exact word or phrase boundaries, preserves surrounding spaces and punctuation, and chooses among replacements with the run seed.
Shortcuts are stored outside refinement keys, can be imported/exported separately, and are not cleared by **Reset session**.
File diff suppressed because it is too large Load Diff
+2 -5
View File
@@ -18,7 +18,7 @@ try:
)
from .model_management import FunPackApplyLoraWeights, FunPackLoraLoader
from .samplers import FunPackHybridEuler2SSampler, FunPackLTXAVSceneChainSampler, sample_funpack_hybrid_euler_2s
from .templates import FunPackRefinementKeyLoader, FunPackSceneBuilder
from .templates import FunPackRefinementKeyLoader
except ImportError:
from conditioning import (
FunPackLorebookEnhancer,
@@ -36,7 +36,7 @@ except ImportError:
)
from model_management import FunPackApplyLoraWeights, FunPackLoraLoader
from samplers import FunPackHybridEuler2SSampler, FunPackLTXAVSceneChainSampler, sample_funpack_hybrid_euler_2s
from templates import FunPackRefinementKeyLoader, FunPackSceneBuilder
from templates import FunPackRefinementKeyLoader
NODE_CLASS_MAPPINGS = {
"FunPackPromptCombiner": FunPackPromptCombiner,
@@ -54,7 +54,6 @@ except ImportError:
"FunPackApplyLoraWeights": FunPackApplyLoraWeights,
"FunPackLoraLoader": FunPackLoraLoader,
"FunPackRefinementKeyLoader": FunPackRefinementKeyLoader,
"FunPackSceneBuilder": FunPackSceneBuilder,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -73,7 +72,6 @@ except ImportError:
"FunPackApplyLoraWeights": "FunPack Apply LoRA Weights",
"FunPackLoraLoader": "FunPack LoRA Loader",
"FunPackRefinementKeyLoader": "FunPack Refinement Key Loader",
"FunPackSceneBuilder": "FunPack Scene Builder",
}
__all__ = [
@@ -94,6 +92,5 @@ __all__ = [
"FunPackApplyLoraWeights",
"FunPackLoraLoader",
"FunPackRefinementKeyLoader",
"FunPackSceneBuilder",
"sample_funpack_hybrid_euler_2s",
]
-1
View File
@@ -66,7 +66,6 @@
const STUDIO_REFINER_ESSENTIALS = [
{ name: "vision_conditioning", label: "Vision conditioning", default: true },
{ name: "reference_injection", label: "Reference injection", default: false },
{ name: "prompt_repair", label: "Prompt repair", default: false },
];
const STUDIO_REFINER_ADVANCED = [
{ name: "value_guidance", label: "Value guidance", kind: "bool", default: true },
+13 -3
View File
@@ -2494,9 +2494,14 @@
// Record a completed run's output: map each of the run's scenes to the one source
// video at its cumulative in-point, so splits/deletes later play the right portions.
function _pruneGhostsAfterRegen(recordedSceneIds) {
function _pruneGhostsAfterRegen(recordedSceneIds, protectedGhostIds) {
const ids = new Set(recordedSceneIds || []);
state.sceneGhosts = (state.sceneGhosts || []).filter((g) => !ids.has(g.afterSceneId) && !ids.has(g.id));
const keep = new Set(protectedGhostIds || []);
// Drop ghosts superseded by this run's fresh renders (anchored after, or sharing id with, a
// recorded scene) — but never a ghost that just received its own finished media this pass.
state.sceneGhosts = (state.sceneGhosts || []).filter(
(g) => keep.has(g.id) || (!ids.has(g.afterSceneId) && !ids.has(g.id))
);
}
function _ghostDurationSec(g) {
@@ -2666,6 +2671,10 @@
let chainSceneIdx = 0;
let clearedRating = false;
const recordedSceneIds = [];
// Ghosts of scenes removed mid-generation that just received their finished media in this
// pass. They must survive _pruneGhostsAfterRegen — they ARE the fresh result, not the stale
// pre-regen render the prune is meant to drop.
const completedGhostIds = [];
for (let i = 0; i < ids.length; i++) {
const id = ids[i];
if (_removedDuringGen.has(id)) {
@@ -2677,6 +2686,7 @@
const inSec = sameUnit ? sourceEnd : _recordInSec(lastChain, sourceEnd, ghost, sceneLayout, chainSceneIdx, sourceSpanSec);
ghosts[gi] = { ...ghost, media: primary, inSec, pendingGen: false };
state.sceneGhosts = ghosts;
completedGhostIds.push(id);
sourceEnd = inSec + _ghostDurationSec(ghost);
lastChain = ghost;
if (!sameUnit) chainSceneIdx += 1;
@@ -2697,7 +2707,7 @@
lastChain = sc;
if (!sameUnit) chainSceneIdx += 1;
}
_pruneGhostsAfterRegen(recordedSceneIds);
_pruneGhostsAfterRegen(recordedSceneIds, completedGhostIds);
_syncSeparatedTracksAfterRegen(recordedSceneIds);
if (clearedRating) scheduleSaveSilent();
if (recordedSceneIds.length) {
+4 -3
View File
@@ -1978,18 +1978,19 @@
?? JSON.stringify({ sel: st.selectedSceneId, sels: st.selectedSceneIds });
const fpRat = _ratingsFingerprint(st);
if (fpData !== _lastDataFp) {
const ok = render(st);
if (!ok) return false; // render declined (focus/drag/scroll) — retry on next notify
_lastDataFp = fpData;
_lastSelFp = fpSel;
_lastRatFp = fpRat;
const ok = render(st);
if (_pendingAutoFit && ok && st.project) {
if (_pendingAutoFit && st.project) {
_pendingAutoFit = false;
if (_autoFitEnabled) {
const total = S.previewTotalSec ? S.previewTotalSec() : tlTotalSec;
requestAnimationFrame(() => { if (total > 0) fit(total); });
}
}
return;
return true;
}
if (fpRat !== _lastRatFp) {
_lastRatFp = fpRat;
+2 -1
View File
@@ -142,8 +142,9 @@
return S.subscribe((st) => {
const fp = fpFn(st);
if (fp === last) return;
const ok = fn(st);
if (ok === false) return; // handler declined to apply this update — retry next notify
last = fp;
fn(st);
});
}
-847
View File
@@ -31,9 +31,6 @@ TEMPLATE_NONE = "-None-"
REFINEMENT_KEY_NONE = "-None-"
SCENE_NONE = "-None-"
TEMPLATE_DB_VERSION = 1
SCENE_DB_VERSION = 1
SCENE_STATE_FIELD = "scene_builder"
SCENE_CATEGORY_SOURCES = {"auto", "refiner", "user"}
WILDCARD_RE = re.compile(r"\{([^{}]*\|[^{}]*)\}")
SCENE_CATEGORIES = {
"negative": {"bad", "blurry", "worst", "low", "noise", "deformed", "artifact", "ugly", "broken"},
@@ -68,9 +65,6 @@ def template_store_path():
return os.path.join(template_store_dir(), "templates.json")
def scene_store_path():
return os.path.join(template_store_dir(), "scenes.json")
def shortcut_store_path():
return os.path.join(template_store_dir(), "shortcuts.json")
@@ -278,15 +272,6 @@ def template_names():
return [TEMPLATE_NONE] + names
def empty_scene_db():
return {
"version": SCENE_DB_VERSION,
"source": "ComfyUI-FunPack",
"universal_memory": {},
"scenes": {},
}
def empty_shortcut_db():
return {
"version": 1,
@@ -295,18 +280,6 @@ def empty_shortcut_db():
}
def normalize_scene_db(data):
if not isinstance(data, dict):
data = empty_scene_db()
data.setdefault("version", SCENE_DB_VERSION)
data.setdefault("source", "ComfyUI-FunPack")
if not isinstance(data.get("universal_memory"), dict):
data["universal_memory"] = {}
if not isinstance(data.get("scenes"), dict):
data["scenes"] = {}
return data
def shortcut_key(value):
value = re.sub(r"[^\w'’.-]+", " ", str(value or "").strip().lower(), flags=re.UNICODE)
return re.sub(r"\s+", " ", value).strip()
@@ -665,410 +638,6 @@ def apply_prompt_shortcuts(text, seed=0, shortcut_db=None):
return expanded, applied
def load_scene_db(refinement_key=""):
key = normalize_refinement_key(refinement_key)
if key:
state, _, _ = load_refinement_key_state(key, create=False)
if isinstance(state, dict):
return normalize_scene_db(state.get(SCENE_STATE_FIELD, {}))
return empty_scene_db()
path = scene_store_path()
if not os.path.exists(path):
return empty_scene_db()
try:
with open(path, "r", encoding="utf-8") as file:
data = json.load(file)
except (json.JSONDecodeError, OSError, ValueError):
return empty_scene_db()
return normalize_scene_db(data)
def save_scene_db(data, refinement_key=""):
key = normalize_refinement_key(refinement_key)
data = normalize_scene_db(data)
if key:
state, _, _ = load_refinement_key_state(key, create=True)
if isinstance(state, dict):
state[SCENE_STATE_FIELD] = data
save_refinement_key_state(state, key)
return
os.makedirs(template_store_dir(), exist_ok=True)
with open(scene_store_path(), "w", encoding="utf-8") as file:
json.dump(data, file, indent=2, sort_keys=True)
def scene_names(refinement_key=""):
data = load_scene_db(refinement_key)
names = sorted(
name for name in data.get("scenes", {}).keys()
if isinstance(name, str) and name.strip()
)
return [SCENE_NONE] + names
def normalize_scene_name(value):
value = str(value or "").strip()
if not value or value == SCENE_NONE:
return ""
return value
def normalize_scene_key(value):
value = re.sub(r"[^\w'’]+", " ", str(value or "").strip().lower(), flags=re.UNICODE)
return re.sub(r"\s+", " ", value).strip()
def normalize_scene_text_spacing(value):
text = re.sub(r"[ \t\r\f\v]+", " ", str(value or "")).strip()
text = re.sub(r"\s+([,.])", r"\1", text)
text = re.sub(r"([,.])([^\s,.])", r"\1 \2", text)
return text.strip()
def scene_token_words(text):
return [
token.lower()
for token in re.findall(r"[\w'’.-]+", str(text or ""), flags=re.UNICODE)
if any(char.isalpha() for char in token) and len(token.strip("._-")) >= 2
]
def categorize_scene_phrase(text, source="positive"):
if source == "negative":
return "negative"
words = set(scene_token_words(text))
best = ("details", 0)
for category, keywords in SCENE_CATEGORIES.items():
if category == "negative":
continue
score = len(words & keywords)
if score > best[1]:
best = (category, score)
return best[0]
def extract_scene_phrases(text, source="positive", include_words=False):
phrases = []
seen = set()
for raw in re.split(r"[,;.\n]+", normalize_scene_text_spacing(text)):
clean = re.sub(r"\s+", " ", raw).strip(" ,;:.").strip()
if len(clean) < 2:
continue
key = clean.lower()
if key in seen:
continue
words = scene_token_words(clean)
if not words:
continue
seen.add(key)
phrases.append({
"text": clean,
"key": key,
"source": source,
"category": categorize_scene_phrase(clean, source),
"tokens": words,
})
if include_words:
for word in words:
word_key = word.lower().strip("._-")
if len(word_key) < 3 or word_key in SCENE_STOP_WORDS or word_key in seen:
continue
seen.add(word_key)
phrases.append({
"text": word_key,
"key": word_key,
"source": source,
"category": categorize_scene_phrase(word_key, source),
"tokens": [word_key],
})
return phrases
def remember_scene_phrases(data, positive_prompt="", negative_prompt=""):
memory = data.setdefault("universal_memory", {})
changed = False
for source, prompt in (("positive", positive_prompt), ("negative", negative_prompt)):
for phrase in extract_scene_phrases(prompt, source, include_words=True):
key = phrase["key"]
current = memory.setdefault(key, {
"text": phrase["text"],
"source": source,
"category": phrase["category"],
"tokens": phrase["tokens"],
"count": 0,
"created_at": now_iso(),
"category_source": "auto",
"category_locked": False,
})
if not bool(current.get("category_locked")):
current["text"] = current.get("text") or phrase["text"]
current["source"] = source
current["category"] = phrase["category"]
current["tokens"] = phrase["tokens"]
current["category_source"] = str(current.get("category_source") or "auto")
current["count"] = int(current.get("count", 0) or 0) + 1
current["updated_at"] = now_iso()
changed = True
return changed
def scene_memory_items(data):
memory = data.get("universal_memory", {}) if isinstance(data, dict) else {}
items = []
for key, item in memory.items():
if not isinstance(item, dict):
continue
text = str(item.get("text") or key).strip()
if not text:
continue
items.append({
"text": text,
"key": str(key),
"source": item.get("source", "positive"),
"category": item.get("category", "details"),
"category_source": item.get("category_source", "auto"),
"category_locked": bool(item.get("category_locked")),
"tokens": item.get("tokens", scene_token_words(text)),
"count": int(item.get("count", 0) or 0),
"wildcard": bool(item.get("wildcard")) or bool(str(item.get("wildcard_group") or "").strip()),
})
return sorted(items, key=lambda item: (item["category"], item["text"].lower()))
def normalize_scene_memory_items(value):
if isinstance(value, dict):
iterable = value.items()
elif isinstance(value, list):
iterable = ((None, item) for item in value)
else:
return {}
memory = {}
for fallback_key, item in iterable:
if isinstance(item, str):
item = {"text": item}
if not isinstance(item, dict):
continue
text = re.sub(r"\s+", " ", str(item.get("text") or fallback_key or "").strip())
if not text:
continue
key = normalize_scene_key(item.get("key") or text) or text.lower()
source = "negative" if item.get("source") == "negative" or item.get("category") == "negative" else "positive"
category = str(item.get("category") or categorize_scene_phrase(text, source)).strip().lower()
if category not in SCENE_CATEGORIES:
category = "negative" if source == "negative" else "details"
tokens = scene_token_words(text)
wildcard = bool(item.get("wildcard")) or bool(str(item.get("wildcard_group") or "").strip())
category_source = str(item.get("category_source") or "auto").strip().lower()
if category_source not in SCENE_CATEGORY_SOURCES:
category_source = "user" if bool(item.get("category_locked")) else "auto"
category_locked = bool(item.get("category_locked")) or category_source == "user"
memory[key] = {
"text": text,
"source": source,
"category": category,
"category_source": category_source,
"category_locked": category_locked,
"tokens": tokens,
"count": max(0, int(item.get("count", 0) or 0)),
"created_at": str(item.get("created_at") or now_iso()),
"updated_at": now_iso(),
"wildcard": wildcard,
}
return memory
def scene_memory_authority_changed(previous, current):
if not isinstance(previous, dict) or not isinstance(current, dict):
return True
return any(
str(previous.get(field, "")).strip().lower() != str(current.get(field, "")).strip().lower()
for field in ("text", "source", "category")
)
def apply_scene_database_authority(previous_memory, incoming_memory):
previous_memory = previous_memory if isinstance(previous_memory, dict) else {}
incoming_memory = incoming_memory if isinstance(incoming_memory, dict) else {}
resolved = {}
for key, item in incoming_memory.items():
if not isinstance(item, dict):
continue
current = dict(item)
previous = previous_memory.get(key)
if isinstance(previous, dict) and not scene_memory_authority_changed(previous, current):
current["category_source"] = str(previous.get("category_source") or "auto")
current["category_locked"] = bool(previous.get("category_locked"))
else:
current["category_source"] = "user"
current["category_locked"] = True
resolved[key] = current
return resolved
def normalize_scene_phrase_list(value):
if isinstance(value, str):
try:
value = json.loads(value)
except (json.JSONDecodeError, TypeError, ValueError):
value = [value]
if not isinstance(value, list):
return []
result = []
seen = set()
for item in value:
text = item.get("text") if isinstance(item, dict) else item
clean = normalize_scene_text_spacing(text).strip(" ,;:.").strip()
key = clean.lower()
if not clean or key in seen:
continue
seen.add(key)
result.append(clean)
return result
def parse_scene_payload(value):
if isinstance(value, dict):
payload = value
elif isinstance(value, str) and value.strip():
try:
payload = json.loads(value)
except (json.JSONDecodeError, TypeError, ValueError):
payload = {}
else:
payload = {}
if not isinstance(payload, dict):
payload = {}
return {
"positive_phrases": normalize_scene_phrase_list(payload.get("positive_phrases", [])),
"negative_phrases": normalize_scene_phrase_list(payload.get("negative_phrases", [])),
}
def scene_text_or_payload(text, phrases):
text = str(text or "").strip()
if text:
return text
return scene_text_from_phrases(phrases)
def scene_payload_from_scene(scene):
if not isinstance(scene, dict):
return {"positive_phrases": [], "negative_phrases": []}
return {
"positive_phrases": normalize_scene_phrase_list(scene.get("positive_phrases", [])),
"negative_phrases": normalize_scene_phrase_list(scene.get("negative_phrases", [])),
}
def scene_text_from_phrases(phrases):
return ", ".join(normalize_scene_phrase_list(phrases))
def scene_prompt_text(scene, text_field, phrases_field):
if not isinstance(scene, dict):
return ""
if text_field in scene:
return str(scene.get(text_field) or "")
return scene_text_from_phrases(scene.get(phrases_field, []))
def scene_from_texts(name, aliases, output_mode, positive_text, negative_text, refinement_key=""):
positive_text = normalize_scene_text_spacing(positive_text)
negative_text = normalize_scene_text_spacing(negative_text)
return {
"name": normalize_scene_name(name),
"aliases": aliases_from_text(aliases),
"output_mode": output_mode if output_mode in {"Manual", "Auto", "Learning"} else "Manual",
"refinement_key": normalize_refinement_key(refinement_key),
"positive_text": positive_text,
"negative_text": negative_text,
"positive_phrases": normalize_scene_phrase_list(extract_scene_phrases(positive_text, "positive")),
"negative_phrases": normalize_scene_phrase_list(extract_scene_phrases(negative_text, "negative")),
}
def aliases_from_text(value):
aliases = []
seen = set()
if isinstance(value, list):
raw_values = value
else:
raw_values = re.split(r"[,;\n]+", str(value or ""))
for raw in raw_values:
clean = re.sub(r"\s+", " ", raw).strip()
key = normalize_scene_key(clean)
if clean and key not in seen:
seen.add(key)
aliases.append(clean)
return aliases
def scene_exact_match(intent_prompt, scenes):
intent = normalize_scene_key(intent_prompt)
if not intent:
return "", None
padded = f" {intent} "
for name, scene in sorted(scenes.items(), key=lambda item: len(item[0]), reverse=True):
keys = [name]
if isinstance(scene, dict):
keys.extend(scene.get("aliases", []) if isinstance(scene.get("aliases"), list) else [])
for key in keys:
clean = normalize_scene_key(key)
if clean and f" {clean} " in padded:
return name, scene
return "", None
def scene_fuzzy_match(intent_prompt, scenes):
intent_words = set(scene_token_words(intent_prompt))
if not intent_words:
return "", None
best = ("", None, 0.0)
for name, scene in scenes.items():
keys = [name]
if isinstance(scene, dict):
keys.extend(scene.get("aliases", []) if isinstance(scene.get("aliases"), list) else [])
for key in keys:
key_words = set(scene_token_words(key))
if not key_words:
continue
overlap = len(intent_words & key_words)
if overlap < max(1, len(key_words)):
continue
score = overlap / max(len(key_words), 1)
if score > best[2]:
best = (name, scene, score)
return best[0], best[1]
def find_scene_for_intent(intent_prompt, scenes):
name, scene = scene_exact_match(intent_prompt, scenes)
if name:
return name, scene, "exact"
name, scene = scene_fuzzy_match(intent_prompt, scenes)
if name:
return name, scene, "fuzzy"
return "", None, "none"
def scene_field_summary(scene):
if not isinstance(scene, dict):
return "none"
fields = []
for field in ("positive_phrases", "negative_phrases", "refinement_key"):
if scene.get(field):
fields.append(field)
if scene.get("positive_text"):
fields.append("positive_text")
if scene.get("negative_text"):
fields.append("negative_text")
return ", ".join(fields) if fields else "none"
def normalize_template_name(value):
value = str(value or "").strip()
if not value or value == TEMPLATE_NONE:
@@ -1147,57 +716,6 @@ def resolve_wildcards(text, seed=0):
return current
def resolve_scene_database_wildcards(text, scene_db):
text = str(text or "")
if not text or not isinstance(scene_db, dict):
return text
wildcard_phrases = {}
for item in scene_db.get("universal_memory", {}).values():
if not isinstance(item, dict):
continue
wildcard = bool(item.get("wildcard")) or bool(str(item.get("wildcard_group") or "").strip())
phrase = str(item.get("text") or "").strip()
if not wildcard or not phrase:
continue
wildcard_phrases[normalize_scene_key(phrase)] = phrase
if not wildcard_phrases:
return text
parts = re.split(r"([,;.\n]+)", text)
changed = False
run = []
def flush_run():
nonlocal changed, run
unique_keys = sorted({phrase_key for _, phrase_key in run})
if len(unique_keys) >= 2:
keep = random.choice(unique_keys)
for index, phrase_key in run:
if phrase_key != keep:
parts[index] = ""
changed = True
run = []
for index in range(0, len(parts), 2):
phrase_key = normalize_scene_key(parts[index])
if phrase_key and phrase_key in wildcard_phrases:
run.append((index, phrase_key))
else:
flush_run()
flush_run()
if not changed:
return text
output = "".join(parts)
output = re.sub(r"\s*([,;.])\s*([,;.]\s*)+", r"\1 ", output)
output = re.sub(r"(?:^|[\s,;.])+\n", "\n", output)
output = re.sub(r"\s+", " ", output.replace("\n", ", ")).strip(" ,;.")
return output
def prompt_key_for_refiner(prompt, mode):
del mode
return re.sub(r"\s+", " ", str(prompt or "").strip())
@@ -1353,120 +871,6 @@ async def funpack_templates_import(request):
return web.json_response({"imported": imported, "templates": template_names()})
@PromptServer.instance.routes.get("/funpack/scenes")
async def funpack_scenes(request):
key = normalize_refinement_key(request.query.get("key", ""))
data = load_scene_db(key)
return web.json_response(
{
"scenes": scene_names(key),
"path": refinement_key_path(key) if key else scene_store_path(),
"refinement_key": key,
"data": data,
"memory": scene_memory_items(data),
},
headers={"Cache-Control": "no-store, max-age=0"},
)
@PromptServer.instance.routes.get("/funpack/scenes/export")
async def funpack_scenes_export(request):
key = normalize_refinement_key(request.query.get("key", ""))
data = load_scene_db(key)
return web.json_response(
data,
headers={
"Cache-Control": "no-store, max-age=0",
"Content-Disposition": f"attachment; filename=funpack_scenes_{key or 'global'}.json",
},
)
@PromptServer.instance.routes.post("/funpack/scenes/scene")
async def funpack_scene_save(request):
key = normalize_refinement_key(request.query.get("key", ""))
body = await request.json()
if not isinstance(body, dict):
return web.json_response({"error": "Scene payload must be an object."}, status=400)
action = str(body.get("action") or "save").lower()
name = normalize_scene_name(body.get("name"))
if not name:
return web.json_response({"error": "Scene name is required."}, status=400)
data = load_scene_db(key)
scenes = data.setdefault("scenes", {})
if action == "delete":
scenes.pop(name, None)
save_scene_db(data, key)
return web.json_response({"deleted": name, "scenes": scene_names(key), "data": data})
previous = scenes.get(name, {}) if isinstance(scenes.get(name), dict) else {}
scene = scene_from_texts(
name,
body.get("aliases", previous.get("aliases", [])),
body.get("mode") or body.get("output_mode") or previous.get("output_mode", "Manual"),
body.get("positive_text", previous.get("positive_text", "")),
body.get("negative_text", previous.get("negative_text", "")),
key,
)
scene["created_at"] = previous.get("created_at", now_iso())
scene["updated_at"] = now_iso()
scenes[name] = scene
save_scene_db(data, key)
return web.json_response({"saved": name, "scenes": scene_names(key), "data": data})
@PromptServer.instance.routes.post("/funpack/scenes/database")
async def funpack_scene_database_save(request):
key = normalize_refinement_key(request.query.get("key", ""))
body = await request.json()
if not isinstance(body, dict):
return web.json_response({"error": "Database payload must be an object."}, status=400)
incoming = body.get("universal_memory", body.get("memory", {}))
memory = normalize_scene_memory_items(incoming)
data = load_scene_db(key)
memory = apply_scene_database_authority(data.get("universal_memory", {}), memory)
data["universal_memory"] = memory
save_scene_db(data, key)
return web.json_response({"saved": len(memory), "data": data, "memory": scene_memory_items(data)})
@PromptServer.instance.routes.post("/funpack/scenes/import")
async def funpack_scenes_import(request):
key = normalize_refinement_key(request.query.get("key", ""))
incoming = await request.json()
if not isinstance(incoming, dict):
return web.json_response({"error": "Imported file is not a scene database."}, status=400)
data = load_scene_db(key)
imported_memory = incoming.get("universal_memory", {})
if isinstance(imported_memory, dict):
memory = data.setdefault("universal_memory", {})
for memory_key, item in imported_memory.items():
if isinstance(item, dict):
memory[str(memory_key)] = item
imported_scenes = incoming.get("scenes", {})
if not isinstance(imported_scenes, dict):
return web.json_response({"error": "Imported file does not contain a scenes object."}, status=400)
scenes = data.setdefault("scenes", {})
imported = 0
for name, scene in imported_scenes.items():
clean_name = normalize_scene_name(name)
if not clean_name or not isinstance(scene, dict):
continue
item = dict(scene)
item["name"] = clean_name
item["updated_at"] = now_iso()
scenes[clean_name] = item
imported += 1
save_scene_db(data, key)
return web.json_response({"imported": imported, "scenes": scene_names(key), "refinement_key": key})
@PromptServer.instance.routes.get("/funpack/shortcuts")
async def funpack_shortcuts(_):
data = load_shortcut_db()
@@ -1833,254 +1237,3 @@ class FunPackRefinementKeyLoader:
return ("", "No refinement key selected. Pick an existing key or type a new key name.")
_, loaded_key, status = load_refinement_key_state(target, create=bool(create_if_missing))
return (loaded_key if loaded_key else target, status)
class FunPackSceneBuilder:
CATEGORY = "FunPack/Scene"
RETURN_TYPES = ("STRING", "STRING", "FUNPACK_LORA_STACK", "STRING")
RETURN_NAMES = (
"positive_prompt",
"negative_prompt",
"lora_stack",
"status",
)
FUNCTION = "build_scene"
OUTPUT_NODE = True
DESCRIPTION = "Builds named scene presets from manually selected universal prompt phrases."
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"scene_name": ("STRING", {"default": "", "multiline": False}),
"mode": (["Manual", "Auto", "Learning"], {"default": "Manual"}),
"scene": (scene_names(), {"default": SCENE_NONE}),
"aliases": ("STRING", {"default": "", "multiline": False}),
"action": (["load", "save", "update", "delete"], {"default": "load"}),
"scene_positive": ("STRING", {
"default": "",
"multiline": False,
"placeholder": "Write or arrange positive scene words and phrases here.",
}),
"scene_negative": ("STRING", {
"default": "",
"multiline": False,
"placeholder": "Write or arrange negative scene words and phrases here.",
}),
"refinement_key": ("STRING", {"default": "", "multiline": False}),
"scene_payload": ("STRING", {"default": "{}", "multiline": False}),
},
"optional": {
"intent_prompt": ("STRING", {
"default": "",
"multiline": True,
"forceInput": True,
"tooltip": "Connected intent text used for Auto scene detection.",
}),
"positive_prompt": ("STRING", {
"default": "",
"multiline": True,
"forceInput": True,
"tooltip": "Connected positive prompt used only to collect universal scene phrase memory.",
}),
"negative_prompt": ("STRING", {
"default": "",
"multiline": True,
"forceInput": True,
"tooltip": "Connected negative prompt used only to collect universal negative phrase memory.",
}),
"refinement_key_input": ("STRING", {
"default": "",
"multiline": False,
"forceInput": True,
"tooltip": "Optional linked refinement key, for example from FunPack Refinement Key Loader.",
}),
"lora_stack": ("FUNPACK_LORA_STACK", {
"tooltip": "Optional current LoRA stack. Scene Builder passes it through unchanged so Refiner can use it for suggestions.",
}),
},
}
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("nan")
@classmethod
def VALIDATE_INPUTS(cls, scene=None, **kwargs):
return True
def _manual_scene(self, selected_name, scene_name, aliases, mode, scene_positive, scene_negative, refinement_key, payload):
target_name = normalize_scene_name(scene_name) or selected_name
positive_text = scene_text_or_payload(scene_positive, payload["positive_phrases"])
negative_text = scene_text_or_payload(scene_negative, payload["negative_phrases"])
return scene_from_texts(target_name, aliases, mode, positive_text, negative_text, refinement_key)
def _shortcut_seed(self, refinement_key, prompt):
key = f"{normalize_refinement_key(refinement_key)}::{str(prompt or '')}"
return int(md5(key.encode("utf-8")).hexdigest()[:12], 16)
def _shortcut_status(self, applied):
if not applied:
return "Shortcuts: none."
labels = []
for item in applied[:8]:
labels.append(f"{item.get('trigger', '')}->{item.get('replacement', '')}")
suffix = f", ... ({len(applied)} total)" if len(applied) > 8 else ""
return f"Shortcuts: expanded {len(applied)} activation(s): {', '.join(labels)}{suffix}."
def _outputs_for_scene(self, name, scene, scene_db=None, lora_stack=None, source="Manual"):
positive = scene_prompt_text(scene, "positive_text", "positive_phrases")
negative = scene_prompt_text(scene, "negative_text", "negative_phrases")
positive, shortcut_applied = apply_prompt_shortcuts(
positive,
seed=self._shortcut_seed(scene.get("refinement_key", "") if isinstance(scene, dict) else "", positive),
)
positive = resolve_scene_database_wildcards(positive, scene_db)
negative = resolve_scene_database_wildcards(negative, scene_db)
lora_count = len(lora_stack.get("loras", [])) if isinstance(lora_stack, dict) else 0
status = (
f"Scene Builder {source}: '{name or scene.get('name', '') or 'unsaved'}'. "
f"Stored fields: {scene_field_summary(scene)}.\n"
f"Positive phrases: {len(scene.get('positive_phrases', []) or [])}. "
f"Negative phrases: {len(scene.get('negative_phrases', []) or [])}. "
f"LoRA stack pass-through: {lora_count} LoRA(s).\n"
f"{self._shortcut_status(shortcut_applied)}"
)
return (positive, negative, lora_stack, status)
def _learning_outputs(self, positive_prompt, negative_prompt, lora_stack=None, refinement_key=""):
positive, shortcut_applied = apply_prompt_shortcuts(
str(positive_prompt or ""),
seed=self._shortcut_seed(refinement_key, positive_prompt),
)
negative = str(negative_prompt or "")
lora_count = len(lora_stack.get("loras", [])) if isinstance(lora_stack, dict) else 0
status = (
"Scene Builder Learning: pass-through active. "
f"Collected positive source: {'yes' if positive else 'no'}. "
f"Collected negative source: {'yes' if negative else 'no'}. "
f"LoRA stack pass-through: {lora_count} LoRA(s).\n"
f"{self._shortcut_status(shortcut_applied)}"
)
return (positive, negative, lora_stack, status)
def _empty_outputs(self, status):
return ("", "", None, status)
def build_scene(
self,
scene_name,
mode="Manual",
scene=SCENE_NONE,
aliases="",
action="load",
scene_positive="",
scene_negative="",
refinement_key="",
scene_payload="{}",
intent_prompt="",
positive_prompt="",
negative_prompt="",
refinement_key_input="",
lora_stack=None,
output_mode=None,
):
linked_key = normalize_refinement_key(refinement_key_input)
if linked_key:
refinement_key = linked_key
selected_name = normalize_scene_name(scene)
if output_mode in {"Manual", "Auto", "Learning"}:
mode = output_mode
mode = mode if mode in {"Manual", "Auto", "Learning"} else "Manual"
action = action if action in {"load", "save", "update", "delete"} else "load"
payload = parse_scene_payload(scene_payload)
data = load_scene_db(refinement_key)
memory_positive = "\n".join(
item for item in (
positive_prompt,
scene_positive,
scene_text_from_phrases(payload.get("positive_phrases", [])),
)
if str(item or "").strip()
)
memory_negative = "\n".join(
item for item in (
negative_prompt,
scene_negative,
scene_text_from_phrases(payload.get("negative_phrases", [])),
)
if str(item or "").strip()
)
memory_changed = remember_scene_phrases(data, memory_positive, memory_negative)
scenes = data.setdefault("scenes", {})
manual = self._manual_scene(
selected_name,
scene_name,
aliases,
mode,
scene_positive,
scene_negative,
refinement_key,
payload,
)
if action == "delete":
if not selected_name or selected_name not in scenes:
if memory_changed:
save_scene_db(data, refinement_key)
return self._empty_outputs("Delete skipped: no selected scene.")
deleted = scenes.pop(selected_name)
save_scene_db(data, refinement_key)
return self._empty_outputs(
f"Deleted scene '{selected_name}'. Removed fields: {scene_field_summary(deleted)}."
)
if action == "save":
target_name = normalize_scene_name(scene_name) or selected_name
if not target_name:
if memory_changed:
save_scene_db(data, refinement_key)
return self._empty_outputs("Save skipped: enter a scene name.")
manual["name"] = target_name
manual["created_at"] = scenes.get(target_name, {}).get("created_at", now_iso())
manual["updated_at"] = now_iso()
scenes[target_name] = manual
save_scene_db(data, refinement_key)
return self._outputs_for_scene(target_name, manual, data, lora_stack, "Manual saved")
if action == "update":
target_name = selected_name or normalize_scene_name(scene_name)
if not target_name:
if memory_changed:
save_scene_db(data, refinement_key)
return self._empty_outputs("Update skipped: select a scene or enter a scene name.")
previous = scenes.get(target_name, {})
manual["name"] = target_name
manual["created_at"] = previous.get("created_at", now_iso()) if isinstance(previous, dict) else now_iso()
manual["updated_at"] = now_iso()
scenes[target_name] = manual
save_scene_db(data, refinement_key)
return self._outputs_for_scene(target_name, manual, data, lora_stack, "Manual updated")
if mode == "Learning":
if memory_changed:
save_scene_db(data, refinement_key)
return self._learning_outputs(positive_prompt, negative_prompt, lora_stack, refinement_key)
if mode == "Auto":
matched_name, matched_scene, match_type = find_scene_for_intent(intent_prompt, scenes)
if matched_name and isinstance(matched_scene, dict):
if memory_changed:
save_scene_db(data, refinement_key)
return self._outputs_for_scene(matched_name, matched_scene, data, lora_stack, f"Auto {match_type}")
if action == "load" and selected_name and selected_name in scenes and not scene_positive and not scene_negative:
selected_scene = scenes.get(selected_name, {})
if memory_changed:
save_scene_db(data, refinement_key)
return self._outputs_for_scene(selected_name, selected_scene, data, lora_stack, "Manual loaded")
if memory_changed:
save_scene_db(data, refinement_key)
return self._outputs_for_scene(manual.get("name", ""), manual, data, lora_stack, "Manual")
File diff suppressed because it is too large Load Diff
+133 -712
View File
@@ -256,141 +256,10 @@ def test_wrong_appearance_rating_is_available():
assert "Wrong appearance" in V2_RATING_LABELS
assert profile["key"] == "wrong_appearance"
assert profile["wrong_categories"] == ["appearance", "subject", "environment"]
def test_prompt_repair_blocks_appearance_subject_and_environment():
refiner = FunPackVideoRefinerV2()
global_state = {
"phrase_memory": {
"white tights": {
"text": "white tights",
"primary": "appearance",
"effective_category_scores": refiner._v2_heuristic_scores("white tights"),
"wanted_axes": {"details": 3},
"score": 6.0,
"liked_count": 4,
},
"detailed background": {
"text": "detailed background",
"primary": "environment",
"effective_category_scores": refiner._v2_heuristic_scores("detailed background"),
"wanted_axes": {"details": 3},
"score": 6.0,
"liked_count": 4,
},
"female character": {
"text": "female character",
"primary": "subject",
"effective_category_scores": refiner._v2_heuristic_scores("female character"),
"wanted_axes": {"details": 3},
"score": 6.0,
"liked_count": 4,
},
"tiny smoke curls": {
"text": "tiny smoke curls",
"primary": "details",
"effective_category_scores": refiner._v2_heuristic_scores("tiny smoke curls"),
"wanted_axes": {"details": 3},
"score": 4.0,
"liked_count": 2,
},
}
}
profile = normalize_refiner_v2_rating("Missing details")
feedback = refiner._v2_axis_feedback(profile, None)
repaired, _, candidates = refiner._v2_repair_prompt_for_missing_axes(
"person smoking",
refiner._v2_classify_phrases(None, prompt_items(refiner, ["person smoking"]), global_state),
global_state,
None,
feedback,
)
assert "tiny smoke curls" in repaired
assert "white tights" not in repaired
assert "detailed background" not in repaired
assert "female character" not in repaired
assert all(candidate["text"] != "white tights" for candidate in candidates)
def test_scene_builder_user_category_overrides_refiner_classification():
refiner = FunPackVideoRefinerV2()
scene_db = {
"universal_memory": {
"running pose": {
"text": "running pose",
"category": "appearance",
"category_source": "user",
"category_locked": True,
}
}
}
phrase = refiner._v2_classify_phrases(
None,
[{"text": "running pose", "tokens": refiner._v2_phrase_words("running pose")}],
{"phrase_memory": {}},
scene_db=scene_db,
)[0]
assert phrase["primary"] == "appearance"
assert phrase["source"] == "scene_builder_user"
assert phrase["scene_category_locked"] is True
def test_scene_builder_locked_category_is_not_trained_by_refiner():
refiner = FunPackVideoRefinerV2()
scene_db = {
"universal_memory": {
"running pose": {
"text": "running pose",
"category": "appearance",
"category_source": "user",
"category_locked": True,
}
}
}
global_state = {"phrase_memory": {}}
phrase = refiner._v2_classify_phrases(
None,
[{"text": "running pose", "tokens": refiner._v2_phrase_words("running pose")}],
global_state,
scene_db=scene_db,
)[0]
refiner._v2_update_phrase_memory(
global_state,
{"prompt": "running pose", "phrases": [phrase]},
normalize_refiner_v2_rating("Perfect"),
1,
refiner._v2_axis_feedback(normalize_refiner_v2_rating("Perfect"), None),
)
entry = global_state["phrase_memory"]["running pose"]
assert entry["primary"] == "appearance"
assert entry["category_locked"] is True
assert entry["category_evidence_count"] == 0
assert entry["liked_count"] == 0
def test_scene_builder_wildcard_cleanup_runs_after_refiner_processing():
refiner = FunPackVideoRefinerV2()
scene_db = {
"universal_memory": {
"red dress": {"text": "red dress", "category": "appearance", "wildcard": True},
"blue dress": {"text": "blue dress", "category": "appearance", "wildcard": True},
}
}
prompt, status = refiner._v2_resolve_scene_builder_wildcards(
"person walking, red dress, blue dress",
scene_db,
)
assert prompt == "person walking, red dress"
assert "removed 1 duplicate" in status
# Wrong appearance now drives the consistency anchor, not prompt repair (the dead
# wrong_categories field was dropped in Stage 2 Part B). It stays quality-neutral.
assert "wrong_categories" not in profile
assert profile["skip_value_function"] is True
def test_lucky_composition_gates_unrelated_old_action_by_current_intent():
@@ -428,26 +297,6 @@ def test_lucky_composition_gates_unrelated_old_action_by_current_intent():
assert "Lucky: on" in status
def test_perfect_repair_does_not_inject_after_scene_change():
refiner = FunPackVideoRefinerV2()
slot = {
"perfect_repairs": {
"dancing in rain": {"text": "dancing in rain", "axes": {"action": 1}, "count": 3}
}
}
repaired, status, adjustments = refiner._v2_apply_perfect_repair_phrases(
"walking on beach",
slot,
intent_prompt="walking on beach",
intent_phrases=prompt_phrases(refiner, "walking on beach"),
)
assert repaired == "walking on beach"
assert adjustments == []
assert "already represented" in status
def test_intent_alignment_learns_missing_original_intent_from_enhancer_variant():
refiner = FunPackVideoRefinerV2()
global_state = {"phrase_memory": {}, "intent_alignment_memory": {}}
@@ -616,96 +465,6 @@ def test_intent_alignment_omits_bad_token_in_new_enhancer_phrase():
]
def test_repeated_intent_family_phrase_becomes_conservative_repair_candidate():
refiner = FunPackVideoRefinerV2()
global_state = {
"phrase_memory": {},
"intent_family_memory": {},
"perfect_anchors": {},
"variant_evidence": {},
"intent_preference_phrases": {},
"conditioning_deltas": {},
}
intent_prompt = "yellow car riding down the road"
enhanced_prompt = "yellow car in a desert"
profile = normalize_refiner_v2_rating("Missing action")
feedback = refiner._v2_axis_feedback(profile, None)
for iteration in range(2):
refiner._v2_update_intent_family_memory(
global_state,
{
"prompt": enhanced_prompt,
"phrases": prompt_phrases(refiner, enhanced_prompt, global_state),
"intent_prompt": intent_prompt,
"intent_phrases": prompt_phrases(refiner, intent_prompt, global_state),
},
profile,
iteration + 1,
feedback,
)
current_intent = "yellow car down the road"
_, family_slot, _ = refiner._v2_intent_family_slot(global_state, current_intent, create=False)
repaired, status, candidates = refiner._v2_repair_prompt_for_missing_axes(
enhanced_prompt,
prompt_phrases(refiner, enhanced_prompt, global_state),
global_state,
None,
feedback,
intent_phrases=prompt_phrases(refiner, current_intent, global_state),
intent_family_slot=family_slot,
)
assert "yellow car riding down the road" in repaired
assert "intent_preference" in status
assert any(candidate["source"] == "intent_preference" for candidate in candidates)
def test_repeated_intent_preference_does_not_repair_unrelated_family():
refiner = FunPackVideoRefinerV2()
global_state = {
"phrase_memory": {},
"intent_family_memory": {},
"perfect_anchors": {},
"variant_evidence": {},
"intent_preference_phrases": {},
"conditioning_deltas": {},
}
yellow_intent = "yellow car riding down the road"
dragon_intent = "dragon sleeping in a cave"
profile = normalize_refiner_v2_rating("Missing action")
feedback = refiner._v2_axis_feedback(profile, None)
for iteration in range(2):
refiner._v2_update_intent_family_memory(
global_state,
{
"prompt": "yellow car in a desert",
"phrases": prompt_phrases(refiner, "yellow car in a desert", global_state),
"intent_prompt": yellow_intent,
"intent_phrases": prompt_phrases(refiner, yellow_intent, global_state),
},
profile,
iteration + 1,
feedback,
)
_, dragon_slot, _ = refiner._v2_intent_family_slot(global_state, dragon_intent, create=True)
repaired, _, candidates = refiner._v2_repair_prompt_for_missing_axes(
"dragon in a cave",
prompt_phrases(refiner, "dragon in a cave", global_state),
global_state,
None,
feedback,
intent_phrases=prompt_phrases(refiner, dragon_intent, global_state),
intent_family_slot=dragon_slot,
)
assert "yellow car riding down the road" not in repaired
assert all(candidate["text"] != "yellow car riding down the road" for candidate in candidates)
def test_active_repair_axes_persist_until_perfect():
refiner = FunPackVideoRefinerV2()
global_state = {"active_repair_axes": []}
@@ -731,78 +490,6 @@ def test_active_repair_axes_persist_until_perfect():
assert "cleared by Perfect" in status
def test_perfect_rating_preserves_successful_repair_phrases():
refiner = FunPackVideoRefinerV2()
global_state = {
"phrase_memory": {},
"intent_family_memory": {},
"perfect_anchors": {},
"variant_evidence": {},
"intent_preference_phrases": {},
"conditioning_deltas": {},
}
intent_prompt = "person smoking"
profile = normalize_refiner_v2_rating("Perfect")
feedback = refiner._v2_axis_feedback(profile, None)
source = tensor_to_serializable(torch.zeros(1, 3, 2))
refined = tensor_to_serializable(torch.ones(1, 3, 2))
refiner._v2_update_intent_family_memory(
global_state,
{
"prompt": "person smoking",
"encoded_prompt": "person smoking, tiny smoke curls",
"phrases": prompt_phrases(refiner, "person smoking", global_state),
"intent_prompt": intent_prompt,
"intent_phrases": prompt_phrases(refiner, intent_prompt, global_state),
"source_conditioning": source,
"conditioning": refined,
"repair_candidates": [{"text": "tiny smoke curls", "axes": ["details"], "score": 2.0, "source": "memory"}],
},
profile,
1,
feedback,
)
_, slot, _ = refiner._v2_intent_family_slot(global_state, intent_prompt, create=False)
repaired, status, adjustments = refiner._v2_apply_perfect_repair_phrases("person smoking", slot)
assert "tiny smoke curls" in repaired
assert "Perfect-proven" in status
assert adjustments == [{"text": "tiny smoke curls", "source": "perfect_repair", "action": "added"}]
def test_prompt_repair_falls_back_to_any_axis_candidates_when_requested_axis_is_empty():
refiner = FunPackVideoRefinerV2()
global_state = {
"phrase_memory": {
"portrait quality": {
"text": "portrait quality",
"primary": "quality",
"effective_category_scores": refiner._v2_heuristic_scores("portrait quality"),
"wanted_axes": {"quality": 3},
"score": 4.0,
"liked_count": 2,
"category_evidence_count": 2,
}
}
}
profile = normalize_refiner_v2_rating("Missing action")
feedback = refiner._v2_axis_feedback(profile, None)
repaired, status, candidates = refiner._v2_repair_prompt_for_missing_axes(
"static portrait",
prompt_phrases(refiner, "static portrait", global_state),
global_state,
None,
feedback,
)
assert "portrait quality" in repaired
assert "showing other-axis repair candidates" in status
assert candidates[0]["text"] == "portrait quality"
assert candidates[0]["source"] == "memory"
def test_intent_family_perfect_anchor_keeps_loved_variant():
refiner = FunPackVideoRefinerV2()
global_state = {
@@ -1899,258 +1586,6 @@ def test_normal_previous_run_can_train_before_current_refusal_is_discarded(tmp_p
assert "sorry, but i can't assist with that request." not in state["prompt_histories"]
def test_liked_action_detail_context_clusters_repair_missing_axes():
refiner = FunPackVideoRefinerV2()
global_state = {"phrase_memory": {}, "preferred_context_memory": {}}
rich_prompt = "woman walking reaching hand tiny particles rain reflections"
liked_phrases = refiner._v2_classify_phrases(
None,
prompt_items(refiner, [
"woman",
"walking",
"reaching hand",
"tiny particles",
"rain reflections",
]),
global_state,
)
profile = normalize_refiner_v2_rating("Perfect")
feedback = refiner._v2_axis_feedback(profile, None)
refiner._v2_update_phrase_memory(
global_state,
{"prompt": rich_prompt, "phrases": liked_phrases},
profile,
1,
feedback,
)
assert "Preferred context stored" in refiner._v2_update_preferred_context_memory(
global_state,
{"phrases": liked_phrases},
profile,
2,
feedback,
)
missing_profile = normalize_refiner_v2_rating("Missing details + action")
missing_feedback = refiner._v2_axis_feedback(missing_profile, None)
current_phrases = refiner._v2_classify_phrases(
None,
prompt_items(refiner, ["woman", "rain"]),
global_state,
)
intent_phrases = refiner._v2_classify_phrases(
None,
refiner._ordered_prompt_phrases(rich_prompt),
global_state,
)
repaired, status, candidates = refiner._v2_repair_prompt_for_missing_axes(
"woman, rain",
current_phrases,
global_state,
None,
missing_feedback,
intent_phrases=intent_phrases,
)
assert "walking" in repaired
assert "tiny particles" in repaired
assert "intent" in status or "preferred_context" in status
assert len(candidates) >= 2
def test_prompt_repair_does_not_pull_unrequested_memory_for_any_missing_axis():
refiner = FunPackVideoRefinerV2()
global_state = {
"phrase_memory": {
"walking": {
"text": "walking",
"primary": "action",
"effective_category_scores": refiner._v2_heuristic_scores("walking"),
"wanted_axes": {"action": 5},
"score": 5.0,
"liked_count": 5,
},
"tiny particles": {
"text": "tiny particles",
"primary": "details",
"effective_category_scores": refiner._v2_heuristic_scores("tiny particles"),
"wanted_axes": {"details": 5},
"score": 5.0,
"liked_count": 5,
},
"cinematic lighting": {
"text": "cinematic lighting",
"primary": "style",
"effective_category_scores": refiner._v2_heuristic_scores("cinematic lighting"),
"wanted_axes": {"quality": 5},
"score": 5.0,
"liked_count": 5,
},
"glass reflections": {
"text": "glass reflections",
"primary": "details",
"effective_category_scores": refiner._v2_heuristic_scores("glass reflections"),
"wanted_axes": {"details": 2},
"score": 2.0,
"liked_count": 1,
},
},
}
profile = normalize_refiner_v2_rating("Awful")
feedback = refiner._v2_axis_feedback(profile, None)
current_phrases = refiner._v2_classify_phrases(
None,
prompt_items(refiner, ["woman", "glass"]),
global_state,
)
repaired, _, candidates = refiner._v2_repair_prompt_for_missing_axes(
"woman, glass",
current_phrases,
global_state,
None,
feedback,
)
candidate_texts = {candidate["text"] for candidate in candidates}
assert "glass reflections" in repaired
assert "walking" not in repaired
assert "tiny particles" not in repaired
assert "cinematic lighting" not in repaired
assert "walking" not in candidate_texts
assert "tiny particles" not in candidate_texts
assert "cinematic lighting" not in candidate_texts
def test_prompt_repair_treats_same_word_with_different_neighbours_as_different():
refiner = FunPackVideoRefinerV2()
global_state = {
"phrase_memory": {
"turning head": {
"text": "turning head",
"primary": "action",
"effective_category_scores": refiner._v2_heuristic_scores("turning head"),
"wanted_axes": {"action": 4},
"score": 4.0,
"liked_count": 4,
"context_senses": {
"mid|eyes,portrait": {
"context": {
"anchor_words": ["head", "turning"],
"context_words": ["portrait", "eyes"],
"position_bucket": "mid",
"window": 3,
},
"category_weights": refiner._v2_category_template(0.0),
"effective_category_scores": refiner._v2_heuristic_scores("turning head"),
"category_evidence_count": 4,
"occurrence_count": 4,
},
},
},
},
}
profile = normalize_refiner_v2_rating("Missing action")
feedback = refiner._v2_axis_feedback(profile, None)
current_phrases = refiner._v2_classify_phrases(
None,
prompt_items(refiner, ["car", "turning wheel", "street"]),
global_state,
)
repaired, _, candidates = refiner._v2_repair_prompt_for_missing_axes(
"car, turning wheel, street",
current_phrases,
global_state,
None,
feedback,
)
assert "turning head" not in repaired
assert all(candidate["text"] != "turning head" for candidate in candidates)
def test_vague_user_intent_lets_enhanced_positive_prompt_anchor_repair():
refiner = FunPackVideoRefinerV2()
global_state = {
"phrase_memory": {
"walking slowly": {
"text": "walking slowly",
"primary": "action",
"effective_category_scores": refiner._v2_heuristic_scores("walking slowly"),
"wanted_axes": {"action": 3},
"score": 3.0,
"liked_count": 3,
},
},
}
assert refiner._v2_user_intent_prompt_is_vague("Figure it out")
assert not refiner._v2_user_intent_prompt_is_vague("person walking")
profile = normalize_refiner_v2_rating("Missing action")
feedback = refiner._v2_axis_feedback(profile, None)
current_phrases = refiner._v2_classify_phrases(
None,
prompt_items(refiner, ["woman", "walking through glass hallway"]),
global_state,
)
repaired, _, candidates = refiner._v2_repair_prompt_for_missing_axes(
"woman, walking through glass hallway",
current_phrases,
global_state,
None,
feedback,
intent_phrases=[],
)
assert "walking slowly" in repaired
assert any(candidate["text"] == "walking slowly" for candidate in candidates)
def test_complex_user_intent_ranks_before_matching_preferred_memory():
refiner = FunPackVideoRefinerV2()
global_state = {
"phrase_memory": {},
"preferred_context_memory": {
"reach-memory": {
"anchor": "reaching hand slowly",
"axes": {"action": 4},
"context": {},
"phrases": ["reaching hand slowly"],
"score": 10.0,
"liked_count": 8,
},
},
}
profile = normalize_refiner_v2_rating("Missing action")
feedback = refiner._v2_axis_feedback(profile, None)
current_phrases = refiner._v2_classify_phrases(
None,
prompt_items(refiner, ["woman"]),
global_state,
)
intent_phrases = refiner._v2_classify_phrases(
None,
refiner._ordered_prompt_phrases("woman reaching hand toward glass"),
global_state,
)
repaired, _, candidates = refiner._v2_repair_prompt_for_missing_axes(
"woman",
current_phrases,
global_state,
None,
feedback,
intent_phrases=intent_phrases,
)
assert "reaching hand toward glass" in repaired
assert candidates[0]["source"] == "intent"
def test_phrase_clusters_train_more_strongly_than_ngrams_and_tokens():
refiner = FunPackVideoRefinerV2()
global_state = {"phrase_memory": {}, "preferred_context_memory": {}}
@@ -2177,29 +1612,6 @@ def test_phrase_clusters_train_more_strongly_than_ngrams_and_tokens():
assert phrase_score > ngram_score > token_score
def test_repair_candidates_saved_as_json_lists(tmp_path):
refiner = FunPackVideoRefinerV2()
state_path = tmp_path / "state.json"
refiner._v2_state_path = lambda refinement_key: str(state_path)
rich_prompt = "woman, walking, reaching hand, tiny particles, rain reflections"
refiner.refine_v2(rich_prompt, FakeClip(), "Perfect", "json-repair-test")
refiner.refine_v2(rich_prompt, FakeClip(), "Perfect", "json-repair-test")
refiner.refine_v2(
"woman, rain",
FakeClip(),
"Missing details + action",
"json-repair-test",
user_intent_prompt=rich_prompt,
)
state = json.loads(state_path.read_text(encoding="utf-8"))
candidates = state["last_run"]["repair_candidates"]
assert candidates
assert isinstance(candidates[0]["axes"], list)
assert "walking" in state["last_run"]["encoded_prompt"]
def test_wrong_action_rating_preserves_quality_but_marks_action_wrong():
refiner = FunPackVideoRefinerV2()
global_state = {"phrase_memory": {}, "preferred_context_memory": {}}
@@ -2306,126 +1718,6 @@ def test_split_by_transitions_disabled_returns_single_entry(tmp_path):
assert "Transition split" not in status
def test_split_prompt_by_transitions_detects_known_phrases():
refiner = FunPackVideoRefinerV2()
segments = refiner._v2_split_prompt_by_transitions(
"a dog running in a field, scene one jumping over a fence, cut to stopping"
)
assert len(segments) == 3
segments_none = refiner._v2_split_prompt_by_transitions("a dog running in a field")
assert len(segments_none) == 1
standalone_then = refiner._v2_split_prompt_by_transitions("a dog running in a field, then jumping")
assert len(standalone_then) == 1
scene_labels = refiner._v2_split_prompt_by_transitions(
"character description, scene one shows walking, scene -999999 shows fighting, scene minus infinity shows resting"
)
assert len(scene_labels) == 4
plural_description = refiner._v2_split_prompt_by_transitions(
"character description shown in three distinct scenes"
)
assert len(plural_description) == 1
def test_transition_scene_preview_uses_full_first_segment():
refiner = FunPackVideoRefinerV2()
segs = refiner._v2_split_prompt_by_transitions(
"In this anime video, Hiyuki is a mature woman, scene one she walks, cut to she sits"
)
assert len(segs) == 3
texts = refiner._v2_transition_scene_texts(segs)
assert len(texts) == 2
assert texts[0].startswith("In this anime video, Hiyuki is a mature woman")
assert texts[1].startswith("In this anime video, Hiyuki is a mature woman")
assert "scene one she walks" in texts[0]
assert "she sits" in texts[1]
def test_scene_labels_after_transitions_are_scene_text_not_boundaries(tmp_path):
refiner = FunPackVideoRefinerV2()
state_path = tmp_path / "state.json"
refiner._v2_state_path = lambda refinement_key: str(state_path)
prompt = (
"In this 3D animated video, Kai'Sa from League of Legends is shown in three distinct scenes. "
"Scene ten shows her walking through a neon corridor, "
"scene -999999 shows her charging her weapons, "
"scene minus infinity shows her landing on a rooftop."
)
segments = refiner._v2_split_prompt_by_transitions(prompt)
texts = refiner._v2_transition_scene_texts(segments)
assert len(texts) == 3
assert all("Kai'Sa from League of Legends" in text for text in texts)
assert "Scene ten shows her walking" in texts[0]
assert "scene -999999 shows her charging" in texts[1]
assert "scene minus infinity shows her landing" in texts[2]
assert "shown in three distinct scenes" not in texts[0].split(", ")[-1]
cond, status, _, _, encoded_prompts, _, _ = refiner.refine_v2(
prompt,
FakeClip(),
"Perfect",
"transition-label-text-test",
split_by_transitions=True,
)
assert len(cond) == 3
assert "Transition split: 3 scenes detected" in status
assert "Scene 3" in encoded_prompts
assert "Scene 4" not in encoded_prompts
def test_prompt_starting_with_scene_label_keeps_first_scene():
refiner = FunPackVideoRefinerV2()
prompt = "scene ten: she walks into a room, scene one: she walks out"
segments = refiner._v2_split_prompt_by_transitions(prompt)
texts = refiner._v2_transition_scene_texts(segments)
assert len(texts) == 2
assert texts[0].startswith("scene ten she walks into a room")
assert texts[1].startswith("scene one she walks out")
def test_new_transition_phrases_and_then_handling():
refiner = FunPackVideoRefinerV2()
prompt = (
"character anchor, as the action continues she runs, "
"as the video progresses she turns, "
"the final sequence shows her smiling, "
"the final shot returns to her face, "
"in the final moments she waves, "
"a final shot zooms out from the city, "
"the camera then shifts to the sky, "
"the camera then pulls back from her, "
"the camera shifts to the window, "
"the camera zooms in on her eyes, "
"finally, the scene transitions into rain, "
"the scene transitions to a rooftop, "
"the scene shifts to a hallway, "
"in the next segment she sits"
)
segments = refiner._v2_split_prompt_by_transitions(prompt)
texts = refiner._v2_transition_scene_texts(segments)
assert len(texts) == 14
assert all(text.startswith("character anchor") for text in texts)
assert "the final shot returns to her face" in texts[3]
assert "a final shot zooms out from the city" in texts[5]
assert "the camera then shifts to the sky" in texts[6]
assert "the camera then pulls back from her" in texts[7]
assert "the camera shifts to the window" in texts[8]
assert "the camera zooms in on her eyes" in texts[9]
assert "finally, the scene transitions into rain" in texts[10]
assert "the scene transitions to a rooftop" in texts[11]
def test_split_by_transitions_has_no_hard_scene_cap(tmp_path):
refiner = FunPackVideoRefinerV2()
state_path = tmp_path / "state.json"
@@ -2580,3 +1872,132 @@ def test_split_by_transitions_uses_provided_scene_seeds(tmp_path):
assert [item[1]["funpack_scene_seed"] for item in cond] == [900, 901]
assert all(item[1]["funpack_seed_source"] == "successful seed memory" for item in cond)
# --- Stage 2 Part B: "Wrong appearance" consistency anchor ---
def _mean_cosine(a, b):
a = a.reshape(-1, a.shape[-1]).float()
b = b.reshape(-1, b.shape[-1]).float()
a = a / a.norm(dim=-1, keepdim=True).clamp_min(1e-8)
b = b / b.norm(dim=-1, keepdim=True).clamp_min(1e-8)
return float((a * b).sum(dim=-1).mean().item())
def test_appearance_anchor_stores_blessed_on_good_rating():
refiner = FunPackVideoRefinerV2()
global_state = {}
payload = tensor_to_serializable(torch.randn(1, 6, 8))
previous_run = {"conditioning": payload, "prompt": "woman in red dress"}
status = refiner._v2_update_appearance_anchor(
global_state, previous_run, normalize_refiner_v2_rating("Perfect"), 3
)
assert "blessed" in status.lower()
assert global_state["appearance_anchor"]["conditioning"] is payload
assert global_state["appearance_anchor"]["count"] == 1
# A good rating should NOT populate the drift slot.
assert "appearance_drift" not in global_state
def test_appearance_anchor_stores_drift_on_wrong_appearance():
refiner = FunPackVideoRefinerV2()
global_state = {}
payload = tensor_to_serializable(torch.randn(1, 6, 8))
previous_run = {"conditioning": payload, "prompt": "woman in red dress"}
refiner._v2_update_appearance_anchor(
global_state, previous_run, normalize_refiner_v2_rating("Wrong appearance"), 4
)
assert global_state["appearance_drift"]["conditioning"] is payload
# Wrong appearance must not overwrite/seed the blessed anchor.
assert "appearance_anchor" not in global_state
def test_good_rating_overwrites_blessed_anchor():
refiner = FunPackVideoRefinerV2()
global_state = {}
first = tensor_to_serializable(torch.randn(1, 6, 8))
second = tensor_to_serializable(torch.randn(1, 6, 8))
refiner._v2_update_appearance_anchor(
global_state, {"conditioning": first, "prompt": "a"}, normalize_refiner_v2_rating("Perfect"), 1
)
refiner._v2_update_appearance_anchor(
global_state, {"conditioning": second, "prompt": "b"}, normalize_refiner_v2_rating("Nailed it"), 2
)
assert global_state["appearance_anchor"]["conditioning"] is second
assert global_state["appearance_anchor"]["count"] == 2
def test_appearance_anchor_skips_when_no_conditioning_or_skip_learning():
refiner = FunPackVideoRefinerV2()
global_state = {}
# No conditioning payload on the run.
refiner._v2_update_appearance_anchor(
global_state, {"prompt": "x"}, normalize_refiner_v2_rating("Perfect"), 1
)
assert "appearance_anchor" not in global_state
# skip_learning rating (e.g. -Just forget it-) never stores.
payload = tensor_to_serializable(torch.randn(1, 6, 8))
refiner._v2_update_appearance_anchor(
global_state, {"conditioning": payload}, normalize_refiner_v2_rating("-Just forget it-"), 1
)
assert "appearance_anchor" not in global_state
def test_wrong_appearance_pulls_toward_blessed_and_repels_drift():
refiner = FunPackVideoRefinerV2()
torch.manual_seed(0)
original = torch.randn(1, 6, 8)
blessed = torch.randn(1, 6, 8)
drift = torch.randn(1, 6, 8)
global_state = {
"appearance_anchor": {"conditioning": tensor_to_serializable(blessed)},
"appearance_drift": {"conditioning": tensor_to_serializable(drift)},
}
refined, status = refiner._v2_apply_conditioning_memory(
original.clone(), global_state, normalize_refiner_v2_rating("Wrong appearance")
)
assert "appearance-anchor: pull→blessed + repel←drift" in status
# Output moved toward the blessed appearance and away from the drift (direction).
assert _mean_cosine(refined, blessed) > _mean_cosine(original, blessed)
assert _mean_cosine(refined, drift) < _mean_cosine(original, drift)
# Gentle: per-token norm is preserved and the net change is small.
assert torch.allclose(
refined.norm(dim=-1), original.norm(dim=-1), atol=1e-3
)
assert (refined - original).norm() / original.norm() < 0.08
def test_appearance_anchor_idle_without_blessed():
refiner = FunPackVideoRefinerV2()
torch.manual_seed(1)
original = torch.randn(1, 6, 8)
refined, status = refiner._v2_apply_conditioning_memory(
original.clone(), {}, normalize_refiner_v2_rating("Wrong appearance")
)
assert "appearance-anchor idle" in status
assert torch.allclose(refined, original, atol=1e-4)
def test_appearance_anchor_not_applied_on_non_wrong_appearance_rating():
refiner = FunPackVideoRefinerV2()
torch.manual_seed(2)
original = torch.randn(1, 6, 8)
blessed = torch.randn(1, 6, 8)
global_state = {"appearance_anchor": {"conditioning": tensor_to_serializable(blessed)}}
# A "Missing details" rating must not trigger the appearance anchor pull.
refined, status = refiner._v2_apply_conditioning_memory(
original.clone(), global_state, normalize_refiner_v2_rating("Missing details")
)
assert "appearance-anchor idle" in status
File diff suppressed because it is too large Load Diff
+2 -196
View File
@@ -9,17 +9,14 @@ const ADVISOR_DTYPES = ["bfloat16", "float16", "float32"];
const REFINER_MODES = ["Refine", "Prompt only", "Learning"];
const ADVISOR_MODES = ["Off", "Only diagnostics", "Only prompt", "Full"];
const TEMPORAL_STYLES = ["natural", "auto", "accelerate", "decelerate", "loop", "freeze", "pulse"];
const SB_MODES = ["Pass-through", "Manual", "Auto", "Learning"];
const CATEGORY_ORDER = ["action", "camera", "subject", "appearance", "environment", "style", "quality", "details"];
const TABS = ["Session", "Scene", "Shortcuts", "Split markers", "Refiner", "Advisor", "LoRA", "Sampler", "Adjustments", "Timeline"];
const TABS = ["Session", "Shortcuts", "Split markers", "Refiner", "Advisor", "LoRA", "Sampler", "Adjustments", "Timeline"];
const SAMPLER_TYPES = ["Hybrid Euler 2S", "Distilled Flow", "Normalizing", "KSampler"];
const MOTION_PULSE_MODES = ["off", "balanced", "aggressive", "custom"];
const VELOCITY_BIAS_MODES = ["off", "capture", "apply", "capture_and_apply"];
const KSAMPLER_NAMES = ["euler", "euler_ancestral", "dpm_2", "dpm_2_ancestral", "dpmpp_2m", "dpmpp_sde", "ddim", "uni_pc"];
let activePanel = null;
let studioSceneData = null;
let studioSceneKey = null;
let studioShortcutData = null;
let studioTransitionData = null;
@@ -68,8 +65,7 @@ function defaultSettings() {
return {
refinement_key: "",
overrides: { refinement_key: false, feedback_prompt: false, user_intent_prompt: false, negative_prompt: false },
scene_builder: { mode: "Pass-through", scene: NONE_SENTINEL, scene_name: "", aliases: "", scene_positive: "", scene_negative: "" },
refiner: { mode: "Refine", advisor_mode: "Off", advisor_thinking: true, prompt_repair: true, im_feeling_lucky: false, reset_session: false, feedback_prompt: "", user_intent_prompt_override: "", negative_prompt: "", temporal_style: "natural", split_by_transitions: false, split_transition_placement: "start", reference_injection: false, vision_conditioning: true, value_guidance: true, steer_mode: "relative", absolute_strength: 0.6 },
refiner: { mode: "Refine", advisor_mode: "Off", advisor_thinking: true, im_feeling_lucky: false, reset_session: false, feedback_prompt: "", user_intent_prompt_override: "", negative_prompt: "", temporal_style: "natural", split_by_transitions: false, split_transition_placement: "start", reference_injection: false, vision_conditioning: true, value_guidance: true, steer_mode: "relative", absolute_strength: 0.6 },
advisor_llm: { enabled: false, model_path: "huihui-ai/Huihui-Qwen3-8B-abliterated-v2", dtype: "bfloat16" },
loras: [],
loras_config: { mode: "ltx2", per_block: false },
@@ -118,27 +114,6 @@ function hideWidget(widget) {
// ─── API calls ────────────────────────────────────────────────────────────────
async function fetchScenes(key = "") {
try {
const params = new URLSearchParams({ cache_bust: Date.now() });
if (key) params.set("key", key);
const res = await api.fetchApi(`/funpack/scenes?${params}`, { cache: "no-store" });
if (!res.ok) return null;
studioSceneData = await res.json();
studioSceneKey = key || "";
return studioSceneData;
} catch { return null; }
}
async function saveScene(key, payload) {
const params = key ? `?key=${encodeURIComponent(key)}` : "";
const res = await api.fetchApi(`/funpack/scenes/scene${params}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "save", ...payload }),
});
if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error || `HTTP ${res.status}`); }
}
async function fetchShortcuts() {
try {
const params = new URLSearchParams({ cache_bust: Date.now() });
@@ -741,7 +716,6 @@ function openPanel(node) {
body.replaceChildren();
errorEl.textContent = "";
if (name === "Session") renderSession();
else if (name === "Scene") renderScene();
else if (name === "Shortcuts") renderShortcuts();
else if (name === "Split markers") renderSplitMarkers();
else if (name === "Refiner") renderRefiner();
@@ -759,8 +733,6 @@ function openPanel(node) {
const keyInput = textInput(settings.refinement_key, "session key name");
keyInput.addEventListener("input", () => {
settings.refinement_key = keyInput.value.trim();
studioSceneData = null;
studioSceneKey = null;
});
const linkedKey = linkedRefinementKey(node);
@@ -776,153 +748,6 @@ function openPanel(node) {
const resetToggle = toggleEl(settings.refiner.reset_session, "Reset session on next run");
resetToggle.inp.addEventListener("change", () => { settings.refiner.reset_session = resetToggle.inp.checked; });
body.append(row("Reset", resetToggle.wrap));
body.append(sectionTitle("Scene Builder"));
const modeSelect = selectEl(SB_MODES, settings.scene_builder.mode);
modeSelect.addEventListener("change", async () => {
settings.scene_builder.mode = modeSelect.value;
saveSettings(node, settings);
studioSceneData = null;
studioSceneKey = null;
if (settings.scene_builder.mode !== "Pass-through") {
await fetchScenes(settings.refinement_key || linkedRefinementKey(node));
}
renderTab(activeTab);
});
body.append(row("Mode", modeSelect));
if (settings.scene_builder.mode !== "Pass-through") {
const hint = el("div", "funpack-studio-hint",
"Scene Builder active - prompt is built from the Scene tab, not from the positive_prompt input.");
body.append(hint);
const toScene = btn("Open Scene tab →", "secondary");
toScene.addEventListener("click", () => switchTab("Scene"));
body.append(toScene);
} else {
const hint = el("div", "funpack-studio-hint",
"Pass-through - the positive_prompt connected to the node is used as-is.");
body.append(hint);
}
}
// SCENE ────────────────────────────────────────────────────────────────────
function renderScene() {
body.append(sectionTitle("Scene Builder"));
if (settings.scene_builder.mode === "Pass-through") {
body.append(el("div", "funpack-studio-hint",
"Scene Builder is in Pass-through mode. Change mode in the Session tab to enable scene construction."));
return;
}
const key = settings.refinement_key || linkedRefinementKey(node);
// Scene name + scene selector
const sceneNames = studioSceneData?.scenes || [NONE_SENTINEL];
const sceneSelect = selectEl(sceneNames, settings.scene_builder.scene || NONE_SENTINEL);
sceneSelect.addEventListener("change", () => {
settings.scene_builder.scene = sceneSelect.value;
const scene = studioSceneData?.data?.scenes?.[sceneSelect.value];
if (scene) {
settings.scene_builder.scene_name = scene.name || sceneSelect.value;
settings.scene_builder.aliases = Array.isArray(scene.aliases) ? scene.aliases.join(", ") : "";
settings.scene_builder.scene_positive = scene.positive_text || "";
settings.scene_builder.scene_negative = scene.negative_text || "";
renderScene();
}
});
body.append(row("Saved scene", sceneSelect));
const nameInput = textInput(settings.scene_builder.scene_name, "Scene name");
nameInput.addEventListener("input", () => { settings.scene_builder.scene_name = nameInput.value; });
body.append(row("Scene name", nameInput));
const aliasInput = textInput(settings.scene_builder.aliases, "Aliases, comma separated");
aliasInput.addEventListener("input", () => { settings.scene_builder.aliases = aliasInput.value; });
body.append(row("Aliases", aliasInput));
body.append(sectionTitle("Positive prompt"));
const posArea = el("textarea", "funpack-studio-textarea");
posArea.value = settings.scene_builder.scene_positive || "";
posArea.placeholder = "Positive prompt phrases...";
posArea.addEventListener("input", () => { settings.scene_builder.scene_positive = posArea.value; });
body.append(posArea);
// Memory phrase bank
const memItems = studioSceneData?.memory || [];
if (memItems.length) {
body.append(sectionTitle("Phrase bank - click to insert"));
const search = el("input", "funpack-studio-search");
search.type = "search";
search.placeholder = "Search phrases";
body.append(search);
const bank = el("div", "funpack-studio-bank");
const renderBank = () => {
const q = search.value.toLowerCase().trim();
const filtered = q ? memItems.filter((m) => String(m.text || "").toLowerCase().includes(q)) : memItems;
bank.replaceChildren();
const byGroup = new Map();
for (const m of filtered) {
const cat = CATEGORY_ORDER.includes(m.category) ? m.category : "details";
if (!byGroup.has(cat)) byGroup.set(cat, []);
byGroup.get(cat).push(m);
}
for (const cat of CATEGORY_ORDER) {
const g = byGroup.get(cat);
if (!g?.length) continue;
bank.append(el("div", "funpack-studio-cat-label", cat));
const chipRow = el("div", "funpack-studio-chip-row");
for (const m of g) {
const chip = el("button", "funpack-studio-chip", m.text);
chip.type = "button";
chip.addEventListener("click", () => {
const cur = posArea.value.trim();
posArea.value = cur ? `${cur}, ${m.text}` : m.text;
settings.scene_builder.scene_positive = posArea.value;
});
chipRow.append(chip);
}
bank.append(chipRow);
}
};
renderBank();
search.addEventListener("input", renderBank);
body.append(bank);
}
// Save/delete scene buttons
const footer = el("div", "funpack-studio-footer");
const refreshBtn = btn("Refresh");
refreshBtn.addEventListener("click", async () => {
saveSettings(node, settings);
await fetchScenes(key);
renderScene();
});
const saveBtn = btn("Save scene", "primary");
saveBtn.addEventListener("click", async () => {
try {
saveSettings(node, settings);
await saveScene(key, {
name: settings.scene_builder.scene_name,
aliases: settings.scene_builder.aliases,
mode: settings.scene_builder.mode,
positive_text: settings.scene_builder.scene_positive,
negative_text: settings.scene_builder.scene_negative,
});
await fetchScenes(key);
settings.scene_builder.scene = settings.scene_builder.scene_name || settings.scene_builder.scene || NONE_SENTINEL;
saveSettings(node, settings);
renderScene();
} catch (e) { showError(root, e.message); }
});
footer.append(refreshBtn, saveBtn);
body.append(footer);
// Fetch scenes if not loaded
if (!studioSceneData || studioSceneKey !== (key || "")) {
fetchScenes(key).then(() => renderScene());
}
}
// SHORTCUTS ────────────────────────────────────────────────────────────────
@@ -1165,15 +990,6 @@ function openPanel(node) {
// REFINER ──────────────────────────────────────────────────────────────────
function renderRefiner() {
const sbMode = settings.scene_builder?.mode || "Pass-through";
const sbActive = sbMode !== "Pass-through";
if (sbActive) {
const banner = el("div", "funpack-studio-banner");
banner.innerHTML = `Scene Builder is active (<b>${sbMode}</b> mode) — positive prompt is built in the <a href="#" class="funpack-studio-tab-link">Scene tab</a>, not from the node input.`;
banner.querySelector("a").addEventListener("click", (e) => { e.preventDefault(); switchTab("Scene"); });
body.append(banner);
}
body.append(sectionTitle("Execution"));
const modeSelect = selectEl(REFINER_MODES, settings.refiner.mode);
@@ -1188,10 +1004,6 @@ function openPanel(node) {
thinkToggle.inp.addEventListener("change", () => { settings.refiner.advisor_thinking = thinkToggle.inp.checked; });
body.append(row("Thinking", thinkToggle.wrap));
const repairToggle = toggleEl(settings.refiner.prompt_repair, "Prompt repair");
repairToggle.inp.addEventListener("change", () => { settings.refiner.prompt_repair = repairToggle.inp.checked; });
body.append(row("Prompt repair", repairToggle.wrap));
const luckyToggle = toggleEl(settings.refiner.im_feeling_lucky, "I'm Feeling Lucky");
luckyToggle.inp.addEventListener("change", () => { settings.refiner.im_feeling_lucky = luckyToggle.inp.checked; });
body.append(row("Lucky", luckyToggle.wrap));
@@ -1304,17 +1116,11 @@ function openPanel(node) {
body.append(fbArea);
body.append(sectionTitle("Intent"));
if (sbActive) {
body.append(el("div", "funpack-studio-hint",
"Intent is derived from the Scene Builder prompt when Scene Builder is active."));
}
body.append(overrideToggle(settings, "user_intent_prompt",
"Override - use popup value even when user_intent_prompt input is connected"));
const intentArea = el("textarea", "funpack-studio-textarea short");
intentArea.value = settings.refiner.user_intent_prompt_override || "";
intentArea.placeholder = "Intent override (overrides the user_intent_prompt node input)...";
intentArea.disabled = sbActive;
if (sbActive) intentArea.style.opacity = "0.45";
intentArea.addEventListener("input", () => { settings.refiner.user_intent_prompt_override = intentArea.value; });
body.append(intentArea);
}