Add visual transition effects to scene chain sampler

Custom transition triggers in Studio can now have a visual_effect assigned
(fade_to_black, crossfade, blur_out_in). When triggered during scene splitting,
the effect fires at that scene boundary: the sampler decodes the latent window
around the boundary, applies the pixel-space effect, and re-encodes in-place.

No new nodes needed — VAE is already connected to the scene chain sampler.
New transition_duration input controls how many pixel frames are affected.
New scene_boundaries output provides the boundary list for debugging.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-05-25 17:50:31 +03:00
co-authored by Claude Sonnet 4.6
parent 8775304fe1
commit 9dbd239702
4 changed files with 201 additions and 25 deletions
+46 -15
View File
@@ -6093,17 +6093,19 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
Custom transitions can override placement per-entry regardless of the global setting.
Returns a list with >= 1 entry. Single-entry list means no transitions were found.
Returns a list of (text, visual_effect) tuples with >= 1 entry.
visual_effect is the effect that fires BEFORE this segment (None for first segment).
Single-entry list means no transitions were found.
"""
text = str(prompt or "").strip()
if not text:
return [text]
return [(text, None)]
try:
from .templates import load_custom_transition_triggers
except ImportError:
from templates import load_custom_transition_triggers
custom_map = load_custom_transition_triggers() # {trigger: placement_override|None}
custom_map = load_custom_transition_triggers() # {trigger: {"placement": ..., "visual_effect": ...}}
custom_triggers = list(custom_map.keys())
if custom_triggers:
@@ -6125,34 +6127,48 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
# [pre, trans1, between, trans2, post, ...]
capturing_pattern = re.compile(r"(" + split_pattern.pattern + r")", re.IGNORECASE)
parts = capturing_pattern.split(text)
segments = []
segments = [] # list of segment text strings
effects = [] # parallel list: visual_effect that started each segment (None for segment 0)
# parts[0] = text before first transition; then pairs of (trans_word, following_text)
first_segment = parts[0].strip().strip(",;.:").strip()
starts_with_transition = not first_segment
if starts_with_transition:
segments.append("")
effects.append(None)
current = first_segment
current_effect = None # effect that triggered the current pending segment
i = 1
while i < len(parts):
trans_word = parts[i].strip()
following = parts[i + 1] if i + 1 < len(parts) else ""
following = following.strip().lstrip(",;.:").strip()
# per-trigger placement override takes priority over global setting
trans_placement = custom_map.get(trans_word.lower()) or placement
entry = custom_map.get(trans_word.lower()) or {}
trans_placement = entry.get("placement") or placement if isinstance(entry, dict) else placement
trans_visual_effect = entry.get("visual_effect", "none") if isinstance(entry, dict) else "none"
if trans_visual_effect == "none":
trans_visual_effect = None
if trans_placement == "end":
segments.append((current + " " + trans_word).strip().strip(",;.:").strip() if current else trans_word)
effects.append(current_effect)
current = following
current_effect = trans_visual_effect
elif trans_placement == "silent":
if current:
segments.append(current)
effects.append(current_effect)
current = following
current_effect = trans_visual_effect
else:
if current:
segments.append(current)
effects.append(current_effect)
current = re.sub(r"\b(a|an|the),\s*", r"\1 ", (trans_word + " " + following).strip().strip(",;.:").strip(), flags=re.IGNORECASE).strip()
current_effect = trans_visual_effect
i += 2
if current:
segments.append(current)
effects.append(current_effect)
# Stacking fix: if a segment consists entirely of transition markers with no real content,
# two transition phrases were adjacent ("after a brief scene cut, in the next scene ...").
@@ -6170,7 +6186,9 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
if not _real_content(segments[i]) and i + 1 < len(segments):
connector = " " if re.search(r"\b(a|an|the)\s*$", segments[i], re.IGNORECASE) else ", "
segments[i + 1] = (segments[i] + connector + segments[i + 1]).strip()
# keep the effect of the segment with real content (i+1), discard i's effect
segments.pop(i)
effects.pop(i)
else:
i += 1
@@ -6178,30 +6196,39 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
# the prompt ended with a dangling transition ("...cuts to the") with no scene after it.
if len(segments) > 1 and not _real_content(segments[-1]):
segments.pop()
effects.pop()
return segments if len(segments) > 1 else [text]
if len(segments) > 1:
return list(zip(segments, effects))
return [(text, None)]
def _v2_transition_scene_texts(self, segments):
"""Accept list of (text, effect) tuples. Return (scene_texts, scene_effects)."""
if not segments:
return []
return [], []
if len(segments) == 1:
return [segments[0]]
text = segments[0][0] if isinstance(segments[0], tuple) else segments[0]
return [text], [None]
anchor = segments[0].strip()
anchor_text = (segments[0][0] if isinstance(segments[0], tuple) else segments[0]).strip()
scene_texts = []
for seg in segments[1:]:
scene_effects = []
for item in segments[1:]:
seg, effect = item if isinstance(item, tuple) else (item, None)
seg = seg.strip()
if not seg:
continue
scene_texts.append(anchor + ", " + seg if anchor else seg)
return scene_texts
scene_texts.append((anchor_text + ", " + seg if anchor_text else seg))
scene_effects.append(effect)
return scene_texts, scene_effects
def _v2_transition_scene_conditionings(self, clip, scene_texts, encode_cache=None):
def _v2_transition_scene_conditionings(self, clip, scene_texts, scene_effects=None, encode_cache=None):
if clip is None:
return None
scene_texts = list(scene_texts or [])
if not scene_texts:
return None
scene_effects = list(scene_effects or [])
conditionings = []
scene_count = len(scene_texts)
@@ -6213,6 +6240,9 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
scene_meta["funpack_scene_index"] = scene_index
scene_meta["funpack_scene_count"] = scene_count
scene_meta["funpack_scene_text"] = scene_text
effect = scene_effects[scene_index] if scene_index < len(scene_effects) else None
if effect and effect != "none":
scene_meta["funpack_transition_effect"] = effect
conditionings.append((cond, scene_meta))
return conditionings
@@ -11224,13 +11254,14 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
})
split_scene_texts = []
split_scene_effects = []
current_scene_seeds = []
current_scene_seed_source = str(_seed_source or "fresh seed")
if split_by_transitions:
try:
split_segments = self._v2_split_prompt_by_transitions(prompt_to_encode, placement=split_transition_placement)
if len(split_segments) > 1:
split_scene_texts = self._v2_transition_scene_texts(split_segments)
split_scene_texts, split_scene_effects = self._v2_transition_scene_texts(split_segments)
current_scene_seeds = self._v2_scene_seed_values(seed, len(split_scene_texts), _scene_seeds)
current_scene_seed_source = (
"successful seed memory"
@@ -11483,7 +11514,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
scene_texts = split_scene_texts
if scene_texts:
scene_conditionings = self._v2_transition_scene_conditionings(
clip, scene_texts, encode_cache=encode_cache,
clip, scene_texts, scene_effects=split_scene_effects, encode_cache=encode_cache,
)
if scene_conditionings is not None:
output_conditioning = self._v2_apply_scene_seed_metadata(
+126 -4
View File
@@ -805,11 +805,15 @@ class FunPackLTXAVSceneChainSampler:
"default": False,
"tooltip": "Experimental: carry protected frames from latent_template noise_mask into each continuation chunk after the overlap.",
}),
"transition_duration": ("INT", {
"default": 16, "min": 2, "max": 128, "step": 2,
"tooltip": "Pixel frames affected per visual transition (split evenly before/after each scene boundary).",
}),
}
}
RETURN_TYPES = ("LATENT", "STRING", "INT", "STRING")
RETURN_NAMES = ("latent", "status", "scene_count", "scene_report")
RETURN_TYPES = ("LATENT", "STRING", "INT", "STRING", "STRING")
RETURN_NAMES = ("latent", "status", "scene_count", "scene_report", "scene_boundaries")
FUNCTION = "sample"
CATEGORY = "FunPack/Sampling"
DESCRIPTION = (
@@ -1237,6 +1241,97 @@ class FunPackLTXAVSceneChainSampler:
latent.pop("noise_mask", None)
return latent
def _blur_pixel_frame(self, frame, sigma):
"""Box-blur approximation on a single [H, W, C] pixel frame."""
if sigma <= 0:
return frame
k = max(3, int(sigma * 3) | 1)
x = frame.permute(2, 0, 1).unsqueeze(0).float()
x = torch.nn.functional.avg_pool2d(x, k, stride=1, padding=k // 2)
return x.squeeze(0).permute(1, 2, 0).to(dtype=frame.dtype)
def _apply_pixel_transitions(self, output, boundary_entries, vae, time_scale, transition_duration):
"""Decode a latent window around each boundary, apply pixel effect, re-encode and splice."""
active = [e for e in boundary_entries if e.get("effect") and e["effect"] != "none"]
if not active:
return output
result_tensors = list(self._latent_tensors(output))
video_tensor = result_tensors[0].clone()
total_latent_frames = self._tensor_frames(video_tensor)
half_pix = max(1, transition_duration // 2)
# Convert pixel half-window to latent frames (ceiling to ensure coverage)
half_lat = max(2, (half_pix + time_scale - 1) // time_scale)
for entry in active:
effect = entry["effect"]
boundary_latent = int(entry["boundary_latent"])
start_l = max(0, boundary_latent - half_lat)
end_l = min(total_latent_frames, boundary_latent + half_lat)
if start_l >= end_l:
continue
window = self._time_slice(video_tensor, start_l, end_l)
try:
decoded = vae.decode(window) # [N, H, W, C]
except Exception as exc:
print(f"[FunPackSceneChain] {effect} decode failed: {exc}")
continue
decoded = decoded.clone().float()
n = decoded.shape[0]
center = n // 2
if effect == "fade_to_black":
for i in range(n):
dist = abs(i - center)
brightness = min(1.0, dist / max(1, center))
decoded[i] = decoded[i] * brightness
elif effect == "crossfade":
orig = decoded.clone()
for k in range(1, center + 1):
alpha = k / (center + 1)
pre_i = center - k
post_i = center + k - 1
if 0 <= pre_i < n and 0 <= post_i < n:
decoded[pre_i] = (1 - alpha) * orig[pre_i] + alpha * orig[post_i]
decoded[post_i] = (1 - alpha) * orig[post_i] + alpha * orig[pre_i]
elif effect == "blur_out_in":
max_sigma = 8.0
for i in range(n):
dist = abs(i - center)
sigma = max_sigma * (1.0 - min(1.0, dist / max(1, center)))
if sigma > 0:
decoded[i] = self._blur_pixel_frame(decoded[i], sigma)
decoded = decoded.clamp(0.0, 1.0).to(dtype=torch.float32)
try:
re_encoded = vae.encode(decoded)
except Exception as exc:
print(f"[FunPackSceneChain] {effect} encode failed: {exc}")
continue
new_window = re_encoded.get("samples") if isinstance(re_encoded, dict) else re_encoded
if not isinstance(new_window, torch.Tensor):
continue
if self._tensor_frames(new_window) != (end_l - start_l):
print(f"[FunPackSceneChain] {effect} encode shape mismatch, skipping")
continue
self._set_time_slice(video_tensor, start_l, end_l,
new_window.to(device=video_tensor.device, dtype=video_tensor.dtype))
result_tensors[0] = video_tensor
result = self._clone_latent(output)
if self._is_nested(output.get("samples")):
result["samples"] = comfy.nested_tensor.NestedTensor(result_tensors)
else:
result["samples"] = result_tensors[0]
return result
def _scene_text(self, scene_conditioning, index):
if (
isinstance(scene_conditioning, (list, tuple))
@@ -1260,9 +1355,19 @@ class FunPackLTXAVSceneChainSampler:
return None
return None
def _scene_transition_effect(self, scene_conditioning):
if (
isinstance(scene_conditioning, (list, tuple))
and len(scene_conditioning) >= 2
and isinstance(scene_conditioning[1], dict)
):
effect = str(scene_conditioning[1].get("funpack_transition_effect") or "none")
return None if effect == "none" else effect
return None
def sample(self, model, vae, positive, negative, sampler, sigmas, seed, latent_template,
num_frames_per_scene, frame_overlap, cfg, max_scenes, use_same_seed=False,
carry_i2v_guides=False):
carry_i2v_guides=False, transition_duration=16):
if not isinstance(positive, list) or not positive:
raise ValueError("positive conditioning must contain at least one scene entry.")
if negative is None:
@@ -1278,6 +1383,8 @@ class FunPackLTXAVSceneChainSampler:
output = None
report_lines = []
carried_guide_frames = 0
boundary_entries = []
cumulative_latent_frames = 0
first_scene_seed = self._scene_seed(scene_conditionings[0])
if first_scene_seed is None:
first_scene_seed = int(seed)
@@ -1293,6 +1400,16 @@ class FunPackLTXAVSceneChainSampler:
if output is None:
chunk = self._clone_latent(latent_template)
else:
# Record boundary (center of the overlap region) before blending
effect = self._scene_transition_effect(scene_cond)
if effect:
boundary_latent = max(0, cumulative_latent_frames - video_overlap // 2)
boundary_pixel = int((boundary_latent - 1) * time_scale + 1) if time_scale > 1 else boundary_latent
boundary_entries.append({
"boundary_latent": boundary_latent,
"pixel_frame": max(0, boundary_pixel),
"effect": effect,
})
chunk = self._build_continuation_chunk(latent_template, output, video_overlap)
if carry_i2v_guides:
chunk, scene_positive, scene_negative, carried = self._append_i2v_guides(
@@ -1307,8 +1424,12 @@ class FunPackLTXAVSceneChainSampler:
if output is not None:
sampled = self._match_audio_amplitude(sampled, video_overlap, video_frames)
output = sampled if output is None else self._blend_latents(output, sampled, video_overlap)
cumulative_latent_frames = self._tensor_frames(self._latent_tensors(output)[0])
report_lines.append(f"Scene {scene_index + 1}: seed={scene_seed}, text={self._scene_text(scene_cond, scene_index)}")
output = self._apply_pixel_transitions(output, boundary_entries, vae, time_scale, transition_duration)
import json as _json
final_frames = self._tensor_frames(self._latent_tensors(output)[0])
status = (
f"Scene chain complete: {scene_count} scene(s), "
@@ -1316,4 +1437,5 @@ class FunPackLTXAVSceneChainSampler:
)
if carry_i2v_guides and carried_guide_frames > 0:
status += f", i2v guide tokens={carried_guide_frames} latent frame(s)"
return (output, status, scene_count, "\n".join(report_lines))
boundaries_out = [{"pixel_frame": e["pixel_frame"], "effect": e["effect"]} for e in boundary_entries]
return (output, status, scene_count, "\n".join(report_lines), _json.dumps(boundaries_out))
+18 -3
View File
@@ -408,6 +408,9 @@ def empty_transition_db():
return {"version": 1, "source": "ComfyUI-FunPack", "transitions": {}}
_VISUAL_EFFECTS = ("none", "fade_to_black", "crossfade", "blur_out_in")
def normalize_transition_item(item, fallback_name=""):
if not isinstance(item, dict):
return None
@@ -418,11 +421,15 @@ def normalize_transition_item(item, fallback_name=""):
placement = str(item.get("placement") or "global").strip().lower()
if placement not in ("global", "start", "end", "silent"):
placement = "global"
visual_effect = str(item.get("visual_effect") or "none").strip().lower()
if visual_effect not in _VISUAL_EFFECTS:
visual_effect = "none"
return {
"name": name,
"trigger": trigger,
"placement": placement,
"enabled": bool(item.get("enabled", True)),
"visual_effect": visual_effect,
}
@@ -504,8 +511,10 @@ def delete_transition_item(name):
def load_custom_transition_triggers():
"""Return {trigger: placement_override} for enabled custom transitions.
placement_override is 'start', 'end', or None (use global setting).
"""Return {trigger: {"placement": override_or_None, "visual_effect": effect}} for enabled custom transitions.
placement is 'start', 'end', 'silent', or None (use global setting).
visual_effect is one of 'none', 'fade_to_black', 'crossfade', 'blur_out_in'.
"""
data = load_transition_db()
result = {}
@@ -516,7 +525,13 @@ def load_custom_transition_triggers():
if not trigger:
continue
placement = str(item.get("placement") or "global").strip().lower()
result[trigger.lower()] = placement if placement in ("start", "end", "silent") else None
visual_effect = str(item.get("visual_effect") or "none").strip().lower()
if visual_effect not in _VISUAL_EFFECTS:
visual_effect = "none"
result[trigger.lower()] = {
"placement": placement if placement in ("start", "end", "silent") else None,
"visual_effect": visual_effect,
}
return result
+11 -3
View File
@@ -753,7 +753,7 @@ function openPanel(node) {
const toolbar = el("div", "funpack-studio-footer");
const addBtn = btn("+ Add transition", "primary");
addBtn.addEventListener("click", () => {
drafts.unshift({ name: "", trigger: "", replacement: "", enabled: true });
drafts.unshift({ name: "", trigger: "", replacement: "", visual_effect: "none", enabled: true });
renderTab("Transitions");
});
const refreshBtn = btn("Refresh");
@@ -791,9 +791,11 @@ function openPanel(node) {
const summary = el("div", "funpack-studio-shortcut-summary");
const label = el("span", "funpack-studio-shortcut-label", item.name || item.trigger || "(unnamed)");
const trigger = el("span", "funpack-studio-shortcut-trigger", item.trigger || "");
const effectLabels = { fade_to_black: "Fade to Black", crossfade: "Crossfade", blur_out_in: "Blur Out → In" };
const effectLabel = effectLabels[item.visual_effect] ? el("span", "funpack-studio-shortcut-trigger", effectLabels[item.visual_effect]) : null;
const badge = el("span", item.enabled !== false ? "funpack-studio-badge-on" : "funpack-studio-badge-off",
item.enabled !== false ? "on" : "off");
summary.append(label, trigger, badge);
summary.append(label, trigger, ...(effectLabel ? [effectLabel] : []), badge);
summary.addEventListener("click", () => { item._expanded = true; renderTab("Transitions"); });
rowEl.append(summary);
list.append(rowEl);
@@ -813,6 +815,11 @@ function openPanel(node) {
const placementSel = selectEl(["global", "start", "end", "silent"], item.placement || "global");
placementSel.addEventListener("change", () => { item.placement = placementSel.value; });
const visualEffectSel = selectEl(["none", "fade_to_black", "crossfade", "blur_out_in"], item.visual_effect || "none");
const visualEffectLabels = { none: "None (cut)", fade_to_black: "Fade to Black", crossfade: "Crossfade", blur_out_in: "Blur Out → In" };
Array.from(visualEffectSel.options).forEach(opt => { opt.text = visualEffectLabels[opt.value] || opt.value; });
visualEffectSel.addEventListener("change", () => { item.visual_effect = visualEffectSel.value; });;
const actions = el("div", "funpack-studio-shortcut-actions");
const saveBtn = btn("Save", "primary");
saveBtn.addEventListener("click", async () => {
@@ -822,6 +829,7 @@ function openPanel(node) {
name: item.name || item.trigger,
trigger: item.trigger,
placement: item.placement || "global",
visual_effect: item.visual_effect || "none",
enabled: item.enabled !== false,
});
await fetchTransitions();
@@ -844,7 +852,7 @@ function openPanel(node) {
} catch (e) { showError(root, e.message); }
});
actions.append(saveBtn, collapseBtn, delBtn);
rowEl.append(top, triggerInput, row("Placement override", placementSel), actions);
rowEl.append(top, triggerInput, row("Placement override", placementSel), row("Visual effect", visualEffectSel), actions);
list.append(rowEl);
});
}