Merge dev into main — Release 3.2.1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,42 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [3.2.1] - 2026-07-06
|
||||
|
||||
### Added
|
||||
- **Shortcut Ideas bulb** (💡 next to prompt fields) is now a habit-aware co-pilot: alongside
|
||||
the existing missing-category browser, it surfaces shortcuts you've historically paired with
|
||||
what's already in the prompt, and what usually follows the previous scene in your own
|
||||
projects — mined on demand from saved project text, no new persistent state.
|
||||
- **Media gallery: one-click continuity pin** — a 📌 hover action on image cards sets/unsets
|
||||
the project's Continuity identity pin directly from the Assets gallery, without a trip to
|
||||
Engine settings.
|
||||
- **Shortcut revolver** — an opt-in no-repeat cycling mode for multi-replacement shortcuts
|
||||
(sequential or shuffled). A shortcut with several replacements now draws through all of them
|
||||
before repeating, instead of a seeded random pick that could repeat early.
|
||||
- **Guide blur** (`alg_blur_guides`) now stands alone from the sampler's anchor ALG: it works
|
||||
with `alg_enabled` off, and has its own strength / sigma-threshold controls independent of
|
||||
the anchor's.
|
||||
- **Best-FaceID identity transfer** (experimental, Engine ▸ Experimental) — tags the existing
|
||||
Continuity "Identity pin" guide with the source-phase RoPE rotation Best-FaceID-style
|
||||
identity LoRAs were trained on. A native port on the Scene Chain Sampler, no external
|
||||
custom-node dependency; the LoRA itself loads the normal way via Models.
|
||||
|
||||
### Changed
|
||||
- Models & Pipeline "+ New node" search collapses to a compact summary once a node is picked,
|
||||
instead of leaving the full results list open above the Values/Wiring panel.
|
||||
- Prompt autocomplete no longer caps suggestions at 8 — a prefix shared by many shortcuts now
|
||||
shows every match (the menu already scrolls).
|
||||
|
||||
### Fixed
|
||||
- **Preview stability**: the preview-segment ffmpeg re-encode ran synchronously inside the
|
||||
request handler, freezing ComfyUI's event loop and stalling every other streaming response —
|
||||
now backgrounded with per-key locking. Ghost clips (and deep-trimmed lone clips) get proper
|
||||
segments instead of deep-seeking the raw chain file. Segment URLs encode the clip duration so
|
||||
re-trimming a clip can't serve an hour-old cached segment. Pooled preview videos default to
|
||||
`preload=metadata` (only the current clip and its neighbours preload fully), so a full
|
||||
timeline no longer starves the browser's connection pool mid-playback.
|
||||
|
||||
## [3.2.0] - 2026-07-05
|
||||
|
||||
### Added
|
||||
|
||||
+68
-14
@@ -663,11 +663,13 @@ def _expand_with_map(text):
|
||||
try:
|
||||
from .templates import (load_shortcut_db, shortcut_list, shortcut_key,
|
||||
_shortcut_replacements, _shortcut_trigger_pattern,
|
||||
normalize_refinement_key)
|
||||
normalize_refinement_key, load_revolver,
|
||||
revolver_next_replacement)
|
||||
except ImportError:
|
||||
from templates import (load_shortcut_db, shortcut_list, shortcut_key,
|
||||
_shortcut_replacements, _shortcut_trigger_pattern,
|
||||
normalize_refinement_key)
|
||||
normalize_refinement_key, load_revolver,
|
||||
revolver_next_replacement)
|
||||
import random
|
||||
from hashlib import md5
|
||||
|
||||
@@ -678,7 +680,7 @@ def _expand_with_map(text):
|
||||
except Exception:
|
||||
return text, whole
|
||||
candidates = []
|
||||
for shortcut in (data.get("shortcuts", {}) or {}).values():
|
||||
for db_key, shortcut in (data.get("shortcuts", {}) or {}).items():
|
||||
if not isinstance(shortcut, dict) or not bool(shortcut.get("enabled", True)):
|
||||
continue
|
||||
reps = _shortcut_replacements(shortcut.get("replacements", shortcut.get("replacement", [])))
|
||||
@@ -689,14 +691,23 @@ def _expand_with_map(text):
|
||||
for trig in shortcut_list(shortcut.get("triggers", [])):
|
||||
pat = _shortcut_trigger_pattern(trig)
|
||||
if pat:
|
||||
candidates.append((trig, pat, reps, sc_key, sc_name))
|
||||
candidates.append((trig, pat, reps, sc_key, sc_name, str(db_key)))
|
||||
if not candidates:
|
||||
return text, whole
|
||||
candidates.sort(key=lambda it: len(shortcut_key(it[0])), reverse=True)
|
||||
combined = "|".join(f"(?P<t{i}>{pat})" for i, (_, pat, _, _, _) in enumerate(candidates))
|
||||
combined = "|".join(f"(?P<t{i}>{pat})" for i, (_, pat, _, _, _, _) in enumerate(candidates))
|
||||
if not combined:
|
||||
return text, whole
|
||||
rng = random.Random(int(md5(text.encode("utf-8")).hexdigest()[:12], 16))
|
||||
# Shortcut revolver: peek the same stored cycle generation will draw from (never saved
|
||||
# here) so split boundaries are found in the text generation will actually see.
|
||||
try:
|
||||
_revolver = load_revolver()
|
||||
except Exception:
|
||||
_revolver = {"enabled": False, "random": False, "state": {}}
|
||||
_rev_on = bool(_revolver.get("enabled"))
|
||||
_rev_state = _revolver.get("state") if isinstance(_revolver.get("state"), dict) else {}
|
||||
_rev_random = bool(_revolver.get("random"))
|
||||
try:
|
||||
pattern = re.compile(combined, re.IGNORECASE | re.UNICODE)
|
||||
except re.error:
|
||||
@@ -711,9 +722,13 @@ def _expand_with_map(text):
|
||||
parts.append(lit); exp_pos += len(lit)
|
||||
rep = None
|
||||
pc_key, pc_name = "", ""
|
||||
for i, (_, _, reps, sc_key, sc_name) in enumerate(candidates):
|
||||
for i, (_, _, reps, sc_key, sc_name, db_key) in enumerate(candidates):
|
||||
if m.group(f"t{i}") is not None:
|
||||
rep = rng.choice(reps); pc_key, pc_name = sc_key, sc_name; break
|
||||
if _rev_on and len(reps) > 1:
|
||||
rep = revolver_next_replacement(_rev_state, db_key, reps, _rev_random)
|
||||
else:
|
||||
rep = rng.choice(reps)
|
||||
pc_key, pc_name = sc_key, sc_name; break
|
||||
if rep is None:
|
||||
rep = m.group(0)
|
||||
pieces.append({"exp_start": exp_pos, "exp_end": exp_pos + len(rep),
|
||||
@@ -6661,13 +6676,13 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
|
||||
def _v2_prompt_key(self, prompt):
|
||||
return re.sub(r"\s+", " ", str(prompt or "").strip())
|
||||
|
||||
def _v2_apply_global_shortcuts(self, text, seed=0):
|
||||
def _v2_apply_global_shortcuts(self, text, seed=0, revolver_commit=False):
|
||||
try:
|
||||
try:
|
||||
from .templates import apply_prompt_shortcuts
|
||||
except ImportError:
|
||||
from templates import apply_prompt_shortcuts
|
||||
expanded, applied = apply_prompt_shortcuts(text, seed=seed)
|
||||
expanded, applied = apply_prompt_shortcuts(text, seed=seed, revolver_commit=revolver_commit)
|
||||
except Exception as error:
|
||||
return str(text or ""), f"Shortcuts: unavailable ({error}).", []
|
||||
if not applied:
|
||||
@@ -11729,8 +11744,13 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
|
||||
# seeds to build N variant conditionings (Studio owns conditioning production).
|
||||
_raw_positive_prompt = str(positive_prompt or "")
|
||||
_raw_intent_prompt = str(user_intent_prompt or "")
|
||||
positive_prompt, shortcut_status, shortcut_applied = self._v2_apply_global_shortcuts(positive_prompt, seed=seed)
|
||||
# revolver_commit only on the real positive prompt: the one draw per run that advances
|
||||
# the shortcut revolver's cycle. The intent prompt peeks, and does so FIRST — if it
|
||||
# echoes the positive prompt's triggers, both then resolve to the same chamber (the
|
||||
# same alignment the shared seed used to give them).
|
||||
user_intent_prompt, intent_shortcut_status, intent_shortcut_applied = self._v2_apply_global_shortcuts(user_intent_prompt, seed=seed)
|
||||
positive_prompt, shortcut_status, shortcut_applied = self._v2_apply_global_shortcuts(
|
||||
positive_prompt, seed=seed, revolver_commit=True)
|
||||
if intent_shortcut_applied:
|
||||
shortcut_status = (
|
||||
f"{shortcut_status}\nIntent {intent_shortcut_status}"
|
||||
@@ -12864,14 +12884,31 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
|
||||
return (cond_i, m)
|
||||
|
||||
try:
|
||||
base_expansion = self._v2_apply_global_shortcuts(str(raw_prompt or ""), seed=seed)[0]
|
||||
try:
|
||||
from .templates import revolver_enabled as _revolver_enabled
|
||||
except ImportError:
|
||||
from templates import revolver_enabled as _revolver_enabled
|
||||
revolver_on = bool(_revolver_enabled())
|
||||
except Exception:
|
||||
base_expansion = str(raw_prompt or "")
|
||||
revolver_on = False
|
||||
out = [tagged(base_entry, 0, base_prompt)]
|
||||
cache = {base_expansion: base_entry}
|
||||
if revolver_on:
|
||||
# Shortcut revolver: each variant draw below COMMITS and advances the shared
|
||||
# cycle, so N variants sweep N consecutive chambers (no repeats until the pool
|
||||
# exhausts). Re-expanding the base here would both burn a chamber and collide
|
||||
# with variant 1's draw — skip seeding the dedupe cache with it.
|
||||
base_expansion = str(raw_prompt or "")
|
||||
cache = {}
|
||||
else:
|
||||
try:
|
||||
base_expansion = self._v2_apply_global_shortcuts(str(raw_prompt or ""), seed=seed)[0]
|
||||
except Exception:
|
||||
base_expansion = str(raw_prompt or "")
|
||||
cache = {base_expansion: base_entry}
|
||||
for i in range(1, n):
|
||||
try:
|
||||
exp_i = self._v2_apply_global_shortcuts(str(raw_prompt or ""), seed=seed + i)[0]
|
||||
exp_i = self._v2_apply_global_shortcuts(str(raw_prompt or ""), seed=seed + i,
|
||||
revolver_commit=True)[0]
|
||||
except Exception:
|
||||
exp_i = base_expansion
|
||||
entry = cache.get(exp_i)
|
||||
@@ -14387,6 +14424,23 @@ class FunPackStudio:
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
# Shortcut revolver advances persistent per-shortcut state on every run, so a
|
||||
# fixed-seed re-run must not be served from ComfyUI's cache while it's enabled —
|
||||
# the next Generate is supposed to draw the next replacement. Normal input-hash
|
||||
# caching applies when the revolver is off.
|
||||
try:
|
||||
try:
|
||||
from .templates import revolver_enabled
|
||||
except ImportError:
|
||||
from templates import revolver_enabled
|
||||
if revolver_enabled():
|
||||
return float("nan")
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _is_output_connected(prompt, unique_id, output_index):
|
||||
return FunPackVideoRefiner()._is_output_connected(prompt, unique_id, output_index)
|
||||
|
||||
@@ -285,6 +285,20 @@ def delete_shortcut(name: str) -> dict:
|
||||
return {"shortcuts": si(), "categories": _shortcut_categories()}
|
||||
|
||||
|
||||
def revolver_settings() -> dict:
|
||||
"""Shortcut-revolver settings (enabled + random order). State lives server-side next to
|
||||
the shortcut DB so previews and generation share one cycle."""
|
||||
return _funpack_attr("templates", "revolver_settings")()
|
||||
|
||||
|
||||
def set_revolver_settings(payload: dict) -> dict:
|
||||
fn = _funpack_attr("templates", "set_revolver_settings")
|
||||
return fn(
|
||||
enabled=payload.get("enabled"),
|
||||
random_order=payload.get("random"),
|
||||
)
|
||||
|
||||
|
||||
def save_category(payload: dict) -> dict:
|
||||
"""Add a category (and optionally a sub-category under it) to the managed list."""
|
||||
add = _funpack_attr("templates", "add_shortcut_category")
|
||||
@@ -305,6 +319,70 @@ def delete_transition(name: str) -> dict:
|
||||
return {"transitions": ti()}
|
||||
|
||||
|
||||
def suggestion_stats() -> dict:
|
||||
"""Mine the user's OWN prompting habits from all saved projects: which shortcuts
|
||||
land in the same scene (pairs) and which shot follows which (follows, in timeline
|
||||
order). Scene texts keep triggers verbatim (editor prompt model), so this is a
|
||||
plain text scan. Derived on demand from project files — no learned state stored.
|
||||
Stats are keyed by shortcut `key` (the library's stable id)."""
|
||||
import re
|
||||
|
||||
from . import projects as _projects
|
||||
|
||||
si, *_ = _library_fns()
|
||||
pats: list[tuple[str, Any]] = []
|
||||
for item in si():
|
||||
if item.get("enabled") is False:
|
||||
continue
|
||||
key = str(item.get("key") or item.get("name") or "")
|
||||
if not key:
|
||||
continue
|
||||
for trig in item.get("triggers") or []:
|
||||
t = str(trig or "").strip().lower()
|
||||
if t:
|
||||
pats.append((key, re.compile(r"(?<![a-z0-9_])" + re.escape(t) + r"(?![a-z0-9_])")))
|
||||
counts: dict[str, int] = {}
|
||||
pairs: dict[tuple, int] = {}
|
||||
follows: dict[tuple, int] = {}
|
||||
scenes_scanned = 0
|
||||
for meta in _projects.list_projects():
|
||||
proj = _projects.get(meta.get("id", ""))
|
||||
if proj is None:
|
||||
continue
|
||||
by_id = {s.id: s for s in proj.scenes}
|
||||
order = [sid for sid in (proj.timeline_order or []) if sid in by_id]
|
||||
if not order:
|
||||
order = [s.id for s in proj.scenes]
|
||||
prev: set[str] | None = None
|
||||
for sid in order:
|
||||
sc = by_id[sid]
|
||||
# Editorial cuts share the root's text — only the root owns the prompt.
|
||||
if sc.excluded or (sc.gen_unit_id and sc.cut_offset_frames):
|
||||
continue
|
||||
text = (sc.text or "").strip().lower()
|
||||
if not text:
|
||||
continue
|
||||
present = {k for k, pat in pats if pat.search(text)}
|
||||
scenes_scanned += 1
|
||||
for k in present:
|
||||
counts[k] = counts.get(k, 0) + 1
|
||||
for a in present:
|
||||
for b in present:
|
||||
if a < b:
|
||||
pairs[(a, b)] = pairs.get((a, b), 0) + 1
|
||||
if prev:
|
||||
for a in prev:
|
||||
for b in present:
|
||||
follows[(a, b)] = follows.get((a, b), 0) + 1
|
||||
prev = present
|
||||
return {
|
||||
"scenes": scenes_scanned,
|
||||
"counts": counts,
|
||||
"pairs": [[a, b, n] for (a, b), n in pairs.items()],
|
||||
"follows": [[a, b, n] for (a, b), n in follows.items()],
|
||||
}
|
||||
|
||||
|
||||
def export_shortcuts() -> dict:
|
||||
return _funpack_attr("templates", "load_shortcut_db")()
|
||||
|
||||
|
||||
@@ -101,8 +101,8 @@ def _anchor_media_ref(scene: Scene) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _identity_pin_guide(media_ref: str, strength: float) -> dict:
|
||||
return {
|
||||
def _identity_pin_guide(media_ref: str, strength: float, *, is_pin: bool = True) -> dict:
|
||||
entry = {
|
||||
"enabled": True,
|
||||
"source": "image",
|
||||
"media_ref": media_ref,
|
||||
@@ -110,12 +110,19 @@ def _identity_pin_guide(media_ref: str, strength: float) -> dict:
|
||||
"apply_at": 0,
|
||||
"strength": max(0.25, min(0.5, float(strength))),
|
||||
}
|
||||
if is_pin:
|
||||
# Marks this entry (not prior-scene/mid-scene/template guides) as the one eligible
|
||||
# for the sampler's identity_transfer source-phase RoPE tag — see samplers.py.
|
||||
entry["identity_pin"] = True
|
||||
return entry
|
||||
|
||||
|
||||
def _prior_scene_guide(prior: Scene, strength: float) -> Optional[dict]:
|
||||
ref = _anchor_media_ref(prior)
|
||||
if ref:
|
||||
return _identity_pin_guide(ref, strength)
|
||||
# Reuses the same image-guide shape as the project identity pin, but this is the
|
||||
# PRIOR SCENE's own anchor (motion/layout continuity) — never the identity_pin tag.
|
||||
return _identity_pin_guide(ref, strength, is_pin=False)
|
||||
return {
|
||||
"enabled": True,
|
||||
"source": "template",
|
||||
|
||||
@@ -82,12 +82,15 @@
|
||||
importTransitions: (data, mode) => j("POST", API(`/library/transitions/import?mode=${mode || "merge"}`), data),
|
||||
clearTransitions: () => j("POST", API("/library/transitions/clear"), {}),
|
||||
shortcuts: () => j("GET", API("/library/shortcuts")),
|
||||
suggestionStats: () => j("GET", API("/library/suggestion_stats")),
|
||||
saveCategory: (payload) => j("POST", API("/library/categories"), payload),
|
||||
saveShortcut: (item) => j("POST", API("/library/shortcuts"), item),
|
||||
deleteShortcut: (name) => j("DELETE", API(`/library/shortcuts/${encodeURIComponent(name)}`)),
|
||||
exportShortcutsUrl: () => API("/library/shortcuts/export"),
|
||||
importShortcuts: (data, mode) => j("POST", API(`/library/shortcuts/import?mode=${mode || "merge"}`), data),
|
||||
clearShortcuts: () => j("POST", API("/library/shortcuts/clear"), {}),
|
||||
revolverSettings: () => j("GET", API("/library/revolver")),
|
||||
setRevolverSettings: (payload) => j("POST", API("/library/revolver"), payload),
|
||||
|
||||
// FunPack file manager (Composer ▸ Files)
|
||||
listFiles: () => j("GET", API("/files")),
|
||||
@@ -182,6 +185,10 @@
|
||||
type: m.type || "output",
|
||||
render_in: String(spec.renderIn != null ? spec.renderIn : 0),
|
||||
});
|
||||
// dur is in the URL for two reasons: ghosts have no scene server-side (the trim
|
||||
// window must travel in the query), and it versions the browser cache — segments
|
||||
// are cached for an hour, so a timeline trim must produce a different URL.
|
||||
if (spec.dur != null) q.set("dur", String(spec.dur));
|
||||
u += "?" + q.toString();
|
||||
}
|
||||
return u;
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
const DELIMS = /[,\n;]/; // token boundaries — a trigger lives between delimiters
|
||||
const MIN_QUERY = 2;
|
||||
const MAX_ITEMS = 8;
|
||||
|
||||
function enabled() { return !!S.getEditorSetting("autocomplete"); }
|
||||
|
||||
@@ -25,7 +24,9 @@
|
||||
}
|
||||
|
||||
// Triggers whose text contains `query` (substring, so "@golden" matches "golden"),
|
||||
// prefix matches ranked first, identical triggers de-duped.
|
||||
// prefix matches ranked first, identical triggers de-duped. Unbounded — the menu itself
|
||||
// scrolls (max-height in CSS), so a prefix shared by many shortcuts (e.g. 15 "lazy_*"
|
||||
// triggers) shows every one of them instead of silently hiding the tail.
|
||||
function matchTriggers(query) {
|
||||
const q = query.toLowerCase();
|
||||
const scored = [];
|
||||
@@ -42,7 +43,6 @@
|
||||
const k = e.trigger.toLowerCase();
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k); uniq.push(e);
|
||||
if (uniq.length >= MAX_ITEMS) break;
|
||||
}
|
||||
return uniq;
|
||||
}
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"Edits apply automatically and stay in sync with the timeline's per-scene prompts. Shortcuts expand at generation time."));
|
||||
composeTextarea = ta;
|
||||
if (window.ShortcutAutocomplete) window.ShortcutAutocomplete.attach(ta);
|
||||
if (st.project && window.ShortcutSuggest) titleRow.append(window.ShortcutSuggest.bulb(ta));
|
||||
|
||||
// Variables ($name) — collapsible, same toggle pattern as Settings ▸ Built-in pipeline.
|
||||
wrap.append(composeVariables());
|
||||
|
||||
@@ -141,6 +141,55 @@
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// Shortcut revolver: server-side library setting (not per-browser) — the no-repeat cycle
|
||||
// is shared by previews and generation, so the toggle lives with the shortcut DB.
|
||||
function revolverSection() {
|
||||
const wrap = el("div", "es-section");
|
||||
wrap.append(el("div", "es-section-title", "Shortcut revolver"));
|
||||
wrap.append(el("div", "es-hint",
|
||||
"For shortcuts with several replacements: remember which replacement fired and don’t reuse it "
|
||||
+ "until the WHOLE set has fired. Off = seeded random pick (may repeat). Saved with your shortcut "
|
||||
+ "library — applies to every browser and to generation."));
|
||||
|
||||
const main = el("label", "chk es-toggle");
|
||||
const mainCb = el("input"); mainCb.type = "checkbox"; mainCb.disabled = true;
|
||||
main.append(mainCb, el("span", null, "Shortcut revolver"));
|
||||
wrap.append(main);
|
||||
|
||||
const sub = el("div", "es-subfields");
|
||||
const rand = el("label", "chk es-toggle");
|
||||
const randCb = el("input"); randCb.type = "checkbox"; randCb.disabled = true;
|
||||
rand.append(randCb, el("span", null, "Random"));
|
||||
sub.append(rand);
|
||||
sub.append(el("div", "es-hint",
|
||||
"Unchecked: replacements fire in order — first, second, … last, then the cycle restarts. "
|
||||
+ "Checked: any order, but still no repeats until the set is exhausted. Changing either "
|
||||
+ "option restarts all cycles."));
|
||||
wrap.append(sub);
|
||||
|
||||
const apply = (s) => {
|
||||
mainCb.checked = !!(s && s.enabled);
|
||||
randCb.checked = !!(s && s.random);
|
||||
mainCb.disabled = false;
|
||||
randCb.disabled = !mainCb.checked;
|
||||
sub.style.display = mainCb.checked ? "" : "none";
|
||||
};
|
||||
const save = () => {
|
||||
mainCb.disabled = randCb.disabled = true;
|
||||
API.setRevolverSettings({ enabled: mainCb.checked, random: randCb.checked })
|
||||
.then(apply)
|
||||
.catch(() => { API.revolverSettings().then(apply).catch(() => {}); });
|
||||
};
|
||||
mainCb.onchange = save;
|
||||
randCb.onchange = save;
|
||||
|
||||
sub.style.display = "none";
|
||||
API.revolverSettings().then(apply).catch(() => {
|
||||
wrap.append(el("div", "es-hint", "Could not load revolver settings."));
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function toggleRow(label, hint, key) {
|
||||
const row = el("div", "es-row");
|
||||
const lbl = el("label", "chk es-toggle");
|
||||
@@ -158,10 +207,15 @@
|
||||
"Prompt autocomplete",
|
||||
"Suggest matching shortcuts while you type in the global prompt and scene prompts. Shows the trigger, its prompt, and category.",
|
||||
"autocomplete"));
|
||||
content.append(toggleRow(
|
||||
"Shortcut ideas",
|
||||
"A 💡 next to prompt fields lights up when shortcut categories in your library (camera moves, sub-actions…) aren't used in the prompt. Click it for insertable ideas — it never pops up on its own.",
|
||||
"suggestions"));
|
||||
content.append(toggleRow(
|
||||
"Use anchor",
|
||||
"Text before the first split trigger is a shared anchor prepended to every scene. Turn off to make that leading text Scene 1 instead.",
|
||||
"anchorEnabled"));
|
||||
content.append(revolverSection());
|
||||
content.append(anchorGuideSection());
|
||||
body.append(content);
|
||||
}
|
||||
@@ -169,7 +223,7 @@
|
||||
window.SettingsWindow.register({
|
||||
id: "editor", group: "", title: "Editor",
|
||||
subtitle: "Per-browser preferences — they apply to this browser, not the project.",
|
||||
keywords: "autocomplete anchor prompt shortcuts i2v bypass guide preferences",
|
||||
keywords: "autocomplete anchor prompt shortcuts ideas suggestions i2v bypass guide preferences revolver random replacements cycle",
|
||||
iconBg: "linear-gradient(180deg,#6aa9ff,#3b6fd9)",
|
||||
icon: '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round"><path d="M2 4.5h12M2 8h12M2 11.5h12"/><circle cx="6" cy="4.5" r="1.7" fill="#fff" stroke="none"/><circle cx="11" cy="8" r="1.7" fill="#fff" stroke="none"/><circle cx="5" cy="11.5" r="1.7" fill="#fff" stroke="none"/></svg>',
|
||||
mount,
|
||||
|
||||
@@ -173,10 +173,16 @@
|
||||
{ name: "joyai_audio_memory", label: "Paired audio memory", kind: "bool", default: false, dependsOn: "joyai_memory" },
|
||||
{ name: "v2a_grad_scale", label: "Video→audio coupling", kind: "float", default: 1.0, min: 0.0, max: 4.0, step: 0.25, dependsOn: "joyai_audio_memory" },
|
||||
{ name: "alg_blur_guides", label: "Blur i2v guides and JoyAI memory", kind: "bool", default: false },
|
||||
{ name: "alg_guide_blur_strength", label: "Guide blur strength", kind: "float", default: 2.0, min: 1.0, max: 4.0, step: 0.1, dependsOn: "alg_blur_guides" },
|
||||
{ name: "alg_guide_blur_sigma_threshold", label: "Guide blur sigma threshold", kind: "float", default: 0.975, min: 0.5, max: 0.999, step: 0.005, dependsOn: "alg_blur_guides" },
|
||||
{ name: "bounded_attention_enabled", label: "Bounded attention (multi-subject)", kind: "bool", default: false },
|
||||
{ name: "dynashift", label: "DynaShift (steer off bad gens)", kind: "bool", default: false },
|
||||
{ name: "dynashift_strength", label: "DynaShift strength", kind: "float", default: 0.3, min: 0.05, max: 1.0, step: 0.05, dependsOn: "dynashift" },
|
||||
{ name: "dynashift_threshold", label: "DynaShift match threshold", kind: "float", default: 0.6, min: 0.3, max: 0.95, step: 0.05, dependsOn: "dynashift" },
|
||||
{ name: "identity_transfer_enabled", label: "Best-FaceID compatibility", kind: "bool", default: false,
|
||||
hint: "Tags Continuity's Identity pin guide (Engine → Continuity) with the source-phase RoPE rotation Best-FaceID-style identity LoRAs were trained on. Load the LoRA itself the normal way — Models → add a LoRA loader onto the model path. No effect without an Identity pin image set." },
|
||||
{ name: "identity_transfer_phase", label: "Source-phase id", kind: "float", default: 2.0, min: 0.0, max: 8.0, step: 0.5, dependsOn: "identity_transfer_enabled",
|
||||
hint: "Matches the LoRA's training convention (ltx-trainer used 2)." },
|
||||
];
|
||||
const SAMPLER_KNOB_MAP = Object.fromEntries(SAMPLER_KNOBS.map((k) => [k.name, k]));
|
||||
|
||||
@@ -263,7 +269,7 @@
|
||||
S.setSamplerInput(k.name, v);
|
||||
};
|
||||
}
|
||||
parentGroup.append(field(k.label + (forced ? " (auto)" : ""), ctrl));
|
||||
parentGroup.append(field(k.label + (forced ? " (auto)" : ""), ctrl, k.hint));
|
||||
}
|
||||
|
||||
function renderKnobList(parentGroup, st, names) {
|
||||
@@ -281,7 +287,7 @@
|
||||
chain_timing: ["frame_overlap", "transition_duration", "use_same_seed"],
|
||||
chain_guidance: ["cfg", "embed_guidance", "embed_guidance_source", "embed_guidance_strength", "score_slider", "score_slider_strength", "output_guidance", "output_guidance_strength", "dynashift", "dynashift_strength", "dynashift_threshold"],
|
||||
chain_decode: ["decode_noise_scale", "decode_timestep", "decode_tile_size"],
|
||||
chain_experimental: ["mid_scene_guide", "mid_scene_guide_strength", "joyai_memory", "joyai_memory_size", "joyai_fix_frames", "joyai_frame_select", "joyai_memory_strength", "joyai_audio_memory", "v2a_grad_scale", "alg_blur_guides", "bounded_attention_enabled"],
|
||||
chain_experimental: ["mid_scene_guide", "mid_scene_guide_strength", "joyai_memory", "joyai_memory_size", "joyai_fix_frames", "joyai_frame_select", "joyai_memory_strength", "joyai_audio_memory", "v2a_grad_scale", "alg_blur_guides", "alg_guide_blur_strength", "alg_guide_blur_sigma_threshold", "bounded_attention_enabled", "identity_transfer_enabled", "identity_transfer_phase"],
|
||||
};
|
||||
|
||||
function countChainView(p, id) {
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
<script src="editor_settings.js"></script>
|
||||
<script src="temp_browser.js"></script>
|
||||
<script src="autocomplete.js"></script>
|
||||
<script src="suggestions.js"></script>
|
||||
<script src="slot_picker.js"></script>
|
||||
<script src="welcome.js"></script>
|
||||
<script src="pipeline_setup.js"></script>
|
||||
|
||||
@@ -288,7 +288,29 @@
|
||||
|
||||
const ta = el("textarea"); ta.rows = 4; ta.value = root.text || ""; ta.placeholder = "Describe this scene…"; ta.dataset.k = "sc-text";
|
||||
ta.oninput = () => S.patchSceneQuiet(scene.id, { text: ta.value });
|
||||
body.append(field("Prompt", ta));
|
||||
// Ideas bulb floats OUTSIDE the label — a button inside a <label> becomes its
|
||||
// implicit control (double-fired clicks, stolen focus target).
|
||||
const promptWrap = el("div", "sugg-fieldwrap");
|
||||
promptWrap.append(field("Prompt", ta));
|
||||
// prevText: the previous shot's prompt in TIMELINE order (skipping this scene's own
|
||||
// editorial cuts and excluded scenes) — feeds the "usual next shot" suggestions.
|
||||
const prevText = () => {
|
||||
const p = S.get().project;
|
||||
if (!p) return "";
|
||||
const order = (Array.isArray(p.timeline_order) && p.timeline_order.length)
|
||||
? p.timeline_order : (p.scenes || []).map((s) => s.id);
|
||||
const byId = new Map((p.scenes || []).map((s) => [s.id, s]));
|
||||
const uid = S.genUnitId(scene);
|
||||
for (let i = order.indexOf(scene.id) - 1; i >= 0; i--) {
|
||||
const sc = byId.get(order[i]);
|
||||
if (!sc || sc.excluded || S.genUnitId(sc) === uid) continue;
|
||||
const prevRoot = S.genUnitRoot(S.genUnitId(sc)) || sc;
|
||||
if ((prevRoot.text || "").trim()) return prevRoot.text;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
if (window.ShortcutSuggest) promptWrap.append(window.ShortcutSuggest.bulb(ta, { prevText }));
|
||||
body.append(promptWrap);
|
||||
if (window.ShortcutAutocomplete) window.ShortcutAutocomplete.attach(ta);
|
||||
renderSourceField(st, root, scene);
|
||||
if (PC()?.usesChainSampler(st)) {
|
||||
|
||||
@@ -306,6 +306,32 @@
|
||||
if (cur) _beginMediaRename(m, cur);
|
||||
};
|
||||
actions.append(ren);
|
||||
// Continuity pin, right on the gallery card — same setting as Engine settings ▸
|
||||
// Continuity ▸ Identity pin, just quicker to reach. Images only, chain-sampler
|
||||
// pipelines only (custom pipelines have no auto-continuity machinery).
|
||||
if (m.kind === "image" && st.project && window.PipelineCaps?.usesChainSampler(st)) {
|
||||
const cs = st.project.continuity_settings || {};
|
||||
const isPin = cs.identity_pin_ref === m.id;
|
||||
const pinBtn = el("button", "media-act media-pin" + (isPin ? " active" : ""), "📌");
|
||||
pinBtn.title = isPin
|
||||
? "Continuity pin — click to unpin"
|
||||
: "Pin as identity guide for every scene (continuity pin)"
|
||||
+ (cs.auto_enabled === false ? " — also turns Auto continuity back on" : "");
|
||||
pinBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const patch = { ...cs, identity_pin_ref: isPin ? null : m.id };
|
||||
// A pin with auto continuity off does nothing — pinning means "use it", so
|
||||
// re-enable (auto_enabled is on by default; only flip an explicit false).
|
||||
if (!isPin && patch.auto_enabled === false) patch.auto_enabled = true;
|
||||
S.patchProject({ continuity_settings: patch });
|
||||
};
|
||||
actions.append(pinBtn);
|
||||
if (isPin) {
|
||||
const badge = el("span", "media-pin-badge", "📌");
|
||||
badge.title = "Continuity pin — identity guide for every scene";
|
||||
thumb.append(badge);
|
||||
}
|
||||
}
|
||||
if (m.kind === "image" || m.kind === "video") {
|
||||
const exp = el("button", "media-act media-exp", "⤓");
|
||||
exp.title = "Export to disk";
|
||||
|
||||
@@ -715,9 +715,23 @@
|
||||
setTimeout(() => search.focus(), 0);
|
||||
|
||||
let curList = [], curCand = null, draft = null;
|
||||
// Collapsed once a node is picked — the full list is clutter once Values/Wiring are
|
||||
// showing below it. Re-expands on "Change" or as soon as the user edits the search again.
|
||||
let listOpen = true;
|
||||
|
||||
function renderList() {
|
||||
clear(listBox);
|
||||
if (curCand && !listOpen) {
|
||||
const row = el("div", "ns-row ns-row-selected active");
|
||||
row.append(el("span", "ns-row-name", curCand.display_name));
|
||||
row.append(el("span", "ns-row-class", curCand.class));
|
||||
const change = el("button", "btn ghost tiny ns-change", "Change");
|
||||
change.type = "button";
|
||||
change.onclick = (e) => { e.stopPropagation(); listOpen = true; renderList(); search.focus(); };
|
||||
row.append(change);
|
||||
listBox.append(row);
|
||||
return;
|
||||
}
|
||||
const q = search.value.trim().toLowerCase();
|
||||
const f = q ? curList.filter((c) => (c.display_name + " " + c.class + " " + (c.category || "")).toLowerCase().includes(q)) : curList;
|
||||
if (!f.length) { listBox.append(el("div", "ns-empty", curList.length ? "No match." : "No matching nodes.")); return; }
|
||||
@@ -730,7 +744,7 @@
|
||||
});
|
||||
if (f.length > 200) listBox.append(el("div", "ns-empty", `…${f.length - 200} more — refine the search.`));
|
||||
}
|
||||
search.oninput = renderList;
|
||||
search.oninput = () => { listOpen = true; renderList(); };
|
||||
|
||||
async function pick(cls) {
|
||||
curCand = specByClass[cls];
|
||||
@@ -741,6 +755,7 @@
|
||||
wires: { ...(extras.wires || {}) }, // outName -> single target
|
||||
input_sources: { ...(extras.input_sources || {}) },
|
||||
};
|
||||
listOpen = false;
|
||||
renderList(); renderDetail();
|
||||
}
|
||||
|
||||
|
||||
@@ -144,24 +144,38 @@
|
||||
|
||||
// Multi-scene chain outputs share one ComfyUI file; deep seeks fail in the browser
|
||||
// unless the MP4 is faststart-trimmed like export. Serve per-scene segments instead.
|
||||
// Ghosts (removed scenes still previewing their render) and lone clips with a deep
|
||||
// in-point take the same path — they'd otherwise deep-seek the moov-at-end source
|
||||
// directly, which stalls/blacks the monitor when scrubbing across them.
|
||||
function _assignChainPreviewSegments(st, clips) {
|
||||
const pid = st.project?.id;
|
||||
if (!pid || !API.previewSegmentUrl) return;
|
||||
const groups = new Map();
|
||||
for (const c of clips) {
|
||||
if (c.pending || c.ghost || c.binUrl || !c.sceneId || !c.media) continue;
|
||||
if (c.pending || c.binUrl || !c.media) continue;
|
||||
if (!c.sceneId && !c.ghostId) continue;
|
||||
const k = _mediaChainKey(c);
|
||||
if (!k) continue;
|
||||
if (!groups.has(k)) groups.set(k, []);
|
||||
groups.get(k).push(c);
|
||||
}
|
||||
for (const list of groups.values()) {
|
||||
if (list.length < 2) continue;
|
||||
const shared = list.length >= 2;
|
||||
for (const c of list) {
|
||||
if (!shared && (c.inSec || 0) <= 0.5) continue; // shallow single clip: direct URL is fine
|
||||
if (c.ghost) {
|
||||
// No scene server-side — the trim window travels entirely in the query.
|
||||
c.streamUrl = API.previewSegmentUrl(pid, c.ghostId, {
|
||||
media: c.media, renderIn: c.inSec || 0, dur: c.durationSec,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const sc = S.scene(c.sceneId);
|
||||
const sourceIn = sc?.source_in || 0;
|
||||
const renderIn = Math.max(0, (c.inSec || 0) - sourceIn);
|
||||
c.streamUrl = API.previewSegmentUrl(pid, c.sceneId, { media: c.media, renderIn });
|
||||
c.streamUrl = API.previewSegmentUrl(pid, c.sceneId, {
|
||||
media: c.media, renderIn, dur: c.durationSec,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -474,7 +488,11 @@
|
||||
const zoom = document.createElement("div");
|
||||
zoom.className = "pm-fx-zoom";
|
||||
v = document.createElement("video");
|
||||
v.className = "pm-video"; v.preload = "auto"; v.playsInline = true; v.src = url;
|
||||
// preload starts as metadata: with preload=auto EVERY pooled clip downloaded at full
|
||||
// tilt simultaneously — on a tunneled rental instance that starves the browser's
|
||||
// per-origin connection pool and aborts the stream actually being watched.
|
||||
// _updatePreloadHints() promotes the current clip and its neighbours to auto.
|
||||
v.className = "pm-video"; v.preload = "metadata"; v.playsInline = true; v.src = url;
|
||||
v._pmFx = { viewport, zoom };
|
||||
zoom.append(v);
|
||||
viewport.append(zoom);
|
||||
@@ -655,6 +673,29 @@
|
||||
else if (_badgeEl) _badgeEl.classList.remove("mixed-mode");
|
||||
}
|
||||
|
||||
// Promote the clip under the playhead and its neighbours to preload=auto (cut boundaries
|
||||
// stay instant); everything else idles at metadata so the pool doesn't saturate the
|
||||
// browser's per-origin connections. A hint change never interrupts an in-flight load.
|
||||
function _updatePreloadHints() {
|
||||
if (!pool.size) return;
|
||||
const hot = new Set();
|
||||
const add = (c) => {
|
||||
if (!c || c.pending || c.blank) return;
|
||||
const u = _clipUrl(c);
|
||||
if (u) hot.add(u);
|
||||
};
|
||||
const idx = _currentClip ? _clips.indexOf(_currentClip) : -1;
|
||||
if (idx >= 0) {
|
||||
add(_clips[idx - 1]); add(_clips[idx]); add(_clips[idx + 1]);
|
||||
} else {
|
||||
add(_clips.find(_hasPlayableClipMedia));
|
||||
}
|
||||
for (const [u, v] of pool) {
|
||||
const want = hot.has(u) ? "auto" : "metadata";
|
||||
if (v.preload !== want) v.preload = want;
|
||||
}
|
||||
}
|
||||
|
||||
function _sameVideoUrl(a, b) {
|
||||
if (!a || !b) return false;
|
||||
const ua = _clipUrl(a), ub = _clipUrl(b);
|
||||
@@ -699,6 +740,7 @@
|
||||
else _clipBoundaryToken = 0;
|
||||
}
|
||||
_applyFx(clip, Math.max(0, offset));
|
||||
_updatePreloadHints();
|
||||
}
|
||||
|
||||
// Advance to the next clip at a clip's out-point (or stop). Same-source contiguous
|
||||
@@ -728,6 +770,7 @@
|
||||
try { _applyFx(next, within); } catch (_) {}
|
||||
}
|
||||
if (_playing) _startTick();
|
||||
_updatePreloadHints();
|
||||
}
|
||||
_notifyPh();
|
||||
}
|
||||
@@ -820,6 +863,7 @@
|
||||
else _goto(clip, offset, false);
|
||||
}
|
||||
_syncInsAudio();
|
||||
_updatePreloadHints();
|
||||
_notifyPh();
|
||||
}
|
||||
|
||||
@@ -1103,6 +1147,7 @@
|
||||
else if (_isBlankProgram()) _goto(_blankClip(), _phSec, _playing);
|
||||
}
|
||||
}
|
||||
_updatePreloadHints();
|
||||
|
||||
const p = st.project;
|
||||
const gen = st.gen || { state: "idle", media: [] };
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
// prompt is parsed/edited, not what is generated from already-distributed scenes.
|
||||
const EDITOR_SETTINGS_KEY = "funpack_editor_settings";
|
||||
const EDITOR_SETTINGS_DEFAULTS = {
|
||||
autocomplete: true, anchorEnabled: true,
|
||||
autocomplete: true, suggestions: true, anchorEnabled: true,
|
||||
// "Anchor as guide" i2v bypass: declare a custom i2v node so it can be forced
|
||||
// to a state (value-only) on anchor_guide runs. anchorGuideBypass = {node, input, value}.
|
||||
anchorGuideHasI2v: false, anchorGuideBypass: null,
|
||||
|
||||
@@ -321,6 +321,11 @@ input[type=number] { font-family: var(--font-mono); }
|
||||
}
|
||||
.media-act:hover { color: var(--amber-2); background: var(--ink-3); }
|
||||
.media-act.media-del:hover { color: var(--danger); }
|
||||
.media-act.media-pin { filter: grayscale(1); }
|
||||
.media-act.media-pin:hover { filter: none; }
|
||||
.media-act.media-pin.active { filter: none; background: var(--amber-soft); }
|
||||
.media-pin-badge { position: absolute; top: 3px; left: 3px; font-size: 10px; line-height: 1;
|
||||
padding: 2px 3px; border-radius: 4px; background: rgba(0, 0, 0, .55); pointer-events: none; }
|
||||
.media-name-edit {
|
||||
flex: 1; min-width: 0; box-sizing: border-box; margin: 0; padding: 2px 4px; font-size: 10px;
|
||||
background: var(--ink-0); border: 1px solid var(--amber); border-radius: 4px; color: var(--text);
|
||||
@@ -1561,6 +1566,30 @@ body.tour-mode { overflow: hidden; }
|
||||
.ac-prompt { color: var(--muted); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
|
||||
.ac-cat { color: var(--teal); font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .5px; text-transform: uppercase; flex-shrink: 0; }
|
||||
|
||||
/* Shortcut ideas bulb + popover (quiet nudge — lights up when categories go unused) */
|
||||
.sugg-bulb { padding: 0 4px; font-size: 12px; line-height: 1; opacity: .35; filter: grayscale(1); transition: opacity .3s, filter .3s; }
|
||||
.sugg-bulb.on { opacity: 1; filter: none; text-shadow: 0 0 7px rgba(255, 196, 87, .55); }
|
||||
.sugg-fieldwrap { position: relative; }
|
||||
.sugg-fieldwrap .sugg-bulb { position: absolute; top: -3px; right: 0; }
|
||||
.sugg-pop { position: fixed; z-index: 99999; width: 330px; max-height: 360px; overflow-y: auto;
|
||||
background: #16140f; border: 1px solid var(--line); border-radius: 8px; box-shadow: var(--shadow); padding: 6px; }
|
||||
.sugg-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 2px 4px 6px; }
|
||||
.sugg-title { color: var(--muted); font-size: 10.5px; letter-spacing: .5px; text-transform: uppercase; }
|
||||
.sugg-list { display: flex; flex-direction: column; }
|
||||
.sugg-item { display: flex; align-items: baseline; gap: 8px; padding: 6px 9px; border-radius: 5px; cursor: pointer; }
|
||||
.sugg-item:hover { background: var(--amber-soft); }
|
||||
.sugg-empty { color: var(--muted); font-size: 11.5px; padding: 6px 9px; }
|
||||
.sugg-sec-head { color: var(--amber-2); font-size: 10px; letter-spacing: .6px; text-transform: uppercase;
|
||||
padding: 8px 4px 3px; border-top: 1px solid var(--line); margin-top: 4px; }
|
||||
.sugg-habits:empty { display: none; }
|
||||
.sugg-habits .sugg-sec-head:first-child, .sugg-list > .sugg-sec-head:first-child { border-top: none; margin-top: 0; }
|
||||
.sugg-why { color: var(--muted); font-family: var(--font-mono); font-size: 9.5px; flex-shrink: 0; }
|
||||
.sugg-cat-head { display: flex; align-items: center; gap: 7px; padding: 7px 4px 2px; }
|
||||
.sugg-cat-head:first-child { padding-top: 2px; }
|
||||
.sugg-cat-name { color: var(--teal); font-family: var(--font-mono); font-size: 10px; letter-spacing: .6px; text-transform: uppercase; }
|
||||
.sugg-cat-count { color: var(--muted); font-size: 9.5px; font-family: var(--font-mono); background: var(--ink-3); border-radius: 8px; padding: 0 6px; }
|
||||
.sugg-more { margin-left: auto; font-size: 10px; padding: 0 4px; color: var(--muted); }
|
||||
|
||||
/* Editor settings modal */
|
||||
.es-content { display: flex; flex-direction: column; gap: 18px; }
|
||||
.es-row { display: flex; flex-direction: column; gap: 5px; }
|
||||
@@ -1737,6 +1766,9 @@ body.tour-mode { overflow: hidden; }
|
||||
.ns-row-name { font-size: 12.5px; color: var(--text); }
|
||||
.ns-row-class { font-size: 10.5px; color: var(--faint); font-family: var(--font-mono);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ns-row-selected { cursor: default; }
|
||||
.ns-row-selected .ns-row-class { flex: 1; }
|
||||
.ns-change { margin-left: auto; flex: 0 0 auto; }
|
||||
.ns-empty { padding: 10px 12px; font-size: 12px; color: var(--faint); }
|
||||
.ns-detail { display: flex; flex-direction: column; gap: 10px; }
|
||||
.ns-foot { display: flex; justify-content: flex-end; gap: 8px; padding-top: 4px; }
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
// Shortcut suggestions ("Ideas" bulb): a prompt-craft co-pilot next to prompt fields.
|
||||
// The bulb glows when whole shortcut categories go unused in the current prompt; the
|
||||
// popover mixes what it knows about YOUR habits (mined from all saved projects — pairs
|
||||
// used in the same shot, what shot usually follows the previous one) with browsing:
|
||||
// continue inside categories you're already using, or jump into ones you're not.
|
||||
// Never opens on its own. Toggled by the "Shortcut ideas" editor setting.
|
||||
// Reusable: ShortcutSuggest.bulb(textarea, { prevText: () => "…" }) — prevText supplies
|
||||
// the previous shot's prompt for "usual next shot" suggestions (scene inspector only).
|
||||
(function () {
|
||||
const { el } = window.dom;
|
||||
const S = window.Store;
|
||||
const API = window.MovieEditorAPI;
|
||||
|
||||
const MAX_CHIPS = 3; // random samples shown per category (before "+N more")
|
||||
const MAX_HABIT = 4; // ranked rows per habit section (pairs / next shot)
|
||||
const MIN_HABIT_N = 2; // ignore one-off co-occurrences — not a habit yet
|
||||
|
||||
function enabled() { return !!S.getEditorSetting("suggestions"); }
|
||||
|
||||
function libraryShortcuts() {
|
||||
return (S.get().shortcuts || []).filter((s) => s.enabled !== false && (s.triggers || []).length);
|
||||
}
|
||||
|
||||
function keyOf(sc) { return String(sc.key || sc.name || (sc.triggers || [])[0] || ""); }
|
||||
|
||||
// Does `text` contain `trigger` as a whole token (word boundaries on both sides)?
|
||||
// Triggers may contain spaces, so this is a boundary-checked substring scan.
|
||||
function containsTrigger(textLc, trigger) {
|
||||
const t = trigger.toLowerCase();
|
||||
if (!t) return false;
|
||||
let at = textLc.indexOf(t);
|
||||
const isWord = (ch) => /[a-z0-9_]/.test(ch || "");
|
||||
while (at >= 0) {
|
||||
const before = textLc[at - 1];
|
||||
const after = textLc[at + t.length];
|
||||
if (!isWord(before) && !isWord(after)) return true;
|
||||
at = textLc.indexOf(t, at + 1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function usesShortcut(textLc, sc) {
|
||||
return (sc.triggers || []).some((t) => containsTrigger(textLc, String(t || "").trim()));
|
||||
}
|
||||
|
||||
// Shortcuts whose trigger appears in `text`.
|
||||
function presentIn(text) {
|
||||
const lc = String(text || "").toLowerCase();
|
||||
if (!lc.trim()) return [];
|
||||
return libraryShortcuts().filter((sc) => usesShortcut(lc, sc));
|
||||
}
|
||||
|
||||
// Group the library by category and split into covered (some trigger appears in the
|
||||
// prompt) vs missing. The missing groups are the "you forgot about these" pool.
|
||||
function analyze(text) {
|
||||
const textLc = String(text || "").toLowerCase();
|
||||
const groups = new Map(); // category -> { used: bool, items: [shortcut] }
|
||||
for (const sc of libraryShortcuts()) {
|
||||
const cat = sc.category || "other";
|
||||
if (!groups.has(cat)) groups.set(cat, { used: false, items: [] });
|
||||
const g = groups.get(cat);
|
||||
g.items.push(sc);
|
||||
if (!g.used && usesShortcut(textLc, sc)) g.used = true;
|
||||
}
|
||||
const missing = [...groups.entries()].filter(([, g]) => !g.used)
|
||||
.map(([cat, g]) => ({ cat, items: g.items }));
|
||||
const used = [...groups.entries()].filter(([, g]) => g.used)
|
||||
.map(([cat, g]) => ({ cat, items: g.items }));
|
||||
return { missing, used, total: groups.size };
|
||||
}
|
||||
|
||||
function shuffled(arr) {
|
||||
const a = arr.slice();
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
// ── habit stats (mined server-side from all saved projects) ────────────────────
|
||||
// {counts, pairs:[[a,b,n]], follows:[[a,b,n]]} keyed by shortcut library key.
|
||||
function fetchStats() {
|
||||
if (!API || !API.suggestionStats) return Promise.resolve(null);
|
||||
return API.suggestionStats().catch(() => null);
|
||||
}
|
||||
|
||||
function statMaps(stats) {
|
||||
const pair = new Map(); // key -> Map(partner -> n), symmetric
|
||||
const addPair = (a, b, n) => {
|
||||
if (!pair.has(a)) pair.set(a, new Map());
|
||||
pair.get(a).set(b, (pair.get(a).get(b) || 0) + n);
|
||||
};
|
||||
(stats.pairs || []).forEach(([a, b, n]) => { addPair(a, b, n); addPair(b, a, n); });
|
||||
const follow = new Map(); // a -> Map(b -> n), directional: shot a then shot b
|
||||
(stats.follows || []).forEach(([a, b, n]) => {
|
||||
if (!follow.has(a)) follow.set(a, new Map());
|
||||
follow.get(a).set(b, (follow.get(a).get(b) || 0) + n);
|
||||
});
|
||||
return { pair, follow };
|
||||
}
|
||||
|
||||
// Rank candidates by habit strength against a set of context keys. Returns
|
||||
// [{sc, n, withKey}] — n = total co-occurrences, withKey = strongest partner.
|
||||
function rankByHabit(map, contextKeys, excludeKeys) {
|
||||
const byKey = new Map(libraryShortcuts().map((sc) => [keyOf(sc), sc]));
|
||||
const score = new Map(); // candidate key -> { n, best, bestN }
|
||||
for (const ctx of contextKeys) {
|
||||
const partners = map.get(ctx);
|
||||
if (!partners) continue;
|
||||
for (const [cand, n] of partners) {
|
||||
if (excludeKeys.has(cand) || !byKey.has(cand)) continue;
|
||||
const cur = score.get(cand) || { n: 0, best: ctx, bestN: 0 };
|
||||
cur.n += n;
|
||||
if (n > cur.bestN) { cur.best = ctx; cur.bestN = n; }
|
||||
score.set(cand, cur);
|
||||
}
|
||||
}
|
||||
return [...score.entries()]
|
||||
.filter(([, v]) => v.n >= MIN_HABIT_N)
|
||||
.sort((a, b) => b[1].n - a[1].n)
|
||||
.slice(0, MAX_HABIT)
|
||||
.map(([cand, v]) => ({ sc: byKey.get(cand), n: v.n, withSc: byKey.get(v.best) }));
|
||||
}
|
||||
|
||||
// Insert at the caret (replacing any selection), padded with spaces against
|
||||
// neighboring words, and fire `input` so the field's normal handlers persist it.
|
||||
function insertIntoTextarea(ta, text) {
|
||||
const v = ta.value;
|
||||
let s = ta.selectionStart, e = ta.selectionEnd;
|
||||
if (s == null) { s = e = v.length; }
|
||||
const before = v.slice(0, s), after = v.slice(e);
|
||||
const lead = before && !/\s$/.test(before) ? " " : "";
|
||||
const trail = !/^(\s|[,;])/.test(after) ? " " : "";
|
||||
const ins = lead + text + trail;
|
||||
ta.value = before + ins + after;
|
||||
const caret = s + ins.length;
|
||||
ta.setSelectionRange(caret, caret);
|
||||
ta.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
ta.focus();
|
||||
}
|
||||
|
||||
function bulb(ta, opts) {
|
||||
opts = opts || {};
|
||||
const btn = el("button", "btn ghost tiny sugg-bulb", "💡");
|
||||
btn.type = "button";
|
||||
if (!enabled()) { btn.style.display = "none"; return btn; }
|
||||
|
||||
let pop = null;
|
||||
let unlisten = null; // detaches the document-level dismiss listeners
|
||||
function closePop() {
|
||||
if (pop) { pop.remove(); pop = null; }
|
||||
if (unlisten) { unlisten(); unlisten = null; }
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
const { missing } = analyze(ta.value);
|
||||
const n = missing.length;
|
||||
btn.classList.toggle("on", n > 0 && !!ta.value.trim());
|
||||
btn.title = n > 0
|
||||
? `Ideas — ${n} shortcut ${n === 1 ? "category" : "categories"} this prompt isn't using`
|
||||
: "Ideas — pairings, next-shot habits, and category browse";
|
||||
}
|
||||
|
||||
function position() {
|
||||
if (!pop) return;
|
||||
const r = btn.getBoundingClientRect();
|
||||
pop.style.top = (r.bottom + 4) + "px";
|
||||
pop.style.left = Math.max(8, Math.min(r.left, window.innerWidth - 340)) + "px";
|
||||
}
|
||||
|
||||
function chipRow(sc, why) {
|
||||
const trig = String((sc.triggers || [])[0] || "").trim();
|
||||
if (!trig) return null;
|
||||
const row = el("div", "sugg-item");
|
||||
row.append(el("span", "ac-trigger", trig));
|
||||
const rep = (sc.replacements || [])[0];
|
||||
if (rep) row.append(el("span", "ac-prompt", rep));
|
||||
if (why) row.append(el("span", "sugg-why", why));
|
||||
else if (sc.sub_category) row.append(el("span", "ac-cat", sc.sub_category));
|
||||
row.title = rep || trig;
|
||||
// mousedown (not click) so the textarea keeps focus context, same as autocomplete.
|
||||
row.addEventListener("mousedown", (ev) => {
|
||||
ev.preventDefault();
|
||||
insertIntoTextarea(ta, trig);
|
||||
closePop();
|
||||
refresh();
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
function secHead(box, label) {
|
||||
box.append(el("div", "sugg-sec-head", label));
|
||||
}
|
||||
|
||||
function trigOf(sc) { return String((sc.triggers || [])[0] || "").trim(); }
|
||||
|
||||
function openPop() {
|
||||
closePop();
|
||||
document.querySelectorAll(".sugg-pop").forEach((p) => p.remove());
|
||||
// Never open off an invisible/detached bulb — the popover would strand at (0,0).
|
||||
const br = btn.getBoundingClientRect();
|
||||
if (!btn.isConnected || (!br.width && !br.height)) return;
|
||||
|
||||
const { missing, used } = analyze(ta.value);
|
||||
const present = presentIn(ta.value);
|
||||
const presentKeys = new Set(present.map(keyOf));
|
||||
pop = el("div", "sugg-pop");
|
||||
|
||||
const head = el("div", "sugg-head");
|
||||
head.append(el("span", "sugg-title", "Ideas"));
|
||||
const reroll = el("button", "btn ghost tiny", "⟳");
|
||||
reroll.title = "Shuffle the samples";
|
||||
reroll.onclick = (ev) => { ev.stopPropagation(); fillBrowse(); };
|
||||
head.append(reroll);
|
||||
pop.append(head);
|
||||
|
||||
// ── habits (async): "you've paired these before" + "your usual next shot" ──
|
||||
const habits = el("div", "sugg-habits");
|
||||
pop.append(habits);
|
||||
fetchStats().then((stats) => {
|
||||
if (!pop || !stats) return;
|
||||
const { pair, follow } = statMaps(stats);
|
||||
// Pairings: shortcuts you historically put in the SAME shot as what's here.
|
||||
if (presentKeys.size) {
|
||||
const ideas = rankByHabit(pair, presentKeys, presentKeys);
|
||||
if (ideas.length) {
|
||||
secHead(habits, "You've paired these before");
|
||||
for (const i of ideas) {
|
||||
const row = chipRow(i.sc, `with «${trigOf(i.withSc)}» ×${i.n}`);
|
||||
if (row) habits.append(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Next shot: what usually comes after the PREVIOUS shot's shortcuts.
|
||||
const prevText = typeof opts.prevText === "function" ? (opts.prevText() || "") : "";
|
||||
const prevKeys = new Set(presentIn(prevText).map(keyOf));
|
||||
if (prevKeys.size) {
|
||||
const ideas = rankByHabit(follow, prevKeys, presentKeys);
|
||||
if (ideas.length) {
|
||||
secHead(habits, "Your usual next shot");
|
||||
for (const i of ideas) {
|
||||
const row = chipRow(i.sc, `after «${trigOf(i.withSc)}» ×${i.n}`);
|
||||
if (row) habits.append(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
position();
|
||||
});
|
||||
|
||||
const list = el("div", "sugg-list");
|
||||
pop.append(list);
|
||||
|
||||
// ── browse (sync): continue current categories + jump into missing ones ────
|
||||
const expanded = new Set();
|
||||
function fillBrowse() {
|
||||
list.replaceChildren();
|
||||
// Continue: more from categories this prompt already uses (minus what's in it).
|
||||
const contCats = used
|
||||
.map((u) => ({ cat: u.cat, items: u.items.filter((sc) => !presentKeys.has(keyOf(sc))) }))
|
||||
.filter((u) => u.items.length);
|
||||
if (contCats.length) {
|
||||
secHead(list, "Continue the thread");
|
||||
for (const u of shuffled(contCats).slice(0, 3)) {
|
||||
const headRow = el("div", "sugg-cat-head");
|
||||
headRow.append(el("span", "sugg-cat-name", "more " + u.cat));
|
||||
list.append(headRow);
|
||||
for (const sc of shuffled(u.items).slice(0, 2)) {
|
||||
const row = chipRow(sc);
|
||||
if (row) list.append(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Jump: whole categories this prompt doesn't touch, with counts and browse.
|
||||
if (missing.length) {
|
||||
secHead(list, "New directions");
|
||||
const cats = missing.slice().sort((a, b) => a.cat.localeCompare(b.cat));
|
||||
for (const m of cats) {
|
||||
const headRow = el("div", "sugg-cat-head");
|
||||
headRow.append(el("span", "sugg-cat-name", m.cat));
|
||||
headRow.append(el("span", "sugg-cat-count", String(m.items.length)));
|
||||
const showAll = expanded.has(m.cat);
|
||||
if (m.items.length > MAX_CHIPS) {
|
||||
const more = el("button", "btn ghost tiny sugg-more",
|
||||
showAll ? "less" : `+${m.items.length - MAX_CHIPS} more`);
|
||||
more.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
showAll ? expanded.delete(m.cat) : expanded.add(m.cat);
|
||||
fillBrowse();
|
||||
};
|
||||
headRow.append(more);
|
||||
}
|
||||
list.append(headRow);
|
||||
const items = showAll ? m.items : shuffled(m.items).slice(0, MAX_CHIPS);
|
||||
for (const sc of items) {
|
||||
const row = chipRow(sc);
|
||||
if (row) list.append(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!contCats.length && !missing.length) {
|
||||
list.append(el("div", "sugg-empty",
|
||||
"Every shortcut category already appears here — nothing left to browse."));
|
||||
}
|
||||
}
|
||||
fillBrowse();
|
||||
|
||||
document.body.append(pop);
|
||||
position();
|
||||
// Dismiss on any outside press or Escape; reposition while scrolling.
|
||||
// Every close path funnels through closePop, which also detaches these.
|
||||
const onDown = (ev) => { if (pop && !pop.contains(ev.target) && ev.target !== btn) closePop(); };
|
||||
const onKey = (ev) => { if (ev.key === "Escape") closePop(); };
|
||||
const onScroll = () => position();
|
||||
document.addEventListener("mousedown", onDown, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
unlisten = () => {
|
||||
document.removeEventListener("mousedown", onDown, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
};
|
||||
}
|
||||
|
||||
btn.onclick = (ev) => { ev.preventDefault(); if (pop) closePop(); else openPop(); };
|
||||
|
||||
// Keep the glow honest while typing — cheap scan, debounced.
|
||||
let timer = null;
|
||||
ta.addEventListener("input", () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(refresh, 400);
|
||||
});
|
||||
// Callers create the bulb before appending the field to the DOM — defer the first scan.
|
||||
setTimeout(refresh, 0);
|
||||
// A panel re-render can remove the bulb while its popover is open; close it (and
|
||||
// stop watching) when the bulb detaches, same pattern as the autocomplete menu.
|
||||
const detachObs = new MutationObserver(() => {
|
||||
if (!btn.isConnected) { closePop(); detachObs.disconnect(); }
|
||||
});
|
||||
setTimeout(() => { if (btn.isConnected) detachObs.observe(document.body, { childList: true, subtree: true }); }, 0);
|
||||
return btn;
|
||||
}
|
||||
|
||||
window.ShortcutSuggest = { bulb };
|
||||
})();
|
||||
@@ -129,6 +129,8 @@
|
||||
projects: st.projects?.length,
|
||||
media: st.mediaBin?.length,
|
||||
mediaPreview: st.mediaPreviewId,
|
||||
// Continuity pin renders on gallery cards (📌 button state + thumb badge).
|
||||
pin: st.project?.continuity_settings?.identity_pin_ref,
|
||||
shortcuts: st.shortcuts?.length,
|
||||
transitions: st.transitions?.length,
|
||||
nleEffects: st.nleEffects?.length,
|
||||
|
||||
+92
-26
@@ -184,7 +184,7 @@ def _clip_needs_trim(clip: dict) -> bool:
|
||||
return inn > 0.001 or dur is not None
|
||||
|
||||
|
||||
def _ffmpeg_trim_clip(src_path: str, out_path: str, inn, dur) -> None:
|
||||
def _ffmpeg_trim_clip(src_path: str, out_path: str, inn, dur, fast: bool = False) -> None:
|
||||
import shutil
|
||||
import subprocess
|
||||
ff = shutil.which("ffmpeg")
|
||||
@@ -195,9 +195,12 @@ def _ffmpeg_trim_clip(src_path: str, out_path: str, inn, dur) -> None:
|
||||
cmd += ["-ss", f"{float(inn):.3f}"]
|
||||
if dur is not None:
|
||||
cmd += ["-t", f"{float(dur):.3f}"]
|
||||
cmd += ["-i", src_path, "-c:v", "libx264", "-pix_fmt", "yuv420p"]
|
||||
if fast:
|
||||
# Preview segments: latency matters far more than a few % of bitrate — the player
|
||||
# is waiting on this encode to unfreeze a scrub across a chain boundary.
|
||||
cmd += ["-preset", "veryfast"]
|
||||
cmd += [
|
||||
"-i", src_path,
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac", "-b:a", "192k",
|
||||
"-movflags", "+faststart", out_path,
|
||||
]
|
||||
@@ -221,6 +224,28 @@ def _playback_render_from_query(query) -> Optional[dict]:
|
||||
}
|
||||
|
||||
|
||||
def _ghost_playback_clip_spec(query) -> Optional[dict]:
|
||||
"""Preview-segment clip spec for a GHOST (removed scene whose render is still shown):
|
||||
the scene no longer exists on the project, so the trim window comes entirely from the
|
||||
query — in-point (render_in, absolute in the source file) and duration (dur)."""
|
||||
filename = query.get("filename") if query else None
|
||||
dur = query.get("dur") if query else None
|
||||
if not filename or dur is None:
|
||||
return None
|
||||
try:
|
||||
inn = float(query.get("render_in") or 0)
|
||||
dur = float(dur)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return {
|
||||
"filename": filename,
|
||||
"subfolder": query.get("subfolder") or "",
|
||||
"type": query.get("type") or "output",
|
||||
"in": inn,
|
||||
"dur": dur,
|
||||
}
|
||||
|
||||
|
||||
def _scene_playback_clip_spec(
|
||||
project: Project,
|
||||
scene_id: str,
|
||||
@@ -254,6 +279,7 @@ def _scene_playback_clip_spec(
|
||||
|
||||
|
||||
_preview_segment_cache: dict[str, str] = {}
|
||||
_preview_segment_locks: dict = {} # cache key -> asyncio.Lock (dedupe concurrent encodes)
|
||||
|
||||
|
||||
def _preview_segment_cache_key(pid: str, scene_id: str, clip: dict) -> str:
|
||||
@@ -1315,6 +1341,15 @@ if web is not None and PromptServer is not None:
|
||||
except Exception as e: # noqa: BLE001
|
||||
return web.json_response({"shortcuts": [], "error": str(e)})
|
||||
|
||||
@routes.get(UI_PREFIX + "/api/library/suggestion_stats")
|
||||
async def _suggestion_stats(_req):
|
||||
"""Shortcut usage mined from all saved projects (pairs / follows / counts) —
|
||||
feeds the prompt Ideas popover. Empty stats on any error, never a 500."""
|
||||
try:
|
||||
return web.json_response(bridge.suggestion_stats())
|
||||
except Exception as e: # noqa: BLE001
|
||||
return web.json_response({"scenes": 0, "counts": {}, "pairs": [], "follows": [], "error": str(e)})
|
||||
|
||||
@routes.post(UI_PREFIX + "/api/library/shortcuts")
|
||||
async def _shortcut_save(req):
|
||||
try:
|
||||
@@ -1329,6 +1364,20 @@ if web is not None and PromptServer is not None:
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise web.HTTPBadRequest(reason=str(e))
|
||||
|
||||
@routes.get(UI_PREFIX + "/api/library/revolver")
|
||||
async def _revolver_get(_req):
|
||||
try:
|
||||
return web.json_response(bridge.revolver_settings())
|
||||
except Exception as e: # noqa: BLE001
|
||||
return web.json_response({"enabled": False, "random": False, "error": str(e)})
|
||||
|
||||
@routes.post(UI_PREFIX + "/api/library/revolver")
|
||||
async def _revolver_set(req):
|
||||
try:
|
||||
return web.json_response(bridge.set_revolver_settings(await req.json()))
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise web.HTTPBadRequest(reason=str(e))
|
||||
|
||||
@routes.post(UI_PREFIX + "/api/library/categories")
|
||||
async def _category_save(req):
|
||||
try:
|
||||
@@ -1777,29 +1826,44 @@ if web is not None and PromptServer is not None:
|
||||
try:
|
||||
clip = _scene_playback_clip_spec(proj, scene_id, render_override=render_override)
|
||||
except KeyError as e:
|
||||
return web.json_response({"detail": str(e)}, status=404)
|
||||
# Ghosts: the scene was removed from the project but its render still previews;
|
||||
# the trim window (in/dur) comes from the query instead of the scene.
|
||||
clip = _ghost_playback_clip_spec(req.query)
|
||||
if clip is None:
|
||||
return web.json_response({"detail": str(e)}, status=404)
|
||||
key = _preview_segment_cache_key(pid, scene_id, clip)
|
||||
cached = _preview_segment_cache.get(key)
|
||||
if cached and os.path.isfile(cached):
|
||||
return web.FileResponse(cached, headers={"Cache-Control": "private, max-age=3600"})
|
||||
try:
|
||||
import folder_paths
|
||||
tempdir = folder_paths.get_temp_directory()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return web.json_response({"detail": f"Temp directory unavailable: {e}"}, status=500)
|
||||
import time as _time
|
||||
out_path = os.path.join(
|
||||
tempdir,
|
||||
f"funpack_preview_{pid[:8]}_{scene_id}_{int(_time.time() * 1000)}.mp4",
|
||||
)
|
||||
try:
|
||||
src_path = _resolve_clip_src_path(clip)
|
||||
_ffmpeg_trim_clip(src_path, out_path, clip.get("in"), clip.get("dur"))
|
||||
except FileNotFoundError as e:
|
||||
return web.json_response({"detail": str(e)}, status=400)
|
||||
except RuntimeError as e:
|
||||
return web.json_response({"detail": str(e)}, status=503)
|
||||
_preview_segment_cache[key] = out_path
|
||||
# Serialize per segment: the pool preloads every clip at once after a run, and a
|
||||
# scrub can re-request the same URL — without this lock each request would start
|
||||
# its own duplicate encode of the same segment.
|
||||
lock = _preview_segment_locks.setdefault(key, asyncio.Lock())
|
||||
async with lock:
|
||||
cached = _preview_segment_cache.get(key)
|
||||
if cached and os.path.isfile(cached):
|
||||
return web.FileResponse(cached, headers={"Cache-Control": "private, max-age=3600"})
|
||||
try:
|
||||
import folder_paths
|
||||
tempdir = folder_paths.get_temp_directory()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return web.json_response({"detail": f"Temp directory unavailable: {e}"}, status=500)
|
||||
import time as _time
|
||||
out_path = os.path.join(
|
||||
tempdir,
|
||||
f"funpack_preview_{pid[:8]}_{scene_id}_{int(_time.time() * 1000)}.mp4",
|
||||
)
|
||||
try:
|
||||
src_path = _resolve_clip_src_path(clip)
|
||||
# to_thread: this encode takes seconds — run inline it would freeze the ONE
|
||||
# aiohttp event loop ComfyUI has, stalling every playing /result stream and
|
||||
# the whole editor API for its duration (stale/black previews on scrub).
|
||||
await asyncio.to_thread(
|
||||
_ffmpeg_trim_clip, src_path, out_path, clip.get("in"), clip.get("dur"),
|
||||
fast=True,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
return web.json_response({"detail": str(e)}, status=400)
|
||||
except RuntimeError as e:
|
||||
return web.json_response({"detail": str(e)}, status=503)
|
||||
_preview_segment_cache[key] = out_path
|
||||
return web.FileResponse(out_path, headers={"Cache-Control": "private, max-age=3600"})
|
||||
|
||||
# --- API: export one clip (ffmpeg trim from a chain output using in/dur) ---
|
||||
@@ -1822,7 +1886,9 @@ if web is not None and PromptServer is not None:
|
||||
out_path = os.path.join(tempdir, out_name)
|
||||
try:
|
||||
src_path = _resolve_clip_src_path(clip)
|
||||
_ffmpeg_trim_clip(src_path, out_path, clip.get("in"), clip.get("dur"))
|
||||
# to_thread: same event-loop-freeze hazard as preview segments — an export
|
||||
# during playback would stall every streaming /result response.
|
||||
await asyncio.to_thread(_ffmpeg_trim_clip, src_path, out_path, clip.get("in"), clip.get("dur"))
|
||||
except FileNotFoundError as e:
|
||||
return web.json_response({"detail": str(e)}, status=400)
|
||||
except RuntimeError as e:
|
||||
|
||||
@@ -421,6 +421,25 @@ def test_scene_playback_clip_spec_live_render_query():
|
||||
assert _playback_render_from_query({}) is None
|
||||
|
||||
|
||||
def test_ghost_playback_clip_spec_from_query():
|
||||
"""Ghost segments: the scene is gone from the project, so the trim window (in/dur)
|
||||
must come entirely from the query — and malformed/incomplete queries return None
|
||||
(the handler then 404s instead of encoding garbage)."""
|
||||
from movie_editor.server import _ghost_playback_clip_spec
|
||||
c = _ghost_playback_clip_spec({
|
||||
"filename": "chain.mp4", "subfolder": "out", "type": "output",
|
||||
"render_in": "7.4", "dur": "2.5",
|
||||
})
|
||||
assert c == {"filename": "chain.mp4", "subfolder": "out", "type": "output",
|
||||
"in": 7.4, "dur": 2.5}
|
||||
assert _ghost_playback_clip_spec({"filename": "chain.mp4"}) is None # no dur
|
||||
assert _ghost_playback_clip_spec({"dur": "2.5"}) is None # no filename
|
||||
assert _ghost_playback_clip_spec({"filename": "c.mp4", "dur": "oops"}) is None # bad float
|
||||
# render_in defaults to 0 (ghost of the chain's first scene).
|
||||
c0 = _ghost_playback_clip_spec({"filename": "c.mp4", "dur": "4.0"})
|
||||
assert c0["in"] == 0 and c0["type"] == "output"
|
||||
|
||||
|
||||
def test_project_models_keeps_empty_slots(monkeypatch):
|
||||
from movie_editor.backend import nodes
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
[project]
|
||||
name = "ComfyUI-FunPack" # Unique identifier for your node. Immutable after creation.
|
||||
description = "An AI movie studio inside ComfyUI: the Cutting Room browser-based video editor with timeline, per-scene prompts and one-click montage generation, plus a self-improving engine that learns from your ratings. Includes the full node stack (Studio, Refiner V2, LTXAV Scene Chain Sampler) for classic graph workflows."
|
||||
version = "3.2.0" # Custom Node version. Must be semantically versioned.
|
||||
version = "3.2.1" # Custom Node version. Must be semantically versioned.
|
||||
license = { text = "GNU GPL v3" }
|
||||
dependencies = [] # Filled in from requirements.txt
|
||||
|
||||
|
||||
+260
-25
@@ -3,6 +3,7 @@ import hashlib
|
||||
import math
|
||||
import os
|
||||
import time as _time
|
||||
import types
|
||||
|
||||
import torch
|
||||
|
||||
@@ -1755,6 +1756,7 @@ def sample_funpack_distilled_flow(model, x, sigmas, extra_args=None, callback=No
|
||||
rescue_prompt_sig=None,
|
||||
alg_enabled=False, alg_strength=2.0, alg_sigma_threshold=0.975,
|
||||
alg_guide_tail_frames=0,
|
||||
alg_guide_blur_strength=2.0, alg_guide_blur_sigma_threshold=0.975,
|
||||
mg_enabled=False, mg_strength=0.5, mg_decay=0.5, mg_sigma_threshold=0.975,
|
||||
quality_sharpness=0.0):
|
||||
"""
|
||||
@@ -1783,7 +1785,11 @@ def sample_funpack_distilled_flow(model, x, sigmas, extra_args=None, callback=No
|
||||
additionally blurs that many trailing video frames — the newly-appended guide-
|
||||
attention frames this scene (mid_scene_guide / carry_i2v_guides-as-guide /
|
||||
configured guides / JoyAI memory), set by the Scene Chain Sampler's alg_blur_guides
|
||||
toggle right before this chunk's sample call. 0 = untouched (default).
|
||||
toggle right before this chunk's sample call. 0 = untouched (default). Standalone:
|
||||
works with alg_enabled off (anchor stays sharp, only the guide tail is blurred), with
|
||||
its own independent controls — alg_guide_blur_strength / alg_guide_blur_sigma_threshold
|
||||
(also set per-chunk by the Scene Chain Sampler), separate from the anchor's
|
||||
alg_strength / alg_sigma_threshold.
|
||||
- EXPERIMENTAL mg_enabled: Momentum Guidance (arXiv:2602.20360). Keeps a running EMA
|
||||
(decay=mg_decay) of the per-step ODE direction and blends the current step's
|
||||
direction toward it (weight=mg_strength) while sigma is BELOW mg_sigma_threshold —
|
||||
@@ -1835,17 +1841,44 @@ def sample_funpack_distilled_flow(model, x, sigmas, extra_args=None, callback=No
|
||||
# denoise_mask) or if the packed-latent layout can't be read (helper returns None).
|
||||
# alg_guide_tail_frames (>0) extends the same idea, beyond the paper, to the trailing
|
||||
# guide-attention frames this scene appended (alg_blur_guides on the Scene Chain Sampler).
|
||||
alg_sharp_latent_image = getattr(model, "latent_image", None) if alg_enabled else None
|
||||
alg_active = bool(alg_enabled) and extra_args.get("denoise_mask") is not None and alg_sharp_latent_image is not None
|
||||
alg_blurred_latent_image = _alg_blur_frames(
|
||||
model, alg_sharp_latent_image, max(1.0, float(alg_strength)),
|
||||
frame_indices=(0,), tail_count=int(alg_guide_tail_frames),
|
||||
) if alg_active else None
|
||||
alg_active = alg_active and alg_blurred_latent_image is not None
|
||||
# Anchor and guide-tail blur are fully independent: each has its own strength and sigma
|
||||
# window (alg_strength/alg_sigma_threshold vs alg_guide_blur_*), so one precomputed
|
||||
# latent per (anchor blurred?, tail blurred?) combination that can occur — the frame
|
||||
# sets are disjoint, so the both-blurred variant just composes the two.
|
||||
alg_guide_tail_frames = max(0, int(alg_guide_tail_frames))
|
||||
alg_anchor_on = bool(alg_enabled)
|
||||
alg_tail_on = alg_guide_tail_frames > 0
|
||||
alg_sharp_latent_image = getattr(model, "latent_image", None) if (alg_anchor_on or alg_tail_on) else None
|
||||
alg_active = ((alg_anchor_on or alg_tail_on) and extra_args.get("denoise_mask") is not None
|
||||
and alg_sharp_latent_image is not None)
|
||||
alg_latents = {}
|
||||
if alg_active:
|
||||
print(f"[FunPack AV] ALG on (strength={alg_strength}, sigma_threshold={alg_sigma_threshold}, "
|
||||
f"guide_tail_frames={alg_guide_tail_frames}) "
|
||||
f"— anchor blurred while sigma > threshold")
|
||||
anchor_blurred = _alg_blur_frames(
|
||||
model, alg_sharp_latent_image, max(1.0, float(alg_strength)), frame_indices=(0,),
|
||||
) if alg_anchor_on else None
|
||||
tail_kappa = max(1.0, float(alg_guide_blur_strength))
|
||||
tail_blurred = _alg_blur_frames(
|
||||
model, alg_sharp_latent_image, tail_kappa, tail_count=alg_guide_tail_frames,
|
||||
) if alg_tail_on else None
|
||||
both_blurred = _alg_blur_frames(
|
||||
model, anchor_blurred, tail_kappa, tail_count=alg_guide_tail_frames,
|
||||
) if (anchor_blurred is not None and tail_blurred is not None) else None
|
||||
alg_anchor_on = alg_anchor_on and anchor_blurred is not None
|
||||
alg_tail_on = alg_tail_on and tail_blurred is not None
|
||||
alg_latents = {
|
||||
(False, False): alg_sharp_latent_image,
|
||||
(True, False): anchor_blurred,
|
||||
(False, True): tail_blurred,
|
||||
(True, True): both_blurred if both_blurred is not None else (anchor_blurred or tail_blurred),
|
||||
}
|
||||
alg_active = alg_anchor_on or alg_tail_on
|
||||
if alg_active:
|
||||
anchor_desc = (f"strength={alg_strength}, sigma_threshold={alg_sigma_threshold}"
|
||||
if alg_anchor_on else "off")
|
||||
tail_desc = (f"{alg_guide_tail_frames} frame(s), strength={alg_guide_blur_strength}, "
|
||||
f"sigma_threshold={alg_guide_blur_sigma_threshold}" if alg_tail_on else "off")
|
||||
print(f"[FunPack AV] ALG on (anchor: {anchor_desc}; guide tail: {tail_desc}) "
|
||||
f"— blurred while sigma > threshold")
|
||||
|
||||
# EXPERIMENTAL Momentum Guidance (arXiv:2602.20360): EMA of the per-step ODE direction,
|
||||
# blended into the direction actually used once sigma drops below mg_sigma_threshold —
|
||||
@@ -1880,7 +1913,10 @@ def sample_funpack_distilled_flow(model, x, sigmas, extra_args=None, callback=No
|
||||
velocity_target = _velocity_bias_target(sigmas, sigma)
|
||||
|
||||
if alg_active:
|
||||
model.latent_image = alg_blurred_latent_image if float(sigma) > float(alg_sigma_threshold) else alg_sharp_latent_image
|
||||
model.latent_image = alg_latents[(
|
||||
alg_anchor_on and float(sigma) > float(alg_sigma_threshold),
|
||||
alg_tail_on and float(sigma) > float(alg_guide_blur_sigma_threshold),
|
||||
)]
|
||||
|
||||
if _velocity_bias_enabled(velocity_bias_mode, "apply"):
|
||||
x_pre = x
|
||||
@@ -2060,11 +2096,11 @@ class FunPackDistilledFlowSampler:
|
||||
}),
|
||||
"alg_strength": ("FLOAT", {
|
||||
"default": 2.0, "min": 1.0, "max": 4.0, "step": 0.1,
|
||||
"tooltip": "Downsample factor for the anchor blur (paper default 2.5, but 2.0 held character/i2v consistency noticeably better in testing here). Higher = blurrier anchor during the affected steps. Only used when alg_enabled.",
|
||||
"tooltip": "Downsample factor for the anchor blur (paper default 2.5, but 2.0 held character/i2v consistency noticeably better in testing here). Higher = blurrier anchor during the affected steps. Only used when alg_enabled; guide-frame blur has its own controls on the Scene Chain Sampler (alg_blur_guides + alg_guide_blur_*).",
|
||||
}),
|
||||
"alg_sigma_threshold": ("FLOAT", {
|
||||
"default": 0.975, "min": 0.5, "max": 0.999, "step": 0.005,
|
||||
"tooltip": "Anchor stays blurred while sigma is above this value (the near-pure-noise steps), then swaps to sharp. Higher = narrower blurred window. Only used when alg_enabled.",
|
||||
"tooltip": "Anchor stays blurred while sigma is above this value (the near-pure-noise steps), then swaps to sharp. Higher = narrower blurred window. Only used when alg_enabled; guide-frame blur has its own controls on the Scene Chain Sampler (alg_blur_guides + alg_guide_blur_*).",
|
||||
}),
|
||||
"mg_enabled": ("BOOLEAN", {
|
||||
"default": False,
|
||||
@@ -2324,7 +2360,7 @@ class FunPackLTXAVSceneChainSampler:
|
||||
}),
|
||||
"alg_blur_guides": ("BOOLEAN", {
|
||||
"default": False,
|
||||
"tooltip": "EXPERIMENTAL: extends ALG (see the sampler's alg_enabled) from just the i2v anchor to also blur newly-appended guide-attention frames this scene (mid_scene_guide / carry_i2v_guides-as-guide / configured per-scene guides / JoyAI memory), for the same early steps. No effect unless the chosen sampler has alg_enabled on, or no guide frames were appended this scene.",
|
||||
"tooltip": "EXPERIMENTAL: extends ALG (see the sampler's alg_enabled) from just the i2v anchor to also blur newly-appended guide-attention frames this scene (mid_scene_guide / carry_i2v_guides-as-guide / configured per-scene guides / JoyAI memory), for the same early steps. Standalone: works even with the sampler's alg_enabled off (anchor stays sharp), with its own alg_guide_blur_strength / alg_guide_blur_sigma_threshold controls below. Requires the FunPack Distilled Flow sampler; no effect if no guide frames were appended this scene.",
|
||||
}),
|
||||
"bounded_attention_enabled": ("BOOLEAN", {
|
||||
"default": False,
|
||||
@@ -2352,6 +2388,22 @@ class FunPackLTXAVSceneChainSampler:
|
||||
"default": 0.6, "min": 0.3, "max": 0.95, "step": 0.05,
|
||||
"tooltip": "Frame-similarity gate: a current frame must match a banked negative frame above this cosine similarity before any steering applies. Steering strength ramps from 0 at the threshold to full at similarity 1.0, so it self-releases once the unwanted feature is gone. Lower = more aggressive (risks steering away from legitimately similar content).",
|
||||
}),
|
||||
"alg_guide_blur_strength": ("FLOAT", {
|
||||
"default": 2.0, "min": 1.0, "max": 4.0, "step": 0.1,
|
||||
"tooltip": "Downsample factor for the guide-frame blur (alg_blur_guides). Higher = blurrier guide/JoyAI frames during the affected steps. Independent of the sampler's anchor alg_strength.",
|
||||
}),
|
||||
"alg_guide_blur_sigma_threshold": ("FLOAT", {
|
||||
"default": 0.975, "min": 0.5, "max": 0.999, "step": 0.005,
|
||||
"tooltip": "Guide frames stay blurred while sigma is above this value (the near-pure-noise steps), then swap to sharp. Higher = narrower blurred window. Independent of the sampler's anchor alg_sigma_threshold.",
|
||||
}),
|
||||
"identity_transfer_enabled": ("BOOLEAN", {
|
||||
"default": False,
|
||||
"tooltip": "EXPERIMENTAL Best-FaceID compatibility: tags Continuity's 'Identity pin' guide (Engine settings) with an extra source-phase RoPE rotation, matching the exact overlap+source_phase conditioning Best-FaceID-style identity LoRAs were trained on. No-op without an identity pin image set. Load the LoRA itself the normal way (Models -> add a LoRA loader onto the model path).",
|
||||
}),
|
||||
"identity_transfer_phase": ("FLOAT", {
|
||||
"default": 2.0, "min": 0.0, "max": 8.0, "step": 0.5,
|
||||
"tooltip": "Source-phase segment id for the identity-pin guide tokens (ltx-trainer's overlap+source_phase convention used 2). 0 disables the rotation while leaving the guide itself active.",
|
||||
}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
@@ -2737,6 +2789,7 @@ class FunPackLTXAVSceneChainSampler:
|
||||
|
||||
def _sample_chunk(self, model, sampler, sigmas, seed, cfg, positive, negative, latent,
|
||||
pbar=None, step_offset=0, alg_guide_tail_frames=0,
|
||||
alg_guide_blur_strength=2.0, alg_guide_blur_sigma_threshold=0.975,
|
||||
bounded_attention_enabled=False):
|
||||
if sampler is None:
|
||||
raise ValueError("sampler input is required.")
|
||||
@@ -2759,6 +2812,8 @@ class FunPackLTXAVSceneChainSampler:
|
||||
extra_options = getattr(sampler, "extra_options", None)
|
||||
if isinstance(extra_options, dict) and sampler.sampler_function is sample_funpack_distilled_flow:
|
||||
extra_options["alg_guide_tail_frames"] = int(alg_guide_tail_frames)
|
||||
extra_options["alg_guide_blur_strength"] = float(alg_guide_blur_strength)
|
||||
extra_options["alg_guide_blur_sigma_threshold"] = float(alg_guide_blur_sigma_threshold)
|
||||
|
||||
# EXPERIMENTAL Bounded Attention: model-level attention hooks (sampler-agnostic, unlike
|
||||
# the toggles above which only work on Distilled Flow), so install/remove here rather
|
||||
@@ -3521,8 +3576,34 @@ class FunPackLTXAVSceneChainSampler:
|
||||
return 0, True
|
||||
return 1 + (at - 1) * time_scale, False
|
||||
|
||||
def _append_guide_latent(self, chunk, guide_frame, apply_at, strength, positive, negative, vae):
|
||||
"""Append one guide latent frame with LTX guide attention at apply_at."""
|
||||
def _tag_last_guide_entry(self, cond, key, value):
|
||||
"""Best-effort: stamp `key=value` onto the guide_attention_entry _append_guide_attention_entry
|
||||
just added (the last one), without touching any earlier entry. Used to mark which guide
|
||||
(e.g. the Best-FaceID identity pin) is eligible for the source-phase RoPE patch — see
|
||||
_install_identity_phase. No-op (returns cond unchanged) if node_helpers isn't importable
|
||||
or no entries are present; never blocks generation."""
|
||||
try:
|
||||
import node_helpers
|
||||
except ImportError:
|
||||
return cond
|
||||
try:
|
||||
for t in cond:
|
||||
entries = t[1].get("guide_attention_entries")
|
||||
if entries:
|
||||
tagged = [*entries[:-1], {**entries[-1], key: value}]
|
||||
return node_helpers.conditioning_set_values(cond, {"guide_attention_entries": tagged})
|
||||
except Exception:
|
||||
pass
|
||||
return cond
|
||||
|
||||
def _append_guide_latent(self, chunk, guide_frame, apply_at, strength, positive, negative, vae,
|
||||
phase_tag=0.0):
|
||||
"""Append one guide latent frame with LTX guide attention at apply_at.
|
||||
|
||||
`phase_tag`: when non-zero, marks this guide's entry with `funpack_identity_phase` so
|
||||
_install_identity_phase can find its exact token range and apply the source-phase RoPE
|
||||
rotation the Best-FaceID LoRA was trained against — see [[project_identity_transfer_native]].
|
||||
"""
|
||||
try:
|
||||
from comfy_extras.nodes_lt import LTXVAddGuide, _append_guide_attention_entry
|
||||
except ImportError:
|
||||
@@ -3548,6 +3629,9 @@ class FunPackLTXAVSceneChainSampler:
|
||||
positive, negative = _append_guide_attention_entry(
|
||||
positive, negative, pre_filter_count, guide_latent_shape, strength=float(strength)
|
||||
)
|
||||
if phase_tag:
|
||||
positive = self._tag_last_guide_entry(positive, "funpack_identity_phase", float(phase_tag))
|
||||
negative = self._tag_last_guide_entry(negative, "funpack_identity_phase", float(phase_tag))
|
||||
|
||||
result = self._clone_latent(chunk)
|
||||
tensors = self._latent_tensors(result)
|
||||
@@ -3761,7 +3845,7 @@ class FunPackLTXAVSceneChainSampler:
|
||||
return None
|
||||
|
||||
def _append_media_guide_at(self, chunk, filename, frame_idx, apply_at, strength,
|
||||
positive, negative, vae):
|
||||
positive, negative, vae, phase_tag=0.0):
|
||||
chunk_tensors = self._latent_tensors(chunk)
|
||||
if not chunk_tensors:
|
||||
return chunk, positive, negative, 0, 0
|
||||
@@ -3772,13 +3856,15 @@ class FunPackLTXAVSceneChainSampler:
|
||||
at = self._resolve_frame_index(total, frame_idx)
|
||||
guide_slice = self._time_slice(guide_frame, at, at + 1)
|
||||
return self._append_guide_latent(
|
||||
chunk, guide_slice, apply_at, strength, positive, negative, vae,
|
||||
chunk, guide_slice, apply_at, strength, positive, negative, vae, phase_tag=phase_tag,
|
||||
) + (0,)
|
||||
|
||||
def _apply_configured_guides(self, chunk, scene_index, guide_list, latent_template,
|
||||
scene_outputs, scene_media_by_ref, positive, negative, vae):
|
||||
scene_outputs, scene_media_by_ref, positive, negative, vae,
|
||||
identity_transfer_enabled=False, identity_transfer_phase=2.0):
|
||||
head_crop = 0
|
||||
tail_crop = 0
|
||||
identity_phase_applied = False
|
||||
for g in guide_list or []:
|
||||
if not g or not g.get("enabled", True):
|
||||
continue
|
||||
@@ -3808,14 +3894,20 @@ class FunPackLTXAVSceneChainSampler:
|
||||
fn = (scene_media_by_ref or {}).get(ref) if ref else None
|
||||
if not fn:
|
||||
continue
|
||||
# The project's Best-FaceID reference face (Continuity's "Identity pin") is the
|
||||
# ONLY guide eligible for the source-phase RoPE tag — never prior-scene/mid-scene
|
||||
# guides, which serve motion continuity rather than a trained-LoRA identity lock.
|
||||
phase_tag = identity_transfer_phase if (identity_transfer_enabled and g.get("identity_pin")) else 0.0
|
||||
chunk, positive, negative, head, tail = self._append_media_guide_at(
|
||||
chunk, fn, frame_idx, apply_at, strength, positive, negative, vae,
|
||||
chunk, fn, frame_idx, apply_at, strength, positive, negative, vae, phase_tag=phase_tag,
|
||||
)
|
||||
if phase_tag:
|
||||
identity_phase_applied = True
|
||||
else:
|
||||
continue
|
||||
head_crop += head
|
||||
tail_crop += tail
|
||||
return chunk, positive, negative, head_crop, tail_crop
|
||||
return chunk, positive, negative, head_crop, tail_crop, identity_phase_applied
|
||||
|
||||
def _append_mid_scene_guide(self, chunk, previous_output, positive, negative, vae, strength):
|
||||
"""Append the middle frame of the previous scene as a guide for the current chunk
|
||||
@@ -4018,6 +4110,129 @@ class FunPackLTXAVSceneChainSampler:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# identity_transfer: source-phase RoPE tag for the Best-FaceID-style identity pin.
|
||||
#
|
||||
# FunPack's guide-attention path (LTXVAddGuide.add_keyframe_index + causal_fix, see
|
||||
# _append_guide_latent) already places a reference image on the frame-0 grid with a
|
||||
# tunable attention strength — structurally the same "overlap" placement an identity
|
||||
# LoRA like Best-FaceID expects. The one piece that path doesn't do is the LoRA's
|
||||
# trained-time "source_phase" tag: an extra per-token RoPE rotation that marks those
|
||||
# tokens as coming from a reference image, not the real timeline. This patches that in,
|
||||
# scoped ONLY to the specific guide entry marked funpack_identity_phase (see
|
||||
# _tag_last_guide_entry) — never to co-active guides (mid_scene_guide, carry, prior-
|
||||
# scene continuity) that happen to share the same keyframe_idxs tensor.
|
||||
#
|
||||
# Two bound methods are wrapped on model.model.diffusion_model:
|
||||
# _prepare_timestep (receives resolved_guide_entries / num_guide_tokens via
|
||||
# kwargs already computed by the model's own guide-token
|
||||
# accounting) -> locates the tagged entry's exact token
|
||||
# range and stashes it on self for the very next call.
|
||||
# _prepare_positional_embeddings (runs right after, same forward pass, no kwargs) ->
|
||||
# rotates just that token range's RoPE freqs.
|
||||
# Idempotent + tag/strip per scene (same discipline as _install_v2a_scale /
|
||||
# _strip_funpack_scene_wrappers) so an interrupt mid-scene can never leave a stale patch
|
||||
# steering a later, unrelated run — see [[project_hook_leak_bug]].
|
||||
# ---------------------------------------------------------------------------
|
||||
_IDENTITY_PHASE_TAG = "_funpack_identity_phase_patched"
|
||||
|
||||
def _install_identity_phase(self, model):
|
||||
try:
|
||||
ltxv = model.model.diffusion_model
|
||||
except Exception:
|
||||
return None
|
||||
if getattr(ltxv, self._IDENTITY_PHASE_TAG, False):
|
||||
return None # already installed (idempotent across scenes/runs)
|
||||
|
||||
orig_prepare_timestep = ltxv._prepare_timestep
|
||||
orig_prepare_pe = ltxv._prepare_positional_embeddings
|
||||
|
||||
def prepare_timestep(self_ltxv, timestep, batch_size, hidden_dtype, **kwargs):
|
||||
self_ltxv._funpack_identity_range = None
|
||||
self_ltxv._funpack_identity_phase_value = 0.0
|
||||
try:
|
||||
entries = kwargs.get("resolved_guide_entries")
|
||||
num_guide_tokens = kwargs.get("num_guide_tokens", 0)
|
||||
if entries and num_guide_tokens:
|
||||
offset = 0
|
||||
for entry in entries:
|
||||
phase = entry.get("funpack_identity_phase")
|
||||
surviving = int(entry.get("surviving_count", 0))
|
||||
if phase:
|
||||
self_ltxv._funpack_identity_range = (offset, surviving, int(num_guide_tokens))
|
||||
self_ltxv._funpack_identity_phase_value = float(phase)
|
||||
break
|
||||
offset += surviving
|
||||
except Exception:
|
||||
self_ltxv._funpack_identity_range = None
|
||||
return orig_prepare_timestep(timestep, batch_size, hidden_dtype, **kwargs)
|
||||
|
||||
def prepare_pe(self_ltxv, pixel_coords, frame_rate, x_dtype):
|
||||
pe = orig_prepare_pe(pixel_coords, frame_rate, x_dtype)
|
||||
rng = getattr(self_ltxv, "_funpack_identity_range", None)
|
||||
phase = getattr(self_ltxv, "_funpack_identity_phase_value", 0.0)
|
||||
if not rng or not phase:
|
||||
return pe
|
||||
try:
|
||||
offset, length, num_guide_tokens = rng
|
||||
# AV models return [(v_pe, av_cross_video), (a_pe, av_cross_audio)]; plain
|
||||
# video models return v_pe directly. Only the video PE carries guide tokens.
|
||||
if isinstance(pe, list) and len(pe) and isinstance(pe[0], (list, tuple)) and isinstance(pe[0][0], (list, tuple)):
|
||||
v_pe, cross_v = pe[0][0], pe[0][1]
|
||||
v_pe = self._rotate_identity_phase(v_pe, offset, length, num_guide_tokens, phase)
|
||||
return [(v_pe, cross_v), pe[1]]
|
||||
return self._rotate_identity_phase(pe, offset, length, num_guide_tokens, phase)
|
||||
except Exception:
|
||||
return pe
|
||||
|
||||
ltxv._prepare_timestep = types.MethodType(prepare_timestep, ltxv)
|
||||
ltxv._prepare_positional_embeddings = types.MethodType(prepare_pe, ltxv)
|
||||
setattr(ltxv, self._IDENTITY_PHASE_TAG, True)
|
||||
return (ltxv, orig_prepare_timestep, orig_prepare_pe)
|
||||
|
||||
def _strip_identity_phase(self, handle):
|
||||
if not handle:
|
||||
return
|
||||
ltxv, orig_prepare_timestep, orig_prepare_pe = handle
|
||||
try:
|
||||
ltxv._prepare_timestep = orig_prepare_timestep
|
||||
ltxv._prepare_positional_embeddings = orig_prepare_pe
|
||||
setattr(ltxv, self._IDENTITY_PHASE_TAG, False)
|
||||
for attr in ("_funpack_identity_range", "_funpack_identity_phase_value"):
|
||||
if hasattr(ltxv, attr):
|
||||
delattr(ltxv, attr)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _rotate_identity_phase(self, pe, offset, length, num_guide_tokens, phase, theta=10000.0):
|
||||
"""Givens-rotate the RoPE freqs of exactly [guide_start+offset, guide_start+offset+length)
|
||||
(a single guide entry's tokens, identified upstream — never the whole guide-token tail)
|
||||
by a phase derived from `phase` — the same source_phase tag the LoRA saw at train time.
|
||||
pe = (cos, sin, split_mode) with cos/sin shaped [..., T, L]. Returns a new pe tuple."""
|
||||
if length <= 0 or not phase:
|
||||
return pe
|
||||
cos, sin = pe[0], pe[1]
|
||||
rest = tuple(pe[2:])
|
||||
T = cos.shape[-2]
|
||||
guide_start = T - int(num_guide_tokens)
|
||||
start = guide_start + int(offset)
|
||||
end = start + int(length)
|
||||
if start < 0 or end > T or start >= end:
|
||||
return pe
|
||||
L = cos.shape[-1]
|
||||
d = torch.arange(L, device=cos.device, dtype=torch.float32)
|
||||
rate = theta ** (-d / float(L))
|
||||
rot = (float(phase) * rate)
|
||||
pc = rot.cos().to(cos.dtype); ps = rot.sin().to(sin.dtype)
|
||||
idx = [slice(None)] * cos.dim()
|
||||
idx[-2] = slice(start, end)
|
||||
idx = tuple(idx)
|
||||
c0, s0 = cos[idx], sin[idx]
|
||||
cos = cos.clone(); sin = sin.clone()
|
||||
cos[idx] = c0 * pc - s0 * ps
|
||||
sin[idx] = s0 * pc + c0 * ps
|
||||
return (cos, sin, *rest)
|
||||
|
||||
def _bounded_attention_region_mask(self, t, h, w, device):
|
||||
"""[T*H*W] region id per video token: 0 = left half (by width), 1 = right half.
|
||||
Assumes (t, h, w) row-major flattening — matches the packed-latent layout
|
||||
@@ -4143,6 +4358,8 @@ class FunPackLTXAVSceneChainSampler:
|
||||
bounded_attention_enabled=False,
|
||||
output_guidance=False, output_guidance_strength=0.02,
|
||||
dynashift=False, dynashift_strength=0.3, dynashift_threshold=0.6,
|
||||
alg_guide_blur_strength=2.0, alg_guide_blur_sigma_threshold=0.975,
|
||||
identity_transfer_enabled=False, identity_transfer_phase=2.0,
|
||||
unique_id=None, prompt=None):
|
||||
if not isinstance(positive, list) or not positive:
|
||||
raise ValueError("positive conditioning must contain at least one scene entry.")
|
||||
@@ -4194,6 +4411,8 @@ class FunPackLTXAVSceneChainSampler:
|
||||
joyai_memory_strength=joyai_memory_strength,
|
||||
joyai_audio_memory=joyai_audio_memory, v2a_grad_scale=v2a_grad_scale,
|
||||
alg_blur_guides=alg_blur_guides,
|
||||
alg_guide_blur_strength=alg_guide_blur_strength,
|
||||
alg_guide_blur_sigma_threshold=alg_guide_blur_sigma_threshold,
|
||||
bounded_attention_enabled=bounded_attention_enabled,
|
||||
output_guidance=output_guidance, output_guidance_strength=output_guidance_strength,
|
||||
dynashift=dynashift, dynashift_strength=dynashift_strength,
|
||||
@@ -4332,6 +4551,7 @@ class FunPackLTXAVSceneChainSampler:
|
||||
soft_carried = 0
|
||||
guide_tail = 0
|
||||
audio_tail = 0
|
||||
identity_phase_applied = False
|
||||
run_mechanisms: list = []
|
||||
anchor_meta = (scene_anchors or {}).get(str(scene_index))
|
||||
if output is None:
|
||||
@@ -4341,9 +4561,11 @@ class FunPackLTXAVSceneChainSampler:
|
||||
custom_guides = per_scene_guides[scene_index]
|
||||
if custom_guides:
|
||||
run_mechanisms.append("custom_guide_stack")
|
||||
chunk, scene_positive, scene_negative, carried, guide_tail = self._apply_configured_guides(
|
||||
chunk, scene_positive, scene_negative, carried, guide_tail, identity_phase_applied = self._apply_configured_guides(
|
||||
chunk, scene_index, custom_guides, latent_template, scene_outputs, scene_media_by_ref,
|
||||
scene_positive, scene_negative, vae,
|
||||
identity_transfer_enabled=identity_transfer_enabled,
|
||||
identity_transfer_phase=identity_transfer_phase,
|
||||
)
|
||||
carried_guide_frames = max(carried_guide_frames, carried)
|
||||
elif anchor_meta:
|
||||
@@ -4383,9 +4605,11 @@ class FunPackLTXAVSceneChainSampler:
|
||||
custom_guides = per_scene_guides[scene_index]
|
||||
if custom_guides:
|
||||
run_mechanisms.append("custom_guide_stack")
|
||||
chunk, scene_positive, scene_negative, carried, guide_tail = self._apply_configured_guides(
|
||||
chunk, scene_positive, scene_negative, carried, guide_tail, identity_phase_applied = self._apply_configured_guides(
|
||||
chunk, scene_index, custom_guides, latent_template, scene_outputs, scene_media_by_ref,
|
||||
scene_positive, scene_negative, vae,
|
||||
identity_transfer_enabled=identity_transfer_enabled,
|
||||
identity_transfer_phase=identity_transfer_phase,
|
||||
)
|
||||
carried_guide_frames = max(carried_guide_frames, carried)
|
||||
elif carry_i2v_guides:
|
||||
@@ -4420,6 +4644,7 @@ class FunPackLTXAVSceneChainSampler:
|
||||
# mode as the block-hook leak; see _strip_funpack_scene_wrappers).
|
||||
_scene_base_wrapper = model.model_options.get("model_function_wrapper")
|
||||
_v2a_handles = []
|
||||
_identity_phase_handle = None
|
||||
try:
|
||||
if embed_guidance and _value_fn is not None and _value_fn.is_ready():
|
||||
run_mechanisms.append("embed_guidance_vf_ascend")
|
||||
@@ -4471,17 +4696,24 @@ class FunPackLTXAVSceneChainSampler:
|
||||
_v2a_handles = self._install_v2a_scale(model, v2a_grad_scale)
|
||||
if _v2a_handles:
|
||||
run_mechanisms.append(f"v2a_grad_scale({v2a_grad_scale})")
|
||||
if identity_transfer_enabled and identity_phase_applied:
|
||||
_identity_phase_handle = self._install_identity_phase(model)
|
||||
if _identity_phase_handle:
|
||||
run_mechanisms.append(f"identity_transfer_phase({identity_transfer_phase})")
|
||||
_t_sample0 = _time.perf_counter()
|
||||
sampled = self._sample_chunk(
|
||||
model, sampler, sigmas, scene_seed, cfg, scene_positive, scene_negative, chunk,
|
||||
pbar=pbar, step_offset=scene_index * steps_per_scene,
|
||||
alg_guide_tail_frames=(guide_tail if (alg_blur_guides and guide_tail > 0) else 0),
|
||||
alg_guide_blur_strength=alg_guide_blur_strength,
|
||||
alg_guide_blur_sigma_threshold=alg_guide_blur_sigma_threshold,
|
||||
bounded_attention_enabled=bounded_attention_enabled,
|
||||
)
|
||||
_scene_sample_s = _time.perf_counter() - _t_sample0
|
||||
_phase_sampling += _scene_sample_s
|
||||
finally:
|
||||
self._remove_v2a_scale(_v2a_handles)
|
||||
self._strip_identity_phase(_identity_phase_handle)
|
||||
if model.model_options.get("model_function_wrapper") is not _scene_base_wrapper:
|
||||
if _scene_base_wrapper is not None:
|
||||
model.model_options["model_function_wrapper"] = _scene_base_wrapper
|
||||
@@ -4755,7 +4987,8 @@ class FunPackLTXAVSceneChainSampler:
|
||||
joyai_audio_memory=False, v2a_grad_scale=1.0,
|
||||
alg_blur_guides=False, bounded_attention_enabled=False,
|
||||
output_guidance=False, output_guidance_strength=0.02,
|
||||
dynashift=False, dynashift_strength=0.3, dynashift_threshold=0.6):
|
||||
dynashift=False, dynashift_strength=0.3, dynashift_threshold=0.6,
|
||||
alg_guide_blur_strength=2.0, alg_guide_blur_sigma_threshold=0.975):
|
||||
"""Sample one chain per Studio-packed variant entry (seed + index), persisting each result
|
||||
(latent + preview + per-entry cond + manifest) under ComfyUI temp for rating in Studio.
|
||||
Reuses sample() per entry with only the seed changed, so each entry is a clean generation."""
|
||||
@@ -4802,6 +5035,8 @@ class FunPackLTXAVSceneChainSampler:
|
||||
transition_duration=transition_duration,
|
||||
decode_tile_size=decode_tile_size, refinement_key_input=key,
|
||||
alg_blur_guides=alg_blur_guides,
|
||||
alg_guide_blur_strength=alg_guide_blur_strength,
|
||||
alg_guide_blur_sigma_threshold=alg_guide_blur_sigma_threshold,
|
||||
bounded_attention_enabled=bounded_attention_enabled,
|
||||
output_guidance=output_guidance, output_guidance_strength=output_guidance_strength,
|
||||
dynashift=dynashift, dynashift_strength=dynashift_strength,
|
||||
|
||||
+122
-7
@@ -548,6 +548,101 @@ def save_shortcut_db(data):
|
||||
json.dump(data, file, indent=2, sort_keys=True)
|
||||
|
||||
|
||||
REVOLVER_STORE_FILENAME = "shortcut_revolver.json"
|
||||
|
||||
|
||||
def revolver_store_path():
|
||||
return os.path.join(template_store_dir(), REVOLVER_STORE_FILENAME)
|
||||
|
||||
|
||||
def load_revolver():
|
||||
"""Shortcut-revolver settings + per-shortcut cycle state. Sidecar of the shortcut DB —
|
||||
deliberately NOT part of the shortcuts export/import payload. ``state[<shortcut key>]`` =
|
||||
{"fp": <fingerprint of the replacement list>, "queue": [remaining replacement indices,
|
||||
next one first]}."""
|
||||
data = {}
|
||||
path = revolver_store_path()
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
except (json.JSONDecodeError, OSError, ValueError):
|
||||
data = {}
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
state = data.get("state")
|
||||
return {
|
||||
"enabled": bool(data.get("enabled", False)),
|
||||
"random": bool(data.get("random", False)),
|
||||
"state": state if isinstance(state, dict) else {},
|
||||
}
|
||||
|
||||
|
||||
def save_revolver(data):
|
||||
os.makedirs(template_store_dir(), exist_ok=True)
|
||||
with open(revolver_store_path(), "w", encoding="utf-8") as file:
|
||||
json.dump({
|
||||
"enabled": bool(data.get("enabled", False)),
|
||||
"random": bool(data.get("random", False)),
|
||||
"state": data.get("state") if isinstance(data.get("state"), dict) else {},
|
||||
}, file, indent=2)
|
||||
|
||||
|
||||
def revolver_enabled():
|
||||
try:
|
||||
return bool(load_revolver()["enabled"])
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def revolver_settings():
|
||||
data = load_revolver()
|
||||
return {"enabled": data["enabled"], "random": data["random"]}
|
||||
|
||||
|
||||
def set_revolver_settings(enabled=None, random_order=None):
|
||||
"""Update revolver settings. Any actual change resets the cycle state — the old queues'
|
||||
ordering semantics no longer apply once the mode flips."""
|
||||
data = load_revolver()
|
||||
changed = False
|
||||
if enabled is not None and bool(enabled) != data["enabled"]:
|
||||
data["enabled"] = bool(enabled)
|
||||
changed = True
|
||||
if random_order is not None and bool(random_order) != data["random"]:
|
||||
data["random"] = bool(random_order)
|
||||
changed = True
|
||||
if changed:
|
||||
data["state"] = {}
|
||||
save_revolver(data)
|
||||
return {"enabled": data["enabled"], "random": data["random"]}
|
||||
|
||||
|
||||
def _revolver_fingerprint(replacements):
|
||||
payload = json.dumps(list(replacements), ensure_ascii=False)
|
||||
return md5(payload.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
def revolver_next_replacement(state, key, replacements, random_order):
|
||||
"""Draw the next replacement for shortcut ``key`` from its revolver queue, mutating
|
||||
``state`` in place. The queue is rebuilt — a fresh full cycle over every replacement —
|
||||
when it's empty or the replacement set changed (fingerprint mismatch): sequential
|
||||
(first, second, … last) by default, shuffled once per cycle when ``random_order``.
|
||||
Either way nothing repeats until the whole set has fired."""
|
||||
n = len(replacements)
|
||||
fp = _revolver_fingerprint(replacements)
|
||||
entry = state.get(key)
|
||||
queue = None
|
||||
if isinstance(entry, dict) and entry.get("fp") == fp and isinstance(entry.get("queue"), list):
|
||||
queue = [int(i) for i in entry["queue"] if isinstance(i, int) and 0 <= int(i) < n]
|
||||
if not queue:
|
||||
queue = list(range(n))
|
||||
if random_order:
|
||||
random.shuffle(queue)
|
||||
index = queue.pop(0)
|
||||
state[key] = {"fp": fp, "queue": queue}
|
||||
return replacements[index]
|
||||
|
||||
|
||||
def shortcut_items(data=None):
|
||||
data = load_shortcut_db() if data is None else normalize_shortcut_db(data)
|
||||
items = []
|
||||
@@ -827,13 +922,18 @@ def resolve_variables(text, variables):
|
||||
return _expand(str(text or ""), frozenset(), 0), sorted(undefined)
|
||||
|
||||
|
||||
def apply_prompt_shortcuts(text, seed=0, shortcut_db=None):
|
||||
def apply_prompt_shortcuts(text, seed=0, shortcut_db=None, revolver_commit=False):
|
||||
"""Expand shortcut triggers. Multi-replacement picks are seeded-random unless the
|
||||
shortcut revolver is enabled (see load_revolver): then each firing draws the next
|
||||
replacement from the shortcut's no-repeat cycle. ``revolver_commit`` persists the
|
||||
advanced cycle state — True only for real generation; previews peek without saving,
|
||||
so they show exactly what the next generation will draw."""
|
||||
original = str(text or "")
|
||||
if not original:
|
||||
return original, []
|
||||
data = load_shortcut_db() if shortcut_db is None else normalize_shortcut_db(shortcut_db)
|
||||
candidates = []
|
||||
for shortcut in data.get("shortcuts", {}).values():
|
||||
for db_key, shortcut in data.get("shortcuts", {}).items():
|
||||
if not isinstance(shortcut, dict) or not bool(shortcut.get("enabled", True)):
|
||||
continue
|
||||
replacements = _shortcut_replacements(shortcut.get("replacements", shortcut.get("replacement", [])))
|
||||
@@ -843,12 +943,13 @@ def apply_prompt_shortcuts(text, seed=0, shortcut_db=None):
|
||||
for trigger in shortcut_list(shortcut.get("triggers", [])):
|
||||
pattern = _shortcut_trigger_pattern(trigger)
|
||||
if pattern:
|
||||
candidates.append((trigger, pattern, replacements, shortcut.get("name", trigger), sc_key))
|
||||
candidates.append((trigger, pattern, replacements, shortcut.get("name", trigger),
|
||||
sc_key, str(db_key)))
|
||||
if not candidates:
|
||||
return original, []
|
||||
|
||||
candidates.sort(key=lambda item: len(shortcut_key(item[0])), reverse=True)
|
||||
combined = "|".join(f"(?P<t{index}>{pattern})" for index, (_, pattern, _, _, _) in enumerate(candidates))
|
||||
combined = "|".join(f"(?P<t{index}>{pattern})" for index, (_, pattern, _, _, _, _) in enumerate(candidates))
|
||||
if not combined:
|
||||
return original, []
|
||||
try:
|
||||
@@ -858,15 +959,24 @@ def apply_prompt_shortcuts(text, seed=0, shortcut_db=None):
|
||||
if rng_seed == 0:
|
||||
rng_seed = int(md5(original.encode("utf-8")).hexdigest()[:12], 16)
|
||||
rng = random.Random(rng_seed)
|
||||
revolver = load_revolver()
|
||||
revolver_on = bool(revolver.get("enabled"))
|
||||
revolver_state = revolver.get("state")
|
||||
revolver_random = bool(revolver.get("random"))
|
||||
revolver_dirty = False
|
||||
applied = []
|
||||
removals_happened = False
|
||||
|
||||
def replace(match):
|
||||
nonlocal removals_happened
|
||||
for index, (trigger, _, replacements, name, sc_key) in enumerate(candidates):
|
||||
nonlocal removals_happened, revolver_dirty
|
||||
for index, (trigger, _, replacements, name, sc_key, db_key) in enumerate(candidates):
|
||||
if match.group(f"t{index}") is None:
|
||||
continue
|
||||
replacement = rng.choice(replacements)
|
||||
if revolver_on and len(replacements) > 1:
|
||||
replacement = revolver_next_replacement(revolver_state, db_key, replacements, revolver_random)
|
||||
revolver_dirty = True
|
||||
else:
|
||||
replacement = rng.choice(replacements)
|
||||
applied.append({"name": str(name), "trigger": trigger,
|
||||
"replacement": replacement, "refinement_key": sc_key})
|
||||
if not replacement:
|
||||
@@ -877,6 +987,11 @@ def apply_prompt_shortcuts(text, seed=0, shortcut_db=None):
|
||||
expanded = re.sub(combined, replace, original, flags=re.IGNORECASE | re.UNICODE)
|
||||
if removals_happened:
|
||||
expanded = _cleanup_removed_phrases(expanded)
|
||||
if revolver_dirty and revolver_commit:
|
||||
try:
|
||||
save_revolver(revolver)
|
||||
except OSError:
|
||||
pass
|
||||
return expanded, applied
|
||||
|
||||
|
||||
|
||||
@@ -137,6 +137,39 @@ def test_alg_blur_reduces_high_frequency_variance():
|
||||
assert float(frame0_out.var()) < float(frame0_in.var())
|
||||
|
||||
|
||||
def test_alg_blur_composes_with_independent_strengths():
|
||||
"""Anchor and guide-tail blur are separate passes with their own kappa: composing the
|
||||
tail pass onto the anchor-blurred latent must leave the anchor frame untouched, and a
|
||||
stronger tail kappa must blur the tail harder than the anchor kappa would."""
|
||||
model = _fake_model()
|
||||
latent_image = _packed_latent_image()
|
||||
anchor_only = samplers._alg_blur_frames(model, latent_image, 2.0, frame_indices=(0,))
|
||||
both = samplers._alg_blur_frames(model, anchor_only, 4.0, tail_count=1)
|
||||
v_anchor_only = _video_frames(anchor_only)
|
||||
v_both = _video_frames(both)
|
||||
|
||||
assert torch.equal(v_both[:, :, 0], v_anchor_only[:, :, 0]) # anchor pass preserved
|
||||
assert torch.equal(v_both[:, :, 1:3], v_anchor_only[:, :, 1:3]) # middle untouched
|
||||
|
||||
# Strength independence needs content that survives downsampling distinctly (the 4x4
|
||||
# checkerboard averages to uniform 0.5 at ANY factor) — use a seeded random 8x8 frame.
|
||||
# Different kappa must yield a different blurred tail: proof the per-region strength
|
||||
# actually reaches the filter (variance ordering is NOT guaranteed without antialias).
|
||||
h8 = 8
|
||||
model8 = _fake_model(shapes=((B, C, T, h8, h8), AUDIO_SHAPE))
|
||||
video8 = torch.rand(B, C, T, h8, h8, generator=torch.Generator().manual_seed(7))
|
||||
packed8 = torch.cat([video8.reshape(B, 1, C * T * h8 * h8),
|
||||
torch.full((B, 1, AUDIO_SIZE), 5.0)], dim=-1)
|
||||
weak = samplers._alg_blur_frames(model8, packed8, 2.0, tail_count=1)
|
||||
strong = samplers._alg_blur_frames(model8, packed8, 4.0, tail_count=1)
|
||||
v_sz = C * T * h8 * h8
|
||||
tail_weak = weak[..., :v_sz].reshape(B, C, T, h8, h8)[:, :, 3]
|
||||
tail_strong = strong[..., :v_sz].reshape(B, C, T, h8, h8)[:, :, 3]
|
||||
tail_orig = video8[:, :, 3]
|
||||
assert not torch.allclose(tail_weak, tail_strong) # kappa reaches the filter
|
||||
assert not torch.allclose(tail_weak, tail_orig) and not torch.allclose(tail_strong, tail_orig)
|
||||
|
||||
|
||||
def test_alg_blur_is_deterministic():
|
||||
model = _fake_model()
|
||||
latent_image = _packed_latent_image()
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Unit tests for the native Best-FaceID identity_transfer port.
|
||||
|
||||
Covers the three pieces added to FunPackLTXAVSceneChainSampler:
|
||||
- _tag_last_guide_entry: best-effort marks the most-recently-appended
|
||||
guide_attention_entry, never touching earlier entries.
|
||||
- _apply_configured_guides: routes the phase tag ONLY to the guide entry the
|
||||
editor marked identity_pin (never prior-scene/mid-scene/template guides that
|
||||
happen to share the same call).
|
||||
- _install_identity_phase / _strip_identity_phase / _rotate_identity_phase: the
|
||||
tag+strip RoPE patch that reproduces the LoRA's trained-time source_phase
|
||||
tag, scoped to the exact token range of the tagged guide (not the whole
|
||||
guide-token tail, which may include co-active unrelated guides).
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
for _name in (
|
||||
"comfy", "comfy.k_diffusion", "comfy.k_diffusion.sampling",
|
||||
"comfy.model_sampling", "comfy.nested_tensor", "comfy.sample",
|
||||
"comfy.samplers", "comfy.utils",
|
||||
):
|
||||
sys.modules.setdefault(_name, types.ModuleType(_name))
|
||||
sys.modules["comfy.nested_tensor"].NestedTensor = object
|
||||
|
||||
import samplers # noqa: E402
|
||||
|
||||
|
||||
def _sampler():
|
||||
return samplers.FunPackLTXAVSceneChainSampler()
|
||||
|
||||
|
||||
def _install_node_helpers():
|
||||
"""node_helpers.conditioning_set_values, the same real implementation shape
|
||||
_append_guide_attention_entry itself relies on (copy metadata dict, overwrite
|
||||
the given keys) — reimplemented here so the test runs without a full ComfyUI env."""
|
||||
mod = types.ModuleType("node_helpers")
|
||||
|
||||
def conditioning_set_values(conditioning, values=None, append=False):
|
||||
values = values or {}
|
||||
out = []
|
||||
for t in conditioning:
|
||||
n = [t[0], t[1].copy()]
|
||||
for k, v in values.items():
|
||||
n[1][k] = v
|
||||
out.append(n)
|
||||
return out
|
||||
|
||||
mod.conditioning_set_values = conditioning_set_values
|
||||
sys.modules["node_helpers"] = mod
|
||||
return mod
|
||||
|
||||
|
||||
def _cond_with_entries(entries):
|
||||
return [[torch.zeros(1, 1, 4), {"guide_attention_entries": list(entries)}]]
|
||||
|
||||
|
||||
# ── _tag_last_guide_entry ──────────────────────────────────────────────────────
|
||||
|
||||
def test_tag_last_guide_entry_marks_only_newest_entry():
|
||||
_install_node_helpers()
|
||||
s = _sampler()
|
||||
cond = _cond_with_entries([{"pre_filter_count": 4}, {"pre_filter_count": 8}])
|
||||
tagged = s._tag_last_guide_entry(cond, "funpack_identity_phase", 2.0)
|
||||
entries = tagged[0][1]["guide_attention_entries"]
|
||||
assert entries[0].get("funpack_identity_phase") is None
|
||||
assert entries[1]["funpack_identity_phase"] == 2.0
|
||||
# Original untouched (conditioning_set_values copies, doesn't mutate in place).
|
||||
assert "funpack_identity_phase" not in cond[0][1]["guide_attention_entries"][1]
|
||||
|
||||
|
||||
def test_tag_last_guide_entry_noop_without_entries():
|
||||
_install_node_helpers()
|
||||
s = _sampler()
|
||||
cond = [[torch.zeros(1, 1, 4), {}]]
|
||||
out = s._tag_last_guide_entry(cond, "funpack_identity_phase", 2.0)
|
||||
assert out is cond # unchanged object, nothing to tag
|
||||
|
||||
|
||||
def test_tag_last_guide_entry_noop_without_node_helpers():
|
||||
sys.modules.pop("node_helpers", None)
|
||||
s = _sampler()
|
||||
cond = _cond_with_entries([{"pre_filter_count": 4}])
|
||||
out = s._tag_last_guide_entry(cond, "funpack_identity_phase", 2.0)
|
||||
assert out is cond
|
||||
|
||||
|
||||
# ── _apply_configured_guides phase-tag routing ─────────────────────────────────
|
||||
|
||||
def test_phase_tag_applies_only_to_identity_pin_entry(monkeypatch):
|
||||
s = _sampler()
|
||||
calls = []
|
||||
|
||||
def fake_append_media(chunk, filename, frame_idx, apply_at, strength, positive, negative, vae,
|
||||
phase_tag=0.0):
|
||||
calls.append((filename, phase_tag))
|
||||
return chunk, positive, negative, 0, 0
|
||||
|
||||
monkeypatch.setattr(s, "_append_media_guide_at", fake_append_media)
|
||||
guide_list = [
|
||||
{"enabled": True, "source": "image", "media_ref": "pin", "identity_pin": True, "strength": 0.35},
|
||||
{"enabled": True, "source": "image", "media_ref": "prior", "strength": 0.35}, # NOT the pin
|
||||
]
|
||||
scene_media_by_ref = {"pin": "pin.png", "prior": "prior.png"}
|
||||
chunk, pos, neg, head, tail, phase_applied = s._apply_configured_guides(
|
||||
{}, 1, guide_list, {}, [], scene_media_by_ref, [], [], object(),
|
||||
identity_transfer_enabled=True, identity_transfer_phase=3.0,
|
||||
)
|
||||
assert calls == [("pin.png", 3.0), ("prior.png", 0.0)]
|
||||
assert phase_applied is True
|
||||
|
||||
|
||||
def test_phase_tag_off_when_identity_transfer_disabled(monkeypatch):
|
||||
s = _sampler()
|
||||
calls = []
|
||||
monkeypatch.setattr(s, "_append_media_guide_at",
|
||||
lambda chunk, filename, *a, phase_tag=0.0, **kw: (calls.append(phase_tag), chunk, [], [], 0, 0)[1:])
|
||||
guide_list = [{"enabled": True, "source": "image", "media_ref": "pin", "identity_pin": True, "strength": 0.35}]
|
||||
_, _, _, _, _, phase_applied = s._apply_configured_guides(
|
||||
{}, 1, guide_list, {}, [], {"pin": "pin.png"}, [], [], object(),
|
||||
identity_transfer_enabled=False, identity_transfer_phase=2.0,
|
||||
)
|
||||
assert calls == [0.0]
|
||||
assert phase_applied is False
|
||||
|
||||
|
||||
# ── _install_identity_phase / _strip_identity_phase / _rotate_identity_phase ──
|
||||
|
||||
class _FakeLTXV:
|
||||
def _prepare_timestep(self, timestep, batch_size, hidden_dtype, **kwargs):
|
||||
return ("timestep", batch_size, hidden_dtype)
|
||||
|
||||
def _prepare_positional_embeddings(self, pixel_coords, frame_rate, x_dtype):
|
||||
# cos/sin shaped [T=10, L=4]; identity token slice under test is [6:9).
|
||||
T, L = 10, 4
|
||||
cos = torch.arange(T * L, dtype=torch.float32).reshape(T, L)
|
||||
sin = torch.arange(T * L, dtype=torch.float32).reshape(T, L) * -1.0
|
||||
return (cos, sin, False)
|
||||
|
||||
|
||||
def _fake_model(ltxv):
|
||||
inner = types.SimpleNamespace(diffusion_model=ltxv)
|
||||
return types.SimpleNamespace(model=inner)
|
||||
|
||||
|
||||
def test_install_is_idempotent_and_strip_restores_originals():
|
||||
s = _sampler()
|
||||
ltxv = _FakeLTXV()
|
||||
orig_pt = ltxv._prepare_timestep
|
||||
orig_pe = ltxv._prepare_positional_embeddings
|
||||
|
||||
handle1 = s._install_identity_phase(_fake_model(ltxv))
|
||||
assert handle1 is not None
|
||||
assert ltxv._prepare_timestep is not orig_pt
|
||||
handle2 = s._install_identity_phase(_fake_model(ltxv))
|
||||
assert handle2 is None # already installed — idempotent, no double-wrap
|
||||
|
||||
s._strip_identity_phase(handle1)
|
||||
# Bound-method objects aren't identity-stable across separate attribute accesses in
|
||||
# CPython (a fresh wrapper is created each time) — compare by equality (same __func__ +
|
||||
# __self__) instead of `is`.
|
||||
assert ltxv._prepare_timestep == orig_pt
|
||||
assert ltxv._prepare_positional_embeddings == orig_pe
|
||||
assert not hasattr(ltxv, "_funpack_identity_range")
|
||||
|
||||
|
||||
def test_prepare_timestep_stashes_range_for_tagged_entry_only():
|
||||
s = _sampler()
|
||||
ltxv = _FakeLTXV()
|
||||
handle = s._install_identity_phase(_fake_model(ltxv))
|
||||
entries = [
|
||||
{"pre_filter_count": 16, "surviving_count": 2}, # unrelated guide, first
|
||||
{"pre_filter_count": 16, "surviving_count": 3, "funpack_identity_phase": 2.5}, # the tagged one
|
||||
{"pre_filter_count": 16, "surviving_count": 4}, # unrelated guide, after
|
||||
]
|
||||
ltxv._prepare_timestep(torch.zeros(1), 1, torch.float32,
|
||||
resolved_guide_entries=entries, num_guide_tokens=9)
|
||||
assert ltxv._funpack_identity_range == (2, 3, 9) # offset past the first entry's 2 tokens
|
||||
assert ltxv._funpack_identity_phase_value == 2.5
|
||||
s._strip_identity_phase(handle)
|
||||
|
||||
|
||||
def test_prepare_timestep_clears_range_without_tagged_entry():
|
||||
s = _sampler()
|
||||
ltxv = _FakeLTXV()
|
||||
handle = s._install_identity_phase(_fake_model(ltxv))
|
||||
ltxv._funpack_identity_range = (0, 1, 1) # stale from a previous scene
|
||||
entries = [{"pre_filter_count": 16, "surviving_count": 5}]
|
||||
ltxv._prepare_timestep(torch.zeros(1), 1, torch.float32,
|
||||
resolved_guide_entries=entries, num_guide_tokens=5)
|
||||
assert ltxv._funpack_identity_range is None
|
||||
s._strip_identity_phase(handle)
|
||||
|
||||
|
||||
def test_prepare_pe_rotates_only_the_tagged_range():
|
||||
s = _sampler()
|
||||
ltxv = _FakeLTXV()
|
||||
handle = s._install_identity_phase(_fake_model(ltxv))
|
||||
baseline_cos, baseline_sin, _ = ltxv._prepare_positional_embeddings(None, 25, torch.float32)
|
||||
|
||||
entries = [
|
||||
{"pre_filter_count": 16, "surviving_count": 2},
|
||||
{"pre_filter_count": 16, "surviving_count": 3, "funpack_identity_phase": 2.5},
|
||||
{"pre_filter_count": 16, "surviving_count": 4},
|
||||
]
|
||||
ltxv._prepare_timestep(torch.zeros(1), 1, torch.float32,
|
||||
resolved_guide_entries=entries, num_guide_tokens=9)
|
||||
cos, sin, split_mode = ltxv._prepare_positional_embeddings(None, 25, torch.float32)
|
||||
|
||||
# T=10, num_guide_tokens=9 -> guide_start=1; offset=2, length=3 -> rotated slice [3:6).
|
||||
untouched = list(range(0, 3)) + list(range(6, 10))
|
||||
for i in untouched:
|
||||
assert torch.equal(cos[i], baseline_cos[i]), f"row {i} should be untouched"
|
||||
assert torch.equal(sin[i], baseline_sin[i]), f"row {i} should be untouched"
|
||||
rotated_rows = [3, 4, 5]
|
||||
changed = any(not torch.equal(cos[i], baseline_cos[i]) for i in rotated_rows)
|
||||
assert changed, "rotated slice should differ from baseline"
|
||||
assert split_mode is False # passthrough element preserved
|
||||
s._strip_identity_phase(handle)
|
||||
|
||||
|
||||
def test_rotate_identity_phase_is_noop_for_zero_phase_or_length():
|
||||
s = _sampler()
|
||||
pe = (torch.ones(5, 4), torch.zeros(5, 4), False)
|
||||
assert s._rotate_identity_phase(pe, 0, 0, 5, 2.0) is pe
|
||||
assert s._rotate_identity_phase(pe, 0, 2, 5, 0.0) is pe
|
||||
|
||||
|
||||
def test_rotate_identity_phase_rejects_out_of_bounds_range():
|
||||
s = _sampler()
|
||||
cos = torch.arange(20, dtype=torch.float32).reshape(5, 4)
|
||||
sin = -cos.clone()
|
||||
pe = (cos, sin, False)
|
||||
out = s._rotate_identity_phase(pe, offset=10, length=3, num_guide_tokens=5, phase=2.0)
|
||||
assert out is pe # start/end fall outside T -> safe no-op
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Shortcut revolver: no-repeat cycling through a shortcut's replacements.
|
||||
|
||||
Covers: sequential cycle order + wrap-around, random mode as a full no-repeat permutation
|
||||
per cycle, peek (previews) vs commit (generation) semantics on apply_prompt_shortcuts,
|
||||
fingerprint reset when the replacement set is edited, settings changes resetting state,
|
||||
and off-by-default behavior staying byte-identical to the seeded random pick.
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
|
||||
class _FakeRoutes:
|
||||
def get(self, _p):
|
||||
return lambda f: f
|
||||
|
||||
def post(self, _p):
|
||||
return lambda f: f
|
||||
|
||||
def put(self, _p):
|
||||
return lambda f: f
|
||||
|
||||
def delete(self, _p):
|
||||
return lambda f: f
|
||||
|
||||
def patch(self, _p):
|
||||
return lambda f: f
|
||||
|
||||
|
||||
sys.modules.setdefault("folder_paths", types.SimpleNamespace(models_dir="", get_user_directory=lambda: ""))
|
||||
sys.modules.setdefault(
|
||||
"server",
|
||||
types.SimpleNamespace(PromptServer=types.SimpleNamespace(instance=types.SimpleNamespace(routes=_FakeRoutes()))),
|
||||
)
|
||||
|
||||
import templates
|
||||
from templates import (
|
||||
apply_prompt_shortcuts,
|
||||
load_revolver,
|
||||
normalize_shortcut_item,
|
||||
revolver_next_replacement,
|
||||
save_revolver,
|
||||
set_revolver_settings,
|
||||
)
|
||||
|
||||
REPS = ["alpha one", "alpha two", "alpha three"]
|
||||
|
||||
|
||||
def _db():
|
||||
item = normalize_shortcut_item({
|
||||
"name": "Alpha", "triggers": ["aa"], "replacements": list(REPS), "refinement_key": "",
|
||||
})
|
||||
single = normalize_shortcut_item({
|
||||
"name": "Solo", "triggers": ["ss"], "replacements": ["only one"], "refinement_key": "",
|
||||
})
|
||||
return {"version": 1, "source": "test",
|
||||
"shortcuts": {templates.shortcut_key("Alpha"): item,
|
||||
templates.shortcut_key("Solo"): single}}
|
||||
|
||||
|
||||
def _use_tmp_store(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(templates, "template_store_dir", lambda: str(tmp_path))
|
||||
|
||||
|
||||
def test_sequential_cycle_then_wraps():
|
||||
state = {}
|
||||
draws = [revolver_next_replacement(state, "k", REPS, False) for _ in range(7)]
|
||||
assert draws == REPS + REPS + [REPS[0]]
|
||||
|
||||
|
||||
def test_random_cycle_is_full_permutation_without_repeats():
|
||||
state = {}
|
||||
for _ in range(20): # many cycles: each must be a permutation of the full set
|
||||
cycle = [revolver_next_replacement(state, "k", REPS, True) for _ in range(len(REPS))]
|
||||
assert sorted(cycle) == sorted(REPS)
|
||||
|
||||
|
||||
def test_replacement_edit_resets_cycle():
|
||||
state = {}
|
||||
revolver_next_replacement(state, "k", REPS, False)
|
||||
edited = REPS + ["alpha four"]
|
||||
assert revolver_next_replacement(state, "k", edited, False) == edited[0]
|
||||
|
||||
|
||||
def test_apply_shortcuts_peek_does_not_advance(monkeypatch, tmp_path):
|
||||
_use_tmp_store(monkeypatch, tmp_path)
|
||||
save_revolver({"enabled": True, "random": False, "state": {}})
|
||||
first, _ = apply_prompt_shortcuts("an aa here", seed=1, shortcut_db=_db())
|
||||
second, _ = apply_prompt_shortcuts("an aa here", seed=2, shortcut_db=_db())
|
||||
assert first == second == f"an {REPS[0]} here"
|
||||
|
||||
|
||||
def test_apply_shortcuts_commit_advances_and_wraps(monkeypatch, tmp_path):
|
||||
_use_tmp_store(monkeypatch, tmp_path)
|
||||
save_revolver({"enabled": True, "random": False, "state": {}})
|
||||
seen = [apply_prompt_shortcuts("an aa here", seed=1, shortcut_db=_db(),
|
||||
revolver_commit=True)[0] for _ in range(4)]
|
||||
assert seen == [f"an {r} here" for r in REPS + [REPS[0]]]
|
||||
|
||||
|
||||
def test_multiple_firings_in_one_prompt_consume_consecutive_chambers(monkeypatch, tmp_path):
|
||||
_use_tmp_store(monkeypatch, tmp_path)
|
||||
save_revolver({"enabled": True, "random": False, "state": {}})
|
||||
expanded, applied = apply_prompt_shortcuts("aa then aa", seed=1, shortcut_db=_db(),
|
||||
revolver_commit=True)
|
||||
assert expanded == f"{REPS[0]} then {REPS[1]}"
|
||||
assert [a["replacement"] for a in applied] == [REPS[0], REPS[1]]
|
||||
|
||||
|
||||
def test_single_replacement_shortcut_untouched_by_revolver(monkeypatch, tmp_path):
|
||||
_use_tmp_store(monkeypatch, tmp_path)
|
||||
save_revolver({"enabled": True, "random": False, "state": {}})
|
||||
expanded, _ = apply_prompt_shortcuts("a ss here", seed=1, shortcut_db=_db(),
|
||||
revolver_commit=True)
|
||||
assert expanded == "a only one here"
|
||||
assert load_revolver()["state"] == {} # nothing multi-replacement fired -> no state written
|
||||
|
||||
|
||||
def test_disabled_matches_seeded_random(monkeypatch, tmp_path):
|
||||
_use_tmp_store(monkeypatch, tmp_path)
|
||||
a, _ = apply_prompt_shortcuts("an aa here", seed=7, shortcut_db=_db())
|
||||
b, _ = apply_prompt_shortcuts("an aa here", seed=7, shortcut_db=_db(), revolver_commit=True)
|
||||
assert a == b # same seed -> same pick; no revolver file involved
|
||||
assert not (tmp_path / templates.REVOLVER_STORE_FILENAME).exists()
|
||||
|
||||
|
||||
def test_settings_change_resets_state(monkeypatch, tmp_path):
|
||||
_use_tmp_store(monkeypatch, tmp_path)
|
||||
save_revolver({"enabled": True, "random": False,
|
||||
"state": {"x": {"fp": "abc", "queue": [2]}}})
|
||||
settings = set_revolver_settings(random_order=True)
|
||||
assert settings == {"enabled": True, "random": True}
|
||||
assert load_revolver()["state"] == {}
|
||||
# No-op update keeps state.
|
||||
save_revolver({"enabled": True, "random": True,
|
||||
"state": {"x": {"fp": "abc", "queue": [2]}}})
|
||||
set_revolver_settings(enabled=True, random_order=True)
|
||||
assert load_revolver()["state"] != {}
|
||||
Reference in New Issue
Block a user