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 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-09-30 15:35:02 +03:00
co-authored by Claude Sonnet 5.5
parent aec5ac4a50
commit 0bdf070fba
5 changed files with 53 additions and 3 deletions
+11
View File
@@ -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
+1 -1
View File
@@ -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",
+3
View File
@@ -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
+9 -2
View File
@@ -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:
+29
View File
@@ -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"