Skip to content

bug(dui/spinner): only frames/2 rotations visible — resvg ignores transform on nested <svg> #385

@Faerkeren

Description

@Faerkeren

Summary

SpinnerFrames produces only frames/2 visually distinct frames when rendered through the resvg backend (currently the only backend). The Python rotation math is correct (step = 360 / frames, angles 0°..315° for 8 frames) but every frame i is pixel-identical to frame i+frames/2. The animation thus appears to do half a rotation, snap back, and repeat.

Reproduction

import hashlib
from pathlib import Path
from deux import add_dui_path, DuiKey
from deux.dui.spinner import SpinnerFrames
from deux.dui.repository import resolve_dui

add_dui_path(Path("src/deux/dui/packages"))
spec = resolve_dui("PictureKey")
key = DuiKey("PictureKey")
rendered = key._renderer.render_svg()
sf = SpinnerFrames(spec, width=120, height=120, image_format="JPEG", rendered_svg=rendered)
hashes = [hashlib.sha1(f).hexdigest()[:12] for f in sf.frames]
print(f"unique={len(set(hashes))}/{len(hashes)}")
for i, h in enumerate(hashes):
    print(f"  {i}: {h}")

Output on current main:

unique=4/8
  0: ff9687168553
  1: 45397abe81a3
  2: 4cde7335331b
  3: b540d7b633b7
  4: ff9687168553   <- duplicate of 0
  5: 45397abe81a3   <- duplicate of 1
  6: 4cde7335331b   <- duplicate of 2
  7: b540d7b633b7   <- duplicate of 3

Regression bisect

Commit Backend Unique frames
93f86ae (parent of ab92095) cairo 8/8
ab92095 "make resvg the sole SVG backend" resvg 4/8
main (current) resvg 4/8

Root cause

Both bundled spinner packages (IconKey.dui/layout.svg, PictureKey.dui/layout.svg) define the spinner as a nested <svg id="spinner"> element containing 8 spokes arranged as 4 mirror-symmetric pairs. The lead spoke is given a distinct color="#dedede" to break the symmetry visually.

SpinnerFrames rotates the spinner by appending transform="rotate(θ cx cy)" to the <svg id="spinner"> element. resvg silently ignores transform on nested <svg> elements (or applies it inconsistently — confirmed via direct rasterisation: rendering at 0° and 180° produced pixel-identical PNGs, ImageChops.difference(...).getbbox() == None). It also appears to drop the per-<rect> color override inside the nested viewport, collapsing all spokes to the parent grey. The result is a purely point-symmetric image, so frame i and frame i+frames/2 are bit-identical.

The cairo backend used to render transform on nested <svg> correctly per SVG 1.1, masking the issue.

Why this matters

The DUI ecosystem encourages third-party authors to ship packages. Any spinner SVG following the nested-<svg> pattern (which is intuitive and was the example we shipped) will silently break under resvg. This is a runtime quirk that's hard to debug from the package author's perspective.

Proposed fix

Option A — Library-side (recommended): In src/deux/dui/spinner.py, when building each rotated frame, apply the rotation to a wrapping <g> we inject around the spinner's children — or rotate each child <rect> individually around the centre. This makes every spinner SVG robust against the resvg quirk regardless of how the author structured the root spinner node.

Option B — Package-side: Convert <svg id="spinner"> to <g id="spinner" transform="translate(...)"> in the bundled IconKey.dui and PictureKey.dui packages. Requires teaching _element_centre (spinner.py:362) to compute a centre for <g> elements that lack x/y/width/height. Smaller change but only fixes shipped packages.

Option C — Both. Recommended: ship A for library robustness and B so the bundled packages demonstrate the cleaner pattern.

Workaround

Use frames=16 instead of the default 8 — produces 8 visually distinct frames (still half what was requested, but smoother). Or asymmetrise the spoke geometry harder (different shape or length, not just colour) so even point-symmetric rendering produces distinct pixels.

Affected files

  • src/deux/dui/spinner.py:96-132 — rotation frame generator (correct math, victim of the rasteriser quirk)
  • src/deux/dui/packages/IconKey.dui/layout.svg:24-30 — nested <svg id="spinner">
  • src/deux/dui/packages/PictureKey.dui/layout.svg:17-26 — nested <svg id="spinner">
  • src/deux/render/svg_rasterize.py — resvg backend (the rasteriser exhibiting the quirk)

Environment

  • macOS (darwin), Python 3.11+
  • resvg backend (only backend since ab92095)
  • Reproduced without device via SpinnerFrames directly; not device- or hot-plug-related.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingseverity:medMedium severity findingtype:bugBug fix

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions