Files

305 lines
12 KiB
Python

"""Math parity guard — pin the Hermite curve family.
The dramatiser_comfyui_v2 spline-editor had a silent-correctness
bug: the JS renderer used an APPROXIMATING B-spline
(``pv.Line.interpolate("basis")``) while the Python sampler used an
INTERPOLATING spline (``scipy.interpolate.make_interp_spline``).
Same control polygon, different curve shape — user designs A
visually, downstream nodes animate from B.
This pack avoids that class of bug by using byte-identical Hermite
math on both sides:
JS: ``src/timelineanim_nodes/web/js/sampler.js``
Python: ``src/timelineanim_nodes/node_timeline_animation.py``
These tests pin specific Hermite-derived values that depend on
the EXACT formula and tangent rule. If anyone "simplifies" or
"optimizes" either side to a different curve family (PCHIP,
Catmull-Rom approximating, monotone cubic, B-spline) the values
shift and these tests fail.
The spike-overshoot test in particular is the pattern recommended
in the user's own dramatiser bug write-up: a 5-point spike polygon,
with assertions on the central spike value AND the overshoot/
undershoot regions on either side.
"""
import importlib.util
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
def _load_python_module():
pytest.importorskip("comfy_api")
target = _REPO / "src" / "timelineanim_nodes" / "node_timeline_animation.py"
spec = importlib.util.spec_from_file_location(
"timelineanim_math_parity_under_test", target,
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
# ---- Python-side math identities ----------------------------------
def test_hermite_basis_at_endpoints():
"""h00(0)=1, h01(0)=0, h00(1)=0, h01(1)=1, h10/h11 vanish at
endpoints. These four identities together force any Hermite-
based formula to pass through both keys exactly. PCHIP and
other interpolating splines also satisfy them, but B-spline /
Bezier (approximating) do not — they don't pass through control
points by construction.
"""
mod = _load_python_module()
h = mod._hermite
# At t=0: value = a (regardless of slopes)
assert h(2.0, 5.0, 99.0, -42.0, 0.0) == 2.0
# At t=1: value = b
assert h(2.0, 5.0, 99.0, -42.0, 1.0) == 5.0
def test_hermite_basis_at_midpoint():
"""At t=0.5, the Hermite basis evaluates to:
h00 = 0.5, h10 = 0.125, h01 = 0.5, h11 = -0.125
Pin a known input/output so any silent rewrite of the formula
that produces the same values at endpoints (where h10/h11
vanish) but DIFFERS in the interior is caught here.
"""
mod = _load_python_module()
h = mod._hermite
# value = 0.5*a + 0.125*ma + 0.5*b + (-0.125)*mb
# = 0.5*2 + 0.125*10 + 0.5*5 + (-0.125)*20
# = 1.0 + 1.25 + 2.5 - 2.5 = 2.25
assert abs(h(2.0, 5.0, 10.0, 20.0, 0.5) - 2.25) < 1e-12
def test_auto_slope_uses_catmull_rom_rule():
"""Catmull-Rom slope at an interior key:
slope = (next.value - prev.value) / (next.frame - prev.frame)
NOT the chord toward either neighbor (that'd be linear-tangent),
NOT a tension-modulated cardinal slope, NOT PCHIP's
monotonicity-preserving slope. Pin the EXACT rule.
"""
mod = _load_python_module()
keys = [
{"frame": 0.0, "value": 0.0, "in_type": "auto", "out_type": "auto"},
{"frame": 4.0, "value": 0.0, "in_type": "auto", "out_type": "auto"},
{"frame": 5.0, "value": 1.0, "in_type": "auto", "out_type": "auto"},
{"frame": 6.0, "value": 0.0, "in_type": "auto", "out_type": "auto"},
{"frame": 10.0, "value": 0.0, "in_type": "auto", "out_type": "auto"},
]
# Spike key (idx 2): both neighbors at value 0, so slope = 0.
assert mod._slope_at(keys, 2, "out") == 0.0
# Key 1 (going INTO the spike): slope = (1 - 0) / (5 - 0) = 0.2.
assert abs(mod._slope_at(keys, 1, "out") - 0.2) < 1e-12
# Endpoint key 0: only one neighbor; falls through to chord
# toward it: (0 - 0) / (4 - 0) = 0.
assert mod._slope_at(keys, 0, "out") == 0.0
def test_segment_type_rule():
"""Maya rule pinned: stepped on EITHER side wins; linear-AND-
linear gives linear; otherwise hermite. PCHIP-style "always
smooth between same-type keys" is not what we want.
"""
mod = _load_python_module()
a_auto = {"out_type": "auto"}
b_auto = {"in_type": "auto"}
a_step = {"out_type": "stepped"}
b_step = {"in_type": "stepped"}
a_lin = {"out_type": "linear"}
b_lin = {"in_type": "linear"}
assert mod._segment_type(a_auto, b_auto) == "hermite"
assert mod._segment_type(a_step, b_auto) == "stepped"
assert mod._segment_type(a_auto, b_step) == "stepped"
assert mod._segment_type(a_lin, b_lin) == "linear"
# linear-only-on-one-side is still hermite (per Maya rule)
assert mod._segment_type(a_lin, b_auto) == "hermite"
assert mod._segment_type(a_auto, b_lin) == "hermite"
# ---- Spike pattern — the user's recommended bug-class regression --
def test_spike_pattern_undershoots_pins_curve_family():
"""5-point spike polygon: keys at (0,0), (4,0), (5,1), (6,0),
(10,0) with auto tangents. With Catmull-Rom Hermite, the
curve UNDERSHOOTS below 0 between keys 0 and 1 (and again
between 6 and 10) because key 1's slope (0.2 — Catmull-Rom
toward the spike) pulls the curve down on its way in. This
is the same diagnostic shape the user used to catch the
dramatiser bug.
If anyone silently switches to:
- PCHIP (monotonic): undershoot disappears, frames 1-3 ≥ 0
- linear interp: undershoot disappears, frames 1-3 == 0
- B-spline (approximating): the spike doesn't reach 1.0
- Catmull-Rom approximating (rare): same as B-spline
All four failure modes are caught here.
"""
cls = _load_python_module().TimelineAnimation
keys_store = (
'{"keys":['
'{"frame":0,"value":0.0},'
'{"frame":4,"value":0.0},'
'{"frame":5,"value":1.0},'
'{"frame":6,"value":0.0},'
'{"frame":10,"value":0.0}'
']}'
)
# Use a wider clamp range than [0, 1] so the undershoot is
# visible — clamping to [0, 1] would mask exactly the
# diagnostic we're testing for.
out = cls.execute(
keys_store=keys_store,
start_frame=0, end_frame=10,
min_value=-1.0, max_value=1.0,
)
floats = out.args[0]
assert len(floats) == 11
# --- Hermite passes through every key exactly ---------------
assert floats[0] == 0.0
assert abs(floats[4] - 0.0) < 1e-12, (
"Flat key at frame 4 not passed through exactly — Hermite "
"should hit every key. If this is non-zero, the math is "
"approximating (B-spline / Bezier-as-control-points)."
)
assert abs(floats[5] - 1.0) < 1e-12, (
f"Spike at frame 5 should reach exactly 1.0; got {floats[5]}. "
"If this is < 1.0, an APPROXIMATING spline (B-spline / "
"Catmull-Rom-approximating) silently replaced Hermite."
)
assert abs(floats[6] - 0.0) < 1e-12
assert floats[10] == 0.0
# --- Undershoot proves Catmull-Rom Hermite specifically ---------
# Frames 1-3 should be NEGATIVE (curve dips below 0 on its way
# to the spike). PCHIP would force >= 0; linear would be == 0.
for i in (1, 2, 3):
assert floats[i] < -0.005, (
f"Hermite undershoot at frame {i} missing — got {floats[i]}. "
"If >= 0, the math is no longer Catmull-Rom Hermite "
"(could be PCHIP, monotonic cubic, or linear)."
)
# Mirror on the way out:
for i in (7, 8, 9):
assert floats[i] < -0.005, (
f"Hermite undershoot at frame {i} missing — got {floats[i]}."
)
# --- Symmetry: pattern is symmetric, so undershoot is too -------
assert abs(floats[1] - floats[9]) < 1e-9, (
f"Undershoot asymmetric: floats[1]={floats[1]}, floats[9]={floats[9]}. "
"The spike pattern is symmetric in frame and value, so "
"asymmetric output indicates a directional bias in the "
"slope rule."
)
assert abs(floats[2] - floats[8]) < 1e-9
assert abs(floats[3] - floats[7]) < 1e-9
# ---- JS sampler must use the same canonical Hermite polynomials ----
def test_js_sampler_pins_canonical_hermite_basis():
"""The JS ``sampler.js`` is the single source of truth for the
JS-side curve math (used by the renderer, the interaction
layer's slope-at-cursor, and the editor's _insertKeyAt). Pin
the canonical Hermite polynomial strings so a refactor that
"optimizes" or rewrites the formula has to update both sides.
NOTE: this is a string-grep, not a runtime test. It catches
accidental drift in the most obvious case (someone rewrites
the polynomial). For deeper changes (e.g. swapping a constant
factor that's mathematically equivalent), only browser testing
against the spike pattern catches it — the user's dramatiser
bug write-up recommends running the Python spike test on the
JS side via headless browser automation.
"""
js_path = _REPO / "src" / "timelineanim_nodes" / "web" / "js" / "sampler.js"
js = js_path.read_text(encoding="utf-8")
# Canonical Hermite basis. Allow a wide net of formatting
# styles — what matters is that the polynomials are present in
# a form a reader can identify as h00/h10/h01/h11.
assert "2 * t3 - 3 * t2 + 1" in js, (
"sampler.js missing canonical h00 = 2t³ - 3t² + 1. If you "
"rewrote this, ensure mathematical equivalence with Python's "
"_hermite formula AND run the spike test on both sides."
)
assert "t3 - 2 * t2 + t" in js, "sampler.js missing canonical h10"
assert "-2 * t3 + 3 * t2" in js, "sampler.js missing canonical h01"
assert "t3 - t2" in js, "sampler.js missing canonical h11"
def test_js_sampler_pins_catmull_rom_auto_rule():
"""The auto-tangent rule used by the JS sampler must be
Catmull-Rom (slope between PREV and NEXT, NOT toward either
individually). Pin the formula shape — the divisor pattern is
distinctive enough to catch a switch to a different rule.
"""
js_path = _REPO / "src" / "timelineanim_nodes" / "web" / "js" / "sampler.js"
js = js_path.read_text(encoding="utf-8")
# The Catmull-Rom rule in JS — both the numerator (uses BOTH
# neighbors, distinguishing from linear-tangent which uses ONE)
# and the denominator (frame span between PREV and NEXT, not
# toward either neighbour individually).
assert "next.frame - prev.frame" in js, (
"sampler.js missing Catmull-Rom denominator ``next.frame - "
"prev.frame``. If you switched to chord-toward-one-neighbor "
"(linear-tangent rule), the curve family flips."
)
assert "(next.value - prev.value) / span" in js, (
"sampler.js missing Catmull-Rom numerator ``(next.value - "
"prev.value) / span``. If you switched to a different "
"scheme (cardinal with tension, PCHIP, monotone), the "
"curve family changes and Python output silently diverges "
"from what the user designed."
)
def test_js_renderer_imports_from_sampler():
"""The renderer must NEVER inline its own curve-math. All
curve evaluation goes through ``sampler.js`` so JS and Python
can't diverge by way of two parallel JS implementations.
"""
renderer_path = _REPO / "src" / "timelineanim_nodes" / "web" / "js" / "canvas_renderer.js"
js = renderer_path.read_text(encoding="utf-8")
assert "from './sampler.js'" in js, (
"canvas_renderer.js must import its curve math from "
"sampler.js — inlining a separate implementation would "
"create a second source of truth that can drift."
)
# And the renderer must NOT redeclare the Hermite basis itself.
assert "2 * t3 - 3 * t2 + 1" not in js, (
"Inline Hermite formula found in canvas_renderer.js — "
"delete it and call sampleSegment / sampleAt instead."
)
def test_insert_key_uses_sampler_not_linear_interp():
"""Reinforces audit critical fix #5: ``_insertKeyAt`` must
sample via the Hermite-aware ``sampleAt``, not its own linear
interp. If this regresses, the user's "ctrl+click drops a key
visibly off the curve" bug returns.
"""
main_js = (_REPO / "src" / "timelineanim_nodes" / "web" / "js" / "timeline_animation.js"
).read_text(encoding="utf-8")
assert "sampleAt(this.curve.keys" in main_js, (
"_insertKeyAt no longer routes through sampler.js's "
"sampleAt — inserted keys will not lie on the rendered curve."
)