From afa5ad2ea9c0d249bcc178cc3db38ea2252f24b0 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Wed, 20 May 2026 15:20:24 +0300 Subject: [PATCH] Context Transition Windows: strip to MODEL in/out, only total_frames All configuration is now auto-computed at sampling time - no user-facing settings except the optional total_frames INT. Window count is derived from the number of conditioning entries Refiner/Studio outputs. Window size is total_frames (or actual latent size) / num_windows. Boundaries are placed at equal intervals. split_conds_to_windows always on. Studio: move split_by_transitions from direct node input to the Refiner tab in the popup editor. Removed direct input, added toggle + hint text. Co-Authored-By: Claude Sonnet 4.6 --- conditioning.py | 13 +- context_transition.py | 342 ++++++++++-------------------------------- web/funpack_studio.js | 10 +- 3 files changed, 91 insertions(+), 274 deletions(-) diff --git a/conditioning.py b/conditioning.py index e7e43188..a2c0a1e9 100644 --- a/conditioning.py +++ b/conditioning.py @@ -12146,11 +12146,6 @@ class FunPackStudio: "feedback_prompt": ("STRING", {"multiline": True, "default": "", "forceInput": True, "tooltip": "Feedback about the previous output. Overrides the feedback set inside Studio."}), "refinement_key_input": ("STRING", {"forceInput": True}), - "split_by_transitions": ("BOOLEAN", { - "default": False, - "label": "Split by Transitions", - "tooltip": "Detect transition words in the prompt and encode each scene segment as a separate conditioning entry. The character description (text before the first comma) is prepended to every segment. Use with FunPack Context Transition Windows + split_conds_to_windows.", - }), "latent": ("LATENT", { "tooltip": "Optional video latent for creativity masking. Takes priority over any saved latent for this key. Connect your i2v or previous KSampler output here.", }), @@ -12163,7 +12158,7 @@ class FunPackStudio: clip_vision_output=None, source_image=None, lora_stack=None, positive_prompt=None, negative_prompt=None, user_intent_prompt=None, feedback_prompt=None, refinement_key_input="", - split_by_transitions=None, latent=None): + latent=None): try: settings = json.loads(str(studio_settings or "{}")) @@ -12213,11 +12208,7 @@ class FunPackStudio: im_feeling_lucky = bool(rf.get("im_feeling_lucky", False)) reset_session = bool(rf.get("reset_session", False)) temporal_style = str(rf.get("temporal_style", "natural") or "natural").strip().lower() - # Direct input wins over popup JSON setting - if split_by_transitions is None: - split_by_transitions = bool(rf.get("split_by_transitions", False)) - else: - split_by_transitions = bool(split_by_transitions) + split_by_transitions = bool(rf.get("split_by_transitions", False)) # feedback_prompt: popup wins if override is on, else external wins popup_feedback = str(rf.get("feedback_prompt", "") or "") diff --git a/context_transition.py b/context_transition.py index f525a051..60cb9cc3 100644 --- a/context_transition.py +++ b/context_transition.py @@ -3,181 +3,100 @@ import logging import comfy.context_windows -TRANSITION_MODES = ["off", "balanced", "aggressive", "custom"] -CONTEXT_SCHEDULES = [ - comfy.context_windows.ContextSchedules.STATIC_STANDARD, - comfy.context_windows.ContextSchedules.UNIFORM_STANDARD, - comfy.context_windows.ContextSchedules.UNIFORM_LOOPED, - comfy.context_windows.ContextSchedules.BATCHED, -] -FUSE_METHODS = comfy.context_windows.ContextFuseMethods.LIST_STATIC - - -def _clamp_float(value, minimum, maximum): - return max(minimum, min(maximum, float(value))) - - -def _clamp_int(value, minimum, maximum): - return max(minimum, min(maximum, int(value))) - - -def _resolve_transition_options(mode, start_pct, count, spacing_pct, strength): - mode = (mode or "off").lower() - if mode not in TRANSITION_MODES: - mode = "off" - - if mode == "off": - return { - "enabled": False, - "mode": mode, - "start_pct": 0.30, - "count": 0, - "spacing_pct": 0.22, - "strength": 0.0, - } - - start_pct = _clamp_float(start_pct, 0.0, 0.95) - count = _clamp_int(count, 1, 8) - spacing_pct = _clamp_float(spacing_pct, 0.02, 0.50) - strength = _clamp_float(strength, 0.0, 1.0) - - if mode == "balanced": - count = min(count, 1) - strength = min(max(strength, 0.60), 0.75) - elif mode == "aggressive": - count = max(count, 2) - strength = max(strength, 0.90) - - return { - "enabled": True, - "mode": mode, - "start_pct": start_pct, - "count": count, - "spacing_pct": spacing_pct, - "strength": strength, - } - - -def _resolve_transition_boundaries(num_frames, options): - if not options["enabled"] or num_frames <= 1: - return [] - - boundaries = [] - for transition_index in range(options["count"]): - pct = options["start_pct"] + options["spacing_pct"] * transition_index - if pct >= 1.0: - break - - boundary = int(round(pct * max(1, num_frames - 1))) - boundary = _clamp_int(boundary, 1, num_frames - 1) - if boundary not in boundaries: - boundaries.append(boundary) - - boundaries.sort() - return boundaries +_BASE_SCHEDULE = comfy.context_windows.ContextSchedules.STATIC_STANDARD +_FUSE_METHOD = comfy.context_windows.ContextFuseMethods.PYRAMID +_DIM = 2 +_CONTEXT_OVERLAP = 4 +_FALLBACK_CONTEXT_LENGTH = 16 def _offset_windows(local_windows, offset, num_frames): - shifted_windows = [] + shifted = [] for window in local_windows: - shifted = [idx + offset for idx in window] - shifted = [idx for idx in shifted if 0 <= idx < num_frames] - if shifted: - shifted_windows.append(shifted) - return shifted_windows + w = [idx + offset for idx in window if 0 <= idx + offset < num_frames] + if w: + shifted.append(w) + return shifted -def _make_segmented_context_schedule(base_schedule_name, transition_options): - base_schedule = comfy.context_windows.get_matching_context_schedule(base_schedule_name) - schedule_name = f"funpack_transition_{base_schedule.name}" +def _make_auto_schedule(): + """Schedule that reads handler.auto_num_windows at call time to place equal-width boundaries.""" + base = comfy.context_windows.get_matching_context_schedule(_BASE_SCHEDULE) - def segmented_schedule(num_frames, handler, model_options): - boundaries = _resolve_transition_boundaries(num_frames, transition_options) + def auto_schedule(num_frames, handler, model_options): + num_windows = getattr(handler, "auto_num_windows", 1) + if num_windows <= 1 or num_frames <= 1: + return base.func(num_frames, handler, model_options) + + # Equal-width hard cuts between windows + boundaries = [ + int(round(num_frames * i / num_windows)) + for i in range(1, num_windows) + ] + boundaries = [b for b in boundaries if 0 < b < num_frames] if not boundaries: - return base_schedule.func(num_frames, handler, model_options) + return base.func(num_frames, handler, model_options) - isolation = transition_options["strength"] - boundary_bleed = int(round(handler.context_overlap * (1.0 - isolation))) cuts = [0] + boundaries + [num_frames] windows = [] seen = set() - - for segment_index in range(len(cuts) - 1): - segment_start = cuts[segment_index] - segment_end = cuts[segment_index + 1] - - if boundary_bleed > 0: - if segment_index > 0: - segment_start = max(0, segment_start - boundary_bleed) - if segment_index < len(cuts) - 2: - segment_end = min(num_frames, segment_end + boundary_bleed) - - segment_length = segment_end - segment_start - if segment_length <= 0: + for i in range(len(cuts) - 1): + seg_start, seg_end = cuts[i], cuts[i + 1] + seg_len = seg_end - seg_start + if seg_len <= 0: continue - - local_windows = base_schedule.func(segment_length, handler, model_options) - for window in _offset_windows(local_windows, segment_start, num_frames): - key = tuple(window) - if key in seen: - continue - seen.add(key) - windows.append(window) + for w in _offset_windows(base.func(seg_len, handler, model_options), seg_start, num_frames): + key = tuple(w) + if key not in seen: + seen.add(key) + windows.append(w) if not windows: - return base_schedule.func(num_frames, handler, model_options) + return base.func(num_frames, handler, model_options) logging.info( - "FunPack context transitions at latent indexes %s with %.2f isolation.", - boundaries, - isolation, + "FunPack context windows: %s segments, boundaries at %s, context_length=%s.", + num_windows, boundaries, handler.context_length, ) return windows - return comfy.context_windows.ContextSchedule(schedule_name, segmented_schedule) + return comfy.context_windows.ContextSchedule("funpack_auto", auto_schedule) + + +_AUTO_SCHEDULE = _make_auto_schedule() class FunPackTransitionContextHandler(comfy.context_windows.IndexListContextHandler): - def __init__(self, *args, transition_options=None, auto_total_frames=0, **kwargs): - super().__init__(*args, **kwargs) - self.transition_options = transition_options or _resolve_transition_options( - "off", 0.30, 0, 0.22, 0.0 + def __init__(self, auto_total_frames=0): + super().__init__( + context_schedule=_AUTO_SCHEDULE, + fuse_method=comfy.context_windows.get_matching_fuse_method(_FUSE_METHOD), + context_length=max(1, int(auto_total_frames) // 2) if auto_total_frames else _FALLBACK_CONTEXT_LENGTH, + context_overlap=_CONTEXT_OVERLAP, + context_stride=1, + closed_loop=False, + dim=_DIM, + freenoise=False, + cond_retain_index_list="", + split_conds_to_windows=True, ) self.auto_total_frames = int(auto_total_frames or 0) - - def _auto_resize_context_length(self, conds): - """When total_frames is set, compute context_length from the number of conditioning entries.""" - if self.auto_total_frames <= 0 or not self.split_conds_to_windows: - return - # conds is [[pos_entry1, pos_entry2, ...], [neg_entry1, ...]] - try: - num_windows = len(conds[0]) if conds and conds[0] else 1 - except Exception: - num_windows = 1 - if num_windows > 1: - self.context_length = max(1, self.auto_total_frames // num_windows) - logging.info( - "FunPack auto context_length=%s from total_frames=%s / %s windows.", - self.context_length, - self.auto_total_frames, - num_windows, - ) + self.auto_num_windows = 1 def should_use_context(self, model, conds, x_in, timestep, model_options): - self._auto_resize_context_length(conds) + # Detect how many conditioning entries Refiner/Studio produced + try: + num_windows = max(1, len(conds[0])) if conds and conds[0] else 1 + except Exception: + num_windows = 1 + self.auto_num_windows = num_windows - if super().should_use_context(model, conds, x_in, timestep, model_options): - return True + # Auto-size context_length to fit windows + if num_windows > 1: + total = self.auto_total_frames if self.auto_total_frames > 0 else int(x_in.size(self.dim)) + self.context_length = max(1, total // num_windows) - frame_count = x_in.size(self.dim) - if _resolve_transition_boundaries(frame_count, self.transition_options): - logging.info( - "Using FunPack transition context windows for %s latent frames.", - frame_count, - ) - return True - - return False + return super().should_use_context(model, conds, x_in, timestep, model_options) or num_windows > 1 class FunPackContextTransitionWindows: @@ -186,100 +105,20 @@ class FunPackContextTransitionWindows: return { "required": { "model": ("MODEL", { - "tooltip": "The model to wrap with transition-aware context windows." - }), - "transition_mode": (TRANSITION_MODES, { - "default": "aggressive", - "tooltip": "Off uses ordinary context windows. Balanced/aggressive/custom split context windows at transition points." - }), - "context_length": ("INT", { - "default": 16, - "min": 1, - "max": 4096, - "step": 1, - "tooltip": "Latent-frame length of each context window. Use a value smaller than the clip length to force windowed denoising." - }), - "context_overlap": ("INT", { - "default": 4, - "min": 0, - "max": 4096, - "step": 1, - "tooltip": "Latent-frame overlap between neighboring context windows inside each transition segment." - }), - "context_schedule": (CONTEXT_SCHEDULES, { - "default": comfy.context_windows.ContextSchedules.STATIC_STANDARD, - "tooltip": "Base context window schedule borrowed from Comfy's manual context windows." - }), - "fuse_method": (FUSE_METHODS, { - "default": comfy.context_windows.ContextFuseMethods.PYRAMID, - "tooltip": "How overlapping context-window results are blended inside each segment." - }), - "dim": ("INT", { - "default": 2, - "min": 0, - "max": 5, - "step": 1, - "tooltip": "Tensor dimension to window. For LTX/WAN-like video latents this is usually 2." - }), - "transition_start_pct": ("FLOAT", { - "default": 0.30, - "min": 0.0, - "max": 0.95, - "step": 0.01, - "tooltip": "Clip progress where the first context reset begins. 0.0 means immediately after latent frame 0." - }), - "transition_count": ("INT", { - "default": 2, - "min": 1, - "max": 8, - "step": 1, - "tooltip": "How many context reset boundaries to create." - }), - "transition_spacing_pct": ("FLOAT", { - "default": 0.22, - "min": 0.02, - "max": 0.50, - "step": 0.01, - "tooltip": "Clip progress spacing between context reset boundaries." - }), - "transition_strength": ("FLOAT", { - "default": 0.85, - "min": 0.0, - "max": 1.0, - "step": 0.01, - "tooltip": "How strongly transition boundaries block context carryover. 1.0 is a hard split; lower values allow a small overlap bleed." + "tooltip": "The model to wrap with per-scene context windows.", }), }, "optional": { - "context_stride": ("INT", { - "default": 1, - "min": 1, - "max": 16, - "step": 1, - "tooltip": "Stride for uniform context schedules." - }), - "closed_loop": ("BOOLEAN", { - "default": False, - "tooltip": "Whether looped schedules may wrap around the clip." - }), - "freenoise": ("BOOLEAN", { - "default": False, - "tooltip": "Use Comfy's FreeNoise wrapper for context-window noise shuffling." - }), "total_frames": ("INT", { "default": 0, "min": 0, "max": 16384, "step": 1, - "tooltip": "When > 0 and split_conds_to_windows is enabled, context_length is auto-computed as total_frames divided by the number of conditioning entries from Refiner/Studio. Connect your latent frame count or leave 0 to set context_length manually." - }), - "cond_retain_index_list": ("STRING", { - "default": "", - "tooltip": "Comma-separated latent indexes to retain in conditioning tensors for every window. Leave empty for transitions so frame 0 does not over-anchor later segments." - }), - "split_conds_to_windows": ("BOOLEAN", { - "default": False, - "tooltip": "When Refiner or Studio outputs multiple conditioning entries (via Split by Transitions), route each entry to its corresponding context window region." + "tooltip": ( + "Latent frame count of your video. When set, window size is " + "auto-computed as total_frames / number_of_conditioning_entries. " + "Leave 0 to derive the count from the actual latent at sampling time." + ), }), }, } @@ -288,37 +127,16 @@ class FunPackContextTransitionWindows: RETURN_NAMES = ("model",) FUNCTION = "apply" CATEGORY = "FunPack/Sampling" - DESCRIPTION = "Manual context windows with transition boundaries that reduce temporal carryover across selected parts of a video clip." + DESCRIPTION = ( + "Wraps a model with per-scene context windows. Connect after Refiner V2 or Studio " + "with 'Split by Transitions' enabled. Window count and size are derived automatically " + "from the number of conditioning entries at sampling time. Only total_frames needs to " + "be set (optional - leave 0 to auto-detect from the latent)." + ) - def apply(self, model, transition_mode, context_length, context_overlap, - context_schedule, fuse_method, dim, transition_start_pct, - transition_count, transition_spacing_pct, transition_strength, - total_frames=0, context_stride=1, closed_loop=False, freenoise=False, - cond_retain_index_list="", split_conds_to_windows=False): - transition_options = _resolve_transition_options( - transition_mode, - transition_start_pct, - transition_count, - transition_spacing_pct, - transition_strength, - ) - schedule = _make_segmented_context_schedule(context_schedule, transition_options) + def apply(self, model, total_frames=0): model = model.clone() - model.model_options["context_handler"] = FunPackTransitionContextHandler( - context_schedule=schedule, - fuse_method=comfy.context_windows.get_matching_fuse_method(fuse_method), - context_length=context_length, - context_overlap=context_overlap, - context_stride=context_stride, - closed_loop=closed_loop, - dim=dim, - freenoise=freenoise, - cond_retain_index_list=cond_retain_index_list, - split_conds_to_windows=split_conds_to_windows, - transition_options=transition_options, - auto_total_frames=total_frames, - ) + handler = FunPackTransitionContextHandler(auto_total_frames=total_frames) + model.model_options["context_handler"] = handler comfy.context_windows.create_prepare_sampling_wrapper(model) - if freenoise: - comfy.context_windows.create_sampler_sample_wrapper(model) return (model,) diff --git a/web/funpack_studio.js b/web/funpack_studio.js index c0f9cc33..82dcfa79 100644 --- a/web/funpack_studio.js +++ b/web/funpack_studio.js @@ -66,7 +66,7 @@ function defaultSettings() { refinement_key: "", overrides: { refinement_key: false, feedback_prompt: false, user_intent_prompt: false, negative_prompt: false }, scene_builder: { mode: "Pass-through", scene: NONE_SENTINEL, scene_name: "", aliases: "", scene_positive: "", scene_negative: "" }, - refiner: { mode: "Refine", advisor_mode: "Off", advisor_thinking: true, prompt_repair: true, im_feeling_lucky: false, reset_session: false, feedback_prompt: "", user_intent_prompt_override: "", negative_prompt: "", temporal_style: "natural" }, + refiner: { mode: "Refine", advisor_mode: "Off", advisor_thinking: true, prompt_repair: true, im_feeling_lucky: false, reset_session: false, feedback_prompt: "", user_intent_prompt_override: "", negative_prompt: "", temporal_style: "natural", split_by_transitions: false }, advisor_llm: { enabled: false, model_path: "huihui-ai/Huihui-Qwen3-8B-abliterated-v2", dtype: "bfloat16" }, loras: [], loras_config: { mode: "ltx2", per_block: false }, @@ -490,6 +490,14 @@ function openPanel(node) { temporalSelect.addEventListener("change", () => { settings.refiner.temporal_style = temporalSelect.value; }); body.append(row("Temporal style", temporalSelect)); + const splitToggle = toggleEl(!!settings.refiner.split_by_transitions, "Split prompt by transitions"); + splitToggle.inp.addEventListener("change", () => { settings.refiner.split_by_transitions = splitToggle.inp.checked; }); + body.append(el("div", "funpack-studio-hint", + "Detect transition words in the prompt (then, suddenly, cut to...) and encode each scene segment as a separate conditioning entry. " + + "The character description (text before the first comma) is prepended to every segment to keep the subject consistent. " + + "Use together with FunPack Context Transition Windows.")); + body.append(row("Split by transitions", splitToggle.container)); + body.append(sectionTitle("Negative prompt")); body.append(el("div", "funpack-studio-hint", "Encoded via CLIP and output as negative conditioning. Skipped when negative_conditioning input is connected."));