fix: camera move runs on GPU, stands down for multi-call samplers and says so, H3-only in the Editor

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-09-30 16:02:16 +03:00
co-authored by Claude Sonnet 5.5
parent 6d606b881c
commit 942202c963
4 changed files with 65 additions and 7 deletions
+9 -4
View File
@@ -29,10 +29,15 @@ import torch
MODES = ("off", "manual")
def finite(v, default, lo, hi):
"""`v` clamped to [lo, hi]; anything unreadable (NaN) becomes `default`."""
v = float(v)
return default if v != v else min(max(v, lo), hi)
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)
return finite(v, 0.0, -1.0, 1.0)
@dataclass(frozen=True)
@@ -86,7 +91,7 @@ def warp(video, move, sigma, generator):
fresh noise at the current noise level `sigma` (x = (1 - sigma) * picture + sigma * noise
with the picture unknown there), so they look like the rest of a noisy latent."""
c, t, h, w = video.shape
iy, ix = sources(move, t, h, w)
iy, ix = (v.to(video.device) for v in sources(move, t, h, w))
ok_y, ok_x = (iy >= 0) & (iy < h), (ix >= 0) & (ix < w)
fresh = torch.randn(video.shape, generator=generator).to(video) * float(sigma)
out = torch.empty_like(video)
@@ -102,7 +107,7 @@ def unwarp(moved, original, move):
Cells the move never showed keep `original`'s value, so the sampler's state there is
left as it was."""
c, t, h, w = moved.shape
iy, ix = sources(move, t, h, w)
iy, ix = (v.to(moved.device) for v in sources(move, t, h, w))
ok_y, ok_x = (iy >= 0) & (iy < h), (ix >= 0) & (ix < w)
out = original.clone()
for f in range(t):
+4
View File
@@ -183,6 +183,9 @@
stas:
"Steers H3's own block outputs by their packed frame layout; LTX's blocks are hooked "
+ "differently.",
camera_move:
"Moves the picture inside H3's packed latent (picture and sound in one sequence); it is "
+ "written for that layout and is not built for LTX.",
h3_video_detail:
"Scales H3's own final layer, which reads video and audio on separate rows. LTX has no "
+ "layer of that shape, so the sampler has nothing to scale.",
@@ -195,6 +198,7 @@
"h3_shadow_negative_start_percent", "h3_shadow_negative_end_percent",
"h3_shadow_negative_compose",
"late_guidance_strength", "late_guidance_block", "stas_alpha", "stas_block",
"camera_pan_x", "camera_pan_y", "camera_zoom", "camera_focus_x", "camera_focus_y", "camera_step",
];
// Sub-settings of identity transfer: meaningless wherever the feature itself cannot run,
+30 -3
View File
@@ -875,6 +875,17 @@ def _step_position(args):
return int(hit[0]), int(sched.shape[0]) - 1
# Samplers that call the model exactly once per schedule step, which the camera move's
# original-frame bookkeeping relies on (heun, dpmpp_2s, dpm_2 ... call it twice: the second
# call would be read as the next step).
_CAMERA_SAFE_SAMPLERS = frozenset({
"sample_euler", "sample_euler_ancestral", "sample_lcm", "sample_dpmpp_2m",
"sample_dpmpp_2m_sde", "sample_dpmpp_3m_sde", "sample_res_multistep", "sample_lms",
"sample_ipndm", "sample_ipndm_v", "sample_deis", "sample_gradient_estimation",
"sample_er_sde", "sample_ddpm",
})
class _InputSteer:
"""Carry a step's edit into the NEXT step's input instead of editing its answer (H3).
@@ -4314,6 +4325,10 @@ class FunPackLTXAVSceneChainSampler:
"picture (second pass, latent anchor or carried overlap), so it stands down")
else:
model, _camera_stats = self._install_camera_move(model, self._camera_move, int(seed))
_pins = self._conditioning_value(positive, "minimax_keyframes") or []
if any(int(p.get("resolved_frame_index", 0)) > 0 for p in _pins):
print("[FunPackSceneChain] camera move: keyframe pins after the first frame "
"stay where they are while the picture moves, so they may fight the move")
_phrase_probe = self._install_phrase_probe(model, positive, latent)
if _phrase_probe is not None:
@@ -8767,6 +8782,7 @@ class FunPackLTXAVSceneChainSampler:
try:
pos = where._where(args)
if pos is None:
stats["why"] = "this sampler's calls are not on the schedule"
return _plain(apply_fn, args)
i, n, _key, _sched = pos
c = args.get("c") if isinstance(args.get("c"), dict) else {}
@@ -8783,6 +8799,9 @@ class FunPackLTXAVSceneChainSampler:
if span is not None:
off, sz, shape = span
cc, tt, hh, ww = (int(v) for v in tuple(shape)[-4:])
if tt < 2:
stats["why"] = "one latent frame has nothing to move across"
return _plain(apply_fn, args)
vid = x[..., off:off + sz].reshape(cc, tt, hh, ww)
elif x.dim() == 5:
off, sz, vid = None, None, x[0]
@@ -9864,9 +9883,9 @@ class FunPackLTXAVSceneChainSampler:
if camera_move != "off":
self._camera_move = _camera.Move(
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),
zoom=_camera.finite(camera_zoom, 1.0, 0.5, 2.0),
focus_x=_camera.finite(camera_focus_x, 0.5, 0.0, 1.0),
focus_y=_camera.finite(camera_focus_y, 0.5, 0.0, 1.0),
step=min(max(int(camera_step), 1), 50))
if not self._is_h3:
print("[FunPackSceneChain] camera move: Inactive | H3 only")
@@ -9879,6 +9898,14 @@ class FunPackLTXAVSceneChainSampler:
print("[FunPackSceneChain] camera move: Inactive | context windows split the "
"clip into pieces that each see only some frames, so the move stands down")
self._camera_move = None
else:
_fn = getattr(sampler, "sampler_function", None)
_name = getattr(_fn, "__name__", "") or type(sampler).__name__
if _name not in _CAMERA_SAFE_SAMPLERS:
print(f"[FunPackSceneChain] camera move: Inactive | sampler {_name} may call "
f"the model more than once per step, which the move cannot follow; use "
f"one of: {', '.join(sorted(n.replace('sample_', '') for n in _CAMERA_SAFE_SAMPLERS))}")
self._camera_move = None
if (shot_memory != "off" or tsr == "learned" or late_guidance == "learned"
or stas == "learned"):
if not refinement_key_input:
+22
View File
@@ -199,3 +199,25 @@ def test_the_sampler_inputs_agree_with_the_editor_defaults():
"camera_focus_x", "camera_focus_y", "camera_step"]:
assert opt[name][1]["default"] == settings_card._EDITOR_DEFAULTS[name], name
assert opt["camera_move"][1]["default"] == "off"
def test_the_move_works_on_an_accelerator_when_there_is_one():
dev = ("cuda" if torch.cuda.is_available()
else "mps" if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available() else None)
if dev is None:
import pytest
pytest.skip("no GPU here; the rental is where this runs")
x = torch.randn(2, 9, 12, 20, device=dev)
move = cm.Move(pan_x=0.5, zoom=1.3)
out = cm.warp(x, move, 0.5, _gen())
back = cm.unwarp(out, x, move)
assert out.device == x.device and back.device == x.device and torch.isfinite(out).all()
assert torch.equal(cm.warp(x.cpu(), move, 0.5, _gen()).to(dev), out) # same as the CPU result
def test_only_single_call_samplers_are_allowed_and_bad_numbers_are_neutral():
import samplers
safe = samplers._CAMERA_SAFE_SAMPLERS
assert {"sample_euler", "sample_lcm"} <= safe
assert not ({"sample_heun", "sample_dpmpp_2s_ancestral", "sample_dpm_2", "sample_dpm_adaptive"} & safe)
assert cm.finite(float("nan"), 1.0, 0.5, 2.0) == 1.0 and cm.finite(9, 1.0, 0.5, 2.0) == 2.0