From 0bdf070fba53c94c45766e7e7ec2e7ac248c0788 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:35:02 +0300 Subject: [PATCH] fix: camera noise declares the FreeNoise collision, matches the probe path, clamps pan; states the zoom-in cost Co-Authored-By: Claude Sonnet 5.5 --- camera_noise.py | 11 +++++++++ movie_editor/frontend/engine_settings.js | 2 +- movie_editor/tests/test_bridge.py | 3 +++ samplers.py | 11 +++++++-- tests/test_camera_noise.py | 29 ++++++++++++++++++++++++ 5 files changed, 53 insertions(+), 3 deletions(-) diff --git a/camera_noise.py b/camera_noise.py index a65fd1b9..1119e7c0 100644 --- a/camera_noise.py +++ b/camera_noise.py @@ -15,6 +15,11 @@ audio noise is never touched. All values are unit-free: pan is the fraction of the frame the noise travels over the clip (x positive = camera moves right, so the picture moves left; y positive = camera moves down), zoom is the end scale (above 1 = camera moves in), focus is where the zoom aims, 0..1. + +Known cost: zooming in copies each source cell into several neighbours, so neighbouring +values become alike (adjacent correlation ~0.34 at zoom 1.5, ~0.51 at 2.0 in the travelling +part, roughly 0.2 to 0.3 after the default blend). Every value is still N(0, 1); whether the +model minds that blockiness is a question for the GPU. """ import math @@ -26,6 +31,12 @@ MODES = ("off", "manual") AMOUNT_MAX = 0.95 +def clamp_pan(v): + """A pan share in [-1, 1]; anything unreadable (NaN) is no pan.""" + v = float(v) + return 0.0 if v != v else min(max(v, -1.0), 1.0) + + @dataclass(frozen=True) class Move: pan_x: float = 0.0 diff --git a/movie_editor/frontend/engine_settings.js b/movie_editor/frontend/engine_settings.js index f6150c0c..63b4172c 100644 --- a/movie_editor/frontend/engine_settings.js +++ b/movie_editor/frontend/engine_settings.js @@ -343,7 +343,7 @@ { name: "camera_pan_y", label: "Pan up / down", kind: "float", default: 0.0, min: -1.0, max: 1.0, step: 0.05, dependsOn: "camera_noise", dependsValue: "manual", hint: "Share of the frame height travelled over the clip. Positive: camera moves down." }, { name: "camera_zoom", label: "Zoom (in / out)", kind: "float", default: 1.0, min: 0.5, max: 2.0, step: 0.05, dependsOn: "camera_noise", dependsValue: "manual", - hint: "Scale reached by the end. Above 1 the camera moves in, below 1 out." }, + hint: "Scale reached by the end. Above 1 the camera moves in, below 1 out. Zooming in stretches the noise, so keep it under 1.5 at first." }, { name: "camera_focus_x", label: "Zoom aims at (left to right)", kind: "float", default: 0.5, min: 0.0, max: 1.0, step: 0.05, dependsOn: "camera_noise", dependsValue: "manual", hint: "The point that stays put while everything else grows or shrinks around it." }, { name: "camera_focus_y", label: "Zoom aims at (top to bottom)", kind: "float", default: 0.5, min: 0.0, max: 1.0, step: 0.05, dependsOn: "camera_noise", dependsValue: "manual", diff --git a/movie_editor/tests/test_bridge.py b/movie_editor/tests/test_bridge.py index 54975774..a6fd5352 100644 --- a/movie_editor/tests/test_bridge.py +++ b/movie_editor/tests/test_bridge.py @@ -281,6 +281,7 @@ def test_the_new_features_lines_carry_their_state_colour(): "shot memory: Active | reusing a liked shot of 5 (reuse 0.71 > fresh 0.40), amount 0.70", "shot memory: Active | fresh (reuse 0.30 < fresh 0.55)", "steering window: Active | 1 of 4 steps (peak gate 0.50)", + "camera noise: Active | pan right 0.30, zoom in x1.30 toward (0.30, 0.50), 80% carried", "[FunPack] upscaled a.mp4 with 4x.safetensors: 96 frames -> funpack_upscaled/a_4x.mp4", ) orange = ( @@ -293,6 +294,8 @@ def test_the_new_features_lines_carry_their_state_colour(): "decisiveness: Inactive | a 1-step schedule has no step before the output to carry an edit into", "shot memory: Inactive | left 1 sampling pass(es) alone, their latent already held a picture", "steering window: Inactive | nothing will steer on this schedule, every rating-driven mechanism is gated off", + "camera noise: Inactive beyond the first context window | FreeNoise refills the later windows", + "camera noise: Inactive | this pass starts from a picture (second pass, latent anchor or carried overlap)", ) for line in green: text = line if line.startswith("[") else P + line diff --git a/samplers.py b/samplers.py index 38373070..374f2833 100644 --- a/samplers.py +++ b/samplers.py @@ -3349,7 +3349,7 @@ class FunPackLTXAVSceneChainSampler: }), "camera_zoom": ("FLOAT", { "default": 1.0, "min": 0.5, "max": 2.0, "step": 0.05, - "tooltip": "Scale reached by the end of the clip. Above 1 = camera moves in, below 1 = out, 1 = none.", + "tooltip": "Scale reached by the end of the clip. Above 1 = camera moves in, below 1 = out, 1 = none. Zooming in stretches the noise, which makes neighbouring values alike; keep it modest (under 1.5) until you have seen it work.", }), "camera_focus_x": ("FLOAT", { "default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05, @@ -5280,6 +5280,8 @@ class FunPackLTXAVSceneChainSampler: cand_noise = comfy.sample.prepare_noise(samples, cand_seed) if getattr(self, "_shot_memory", None) is not None: cand_noise = self._shot_memory.shape(cand_noise, samples, _raw_cond(positive)) + if getattr(self, "_camera_move", None) is not None: + cand_noise, _ = _camera.shape(cand_noise, samples, self._camera_move, cand_seed) comfy.sample.sample_custom( model, cand_noise, float(cfg), sampler, probe_sigmas, positive, negative, samples, noise_mask=probe_latent.get("noise_mask"), seed=cand_seed, @@ -9777,11 +9779,16 @@ class FunPackLTXAVSceneChainSampler: self._camera_move, self._camera_said = None, set() if camera_noise != "off": self._camera_move = _camera.Move( - pan_x=float(camera_pan_x), pan_y=float(camera_pan_y), + pan_x=_camera.clamp_pan(camera_pan_x), pan_y=_camera.clamp_pan(camera_pan_y), zoom=min(max(float(camera_zoom), 0.5), 2.0), focus_x=min(max(float(camera_focus_x), 0.0), 1.0), focus_y=min(max(float(camera_focus_y), 0.0), 1.0), amount=float(camera_amount)) + if context_windows and context_window_freenoise: + print("[FunPackSceneChain] camera noise: Inactive beyond the first context window | " + "FreeNoise refills the later windows with shuffled earlier noise after this, " + "so a scene longer than one window loses the move there; switch " + "context_window_freenoise off to keep it") if (shot_memory != "off" or tsr == "learned" or late_guidance == "learned" or stas == "learned"): if not refinement_key_input: diff --git a/tests/test_camera_noise.py b/tests/test_camera_noise.py index 3283ab23..35623377 100644 --- a/tests/test_camera_noise.py +++ b/tests/test_camera_noise.py @@ -113,3 +113,32 @@ def test_audio_noise_is_never_touched(monkeypatch): def test_describe_reads_back_the_move(): m = cn.Move(pan_x=-0.3, zoom=1.5, focus_x=0.2, amount=0.8) assert m.describe() == "pan left 0.30, zoom in x1.50 toward (0.20, 0.50), 80% carried" + + +def _adjacent_correlation(x): + a, b = x[..., :-1], x[..., 1:] + return float((a * b).mean() / (x.std() ** 2)) + + +def test_zoom_in_makes_neighbours_alike_but_pan_and_zoom_out_do_not(): + """The cost is stated in the docstring and tooltip; this pins its size.""" + last = lambda m: _travel(m, t=5, h=48, w=84, c=8)[:, -1] + assert abs(_adjacent_correlation(last(cn.Move(pan_x=0.5)))) < 0.05 + assert abs(_adjacent_correlation(last(cn.Move(zoom=0.5)))) < 0.05 + assert 0.2 < _adjacent_correlation(last(cn.Move(zoom=1.5))) < 0.5 + + +def test_pan_is_clamped_and_nan_is_no_pan(): + assert cn.clamp_pan(7) == 1.0 and cn.clamp_pan(-7) == -1.0 + assert cn.clamp_pan(float("nan")) == 0.0 and cn.clamp_pan(0.25) == 0.25 + + +def test_the_sampler_inputs_agree_with_the_editor_defaults(): + import samplers + from movie_editor.backend import settings_card + opt = samplers.FunPackLTXAVSceneChainSampler.INPUT_TYPES()["optional"] + names = ["camera_noise", "camera_pan_x", "camera_pan_y", "camera_zoom", + "camera_focus_x", "camera_focus_y", "camera_amount"] + for name in names: + assert opt[name][1]["default"] == settings_card._EDITOR_DEFAULTS[name], name + assert opt["camera_noise"][1]["default"] == "off" and settings_card._EDITOR_DEFAULTS["camera_noise"] == "off"