Merge dev into main — Release 3.2.1

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-07-06 21:00:08 +03:00
co-authored by Claude Sonnet 5
26 changed files with 1667 additions and 89 deletions
+36
View File
@@ -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
View File
@@ -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)
+78
View File
@@ -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")()
+10 -3
View File
@@ -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",
+7
View File
@@ -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;
+3 -3
View File
@@ -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;
}
+1
View File
@@ -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());
+55 -1
View File
@@ -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,
+8 -2
View File
@@ -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) {
+1
View File
@@ -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>
+23 -1
View File
@@ -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)) {
+26
View File
@@ -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";
+16 -1
View File
@@ -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();
}
+49 -4
View File
@@ -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: [] };
+1 -1
View File
@@ -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,
+32
View File
@@ -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; }
+345
View File
@@ -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 };
})();
+2
View File
@@ -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
View File
@@ -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:
+19
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+33
View File
@@ -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()
+239
View File
@@ -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
+140
View File
@@ -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"] != {}