diff --git a/movie_editor/frontend/engine_settings.js b/movie_editor/frontend/engine_settings.js index 566fad2b..ab31de20 100644 --- a/movie_editor/frontend/engine_settings.js +++ b/movie_editor/frontend/engine_settings.js @@ -554,24 +554,17 @@ else S.setStudioInputNow("studio_settings", next); syncSecondPassFromSchedule(updatedSamplers, quiet); } - // The second pass is driven by ONE thing here: the schedule. Typing one turns it on and - // pass 1 stops where that schedule starts; clearing it turns it off. The node's own - // enable/cut widgets still exist for graph users with no schedule wired, but exposing - // them alongside the schedule meant two controls saying the same thing in different - // words ("starts at" vs "re-enters at"), which is exactly what made it confusing. + // 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. 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 set = quiet ? S.setSamplerInput : S.setSamplerInputNow; - // Two real sigmas minimum — one number is not a schedule. - if (vals.length >= 2 && vals[0] > 0 && vals[0] < 1) { - set("second_pass", true); - set("second_pass_sigma", vals[0]); // cut == entry -> an exact hand-over, no re-noise - } else { - S.unsetSamplerInput("second_pass"); - S.unsetSamplerInput("second_pass_sigma"); - } + if (vals.length >= 2) set("second_pass", true); // one number is not a schedule + else S.unsetSamplerInput("second_pass"); } + try { window.SamplerPanel.render(box, samplers, (s) => persistSamplers(s, true), diff --git a/movie_editor/frontend/sampler_panel.js b/movie_editor/frontend/sampler_panel.js index 9902c193..3a9f364f 100644 --- a/movie_editor/frontend/sampler_panel.js +++ b/movie_editor/frontend/sampler_panel.js @@ -347,13 +347,15 @@ const spSig = textCtrl(s.low.sigmas, "e.g. 0.812, 0.6, 0.35, 0.15, 0.0", "sp-second-sig", (v) => { s.low.sigmas = v; mkSave(true)(); }); row(container, "Second pass schedule", spSig); - hint(container, "Fill this in and the scene is sampled in two passes; leave it empty and it " - + "isn't. Comma-separated floats. Pass 1 runs the main schedule down to the " - + "FIRST sigma listed here, then pass 2 takes over and runs this schedule to " - + "the end — so the second half can have its own step count and spacing. " - + "Nothing is added at the hand-over, so with a schedule that simply continues " - + "where the first left off the result matches a single pass; the cost is only " - + "whatever extra steps you list."); +hint(container, "Fill this in and each scene is sampled in two passes; leave it empty and " + + "it isn't. Comma-separated floats, high to low, ending at 0. Pass 1 " + + "runs the main Sigmas schedule above in full, then pass 2 runs THIS " + + "one in full, so the total is simply the two added up (a 9-step main " + + "plus a 4-step second pass is 13). Pass 2 starts from the finished " + + "clip and re-noises it up to your first sigma, so that value is the " + + "strength dial — near 1.0 reworks the shot, low values only polish " + + "it — and the rest sets how many steps it gets. To make pass 1 " + + "shorter, shorten the schedule above; nothing here cuts it short."); } window.SamplerPanel = { render, defaultSamplers, defaultPass }; diff --git a/samplers.py b/samplers.py index 4e08dfcc..30f62699 100644 --- a/samplers.py +++ b/samplers.py @@ -2576,15 +2576,7 @@ class FunPackLTXAVSceneChainSampler: # builder's positional reference-workflow mapping (extract_widgets) stays aligned. "second_pass": ("BOOLEAN", { "default": False, - "tooltip": "Stop the schedule partway and finish the scene in a SECOND sampling pass. Independent of anchor_shift (which runs its own two passes — when anchor_shift acts on a scene this is skipped, with a note). On its own the split is behaviour-neutral at second_pass_restart_sigma=0: pass 2 resumes from exactly the state pass 1 handed over, no noise added, same total step count, so the output matches a single pass. It becomes useful when the second half is made DIFFERENT — re-entering higher up the schedule (second_pass_restart_sigma) to let the model rework what it has, or finishing on a different schedule entirely (the second_pass_sigmas input). The i2v anchor stays pinned across the split, so the scene does not drift off its reference. Cost is exactly the extra steps you add: at restart 0 with no second schedule it is free; re-entering higher costs the replayed steps. Off by default.", - }), - "second_pass_sigma": ("FLOAT", { - "default": 0.603, "min": 0.001, "max": 0.999, "step": 0.001, - "tooltip": "Where pass 1 stops. The schedule is walked and the last pass-1 step is the one whose NEXT sigma has crossed this value, so the cut always lands on a real schedule step — only the sigmas actually in your schedule are reachable. Both halves need at least one step, so a value outside the schedule's range is refused with a reason rather than silently ignored.", - }), - "second_pass_restart_sigma": ("FLOAT", { - "default": 0.0, "min": 0.0, "max": 0.999, "step": 0.001, - "tooltip": "Where pass 2 re-enters, when no second_pass_sigmas schedule is connected. 0 (default) = carry straight on from the cut: nothing is added, total cost unchanged. A value ABOVE second_pass_sigma rewinds — the half-denoised latent is rebuilt at that higher sigma (exact for a mid-trajectory latent, not the naive img2img re-noise) and those steps run again, letting the model rework structure it has already committed to, at the cost of repeating them. BELOW the cut is refused: noise can be added back, never removed. Ignored when second_pass_sigmas is connected, since that schedule's own first sigma is the entry point.", + "tooltip": "Sample each scene in TWO passes. Pass 1 runs the main sigmas schedule in full, exactly as written; pass 2 then runs the second_pass_sigmas schedule in full, exactly as written, starting from pass 1's finished clip. Nothing is cut short and nothing is derived — to make pass 1 shorter, shorten the main schedule. Total steps are simply the two schedules added up (a 9-step main plus a 4-step second pass is 13). Pass 2 re-enters the way any img2img refine does, by re-noising the finished clip up to its first sigma, so that first sigma controls how much it is allowed to change: nearer 1.0 reworks the shot, low values only polish it. Requires a second_pass_sigmas schedule; without one the pass is skipped with a note. Independent of anchor_shift, which runs its own two passes for a different reason — when it acts on a scene, this is skipped.", }), "second_pass_op": (["none", "sharpen", "upscale_2x"], { "default": "none", @@ -2593,7 +2585,8 @@ class FunPackLTXAVSceneChainSampler: # 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", { - "tooltip": "Optional SEPARATE schedule for pass 2. Wire any scheduler here and pass 2 runs that schedule end to end instead of continuing along the main one — so the second half can have a different step count, spacing or shift from the first (e.g. a short, fine tail schedule over a coarse first pass). Its FIRST sigma is where pass 2 re-enters, so it must start at or above second_pass_sigma (the state can be re-noised back up, never un-noised down); second_pass_restart_sigma is ignored when this is connected. The sampler is told this schedule is pass 2's real schedule, so its own phases (quality steps, final-correction window, AB2 ramp) are measured against it rather than against the main one.", + "tooltip": "The schedule pass 2 runs — required for second_pass, and it is run EXACTLY as written, high to low, ending at 0. Wire any scheduler here, or type sigmas in the Editor. Pass 1 has already finished the main schedule by this point, so pass 2 starts from a clean clip and re-enters by re-noising it up to this schedule's FIRST sigma: that value is the strength dial (near 1.0 reworks the shot, low values only polish it), and the rest of the schedule sets how many steps it gets. A schedule that ascends, or that stops above 0, is refused with the reason — both would silently produce a distorted or under-denoised clip rather than fail loudly.", + }), }, "hidden": { @@ -3170,70 +3163,35 @@ class FunPackLTXAVSceneChainSampler: # which the sampler needs to keep its phase boundaries where the schedule put them. return first, second, vals[cut], vals[start], resume, start - def _second_pass_plan(self, sigmas, cut_sigma, restart_sigma, alt_sigmas=None): - """Plan a general two-pass run, optionally finishing on a DIFFERENT schedule. + def _second_pass_schedule(self, alt_sigmas): + """Validate the pass-2 schedule. Returns (sigmas, reason); one of them is None. - Returns (plan, reason). `plan` is None when the request cannot be honoured, and - `reason` then says why and what to change — never a silent no-op. + There is nothing to derive here and nothing to cut. Pass 1 runs the main schedule in + full, exactly as written; pass 2 then runs THIS schedule in full, exactly as written. + Pass 2 therefore starts from a FINISHED clip, which is the ordinary img2img re-entry + (comfy's CONST scaling, x = s*noise + (1-s)*clean, valid precisely because the input + is clean). "Continue from where pass 1 stopped" belongs to anchor_shift, which stops + mid-trajectory on purpose; it has no meaning here. To make pass 1 shorter, shorten + the main schedule. - plan = (pass1_sigmas, pass2_sigmas, cut_at, entry_at, resume, pass2_context) - - Pass 1 always runs the top of `sigmas` down to the first sigma at or below - `cut_sigma` (same walk as anchor_shift: the last pre-cut step is the one whose NEXT - sigma crosses the threshold). Pass 2 is either a slice of the same schedule starting - at `restart_sigma` (0 = continue straight on from the cut), or — when `alt_sigmas` is - connected — that schedule in its entirety, entered at its own first sigma. - - Either way pass 2 re-enters at a sigma at or above the cut, because _renoise_to_sigma - can add noise back but never remove it. `pass2_context` is what the sampler must be - told its real schedule is: the main schedule with an offset when pass 2 is a slice of - it, or the alternate schedule at offset 0 when pass 2 IS that whole schedule. + A hand-typed schedule is the one input that can be malformed, and both ways it can + be are silent in the OUTPUT rather than loud at runtime, so they are caught here. """ - if not isinstance(sigmas, torch.Tensor) or sigmas.numel() < 3: - return None, "the main schedule needs at least 3 sigmas to be split" - vals = [float(v) for v in sigmas.tolist()] - eps = 1e-4 # SIGMAS are float32; see _anchor_shift_split_sigmas for why this matters - - def _first_at_or_below(seq, s): - return next((i for i, v in enumerate(seq) if v <= s + eps), None) - - cut = _first_at_or_below(vals, float(cut_sigma)) - if cut is None or cut < 1: - return None, (f"second_pass_sigma={float(cut_sigma):g} is not reached by the schedule " - f"with a step left before it — pass 1 would have no step to run") - first = sigmas[:cut + 1] - if first.numel() < 2: - return None, "pass 1 would have no step" - cut_at = vals[cut] - - if isinstance(alt_sigmas, torch.Tensor) and alt_sigmas.numel() >= 2: - # Pass 2 runs its OWN schedule end to end. Its first sigma is where it re-enters, - # so that is what the state has to be rebuilt at. - second = alt_sigmas - entry_at = float(alt_sigmas[0].item()) - ctx = {"sigmas": alt_sigmas, "offset": 0} - if entry_at < cut_at - eps: - return None, (f"second_pass_sigmas starts at {entry_at:g}, below " - f"second_pass_sigma={cut_at:g} — pass 2 can be handed a noisier " - f"latent than pass 1 left, never a cleaner one. Start the second " - f"schedule at or above the cut, or lower second_pass_sigma") - else: - restart = float(restart_sigma) - if restart <= 0: - start = cut - else: - start = _first_at_or_below(vals, restart) - if start is None or start > cut: - return None, (f"second_pass_restart_sigma={restart:g} is below " - f"second_pass_sigma={cut_at:g} — pass 2 can be handed a noisier " - f"latent than pass 1 left, never a cleaner one. Use 0 to carry " - f"straight on from the cut, or a value at or above it") - second = sigmas[start:] - entry_at = vals[start] - ctx = {"sigmas": sigmas, "offset": start} - if second.numel() < 2: - return None, "pass 2 would have no step" - return (first, second, cut_at, entry_at, abs(entry_at - cut_at) <= eps, ctx), None + if not isinstance(alt_sigmas, torch.Tensor) or alt_sigmas.numel() < 2: + return None, ("no second-pass schedule — set one (comma-separated sigmas, high to " + "low, ending at 0) or turn the second pass off") + vals = [float(v) for v in alt_sigmas.tolist()] + eps = 1e-4 + if any(b > a + eps for a, b in zip(vals, vals[1:])): + return None, ("second-pass schedule must descend — " + f"{', '.join(f'{v:g}' for v in vals)} goes back up, which walks the " + f"trajectory backwards. List the sigmas high to low") + if vals[-1] > eps: + return None, (f"second-pass schedule ends at {vals[-1]:g}, not 0 — the scene would " + f"come out partially denoised (noise artefacts). End it at 0") + if vals[0] <= eps: + return None, "second-pass schedule starts at 0 — there is nothing for it to denoise" + return alt_sigmas, None def _second_pass_operate(self, latent, op, upsampler, vae): """Apply the chosen latent-space operation between the two passes. @@ -5282,8 +5240,7 @@ class FunPackLTXAVSceneChainSampler: anchor_shift=False, anchor_shift_sigma=0.909, anchor_shift_frames=8, anchor_shift_restart_sigma=0.0, anchor_shift_tail="extend_last", anchor_shift_fresh_audio=True, - second_pass=False, second_pass_sigma=0.603, - second_pass_restart_sigma=0.0, second_pass_op="none", second_pass_sigmas=None, + second_pass=False, second_pass_op="none", second_pass_sigmas=None, unique_id=None, prompt=None): if not isinstance(positive, list) or not positive: raise ValueError("positive conditioning must contain at least one scene entry.") @@ -5353,9 +5310,8 @@ class FunPackLTXAVSceneChainSampler: anchor_shift_restart_sigma=anchor_shift_restart_sigma, anchor_shift_tail=anchor_shift_tail, anchor_shift_fresh_audio=anchor_shift_fresh_audio, - second_pass=second_pass, second_pass_sigma=second_pass_sigma, - second_pass_restart_sigma=second_pass_restart_sigma, - second_pass_op=second_pass_op, second_pass_sigmas=second_pass_sigmas, + second_pass=second_pass, second_pass_op=second_pass_op, + second_pass_sigmas=second_pass_sigmas, ) max_scene_count = max(1, int(max_scenes)) @@ -5934,24 +5890,31 @@ class FunPackLTXAVSceneChainSampler: # General second pass. Reaching this branch means anchor_shift did not act # on this scene (it is either off, or was skipped with a reason above), so # the two features never nest — they stay independent by construction. - _sp_plan = None + # + # The shape is deliberately plain: pass 1 runs the main schedule IN FULL, + # exactly as written, then pass 2 runs its own schedule IN FULL, exactly as + # written. Nothing is cut and nothing is "continued from" — those belong to + # anchor_shift, which stops mid-trajectory on purpose. To make pass 1 + # shorter, shorten the main schedule. Pass 2 therefore starts from a + # FINISHED clip, so it re-enters through comfy's ordinary CONST noise + # scaling (continue_from_state stays off) — valid precisely because the + # input is clean. Total steps are simply the two schedules added up. + _sp_b = None if second_pass: - _sp_plan, _sp_reason = self._second_pass_plan( - sigmas, second_pass_sigma, second_pass_restart_sigma, - second_pass_sigmas) - if _sp_plan is None: + _sp_b, _sp_reason = self._second_pass_schedule(second_pass_sigmas) + if _sp_b is None: run_mechanisms.append(f"second_pass(SKIPPED: {_sp_reason})") - if _sp_plan is not None: - _sp_a, _sp_b, _sp_cut, _sp_entry, _sp_resume, _sp_ctx = _sp_plan - _sp_p1 = self._sample_chunk( - model, sampler, _sp_a, scene_seed, cfg, scene_positive, - scene_negative, chunk, - schedule_context={"sigmas": sigmas, "offset": 0}, **_sample_kwargs) - _sp_state = _sp_p1 + _full = self._sample_chunk( + model, sampler, sigmas, scene_seed, cfg, scene_positive, + scene_negative, chunk, **_sample_kwargs) + if _sp_b is None: + sampled = _full + else: + _sp_state = _full if second_pass_op not in (None, "none", ""): - # Operate BEFORE the re-noise: sharpening a latent that has just had - # noise added back would sharpen the noise. Shares segmented - # detailing's lazily-loaded upsampler — same model, same knob. + # The op runs on the FINISHED pass-1 clip, which is the only state + # it makes sense on: sharpening a half-denoised latent sharpens the + # noise in it. Shares segmented detailing's lazily-loaded upsampler. if _detail_upsampler_model is None and _detail_disabled_reason is None: try: try: @@ -5970,17 +5933,12 @@ class FunPackLTXAVSceneChainSampler: run_mechanisms.append( f"second_pass_op={second_pass_op} skipped: upsampler " f"unavailable ({_detail_disabled_reason})") - if not _sp_resume: - # Rebuild the state at the higher entry sigma. Fresh field: reusing - # scene_seed would re-apply the exact noise pass 1 just resolved. - _sp_state = self._renoise_to_sigma( - _sp_state, _sp_cut, _sp_entry, scene_seed + 4242) # Unlike anchor_shift, a plain second pass must KEEP the i2v anchor # pinned — otherwise pass 2 re-denoises the reference frame and the # scene drifts away from the image it was supposed to start from. _sp_before = _sp_state _sp_state = self._restore_pinned_prefix(_sp_state, chunk) - if (_sp_state is _sp_before and self._anchor_pinned_frames(chunk) > 0): + if _sp_state is _sp_before and self._anchor_pinned_frames(chunk) > 0: # Only happens when the op changed the resolution — say so, because # an unpinned pass 2 can drift off the reference image. run_mechanisms.append( @@ -5988,23 +5946,15 @@ class FunPackLTXAVSceneChainSampler: "because second_pass_op changed the latent resolution — pass 2 runs " "unpinned and may drift from the reference image.") _sp_kw = dict(_sample_kwargs) - _sp_kw["step_offset"] = _sample_kwargs["step_offset"] + (_sp_a.numel() - 1) - _sp_kw["continue_from_state"] = True - _sp_kw["schedule_context"] = _sp_ctx + _sp_kw["step_offset"] = _sample_kwargs["step_offset"] + (int(sigmas.numel()) - 1) sampled = self._sample_chunk( model, sampler, _sp_b, scene_seed + 4242, cfg, scene_positive, scene_negative, _sp_state, **_sp_kw) - _sp_alt = (isinstance(second_pass_sigmas, torch.Tensor) - and second_pass_sigmas.numel() >= 2) run_mechanisms.append( - f"second_pass({'continue' if _sp_resume else 'rewind'} " - f"cut@{_sp_cut:g} entry@{_sp_entry:g}, " - f"{_sp_a.numel() - 1}+{_sp_b.numel() - 1} steps" - f"{', own schedule' if _sp_alt else ''})") - else: - sampled = self._sample_chunk( - model, sampler, sigmas, scene_seed, cfg, scene_positive, - scene_negative, chunk, **_sample_kwargs) + f"second_pass({int(sigmas.numel()) - 1} steps + " + f"{int(_sp_b.numel()) - 1} steps = " + f"{int(sigmas.numel()) + int(_sp_b.numel()) - 2} total, " + f"pass 2 from {float(_sp_b[0].item()):g})") _scene_sample_s = _time.perf_counter() - _t_sample0 _phase_sampling += _scene_sample_s if _plateau_stats is not None: @@ -6043,7 +5993,7 @@ class FunPackLTXAVSceneChainSampler: if _detail_upsampler_model is None: _resolved = _detailing.resolve_upsampler_name(detail_upsampler) _detail_upsampler_model = _detailing.load_latent_upsampler(_resolved) - print(f"[FunPackSceneChain] segmented detailing upsampler: {_resolved}") + print(f"[FunPackSceneChain] latent upsampler loaded: {_resolved}") _t_detail0 = _time.perf_counter() sampled, _detail_note = _detailing.detail_refine_scene( self, model, vae, sampler, scene_positive, scene_negative, sampled, @@ -6345,8 +6295,7 @@ class FunPackLTXAVSceneChainSampler: anchor_shift=False, anchor_shift_sigma=0.909, anchor_shift_frames=8, anchor_shift_restart_sigma=0.0, anchor_shift_tail="extend_last", anchor_shift_fresh_audio=True, - second_pass=False, second_pass_sigma=0.603, - second_pass_restart_sigma=0.0, second_pass_op="none", + second_pass=False, second_pass_op="none", second_pass_sigmas=None): """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. @@ -6412,9 +6361,8 @@ class FunPackLTXAVSceneChainSampler: anchor_shift_restart_sigma=anchor_shift_restart_sigma, anchor_shift_tail=anchor_shift_tail, anchor_shift_fresh_audio=anchor_shift_fresh_audio, - second_pass=second_pass, second_pass_sigma=second_pass_sigma, - second_pass_restart_sigma=second_pass_restart_sigma, - second_pass_op=second_pass_op, second_pass_sigmas=second_pass_sigmas, + second_pass=second_pass, second_pass_op=second_pass_op, + second_pass_sigmas=second_pass_sigmas, unique_id=None, prompt=None, ) last = out diff --git a/tests/test_second_pass.py b/tests/test_second_pass.py index 10a5bd7b..b8d08515 100644 --- a/tests/test_second_pass.py +++ b/tests/test_second_pass.py @@ -4,14 +4,15 @@ anchor_shift built the machinery (stop the schedule, hand the state over, re-ent this exposes it on its own so a scene can be finished differently from how it was started. The invariants that matter here are the ones a split can silently get wrong: -1. Pass 1 stops on a REAL step of the user's schedule, found by walking it. -2. Pass 2 re-enters at or above the cut — never below, because noise can be added back but - never removed (the same rule anchor_shift's rewind lives by). -3. A connected second schedule replaces pass 2 wholesale, and its own first sigma becomes - the entry point, so the sampler's phases are measured against IT and not the main one. -4. Nothing is ever a silent no-op: every refusal comes back with a reason naming the fix. -5. The i2v anchor stays PINNED across the split — the opposite of anchor_shift, which - deletes the pin on purpose. Without this pass 2 re-denoises the reference frame. +1. BOTH schedules run in full, exactly as written. Pass 1 is not cut short and pass 2 is + not derived from it — total steps are simply the two added up. (An earlier version cut + pass 1 at pass 2's first sigma, which is anchor_shift's behaviour and does not belong + here; it silently shortened the run and distorted the result.) +2. A hand-typed schedule is the one malformed-able input, and both ways it can be wrong + are silent in the OUTPUT rather than loud at runtime, so they are refused up front. +3. Nothing is ever a silent no-op: every refusal comes back with a reason naming the fix. +4. The i2v anchor stays PINNED for pass 2 — the opposite of anchor_shift, which deletes + the pin on purpose. Without this pass 2 re-denoises the reference frame. """ import sys import types @@ -44,88 +45,50 @@ def _node(): return samplers.FunPackLTXAVSceneChainSampler() -def _plan(cut, restart=0.0, alt=None): - return _node()._second_pass_plan(SCHEDULE, cut, restart, alt) +# ── the pass-2 schedule ───────────────────────────────────────────────────── + +def _sched(alt): + return _node()._second_pass_schedule(alt) -# ── the split ─────────────────────────────────────────────────────────────── - -def test_continue_splits_the_schedule_without_adding_a_step(): - """restart 0 is behaviour-neutral: the two halves are the whole schedule, cut once, and - pass 2 resumes from the exact handed-over state (resume=True -> no re-noise).""" - plan, reason = _plan(0.603) - assert reason is None - first, second, cut_at, entry_at, resume, ctx = plan - assert [round(v, 3) for v in first.tolist()] == [1.0, 0.955, 0.893, 0.812, 0.715, 0.603] - assert [round(v, 3) for v in second.tolist()] == [0.603, 0.482, 0.241, 0.121, 0.0] - assert abs(cut_at - 0.603) < 1e-5 and abs(entry_at - 0.603) < 1e-5 - assert resume is True - # No step is run twice: 5 + 4 == the schedule's own 9. - assert (first.numel() - 1) + (second.numel() - 1) == SCHEDULE.numel() - 1 - # Pass 2's phases must still be measured against the whole schedule. - assert ctx["sigmas"] is SCHEDULE and ctx["offset"] == 5 +def test_a_well_formed_schedule_is_taken_exactly_as_written(): + """Nothing is derived, cut or continued. Pass 1 runs the main schedule in full and pass 2 + runs this one in full, so on the user's 9-step main a 4-step second pass is 13 steps — + not a re-slicing of the main schedule.""" + alt = torch.tensor([0.812, 0.6, 0.35, 0.15, 0.0]) + out, reason = _sched(alt) + assert reason is None and out is alt + assert (SCHEDULE.numel() - 1) + (out.numel() - 1) == 13 -def test_rewind_re_enters_higher_and_replays_those_steps(): - plan, reason = _plan(0.482, restart=0.812) - assert reason is None - first, second, cut_at, entry_at, resume, ctx = plan - assert [round(v, 3) for v in first.tolist()] == [1.0, 0.955, 0.893, 0.812, 0.715, 0.603, 0.482] - assert [round(v, 3) for v in second.tolist()] == [0.812, 0.715, 0.603, 0.482, 0.241, 0.121, 0.0] - assert resume is False # the caller must rebuild the state at 0.812 first - assert ctx["offset"] == 3 +def test_an_ascending_schedule_is_refused(): + """Both malformed cases are silent in the OUTPUT rather than loud at runtime, so they + have to be caught before sampling. An ascending pair walks the trajectory backwards.""" + out, reason = _sched(torch.tensor([0.812, 0.4, 0.55, 0.0])) + assert out is None and "must descend" in reason -def test_restart_below_the_cut_is_refused_with_the_fix_named(): - plan, reason = _plan(0.812, restart=0.482) - assert plan is None - assert "never a cleaner one" in reason and "second_pass_restart_sigma" in reason +def test_a_schedule_that_stops_short_of_zero_is_refused(): + """Stopping above 0 leaves the clip partially denoised — the noise-artefact symptom.""" + out, reason = _sched(torch.tensor([0.812, 0.5, 0.25])) + assert out is None and "not 0" in reason -def test_a_cut_outside_the_schedule_is_refused_not_ignored(): - for cut in (1.0, 0.9999, 0.0005): - plan, reason = _plan(cut) - assert plan is None and reason, cut +def test_equal_neighbouring_sigmas_are_not_treated_as_ascending(): + out, reason = _sched(torch.tensor([0.812, 0.6, 0.6, 0.0])) + assert reason is None and out is not None -# ── a separate schedule for pass 2 ────────────────────────────────────────── - -def test_a_connected_schedule_replaces_pass_two_wholesale(): - """The point of the feature: the second half can have its own step count and spacing. - Its first sigma is the entry point, and the sampler must be told THAT is its schedule — - otherwise it measures its quality phase and correction window against the main one.""" - alt = torch.tensor([0.715, 0.5, 0.3, 0.15, 0.0]) - plan, reason = _plan(0.603, restart=0.0, alt=alt) - assert reason is None - first, second, cut_at, entry_at, resume, ctx = plan - assert second is alt - assert abs(entry_at - 0.715) < 1e-5 # the alt schedule's own first sigma - assert resume is False # 0.603 -> 0.715 needs the state rebuilt - assert ctx["sigmas"] is alt and ctx["offset"] == 0 - # ...and pass 1 is unaffected by it. - assert [round(v, 3) for v in first.tolist()] == [1.0, 0.955, 0.893, 0.812, 0.715, 0.603] +def test_a_missing_or_degenerate_schedule_is_refused_with_a_reason(): + """second_pass with nothing to run must say so, not silently sample once.""" + for alt in (None, torch.tensor([]), torch.tensor([0.5]), "0.8, 0.0"): + out, reason = _sched(alt) + assert out is None and reason -def test_a_connected_schedule_starting_at_the_cut_is_an_exact_handover(): - alt = torch.tensor([0.603, 0.4, 0.2, 0.0]) - plan, reason = _plan(0.603, alt=alt) - assert reason is None and plan[4] is True # resume -> nothing added - - -def test_a_connected_schedule_starting_below_the_cut_is_refused(): - alt = torch.tensor([0.3, 0.15, 0.0]) - plan, reason = _plan(0.603, alt=alt) - assert plan is None - assert "second_pass_sigmas starts at" in reason - - -def test_a_degenerate_connected_schedule_falls_back_to_the_restart_sigma(): - """A single-sigma (or empty) SIGMAS input is not a schedule — it must not be treated as - one, and the restart sigma still governs.""" - for alt in (None, torch.tensor([0.5]), torch.tensor([])): - plan, reason = _plan(0.603, restart=0.812, alt=alt) - assert reason is None - assert plan[1].tolist()[0] == SCHEDULE[3].item() # a slice of the MAIN schedule +def test_a_schedule_starting_at_zero_is_refused(): + out, reason = _sched(torch.tensor([0.0, 0.0])) + assert out is None and "nothing for it to denoise" in reason # ── the pin survives the split ──────────────────────────────────────────────