Files
Ewald a4154ccf05 Fix P0 bugs, remove Append V2/Lens Artifacts, and fix crop reload reset
- Fix batch bypass tuple handling, memory budget device-awareness, grain
  RNG device, roto payload limits, preview temp-file cleanup, and route
  double-registration.
- Add native VIDEO output (fps/audio) to effect nodes that previously
  only returned IMAGE/MASK when fed a VIDEO input.
- Split the oversized src/preview/ops/geometry.ts and add a starting
  golden-fixture test harness (spherize, transform) comparing backend
  and frontend math.
- Remove ImageOpsAppendV2 (Autogrow) and ImageOpsLensArtifacts; inline
  Append's helper back into its single caller.
- Collapse the Color Correct curve widget behind a toggle so the node
  no longer grows too tall.
- Fix Crop/generic-node video preview playback running at the RAF rate
  instead of the source video's real fps.
- Fix Crop losing its saved output size after a workflow reload:
  syncCropWidgets recomputed height from width and a preset aspect
  ratio on every resync (including onConfigure on load), discarding
  the restored value. It now only refreshes aspect-ratio bookkeeping
  on a resync.
2026-09-30 12:49:42 +02:00

144 lines
4.9 KiB
Python

"""Tests for nodes.core.roto: keyframe evaluation and matte rasterisation."""
from __future__ import annotations
import json
import numpy as np
from nodes.core.roto import (
_MAX_JSON_CHARS,
_MAX_KEYFRAMES_PER_SHAPE,
_MAX_POINTS_PER_SHAPE,
_MAX_SHAPES,
eval_shape,
flatten,
is_animated,
parse_shapes,
render_matte,
)
K = 0.5522847498
def _rect(x0, y0, x1, y1):
return [[x0, y0, 0, 0, 0, 0], [x1, y0, 0, 0, 0, 0], [x1, y1, 0, 0, 0, 0], [x0, y1, 0, 0, 0, 0]]
def _ellipse(cx, cy, rx, ry):
kx, ky = K * rx, K * ry
return [
[cx, cy - ry, -kx, 0, kx, 0],
[cx + rx, cy, 0, -ky, 0, ky],
[cx, cy + ry, kx, 0, -kx, 0],
[cx - rx, cy, 0, ky, 0, -ky],
]
def _doc(*shapes):
return json.dumps({"v": 1, "shapes": list(shapes)})
def _shape(pts, op="add", **extra):
return {"id": "s", "keys": [{"f": 0, "pts": pts}], "op": op, **extra}
def test_parse_ignores_garbage():
assert parse_shapes("") == []
assert parse_shapes("not json") == []
assert parse_shapes(json.dumps({"shapes": [{"keys": []}]})) == []
def test_rect_matte_covers_expected_area():
shapes = parse_shapes(_doc(_shape(_rect(0.25, 0.25, 0.75, 0.75))))
matte = render_matte(shapes, 0, 64, 64)
assert matte.shape == (64, 64)
assert matte[32, 32] == 1.0
assert matte[2, 2] == 0.0
assert abs(float(matte.sum()) - 32 * 32) < 40
def test_ellipse_area_matches_formula():
shapes = parse_shapes(_doc(_shape(_ellipse(0.5, 0.5, 0.25, 0.25))))
matte = render_matte(shapes, 0, 128, 128)
expected = np.pi * (0.25 * 128) ** 2
assert abs(float(matte.sum()) - expected) / expected < 0.02
def test_subtract_and_intersect():
base = _shape(_rect(0.0, 0.0, 1.0, 1.0))
hole = _shape(_rect(0.25, 0.25, 0.75, 0.75), op="subtract")
matte = render_matte(parse_shapes(_doc(base, hole)), 0, 32, 32)
assert matte[16, 16] == 0.0 and matte[1, 1] == 1.0
clip = _shape(_rect(0.0, 0.0, 0.5, 1.0), op="intersect")
matte = render_matte(parse_shapes(_doc(base, clip)), 0, 32, 32)
assert matte[16, 4] == 1.0 and matte[16, 28] == 0.0
def test_expand_feather_invert_opacity():
shapes = parse_shapes(_doc(_shape(_rect(0.25, 0.25, 0.75, 0.75))))
plain = render_matte(shapes, 0, 64, 64)
grown = render_matte(shapes, 0, 64, 64, expand=4.0)
shrunk = render_matte(shapes, 0, 64, 64, expand=-4.0)
assert grown.sum() > plain.sum() > shrunk.sum()
soft = render_matte(shapes, 0, 64, 64, feather=8.0)
assert 0.0 < float(soft[16, 32]) < 1.0
inverted = render_matte(shapes, 0, 64, 64, invert=True)
assert inverted[32, 32] == 0.0 and inverted[2, 2] == 1.0
assert abs(float(render_matte(shapes, 0, 64, 64, opacity=0.5)[32, 32]) - 0.5) < 1e-6
def test_keyframes_interpolate_and_hold():
shape = _shape(_rect(0.0, 0.0, 0.2, 0.2))
shape["keys"].append({"f": 10, "pts": _rect(0.6, 0.6, 0.8, 0.8)})
parsed = parse_shapes(_doc(shape))
assert is_animated(parsed)
assert np.allclose(eval_shape(parsed[0], -5)[0, :2], [0.0, 0.0])
assert np.allclose(eval_shape(parsed[0], 5)[0, :2], [0.3, 0.3])
assert np.allclose(eval_shape(parsed[0], 50)[0, :2], [0.6, 0.6])
early = render_matte(parsed, 0, 50, 50)
late = render_matte(parsed, 10, 50, 50)
assert early[5, 5] == 1.0 and early[35, 35] == 0.0
assert late[35, 35] == 1.0 and late[5, 5] == 0.0
def test_open_path_flatten_keeps_endpoint():
pts = np.asarray(_rect(0.0, 0.0, 1.0, 1.0), dtype=np.float64)
assert np.allclose(flatten(pts, False, 10, 10)[-1], [0.0, 10.0])
assert len(flatten(pts, True, 10, 10)) > len(flatten(pts, False, 10, 10))
def test_js_parity_anchor():
"""Same document and expected values as tests/frontend/roto-shapes.test.cjs."""
shape = {"id": "s1", "closed": True, "keys": [
{"f": 0, "pts": _ellipse(0.4, 0.5, 0.2, 0.3)},
{"f": 10, "pts": _ellipse(0.6, 0.4, 0.25, 0.2)},
]}
parsed = parse_shapes(_doc(shape))
poly = flatten(eval_shape(parsed[0], 4), True, 200, 100)
assert len(poly) == 80
assert np.allclose(poly[0], [96, 20], atol=1e-4)
def test_oversized_json_payload_is_rejected():
huge = "x" * (_MAX_JSON_CHARS + 1)
assert parse_shapes(huge) == []
def test_shape_count_is_capped():
shapes = [_shape(_rect(0.0, 0.0, 0.1, 0.1)) for _ in range(_MAX_SHAPES + 50)]
parsed = parse_shapes(_doc(*shapes))
assert len(parsed) == _MAX_SHAPES
def test_keyframe_count_is_capped():
shape = _shape(_rect(0.0, 0.0, 0.1, 0.1))
shape["keys"] = [{"f": i, "pts": _rect(0.0, 0.0, 0.1, 0.1)} for i in range(_MAX_KEYFRAMES_PER_SHAPE + 20)]
parsed = parse_shapes(_doc(shape))
assert len(parsed[0]["keys"]) == _MAX_KEYFRAMES_PER_SHAPE
def test_point_count_per_key_is_capped():
shape = _shape([[0.0, 0.0, 0.0, 0.0, 0.0, 0.0]] * (_MAX_POINTS_PER_SHAPE + 20))
parsed = parse_shapes(_doc(shape))
assert len(parsed[0]["keys"][0][1]) == _MAX_POINTS_PER_SHAPE