From 9dbd239702105527ccf8001ef96405901fef6cc2 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Mon, 25 May 2026 17:50:31 +0300 Subject: [PATCH] Add visual transition effects to scene chain sampler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- conditioning.py | 61 +++++++++++++++----- samplers.py | 130 ++++++++++++++++++++++++++++++++++++++++-- templates.py | 21 ++++++- web/funpack_studio.js | 14 ++++- 4 files changed, 201 insertions(+), 25 deletions(-) diff --git a/conditioning.py b/conditioning.py index f4b45891..f6b7c963 100644 --- a/conditioning.py +++ b/conditioning.py @@ -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( diff --git a/samplers.py b/samplers.py index c25346d8..a58a000f 100644 --- a/samplers.py +++ b/samplers.py @@ -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)) diff --git a/templates.py b/templates.py index ef0f05f2..2337c3ee 100644 --- a/templates.py +++ b/templates.py @@ -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 diff --git a/web/funpack_studio.js b/web/funpack_studio.js index 6d97fee8..31750c73 100644 --- a/web/funpack_studio.js +++ b/web/funpack_studio.js @@ -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); }); }