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.
Summary
SpinnerFramesproduces onlyframes/2visually 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 frameiis pixel-identical to framei+frames/2. The animation thus appears to do half a rotation, snap back, and repeat.Reproduction
Output on current
main:Regression bisect
93f86ae(parent ofab92095)ab92095"make resvg the sole SVG backend"main(current)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 distinctcolor="#dedede"to break the symmetry visually.SpinnerFramesrotates the spinner by appendingtransform="rotate(θ cx cy)"to the<svg id="spinner">element. resvg silently ignorestransformon 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>coloroverride inside the nested viewport, collapsing all spokes to the parent grey. The result is a purely point-symmetric image, so frameiand framei+frames/2are bit-identical.The cairo backend used to render
transformon 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 bundledIconKey.duiandPictureKey.duipackages. Requires teaching_element_centre(spinner.py:362) to compute a centre for<g>elements that lackx/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=16instead 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
ab92095)SpinnerFramesdirectly; not device- or hot-plug-related.