305 lines
12 KiB
Python
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."
|
|
)
|