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 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-05-20 15:20:24 +03:00
co-authored by Claude Sonnet 4.6
parent 9c6e527ea6
commit afa5ad2ea9
3 changed files with 91 additions and 274 deletions
+2 -11
View File
@@ -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 "")
+80 -262
View File
@@ -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,)
+9 -1
View File
@@ -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."));