From b8422d647b88eec02098339afc8ea7ed76391c5e Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:17:23 +0300 Subject: [PATCH] Guide blur: own strength + sigma-threshold controls, independent of ALG's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit alg_blur_guides no longer borrows the sampler's anchor knobs: the Scene Chain Sampler gains alg_guide_blur_strength (default 2.0) and alg_guide_blur_sigma_threshold (default 0.975), threaded per-chunk via extra_options like the tail count. The sampler now precomputes one latent per (anchor blurred?, tail blurred?) combination — the frame sets are disjoint, so the both-blurred variant composes the two passes — and each region follows its own sigma window per step. Anchor ALG (alg_enabled/alg_strength/ alg_sigma_threshold) is untouched and back to anchor-only semantics. Editor: 'Guide blur strength' and 'Guide blur sigma threshold' rows appear under Engine ▸ Experimental when the blur toggle is on (sampler_inputs bridge, names match the new node widgets; inputs appended at the block end to keep the builder's positional widget mapping aligned). Verified live: object_info shows both widgets appended last; Editor rows appear on toggle and persist to project.sampler_inputs. 11 ALG unit tests pass (new: composed independent-strength blur); same 20 pre-existing suite failures. Co-Authored-By: Claude Fable 5 --- movie_editor/frontend/engine_settings.js | 4 +- samplers.py | 86 ++++++++++++++++++------ tests/test_alg_guidance.py | 33 +++++++++ 3 files changed, 103 insertions(+), 20 deletions(-) diff --git a/movie_editor/frontend/engine_settings.js b/movie_editor/frontend/engine_settings.js index 0b445b39..a88c9166 100644 --- a/movie_editor/frontend/engine_settings.js +++ b/movie_editor/frontend/engine_settings.js @@ -173,6 +173,8 @@ { 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" }, @@ -281,7 +283,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"], }; function countChainView(p, id) { diff --git a/samplers.py b/samplers.py index 612ba0a3..c91e304c 100644 --- a/samplers.py +++ b/samplers.py @@ -1755,6 +1755,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): """ @@ -1784,8 +1785,10 @@ def sample_funpack_distilled_flow(model, x, sigmas, extra_args=None, callback=No 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). Standalone: - works with alg_enabled off (anchor stays sharp, only the guide tail is blurred), - reusing the same alg_strength / alg_sigma_threshold parameters. + 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 — @@ -1837,20 +1840,43 @@ 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). - # The two are independent: guide-tail blur runs standalone with alg_enabled off (anchor - # stays sharp), sharing the same alg_strength / alg_sigma_threshold blur parameters. + # 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_wanted = bool(alg_enabled) or alg_guide_tail_frames > 0 - alg_sharp_latent_image = getattr(model, "latent_image", None) if alg_wanted else None - alg_active = alg_wanted 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,) if alg_enabled else (), tail_count=alg_guide_tail_frames, - ) if alg_active else None - alg_active = alg_active and alg_blurred_latent_image is not None + 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 (anchor={'on' if alg_enabled else 'off'}, strength={alg_strength}, " - f"sigma_threshold={alg_sigma_threshold}, guide_tail_frames={alg_guide_tail_frames}) " + 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, @@ -1886,7 +1912,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 @@ -2066,11 +2095,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. Used when alg_enabled, and also by the Scene Chain Sampler's alg_blur_guides (which works without 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. Used when alg_enabled, and also by the Scene Chain Sampler's alg_blur_guides (which works without 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, @@ -2330,7 +2359,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. Standalone: works even with the sampler's alg_enabled off (anchor stays sharp), reusing its alg_strength / alg_sigma_threshold. Requires the FunPack Distilled Flow sampler; no effect if 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, @@ -2358,6 +2387,14 @@ 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.", + }), }, "hidden": { "unique_id": "UNIQUE_ID", @@ -2743,6 +2780,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.") @@ -2765,6 +2803,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 @@ -4149,6 +4189,7 @@ 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, unique_id=None, prompt=None): if not isinstance(positive, list) or not positive: raise ValueError("positive conditioning must contain at least one scene entry.") @@ -4200,6 +4241,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, @@ -4482,6 +4525,8 @@ class FunPackLTXAVSceneChainSampler: 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 @@ -4761,7 +4806,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.""" @@ -4808,6 +4854,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, diff --git a/tests/test_alg_guidance.py b/tests/test_alg_guidance.py index a6a50738..8b6a387b 100644 --- a/tests/test_alg_guidance.py +++ b/tests/test_alg_guidance.py @@ -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()