feat: schedules for every sampler, a usable frames field, ALG on any sampler

Three things that were each locked to one implementation detail:

- steps + schedule were a KSampler-branch feature, though every sampler here
  walks a sigma list handed in from outside. They are now pass-level, so
  Distilled Flow and Hybrid Euler 2S can run a named schedule (and the second
  pass can be computed rather than typed) in both frontends and on the node.
- the project's Frames field snapped to the model's grid on every family. On
  H3 that is required, but its arrows moved by 1 and the snap put the value
  straight back; on LTX the builder rounds up by itself, so the snap only
  fought the user. H3 now steps a whole 17 frames per arrow, LTX keeps what
  you type and walks 8k+1 with its arrows.
- ALG lived inside the Distilled Flow step loop, so it was unreachable with a
  stock KSampler. Its blurred/sharp swap is decided by the step's sigma, and
  sigma is an argument of every model call, so a denoiser proxy drives it from
  outside any sampler. New alg_anchor toggle on the Chain Sampler drives the
  in-loop version too, so there is one control wherever you are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-08-11 03:53:11 +03:00
co-authored by Claude Opus 5
parent 75ebbb790b
commit 48ff7f0f48
11 changed files with 583 additions and 127 deletions
+48 -32
View File
@@ -14572,8 +14572,8 @@ class FunPackConditioningAdjust:
return (new_conditioning, status)
# The KSampler pass's schedule dropdown holds ComfyUI's scheduler names plus this one extra
# entry, which means "don't compute anything, run the sigmas typed in the Sigmas field".
# A pass's schedule dropdown holds ComfyUI's scheduler names plus this one extra entry,
# which means "don't compute anything, run the sigmas typed in the Sigmas field".
# Both frontends offer it under the same string — keep them in sync if it ever changes.
KSAMPLER_USER_SIGMAS = "use_user_sigmas"
@@ -14949,10 +14949,53 @@ class FunPackStudio:
pass
return None
@classmethod
def _pass_schedule(cls, cfg, model, typed_sigmas, sampler_type):
"""The pass's SIGMAS: either the typed field or a computed schedule.
Every sampler type takes its schedule from the outside — the FunPack samplers walk
whatever sigma list they are handed exactly like a stock KSampler does — so steps +
schedule belong to the PASS, not to the KSampler branch that first grew them. The
dropdown is the switch between the two sources, deliberately: KSAMPLER_USER_SIGMAS
runs the typed Sigmas field, anything else COMPUTES (the way ComfyUI's BasicScheduler
does) and the typed list is ignored for this pass — so a hand-written schedule can be
parked in the field and switched back to without retyping it.
`ksampler_scheduler` / `ksampler_steps` are the pre-split key names, still read here
so configs saved by an older UI keep their schedule.
"""
scheduler = str(cfg.get("scheduler", cfg.get("ksampler_scheduler", KSAMPLER_USER_SIGMAS))
or KSAMPLER_USER_SIGMAS)
if scheduler == KSAMPLER_USER_SIGMAS:
return typed_sigmas
steps = int(cfg.get("steps", cfg.get("ksampler_steps", 0)) or 0)
if model is None or steps <= 0:
print(f"[FunPackStudio] {sampler_type}: schedule '{scheduler}' needs steps > 0 "
f"and a model — falling back to the typed sigmas field.")
return typed_sigmas
try:
import comfy.samplers as _cs
out = _cs.calculate_sigmas(
model.get_model_object("model_sampling"), scheduler, steps
).cpu()
except Exception as e:
# Keep the sampler: a bad scheduler name should cost the schedule, not the pass.
print(f"[FunPackStudio] {sampler_type}: schedule build failed "
f"({scheduler}, {steps} steps): {e}")
return typed_sigmas
if typed_sigmas is not None:
print(f"[FunPackStudio] {sampler_type}: schedule '{scheduler}' ({steps} steps) in "
f"use — the typed sigmas field is ignored until the schedule is set back to "
f"'{KSAMPLER_USER_SIGMAS}'.")
return out
@classmethod
def _build_one_sampler(cls, cfg, prompt_sig=None, refinement_key="", model=None):
sampler_type = str(cfg.get("type", "Hybrid Euler 2S") or "Hybrid Euler 2S")
sigmas_raw = cls._parse_sigmas(cfg.get("sigmas", ""))
# steps + schedule are pass-level and apply to every sampler type; the FunPack
# samplers then prepare (motion pulses, quality split) whatever list this produces.
sigmas_raw = cls._pass_schedule(cfg, model, sigmas_raw, sampler_type)
try:
if sampler_type == "Distilled Flow":
@@ -14996,38 +15039,11 @@ class FunPackStudio:
elif sampler_type == "KSampler":
import comfy.samplers as _cs
sampler_name = str(cfg.get("ksampler_name", "euler") or "euler")
# A stock KSampler carries no schedule of its own — the SAMPLER object is
# just the step function — so the pass's steps + schedule (built above, and
# shared with the FunPack samplers) is the only thing that can supply one.
sampler = _cs.sampler_object(sampler_name)
out_sigmas = sigmas_raw
# A stock KSampler carries no schedule of its own — the SAMPLER object is
# just the step function — so with the Sigmas field empty this pass used to
# hand the chain sampler nothing at all. steps + schedule build one the way
# ComfyUI's BasicScheduler does. The schedule is the switch between the two
# sources, deliberately: KSAMPLER_USER_SIGMAS reads the typed Sigmas field,
# anything else COMPUTES and the typed list is ignored for this pass — so a
# hand-written schedule can be parked in the field and switched back to
# without retyping it.
steps = int(cfg.get("ksampler_steps", 0) or 0)
scheduler = str(cfg.get("ksampler_scheduler", KSAMPLER_USER_SIGMAS)
or KSAMPLER_USER_SIGMAS)
if scheduler != KSAMPLER_USER_SIGMAS:
if model is None or steps <= 0:
print(f"[FunPackStudio] KSampler schedule '{scheduler}' needs steps > 0 "
f"and a model — falling back to the typed sigmas field.")
else:
try:
out_sigmas = _cs.calculate_sigmas(
model.get_model_object("model_sampling"), scheduler, steps
).cpu()
if sigmas_raw is not None:
print(f"[FunPackStudio] KSampler: schedule '{scheduler}' "
f"({steps} steps) in use — the typed sigmas field is "
f"ignored until the schedule is set back to "
f"'{KSAMPLER_USER_SIGMAS}'.")
except Exception as e:
# Keep the sampler: a bad scheduler name should cost the
# schedule, not the whole pass.
print(f"[FunPackStudio] KSampler schedule build failed "
f"({scheduler}, {steps} steps): {e}")
else: # Hybrid Euler 2S (default)
try:
from .samplers import FunPackHybridEuler2SSampler
+13 -6
View File
@@ -58,16 +58,23 @@
out.append(field("Height", numberInput(p.height, (v) => commit((pr) => pr.height = v), { min: 64, step: 16 })));
// Frame geometry is per model family: LTX generates on an 8k+1 grid at the project's
// fps, MiniMax H3 on a 17k+5 grid at a fixed 24 fps. Off-grid lengths fail the run
// outright on H3, so the input snaps to the family's grid rather than warning after.
// outright on H3, so there the input snaps; on LTX the builder rounds up by itself, so
// the typed number is kept and only the arrows follow the grid.
const grid = window.PipelineCaps?.frameGrid ? window.PipelineCaps.frameGrid(st)
: { step: 8, base: 1, fps: null, label: "8k+1" };
const snapTo = (v) => (window.PipelineCaps?.snapFramesTo
? window.PipelineCaps.snapFramesTo(v, st, "round")
: Math.max(9, Math.round((v - 1) / 8) * 8 + 1));
const spec = window.PipelineCaps?.frameInputSpec
? window.PipelineCaps.frameInputSpec(st) : { step: grid.step, min: grid.base, snap: false };
const snapTo = (v) => (window.PipelineCaps?.snapFramesIfRequired
? window.PipelineCaps.snapFramesIfRequired(v, st)
: Math.max(1, Math.round(Number(v) || 0)));
out.append(field("Frames",
numberInput(p.num_frames_per_scene, (v) => commit((pr) => pr.num_frames_per_scene = snapTo(v)),
{ min: grid.step + grid.base, step: grid.step }),
"Length of the generated clip, in frames — snapped to this model's " + grid.label + " grid."));
{ min: spec.min, step: spec.step }),
spec.snap
? "Length of the generated clip, in frames — MiniMax H3 only generates on its "
+ grid.label + " grid, so this snaps to it and the arrows move " + spec.step + " at a time."
: "Length of the generated clip, in frames. The arrows walk this model's " + grid.label
+ " grid; an off-grid number is rounded up to it when the graph is built."));
out.append(field("Frame rate", numberInput(p.frame_rate, (v) => commit((pr) => pr.frame_rate = v), { min: 1 }),
grid.fps ? "MiniMax H3 always generates at " + grid.fps + " fps; the render is muxed at that rate." : null));
wrap.append(out);
+17 -6
View File
@@ -237,8 +237,14 @@
hint: "Carries voice and ambience across shots too, not just the face. This deliberately changes the audio, which nothing else here does. Needs JoyAI-Echo memory on." },
{ 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",
hint: "How much the carried audio follows the new shot's picture. 1.0 is the model's own behaviour and costs nothing; JoyAI uses 2.0; 0 makes audio ignore the video." },
{ name: "alg_anchor", label: "Blur the i2v anchor (ALG)", kind: "bool", default: false,
hint: "Hides the anchor image's fine detail during the first, noisiest steps so the shot can't shortcut to a near-still that just matches it — the usual fix for an anchored scene that barely moves. Works with whatever sampler you have wired, including a plain KSampler, and does nothing on a scene with no anchor image." },
{ name: "alg_anchor_strength", label: "Anchor blur strength", kind: "float", default: 2.0, min: 1.0, max: 4.0, step: 0.1, dependsOn: "alg_anchor",
hint: "How blurry the anchor gets while it's blurred. The paper says 2.5; 2.0 held character likeness noticeably better here." },
{ name: "alg_anchor_sigma_threshold", label: "Anchor blur sigma threshold", kind: "float", default: 0.975, min: 0.5, max: 0.999, step: 0.005, dependsOn: "alg_anchor",
hint: "How long the anchor stays blurred before switching to sharp. Higher = a shorter blurred window." },
{ name: "alg_blur_guides", label: "Blur i2v guides and JoyAI memory", kind: "bool", default: false,
hint: "Blurs guide and memory frames during the first, noisiest steps, so they steer composition without pasting their own detail into the shot — which is what makes anchored scenes look static. Needs the FunPack Distilled Flow sampler, and does nothing on a scene with no guide frames." },
hint: "The same treatment for guide and memory frames, not just the anchor: blurred during the first, noisiest steps so they steer composition without pasting their own detail into the shot. Works with any sampler, and does nothing on a scene with no guide frames." },
{ 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",
hint: "How blurry those frames get while they're blurred. Higher = looser guidance and more freedom to move." },
{ 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",
@@ -451,7 +457,7 @@
chain_timing: ["frame_overlap", "transition_duration", "use_same_seed", "cut_opening_frames"],
chain_guidance: ["cfg", "embed_guidance", "embed_guidance_source", "embed_guidance_strength", "score_slider", "score_slider_strength", "taste_nearest_prompt", "output_guidance", "output_guidance_strength", "dynashift", "dynashift_strength", "dynashift_threshold"],
chain_decode: ["decode_noise_scale", "decode_timestep", "decode_tile_size"],
chain_experimental: ["context_windows", "context_window_length", "context_window_overlap", "context_window_schedule", "context_window_fuse", "context_window_freenoise", "context_window_retain_first", "plateau_cache", "plateau_cache_threshold", "h3_audio_clock", "segmented_detailing", "detail_targets", "detail_strength", "detail_threshold", "detail_max_area", "detail_mode", "detail_denoise", "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", "identity_transfer_enabled", "source_id", "phase_scale", "id_strength", "arcface_mode", "debug_log"],
chain_experimental: ["context_windows", "context_window_length", "context_window_overlap", "context_window_schedule", "context_window_fuse", "context_window_freenoise", "context_window_retain_first", "plateau_cache", "plateau_cache_threshold", "h3_audio_clock", "segmented_detailing", "detail_targets", "detail_strength", "detail_threshold", "detail_max_area", "detail_mode", "detail_denoise", "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_anchor", "alg_anchor_strength", "alg_anchor_sigma_threshold", "alg_blur_guides", "alg_guide_blur_strength", "alg_guide_blur_sigma_threshold", "bounded_attention_enabled", "identity_transfer_enabled", "source_id", "phase_scale", "id_strength", "arcface_mode", "debug_log"],
};
function countChainView(p, id) {
@@ -626,14 +632,19 @@
else S.setStudioInputNow("studio_settings", next);
syncSecondPassFromSchedule(updatedSamplers, quiet);
}
// The second pass is driven by ONE thing: the schedule. Typing one turns it on; clearing
// it turns it off. There is no cut and no re-entry point to configure — pass 1 always runs
// the main schedule in full and pass 2 always runs this one in full.
// The second pass is driven by ONE thing: the schedule. Giving it one turns it on;
// taking it away turns it off. There is no cut and no re-entry point to configure —
// pass 1 always runs the main schedule in full and pass 2 always runs this one in full.
// A schedule is either typed into the low pass's Sigmas field or COMPUTED by picking a
// scheduler for it; both must count here, or picking one would be an inert control.
function syncSecondPassFromSchedule(samplers, quiet) {
const raw = String(samplers?.low?.sigmas || "").replace(/;/g, ",");
const vals = raw.split(",").map((v) => parseFloat(v.trim())).filter((v) => !isNaN(v));
const sched = String(samplers?.low?.scheduler || "use_user_sigmas");
const computed = sched !== "use_user_sigmas" && Number(samplers?.low?.steps || 0) > 0;
const set = quiet ? S.setSamplerInput : S.setSamplerInputNow;
if (vals.length >= 2) set("second_pass", true); // one number is not a schedule
// one number is not a schedule
if (computed || vals.length >= 2) set("second_pass", true);
else S.unsetSamplerInput("second_pass");
}
+33 -8
View File
@@ -111,7 +111,13 @@
// Per-scene length / fps with a 3-way mode (project | timeline | custom).
const LEN_META = {
frames: { label: "Frames", projKey: "num_frames_per_scene", snap: (v) => S.snapFrames(v) },
// Same rule as the project's Frames field: snap only on H3, where an off-grid length is
// a dead run. On LTX the builder rounds up on its own, so the typed number survives.
frames: {
label: "Frames", projKey: "num_frames_per_scene",
snap: (v) => (window.PipelineCaps?.snapFramesIfRequired
? window.PipelineCaps.snapFramesIfRequired(v, S.get()) : S.snapFrames(v)),
},
fps: { label: "FPS", projKey: "frame_rate", snap: (v) => Math.max(1, v) },
};
const LEN_DEFAULT_MODE = { frames: "project", fps: "project" };
@@ -143,6 +149,12 @@
if (mode === "custom") {
const i = el("input"); i.type = "number"; i.value = effOf(scene, kind); i.dataset.k = "sc-" + kind;
if (kind === "frames" && window.PipelineCaps?.frameInputSpec) {
// arrows walk the model's frame grid instead of moving by 1 into a value the snap
// (H3) or the builder (LTX) will not keep
const sp = window.PipelineCaps.frameInputSpec(S.get());
i.step = String(sp.step); i.min = String(sp.min);
}
i.oninput = () => S.patchSceneQuiet(scene.id, { [kind]: m.snap(parseInt(i.value || "0", 10)) });
// Stored value is snapped (frames → 9/17/25…, fps → ≥1); reflect that back on commit
// so the field doesn't keep showing an unsnapped number the engine won't actually use.
@@ -423,20 +435,33 @@
name.oninput = () => S.patchProjectQuiet({ name: name.value });
body.append(field("Project name", name));
// Frames snap to the model family's grid on blur (LTX 8k+1, MiniMax H3 17k+5). Typing is
// left alone — snapping mid-keystroke fights the user — but the value that reaches the
// project is always one the model can actually generate.
// Frames only SNAP where the model makes them: MiniMax H3 refuses an off-grid length,
// so there the field snaps on blur and its arrows step 17 at a time. On LTX the builder
// rounds up by itself, so the number is left exactly as typed and only the arrows follow
// the 8k+1 grid — an arrow that adds 1 and then springs back is not a control.
const grid = window.PipelineCaps?.frameGrid ? window.PipelineCaps.frameGrid(st) : null;
const spec = window.PipelineCaps?.frameInputSpec
? window.PipelineCaps.frameInputSpec(st) : null;
const row1 = el("div", "fields-row");
const framesField = numberField("Frames / scene", p.num_frames_per_scene,
(v) => S.patchProjectQuiet({ num_frames_per_scene: v }), "pj-frames");
const framesInput = framesField.querySelector("input");
if (framesInput) {
if (grid) framesInput.title = "Snaps to the model's " + grid.label + " frame grid.";
if (spec) {
framesInput.step = String(spec.step);
framesInput.min = String(spec.min);
framesInput.title = spec.snap
? "MiniMax H3 only generates on its " + grid.label + " frame grid, so this snaps to it "
+ "— the arrows move a whole " + spec.step + " frames."
: "Any length works; the arrows walk this model's " + grid.label + " grid, and an "
+ "off-grid number is rounded up to it when the graph is built.";
}
framesInput.onchange = () => {
const snapped = S.snapFrames(parseInt(framesInput.value || "0", 10));
framesInput.value = snapped;
S.patchProjectQuiet({ num_frames_per_scene: snapped });
const typed = parseInt(framesInput.value || "0", 10);
const val = window.PipelineCaps?.snapFramesIfRequired
? window.PipelineCaps.snapFramesIfRequired(typed, st) : S.snapFrames(typed);
framesInput.value = val;
S.patchProjectQuiet({ num_frames_per_scene: val });
};
}
row1.append(framesField);
+24
View File
@@ -61,6 +61,28 @@
return Math.max(min, rounded * g.step + g.base);
}
// What a frames INPUT should do while the user is using it. The two families are not
// equally strict, and treating them as if they were is what made the field unusable:
// H3 — off-grid is a dead run (its latent node snaps its own length up while the
// sampler expects the number the project asked for), so the field snaps on
// commit and its arrows walk WHOLE grid steps (22 -> 39 -> 56). Before this the
// arrows moved by 1 and the snap put the value straight back.
// LTX — the builder rounds an off-grid length up to 8k+1 by itself, so nothing is at
// risk: the field behaves like a plain number input and only its arrows follow
// the grid (min 1 + step 8 lands them on 9 / 17 / 25 …).
// `min` is the arrow BASE as well as the floor, which is what puts the arrows on the grid.
function frameInputSpec(st) {
const g = frameGrid(st);
const hard = isH3(st);
return { step: g.step, min: hard ? g.step + g.base : g.base, snap: hard, grid: g };
}
// Snap only where the model actually demands it (H3). On LTX the typed number is kept.
function snapFramesIfRequired(n, st, mode) {
if (isH3(st)) return snapFramesTo(n, st, mode);
return Math.max(1, Math.round(Number(n) || 0));
}
// H3 renders at a fixed 24 fps. The project's frame rate still drives the container the
// clip is muxed into, so anything else plays the generated frames at the wrong speed —
// the builder pins it, and this is what says so before the run.
@@ -283,6 +305,8 @@
isH3,
frameGrid,
snapFramesTo,
frameInputSpec,
snapFramesIfRequired,
frameRateIssue,
h3InertSettings,
effectiveSourceType,
+45 -19
View File
@@ -222,6 +222,29 @@
const dk = "sp-" + passKey;
// Steps + schedule belong to the PASS, not to the KSampler branch they first grew in:
// every sampler here walks a sigma list handed in from outside, so Distilled Flow and
// Hybrid Euler 2S can be driven by a computed schedule exactly like a stock KSampler.
// ksampler_* are the pre-split key names — read once so an older project keeps its
// schedule, then written under the shared ones.
if (cfg.scheduler === undefined) cfg.scheduler = cfg.ksampler_scheduler || KSAMPLER_USER_SIGMAS;
if (cfg.steps === undefined) cfg.steps = cfg.ksampler_steps === undefined ? 8 : cfg.ksampler_steps;
const scheds = ksamplerSchedulers(saveNow); // saveNow re-renders once the live list lands
const schedCur = cfg.scheduler || KSAMPLER_USER_SIGMAS;
row(container, "Schedule",
selCtrl(scheds.includes(schedCur) ? scheds : scheds.concat([schedCur]), schedCur, dk + "-sch",
(v) => { cfg.scheduler = v; saveNow(); }));
hint(container, "'" + KSAMPLER_USER_SIGMAS + "' runs the Sigmas field above exactly as typed; "
+ "any other entry COMPUTES the schedule from the step count, the way ComfyUI's "
+ "own scheduler does, and the Sigmas field is ignored until you switch back — so "
+ "you can leave a hand-written schedule parked there. Works for every sampler "
+ "below, not just KSampler.");
if (schedCur !== KSAMPLER_USER_SIGMAS) {
row(container, "Steps",
intCtrl(cfg.steps, 1, 200, dk + "-steps", (v) => { cfg.steps = v; save(); }));
hint(container, "8 suits the distilled LTX model; a non-distilled one wants 20-30.");
}
if (cfg.type === "Hybrid Euler 2S") {
const hc = cfg.hybrid;
sectionTag(container, "Hybrid Euler 2S");
@@ -346,25 +369,8 @@
row(container, "sampler name",
selCtrl(names.includes(cur) ? names : names.concat([cur]), cur, dk + "-ks",
(v) => { cfg.ksampler_name = v; save(); }));
const scheds = ksamplerSchedulers(saveNow);
const schedCur = cfg.ksampler_scheduler || KSAMPLER_USER_SIGMAS;
row(container, "schedule",
selCtrl(scheds.includes(schedCur) ? scheds : scheds.concat([schedCur]), schedCur, dk + "-kssch",
(v) => { cfg.ksampler_scheduler = v; saveNow(); }));
hint(container, "A KSampler is only the step function — it carries no schedule. '"
+ KSAMPLER_USER_SIGMAS + "' runs the Sigmas field above exactly as typed; any "
+ "other entry COMPUTES the schedule from steps, the way ComfyUI's own "
+ "scheduler does, and the Sigmas field is ignored until you switch back — so "
+ "you can leave your hand-written schedule parked there.");
if (schedCur !== KSAMPLER_USER_SIGMAS) {
const steps = cfg.ksampler_steps === undefined ? 8 : cfg.ksampler_steps;
row(container, "steps",
intCtrl(steps, 1, 200, dk + "-kss",
(v) => { cfg.ksampler_steps = v; save(); }));
hint(container, "8 suits the distilled LTX model; a non-distilled one wants 20-30.");
}
hint(container, "A KSampler is only the step function — it carries no schedule of its own, "
+ "so the Schedule / Steps above are the only thing that can give it one.");
}
}
@@ -423,6 +429,26 @@ hint(container, "Fill this in and each scene is sampled in two passes; leave it
+ "nearly pure detail work. The rest of the list sets how many steps it "
+ "gets. To make pass 1 "
+ "shorter, shorten the schedule above; nothing here cuts it short.");
// Pass 2 can be computed instead of typed, same switch as pass 1 — its SIGMAS is the
// only thing of the low pass that is wired, so a schedule chosen here IS pass 2.
if (s.low.scheduler === undefined) s.low.scheduler = s.low.ksampler_scheduler || KSAMPLER_USER_SIGMAS;
if (s.low.steps === undefined) s.low.steps = s.low.ksampler_steps === undefined ? 4 : s.low.ksampler_steps;
const spScheds = ksamplerSchedulers(mkSave(false));
const spSchedCur = s.low.scheduler || KSAMPLER_USER_SIGMAS;
row(container, "Second pass schedule (computed)",
selCtrl(spScheds.includes(spSchedCur) ? spScheds : spScheds.concat([spSchedCur]), spSchedCur,
"sp-second-sch", (v) => { s.low.scheduler = v; mkSave(false)(); }));
hint(container, "Leave on '" + KSAMPLER_USER_SIGMAS + "' to use the typed field above. Pick a "
+ "scheduler and pass 2 runs a computed schedule of the step count below instead "
+ "— which also TURNS THE SECOND PASS ON, whatever the field above says. A full "
+ "computed schedule starts at the model's top sigma, so pass 2 reworks the shot "
+ "rather than polishing it; for a polish, type the short low-sigma list above "
+ "and leave this alone.");
if (spSchedCur !== KSAMPLER_USER_SIGMAS) {
row(container, "Second pass steps",
intCtrl(s.low.steps, 1, 200, "sp-second-steps", (v) => { s.low.steps = v; mkSave(true)(); }));
}
}
window.SamplerPanel = { render, defaultSamplers, defaultPass };
+195 -36
View File
@@ -1111,6 +1111,138 @@ def _alg_blur_frames(model, latent_image, kappa, frame_indices=(), tail_count=0)
return None
def _alg_prepare(model, extra_args, anchor_on, anchor_strength, tail_frames, tail_strength):
"""Precompute the ALG latent variants for this run.
Returns ``(sharp, latents, anchor_on, tail_on)``. `latents` is keyed by
``(anchor blurred?, tail blurred?)`` — one entry per combination that can occur; the two
frame sets are disjoint, so the both-blurred variant just composes the two. Anchor and
guide-tail blur are fully independent (own strength, own sigma window), which is why the
key is a pair rather than a single flag. Both returned flags are False when ALG cannot
run at all: no real anchor (no denoise_mask), no latent_image, or a packed layout
`_alg_blur_frames` refuses to guess at.
Shared by the in-loop version (Distilled Flow) and the denoiser-proxy version that gives
every other sampler the same behaviour — one place, so the two can't drift.
"""
tail_frames = max(0, int(tail_frames))
anchor_on = bool(anchor_on)
tail_on = tail_frames > 0
sharp = getattr(model, "latent_image", None) if (anchor_on or tail_on) else None
if not (anchor_on or tail_on) or sharp is None or (extra_args or {}).get("denoise_mask") is None:
return sharp, {}, False, False
anchor_blurred = _alg_blur_frames(
model, sharp, max(1.0, float(anchor_strength)), frame_indices=(0,),
) if anchor_on else None
tail_kappa = max(1.0, float(tail_strength))
tail_blurred = _alg_blur_frames(
model, sharp, tail_kappa, tail_count=tail_frames,
) if tail_on else None
both_blurred = _alg_blur_frames(
model, anchor_blurred, tail_kappa, tail_count=tail_frames,
) if (anchor_blurred is not None and tail_blurred is not None) else None
anchor_on = anchor_on and anchor_blurred is not None
tail_on = tail_on and tail_blurred is not None
latents = {
(False, False): sharp,
(True, False): anchor_blurred,
(False, True): tail_blurred,
# both → anchor → tail → None. Explicit None checks, NOT `anchor_blurred or
# tail_blurred`: `or` calls bool() on the first operand, which is a multi-element
# tensor when anchor blur is on but tail blur is off (both_blurred is then None) —
# "Boolean value of Tensor is ambiguous". This value is only a defensive fallback:
# when (True, True) is actually indexed, anchor_on and tail_on are both True, so
# both_blurred is non-None and the chain never reaches the singles.
(True, True): (both_blurred if both_blurred is not None
else anchor_blurred if anchor_blurred is not None
else tail_blurred),
}
return sharp, latents, anchor_on, tail_on
class _ALGDenoiser:
"""Denoiser proxy that runs ALG's blurred/sharp swap for a sampler we can't get inside.
ALG is a per-step swap of the pinned latent_image, and the swap is decided by ONE thing:
the sigma of the step. That sigma is an argument of every model call, so the schedule
does not need the sampler's loop at all — it only needed it in the original implementation
because that is where the code happened to live. Driving it from here makes ALG work with
a stock KSampler (any sampler_name), with Hybrid Euler 2S, and with multi-eval samplers
such as heun, where each evaluation gets the anchor its own sigma calls for.
Transparent for everything else: attribute access forwards to the wrapped denoiser.
"""
_OWN = ("_inner", "_latents", "_anchor_on", "_tail_on", "_anchor_thr", "_tail_thr")
def __init__(self, inner, latents, anchor_on, tail_on, anchor_thr, tail_thr):
self._inner = inner
self._latents = latents
self._anchor_on = anchor_on
self._tail_on = tail_on
self._anchor_thr = float(anchor_thr)
self._tail_thr = float(tail_thr)
def __getattr__(self, name):
return getattr(self._inner, name)
def __setattr__(self, name, value):
if name in _ALGDenoiser._OWN:
object.__setattr__(self, name, value)
else: # e.g. samplers assigning model.latent_image
setattr(self._inner, name, value)
def __call__(self, x, sigma, **kwargs):
try:
s = float(sigma.flatten()[0]) if hasattr(sigma, "flatten") else float(sigma)
self._inner.latent_image = self._latents[(
self._anchor_on and s > self._anchor_thr,
self._tail_on and s > self._tail_thr,
)]
except Exception:
pass # never cost the step over the guidance
return self._inner(x, sigma, **kwargs)
def _alg_wrap_sampler(sampler, anchor_on, anchor_strength, anchor_threshold,
tail_frames, tail_strength, tail_threshold):
"""A SAMPLER equivalent to `sampler` with ALG applied around its model.
Returns None when the sampler exposes no `sampler_function` to wrap — some SAMPLER
producers are not KSAMPLER-shaped, and guessing at their internals is not worth it.
"""
fn = getattr(sampler, "sampler_function", None)
if fn is None:
return None
def _wrapped(model, x, sigmas, extra_args=None, callback=None, disable=None, **options):
sharp, latents, a_on, t_on = _alg_prepare(
model, extra_args, anchor_on, anchor_strength, tail_frames, tail_strength)
if not (a_on or t_on): # no anchor, or a layout we won't guess at — run untouched
return fn(model, x, sigmas, extra_args=extra_args, callback=callback,
disable=disable, **options)
print(f"[FunPack AV] ALG on (anchor: "
f"{f'strength={anchor_strength}, sigma_threshold={anchor_threshold}' if a_on else 'off'}; "
f"guide tail: "
f"{f'{tail_frames} frame(s), strength={tail_strength}, sigma_threshold={tail_threshold}' if t_on else 'off'}) "
f"— driven from outside {getattr(fn, '__name__', 'the sampler')}'s loop, off the "
f"sigma of each model call")
proxy = _ALGDenoiser(model, latents, a_on, t_on, anchor_threshold, tail_threshold)
try:
return fn(proxy, x, sigmas, extra_args=extra_args, callback=callback,
disable=disable, **options)
finally:
model.latent_image = sharp
_wrapped.__name__ = getattr(fn, "__name__", "sampler") + "_alg"
return comfy.samplers.KSAMPLER(
_wrapped,
extra_options=getattr(sampler, "extra_options", None) or {},
inpaint_options=getattr(sampler, "inpaint_options", None) or {},
)
class _JoyAIMemoryBank:
"""JoyAI-Echo cross-shot memory: a rolling set of clean prior-shot latent frames.
@@ -2061,40 +2193,11 @@ def sample_funpack_distilled_flow(model, x, sigmas, extra_args=None, callback=No
# 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_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:
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,
# both → anchor → tail → None. Explicit None checks, NOT `anchor_blurred or
# tail_blurred`: `or` calls bool() on the first operand, which is a multi-element
# tensor when anchor blur is on but tail blur is off (both_blurred is then None) —
# "Boolean value of Tensor is ambiguous". This value is only a defensive fallback:
# when (True, True) is actually indexed, alg_anchor_on and alg_tail_on are both
# True, so both_blurred is non-None and the chain never reaches the singles.
(True, True): (both_blurred if both_blurred is not None
else anchor_blurred if anchor_blurred is not None
else tail_blurred),
}
alg_active = alg_anchor_on or alg_tail_on
alg_sharp_latent_image, alg_latents, alg_anchor_on, alg_tail_on = _alg_prepare(
model, extra_args, bool(alg_enabled), alg_strength,
alg_guide_tail_frames, alg_guide_blur_strength,
)
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")
@@ -2601,7 +2704,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), 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.",
"tooltip": "EXPERIMENTAL: extends ALG (see alg_anchor) 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 anchor blur off (anchor stays sharp), with its own alg_guide_blur_strength / alg_guide_blur_sigma_threshold controls below. Works with ANY wired sampler — inside the loop on FunPack Distilled Flow, and through a denoiser proxy (same sigma schedule, same result) on everything else. No effect if no guide frames were appended this scene.",
}),
"bounded_attention_enabled": ("BOOLEAN", {
"default": False,
@@ -2766,6 +2869,18 @@ class FunPackLTXAVSceneChainSampler:
"default": False,
"tooltip": "EXPERIMENTAL, MiniMax H3 only: integrate the audio stream on its OWN flow schedule. H3 denoises video and audio on two different schedules (the MiniMax H3 Sigma Shift node's shift_video 12 / shift_audio 3), but only one sigma grid reaches a sampler, so the model reconciles them by scaling the audio velocity by the slope between the two schedules at the START of each step. That is exact for infinitesimal steps and increasingly wrong as steps get bigger: on a 4-step schedule the last step drives audio roughly 2.5x past where its own schedule puts it, which is heard as distortion. This replaces that start-of-step slope with the one that actually spans the step, so audio lands where its schedule says. Costs one scalar multiply per step — no extra model call. Aimed at few-step schedules (turbo/distilled LoRAs, 4-8 steps), where nothing else fixes this. NO-OP when shift_video and shift_audio are EQUAL — the streams are then on one schedule and there is nothing to correct (it says so on the console). SAMPLER MATTERS, measured against a perfect predictor so the numbers are pure schedule error (audio error as a % of the stream's full range, 4/8/20 steps): WORKS BEST — FunPack Distilled Flow and Hybrid Euler 2S (runs inside their step loop, exact), and stock `euler` (85/38/14% -> 0/0/0%, exact at every step count). PERFORMS POORLY — the higher-order multistep family (`res_multistep`, `dpmpp_2m`, `gradient_estimation`, `ipndm`, `lms`, `deis`): they already absorb most of this error themselves, so the clock helps them at 4 steps (69% -> 21%) but HURTS at 20 (1% -> 15%); leave it off there. NO EFFECT — two-evals-per-step samplers (`heun`, `dpm_2`, `dpmpp_2s_ancestral`, `dpmpp_sde`, `seeds_2`): a model call cannot be tied to a step from outside their loop, so the wrapper detects that on the first call and switches itself off with a console note rather than guessing. Ancestral/SDE samplers additionally add noise to the audio stream, which this does not address. The clock never touches the video stream directly, though on H3 the two share one attention sequence, so a changed audio latent can still shift the video slightly.",
}),
"alg_anchor": ("BOOLEAN", {
"default": False,
"tooltip": "EXPERIMENTAL: run ALG's i2v anchor blur (arXiv:2506.08456) on WHATEVER sampler is wired — a stock KSampler with any sampler_name, Hybrid Euler 2S, a two-evals-per-step sampler like heun, anything. The blur de-statics an anchored scene by hiding the anchor's high-frequency detail during the near-pure-noise steps, so the model cannot shortcut to a video that just matches the still. It is the same guidance as the FunPack Distilled Flow sampler's own alg_enabled, and this switch drives that one too when Distilled Flow is the wired sampler, so there is one control wherever you are. The swap is decided by the step's sigma alone, which is an argument of every model call, so it does not need to run inside a sampler's loop. No effect on a scene with no i2v anchor.",
}),
"alg_anchor_strength": ("FLOAT", {
"default": 2.0, "min": 1.0, "max": 4.0, "step": 0.1,
"tooltip": "Downsample factor for the anchor blur (alg_anchor). Paper default is 2.5; 2.0 held character/i2v consistency noticeably better in testing here. Higher = blurrier anchor during the affected steps.",
}),
"alg_anchor_sigma_threshold": ("FLOAT", {
"default": 0.975, "min": 0.5, "max": 0.999, "step": 0.005,
"tooltip": "The anchor stays blurred while sigma is above this value (the near-pure-noise steps), then swaps to sharp. Higher = narrower blurred window. Independent of the guide-frame window (alg_guide_blur_sigma_threshold).",
}),
# A connection socket, never a widget — safe at the end, and it must stay after
# every widget above (see the widgets_values note at the top of this block).
"second_pass_sigmas": ("SIGMAS", {
@@ -3364,6 +3479,8 @@ 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,
alg_anchor=False, alg_anchor_strength=2.0,
alg_anchor_sigma_threshold=0.975,
bounded_attention_enabled=False, h3_audio_clock=False):
if sampler is None:
raise ValueError("sampler input is required.")
@@ -3385,10 +3502,19 @@ class FunPackLTXAVSceneChainSampler:
# right before this call (and always resetting it, even to 0) is safe and chunk-scoped,
# with no risk of leaking into the next chunk or a differently-configured sampler.
extra_options = getattr(sampler, "extra_options", None)
if isinstance(extra_options, dict) and sampler.sampler_function is sample_funpack_distilled_flow:
_wired_fn = getattr(sampler, "sampler_function", None)
_alg_in_sampler = _wired_fn is sample_funpack_distilled_flow
if isinstance(extra_options, dict) and _alg_in_sampler:
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)
# alg_anchor is the same guidance as this sampler's own alg_enabled, so on
# Distilled Flow it drives that rather than a second, competing blur. Only when
# ON — off leaves the sampler node's own setting exactly as the user left it.
if alg_anchor:
extra_options["alg_enabled"] = True
extra_options["alg_strength"] = float(alg_anchor_strength)
extra_options["alg_sigma_threshold"] = float(alg_anchor_sigma_threshold)
# h3_audio_clock reaches a FunPack sampler the same way, but it belongs to every one
# of them that can run on H3 (see _AUDIO_CLOCK_SAMPLERS), not just this one.
if isinstance(extra_options, dict) and getattr(sampler, "sampler_function", None) in _AUDIO_CLOCK_SAMPLERS:
@@ -3420,6 +3546,27 @@ class FunPackLTXAVSceneChainSampler:
print("[FunPackSceneChain] h3_audio_clock is on, but the wired SAMPLER exposes "
"no sampler_function to wrap, so the correction cannot be applied to it. "
"Sampling continues without it.")
# EXPERIMENTAL ALG on a sampler whose loop we cannot get inside. ALG lived in the
# Distilled Flow loop only because that is where it was written: the blurred/sharp
# swap is decided by the step's sigma, and sigma is an argument of every model call,
# so a denoiser proxy can drive it from outside any sampler. That is what makes the
# anchor blur (and the guide-tail blur, same mechanism) available with a stock
# KSampler, with Hybrid Euler 2S, and with two-evals-per-step samplers. Wrapped LAST
# so h3_audio_clock above still sees the real sampler function and takes its exact
# in-loop path where it has one.
_alg_tail = int(alg_guide_tail_frames)
if not _alg_in_sampler and (alg_anchor or _alg_tail > 0):
_alg_wrapped = _alg_wrap_sampler(
sampler, bool(alg_anchor), alg_anchor_strength, alg_anchor_sigma_threshold,
_alg_tail, alg_guide_blur_strength, alg_guide_blur_sigma_threshold,
)
if _alg_wrapped is not None:
sampler = _alg_wrapped
elif not self._alg_unreachable_noted:
self._alg_unreachable_noted = True
print("[FunPackSceneChain] ALG is on, but the wired SAMPLER exposes no "
"sampler_function to wrap, so the anchor/guide blur cannot be applied "
"to it. Sampling continues without it.")
# EXPERIMENTAL Bounded Attention: model-level attention hooks (sampler-agnostic, unlike
# the toggles above which only work on Distilled Flow), so install/remove here rather
# than via extra_options. Cheap to attempt (no-ops fast without the right metadata).
@@ -4533,6 +4680,7 @@ class FunPackLTXAVSceneChainSampler:
_h3_ref_cache: dict = {}
_h3_mode_noted = False
_h3_clock_unreachable_noted = False
_alg_unreachable_noted = False
def _h3_add_keyframes(self, conditioning, pins, frame_count, aug=None):
"""Merge keyframe pins into a conditioning, keeping any already attached.
@@ -5854,6 +6002,7 @@ class FunPackLTXAVSceneChainSampler:
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,
alg_anchor=False, alg_anchor_strength=2.0, alg_anchor_sigma_threshold=0.975,
identity_transfer_enabled=False, identity_projector="None", source_id=2.0,
phase_scale=1.0, id_strength=1.0, arcface_mode="auto_adjust", debug_log=False,
carry_overlap_through_anchor=False,
@@ -5902,6 +6051,7 @@ class FunPackLTXAVSceneChainSampler:
self._h3_ref_cache = {}
self._h3_mode_noted = False
self._h3_clock_unreachable_noted = False
self._alg_unreachable_noted = False
if self._is_h3:
print("[FunPackSceneChain] MiniMax H3 detected — audio stream time axis is the last "
"dim, frame grid is 17k+5, conditioning is a single packed self-attention "
@@ -6005,6 +6155,8 @@ class FunPackLTXAVSceneChainSampler:
alg_blur_guides=alg_blur_guides,
alg_guide_blur_strength=alg_guide_blur_strength,
alg_guide_blur_sigma_threshold=alg_guide_blur_sigma_threshold,
alg_anchor=alg_anchor, alg_anchor_strength=alg_anchor_strength,
alg_anchor_sigma_threshold=alg_anchor_sigma_threshold,
bounded_attention_enabled=bounded_attention_enabled,
output_guidance=output_guidance, output_guidance_strength=output_guidance_strength,
dynashift=dynashift, dynashift_strength=dynashift_strength,
@@ -6502,6 +6654,9 @@ class FunPackLTXAVSceneChainSampler:
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,
alg_anchor=alg_anchor,
alg_anchor_strength=alg_anchor_strength,
alg_anchor_sigma_threshold=alg_anchor_sigma_threshold,
bounded_attention_enabled=bounded_attention_enabled,
h3_audio_clock=h3_audio_clock,
)
@@ -7015,6 +7170,8 @@ class FunPackLTXAVSceneChainSampler:
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,
alg_anchor=False, alg_anchor_strength=2.0,
alg_anchor_sigma_threshold=0.975,
plateau_cache=False, plateau_cache_threshold=0.975,
context_windows=False, context_window_length=145,
context_window_overlap=40, context_window_schedule="standard_uniform",
@@ -7071,6 +7228,8 @@ class FunPackLTXAVSceneChainSampler:
alg_blur_guides=alg_blur_guides,
alg_guide_blur_strength=alg_guide_blur_strength,
alg_guide_blur_sigma_threshold=alg_guide_blur_sigma_threshold,
alg_anchor=alg_anchor, alg_anchor_strength=alg_anchor_strength,
alg_anchor_sigma_threshold=alg_anchor_sigma_threshold,
bounded_attention_enabled=bounded_attention_enabled,
output_guidance=output_guidance, output_guidance_strength=output_guidance_strength,
dynashift=dynashift, dynashift_strength=dynashift_strength,
+139
View File
@@ -176,3 +176,142 @@ def test_alg_blur_is_deterministic():
out1 = samplers._alg_blur_frames(model, latent_image, 2.5, frame_indices=(0,), tail_count=1)
out2 = samplers._alg_blur_frames(model, latent_image, 2.5, frame_indices=(0,), tail_count=1)
assert torch.equal(out1, out2)
# --- ALG outside a FunPack sampler's loop --------------------------------------------
#
# ALG's blurred/sharp swap is decided by ONE thing: the sigma of the step. Sigma is an
# argument of every model call, so the swap does not need the sampler's loop — it only
# lived there because that is where the code was written. These cover the proxy that gives
# the same behaviour to a stock KSampler (any sampler_name), to Hybrid Euler 2S, and to
# multi-eval samplers, where each evaluation gets the anchor its own sigma calls for.
class _RecordingDenoiser:
"""Stands in for comfy's CFGGuider denoiser: has latent_image, records what it saw."""
def __init__(self, latent_image):
self.latent_image = latent_image
self.seen = []
def __call__(self, x, sigma, **kwargs):
self.seen.append((float(sigma), self.latent_image))
return x
def _alg_model():
m = _RecordingDenoiser(_packed_latent_image())
m.inner_model = _fake_model().inner_model
return m
def test_alg_prepare_is_a_no_op_without_a_denoise_mask():
"""No mask = no pinned anchor to blur; ALG must stand down rather than blur a frame the
model is generating from scratch."""
m = _alg_model()
_, _, anchor_on, tail_on = samplers._alg_prepare(m, {}, True, 2.0, 0, 2.0)
assert (anchor_on, tail_on) == (False, False)
def test_alg_prepare_reports_both_halves_independently():
m = _alg_model()
sharp, latents, anchor_on, tail_on = samplers._alg_prepare(
m, {"denoise_mask": torch.ones(1)}, True, 2.0, 1, 2.0)
assert (anchor_on, tail_on) == (True, True)
assert torch.equal(latents[(False, False)], sharp)
for key in ((True, False), (False, True), (True, True)):
assert not torch.equal(latents[key], sharp)
def test_alg_proxy_swaps_the_anchor_on_the_sigma_of_each_call():
m = _alg_model()
sharp = m.latent_image
_, latents, anchor_on, tail_on = samplers._alg_prepare(
m, {"denoise_mask": torch.ones(1)}, True, 2.0, 0, 2.0)
proxy = samplers._ALGDenoiser(m, latents, anchor_on, tail_on, 0.9, 0.9)
x = torch.zeros(1)
proxy(x, torch.tensor(0.95)) # above the threshold -> blurred
proxy(x, torch.tensor(0.5)) # below -> sharp again
assert not torch.equal(m.seen[0][1], sharp)
assert torch.equal(m.seen[1][1], sharp)
def test_alg_proxy_forwards_unknown_attributes_and_assignments():
m = _alg_model()
m.inner_model = "INNER"
proxy = samplers._ALGDenoiser(m, {}, False, False, 0.9, 0.9)
assert proxy.inner_model == "INNER"
proxy.latent_image = "REPLACED" # samplers assign this; it must reach the real model
assert m.latent_image == "REPLACED"
# --- reaching the sampler from the Chain Sampler node ---------------------------------
def _chain_node():
"""The Chain Sampler with just enough comfy surface stubbed for _sample_chunk."""
sys.modules["comfy.sample"].prepare_noise = lambda samples, seed: torch.zeros_like(samples)
sys.modules["comfy.samplers"].KSAMPLER = lambda fn, extra_options=None, inpaint_options=None: (
types.SimpleNamespace(sampler_function=fn, extra_options=extra_options or {},
inpaint_options=inpaint_options or {}))
return samplers.FunPackLTXAVSceneChainSampler()
def _fake_sampler(fn):
return types.SimpleNamespace(sampler_function=fn, extra_options={}, inpaint_options={})
def _run_chunk(sampler, **kw):
"""Run _sample_chunk against a stubbed sample_custom; return the sampler it was handed."""
seen = {}
def _sample_custom(model, noise, cfg, smp, sigmas, positive, negative, samples, **kwargs):
seen["sampler"] = smp
return samples
sys.modules["comfy.sample"].sample_custom = _sample_custom
latent = {"samples": torch.zeros(1, 2, 2, 2, 2)}
_chain_node()._sample_chunk(object(), sampler, torch.tensor([1.0, 0.0]), 0, 1.0,
[], [], latent, **kw)
return seen["sampler"]
def test_a_stock_ksampler_gets_alg_through_the_wrapper():
"""The point of the proxy: ALG used to be unreachable with anything but Distilled Flow,
because it lived inside that sampler's loop."""
def euler(model, x, sigmas, **kw):
return x
out = _run_chunk(_fake_sampler(euler), alg_anchor=True)
assert out.sampler_function is not euler
assert out.sampler_function.__name__ == "euler_alg"
def test_the_wrapper_is_not_installed_when_alg_is_off():
"""Off means untouched — no proxy, no per-call work, same sampler object."""
def euler(model, x, sigmas, **kw):
return x
sampler = _fake_sampler(euler)
assert _run_chunk(sampler, alg_anchor=False) is sampler
def test_distilled_flow_drives_its_own_in_loop_alg_instead_of_being_wrapped():
"""One control, two routes: on the sampler that already implements ALG the toggle sets
its option rather than stacking a second blur on top of it."""
sampler = _fake_sampler(samplers.sample_funpack_distilled_flow)
out = _run_chunk(sampler, alg_anchor=True, alg_anchor_strength=3.0,
alg_anchor_sigma_threshold=0.9)
assert out is sampler
assert sampler.extra_options["alg_enabled"] is True
assert sampler.extra_options["alg_strength"] == 3.0
assert sampler.extra_options["alg_sigma_threshold"] == 0.9
def test_the_guide_tail_alone_also_wraps_a_foreign_sampler():
"""alg_blur_guides used to say 'requires Distilled Flow'; it no longer does."""
def euler(model, x, sigmas, **kw):
return x
out = _run_chunk(_fake_sampler(euler), alg_guide_tail_frames=2)
assert out.sampler_function.__name__ == "euler_alg"
+38 -1
View File
@@ -1,4 +1,4 @@
"""The KSampler pass builds its own schedule from steps + scheduler.
"""A pass builds its own schedule from steps + scheduler — for every sampler type.
A SAMPLER object is only the step function — it carries no schedule — so before this the
KSampler pass handed the chain sampler no SIGMAS at all unless the user typed a sigma list
@@ -135,3 +135,40 @@ def test_missing_model_is_survivable(comfy_sampler_stub):
assert sampler == "SAMPLER::euler"
assert sigmas is None
# The schedule belongs to the PASS, not to the KSampler branch it first grew in: the FunPack
# samplers walk a sigma list handed in from outside exactly like a stock KSampler does, so
# steps + scheduler have to reach them too — otherwise the only way to run Distilled Flow or
# Hybrid Euler 2S on a named schedule is to type its sigmas out by hand.
@pytest.mark.parametrize("sampler_type", ["Distilled Flow", "Hybrid Euler 2S"])
def test_funpack_samplers_take_a_computed_schedule(comfy_sampler_stub, sampler_type):
sampler, sigmas = FunPackStudio._build_one_sampler(
{"type": sampler_type, "steps": 12, "scheduler": "karras"}, model=FakeModel(),
)
assert sampler is not None
assert comfy_sampler_stub.sigmas == [("model_sampling::model_sampling", "karras", 12)]
assert sigmas is not None and len(sigmas) == 13
@pytest.mark.parametrize("sampler_type", ["Distilled Flow", "Hybrid Euler 2S"])
def test_funpack_samplers_default_to_the_typed_field(comfy_sampler_stub, sampler_type):
_, sigmas = FunPackStudio._build_one_sampler(
{"type": sampler_type, "sigmas": "0.9, 0.5, 0.0"}, model=FakeModel(),
)
assert comfy_sampler_stub.sigmas == []
assert [round(float(v), 3) for v in sigmas] == [0.9, 0.5, 0.0]
def test_the_shared_keys_win_over_the_legacy_ksampler_ones(comfy_sampler_stub):
"""`ksampler_scheduler` / `ksampler_steps` are the pre-split names, still read so a
config saved by an older UI keeps its schedule — but the shared keys are authoritative."""
FunPackStudio._build_one_sampler(
{"type": "KSampler", "steps": 20, "scheduler": "beta",
"ksampler_steps": 8, "ksampler_scheduler": "karras"},
model=FakeModel(),
)
assert comfy_sampler_stub.sigmas == [("model_sampling::model_sampling", "beta", 20)]
+4 -1
View File
@@ -462,4 +462,7 @@ def test_the_toggle_stays_behind_the_second_pass_sigmas_socket():
from samplers import FunPackLTXAVSceneChainSampler
names = list(FunPackLTXAVSceneChainSampler.INPUT_TYPES()["optional"])
assert names.index("h3_audio_clock") == names.index("second_pass_sigmas") - 1
# The socket stays LAST, and the toggle stays behind it — later widgets (alg_anchor and
# friends) append between the two, which is exactly where a new widget belongs.
assert names[-1] == "second_pass_sigmas"
assert names.index("h3_audio_clock") < names.index("second_pass_sigmas")
+27 -18
View File
@@ -1266,6 +1266,31 @@ function openPanel(node) {
body.append(el("div", "funpack-studio-hint",
"Comma-separated floats. Leave empty to let the sampler decide (pass in sigmas externally)."));
// Steps + schedule belong to the PASS, not to the KSampler branch they first grew in:
// every sampler here walks a sigma list handed in from outside, so Distilled Flow and
// Hybrid Euler 2S can be driven by a computed schedule exactly like a stock KSampler.
// ksampler_scheduler / ksampler_steps are the pre-split key names, read once so an
// existing node keeps its schedule and then written under the shared ones.
if (cfg.scheduler === undefined) cfg.scheduler = cfg.ksampler_scheduler || KSAMPLER_USER_SIGMAS;
if (cfg.steps === undefined) cfg.steps = cfg.ksampler_steps === undefined ? 8 : cfg.ksampler_steps;
const _schedCur = cfg.scheduler || KSAMPLER_USER_SIGMAS;
const schedSelect = selectEl(
KSAMPLER_SCHEDULERS.includes(_schedCur) ? KSAMPLER_SCHEDULERS : KSAMPLER_SCHEDULERS.concat([_schedCur]), _schedCur);
schedSelect.addEventListener("change", () => { cfg.scheduler = schedSelect.value; renderSampler(); });
body.append(row("schedule", schedSelect));
body.append(el("div", "funpack-studio-hint",
"'" + KSAMPLER_USER_SIGMAS + "' runs the Sigmas field above exactly as typed; any other entry "
+ "COMPUTES the schedule from the step count, the way ComfyUI's own scheduler does, and the "
+ "Sigmas field is ignored until you switch back — so you can leave a hand-written schedule "
+ "parked there. Applies to every sampler type, not just KSampler."));
if (_schedCur !== KSAMPLER_USER_SIGMAS) {
const stepsInput = numInput(cfg.steps, 1, 200, 1);
stepsInput.addEventListener("input", () => { cfg.steps = parseInt(stepsInput.value) || 0; });
body.append(row("steps", stepsInput));
body.append(el("div", "funpack-studio-hint",
"8 suits the distilled LTX model; a non-distilled one wants 20-30."));
}
if (cfg.type === "Hybrid Euler 2S") {
const hc = cfg.hybrid;
body.append(sectionTitle("Hybrid Euler 2S settings"));
@@ -1462,25 +1487,9 @@ function openPanel(node) {
KSAMPLER_NAMES.includes(_ksCur) ? KSAMPLER_NAMES : KSAMPLER_NAMES.concat([_ksCur]), _ksCur);
ksSelect.addEventListener("change", () => { cfg.ksampler_name = ksSelect.value; });
body.append(row("sampler name", ksSelect));
if (cfg.ksampler_scheduler === undefined) cfg.ksampler_scheduler = KSAMPLER_USER_SIGMAS;
const _schedCur = cfg.ksampler_scheduler || KSAMPLER_USER_SIGMAS;
const ksSched = selectEl(
KSAMPLER_SCHEDULERS.includes(_schedCur) ? KSAMPLER_SCHEDULERS : KSAMPLER_SCHEDULERS.concat([_schedCur]), _schedCur);
ksSched.addEventListener("change", () => { cfg.ksampler_scheduler = ksSched.value; renderSampler(); });
body.append(row("schedule", ksSched));
body.append(el("div", "funpack-studio-hint",
"A KSampler is only the step function — it carries no schedule. '" + KSAMPLER_USER_SIGMAS
+ "' runs the Sigmas field above exactly as typed; any other entry COMPUTES the schedule from steps, the way ComfyUI's own scheduler does, and the Sigmas field is ignored until you switch back — so you can leave your hand-written schedule parked there."));
if (_schedCur !== KSAMPLER_USER_SIGMAS) {
if (cfg.ksampler_steps === undefined) cfg.ksampler_steps = 8;
const ksSteps = numInput(cfg.ksampler_steps, 1, 200, 1);
ksSteps.addEventListener("input", () => { cfg.ksampler_steps = parseInt(ksSteps.value) || 0; });
body.append(row("steps", ksSteps));
body.append(el("div", "funpack-studio-hint",
"8 suits the distilled LTX model; a non-distilled one wants 20-30."));
}
"A KSampler is only the step function — it carries no schedule of its own, so the "
+ "schedule / steps above are the only thing that can give it one."));
}
}