fix(misc): restore wheel beta renderer

This commit is contained in:
2026-07-06 15:30:29 +07:00
parent a639949d96
commit a6b4085813
12 changed files with 283 additions and 812 deletions
+4 -25
View File
@@ -5,40 +5,19 @@ go 1.25.0
require (
github.com/go-telegram/bot v1.20.0
github.com/robfig/cron/v3 v3.0.1
github.com/tdewolff/canvas v0.0.0-20260617131110-529326a1955e
go.mongodb.org/mongo-driver/v2 v2.7.0
golang.org/x/image v0.41.0
golang.org/x/text v0.37.0
)
require (
codeberg.org/go-pdf/fpdf v0.11.1 // indirect
github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298 // indirect
github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966 // indirect
github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc // indirect
github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 // indirect
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/benoitkugler/textlayout v0.3.2 // indirect
github.com/benoitkugler/textprocessing v0.0.6 // indirect
github.com/go-fonts/latin-modern v0.3.3 // indirect
github.com/go-text/typesetting v0.3.4 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/klauspost/compress v1.17.6 // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/srwiley/scanx v0.0.0-20190309010443-e94503791388 // indirect
github.com/tdewolff/font v0.0.0-20260424075104-b5eeb1e23189 // indirect
github.com/tdewolff/minify/v2 v2.24.13 // indirect
github.com/tdewolff/parse/v2 v2.8.12 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/image v0.41.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.37.0 // indirect
modernc.org/knuth v0.5.5 // indirect
modernc.org/token v1.1.0 // indirect
star-tex.org/x/tex v0.7.1 // indirect
)
-79
View File
@@ -1,108 +1,40 @@
codeberg.org/go-fonts/liberation v0.5.0 h1:SsKoMO1v1OZmzkG2DY+7ZkCL9U+rrWI09niOLfQ5Bo0=
codeberg.org/go-fonts/liberation v0.5.0/go.mod h1:zS/2e1354/mJ4pGzIIaEtm/59VFCFnYC7YV6YdGl5GU=
codeberg.org/go-latex/latex v0.2.0 h1:Ol/a6VHY06N+5gPfewswymoRb5ZcKDXWVaVegcx4hbI=
codeberg.org/go-latex/latex v0.2.0/go.mod h1:VJAwQir7/T8LZxj7xAPivISKiVOwkMpQ8bTuPQ31X0Y=
codeberg.org/go-pdf/fpdf v0.11.1 h1:U8+coOTDVLxHIXZgGvkfQEi/q0hYHYvEHFuGNX2GzGs=
codeberg.org/go-pdf/fpdf v0.11.1/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoPOc4LjU=
git.sr.ht/~sbinet/cmpimg v0.1.0 h1:E0zPRk2muWuCqSKSVZIWsgtU9pjsw3eKHi8VmQeScxo=
git.sr.ht/~sbinet/cmpimg v0.1.0/go.mod h1:FU12psLbF4TfNXkKH2ZZQ29crIqoiqTZmeQ7dkp/pxE=
git.sr.ht/~sbinet/gg v0.7.0 h1:YmNf7YKd7diDMTPm86hZa1EM3pbkOyD/zzjl0LZUdNM=
git.sr.ht/~sbinet/gg v0.7.0/go.mod h1:VYeli15tpMM4EvqlivlVbbyvWZlOU+EZn4XZmfBGUdM=
github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298 h1:1qlsVAQJXZHsaM8b6OLVo6muQUQd4CwkH/D3fnnbHXA=
github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298/go.mod h1:D+QujdIlUNfa0igpNMk6UIvlb6C252URs4yupRUV4lQ=
github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966 h1:lTG4HQym5oPKjL7nGs+csTgiDna685ZXjxijkne828g=
github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966/go.mod h1:Mid70uvE93zn9wgF92A/r5ixgnvX8Lh68fxp9KQBaI0=
github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc h1:7D+Bh06CRPCJO3gr2F7h1sriovOZ8BMhca2Rg85c2nk=
github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 h1:O/r2Sj+8QcMF7V5IcmiE2sMFV2q3J47BEirxbXJAdzA=
github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046/go.mod h1:uw9h2sd4WWHOPdJ13MQpwK5qYWKYDumDqxWWIknEQ+k=
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f h1:l7moT9o/v/9acCWA64Yz/HDLqjcRTvc0noQACi4MsJw=
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f/go.mod h1:vIOkSdX3NDCPwgu8FIuTat2zDF0FPXXQ0RYFRy+oQic=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/benoitkugler/pstokenizer v1.0.0/go.mod h1:l1G2Voirz0q/jj0TQfabNxVsa8HZXh/VMxFSRALWTiE=
github.com/benoitkugler/textlayout v0.3.2 h1:Y/LTkwpQ9G0Fia9yhPmZA9IR5AnE8Cq30j3C+Gx5/IE=
github.com/benoitkugler/textlayout v0.3.2/go.mod h1:o+1hFV+JSHBC9qNLIuwVoLedERU7sBPgEFcuSgfvi/w=
github.com/benoitkugler/textlayout-testdata v0.1.1/go.mod h1:i/qZl09BbUOtd7Bu/W1CAubRwTWrEXWq6JwMkw8wYxo=
github.com/benoitkugler/textprocessing v0.0.6 h1:obkMyj62GEPg3xUVYqROlCN22z1OleuZm6ULqX9Om1g=
github.com/benoitkugler/textprocessing v0.0.6/go.mod h1:Io0gN08/PXEzrSOWFa88xHx2Xv3VjvLMY7H76YoI23A=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-fonts/latin-modern v0.3.3 h1:g2xNgI8yzdNzIVm+qvbMryB6yGPe0pSMss8QT3QwlJ0=
github.com/go-fonts/latin-modern v0.3.3/go.mod h1:tHaiWDGze4EPB0Go4cLT5M3QzRY3peya09Z/8KSCrpY=
github.com/go-telegram/bot v1.20.0 h1:4Pea/qTidSspr4WBJw9FbHUMNhYeqszBqQUfsQEyFbc=
github.com/go-telegram/bot v1.20.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU=
github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/srwiley/scanFT v0.0.0-20220128184157-0d1ee492111f h1:uLR2GaV0kWYZ3Ns3l3sjtiN+mOWAQadvrL8HXcyKjl0=
github.com/srwiley/scanFT v0.0.0-20220128184157-0d1ee492111f/go.mod h1:LZwgIPG9X6nH6j5Ef+xMFspl6Hru4b5EJxzMfeqHYJY=
github.com/srwiley/scanx v0.0.0-20190309010443-e94503791388 h1:ZdkidVdpLW13BQ9a+/3uerT2ezy9J7KQWH18JCfhDmI=
github.com/srwiley/scanx v0.0.0-20190309010443-e94503791388/go.mod h1:C/WY5lmWfMtPFYYBTd3Lzdn4FTLr+RxlIeiBNye+/os=
github.com/tdewolff/canvas v0.0.0-20260617131110-529326a1955e h1:Z9HqI3WUPBDwJEDH9BCLpQGvjmGs0XNRDBLry4shZfs=
github.com/tdewolff/canvas v0.0.0-20260617131110-529326a1955e/go.mod h1:Lz12ClttuKvo2jb1UxBfJJWmKV0LmEc8IRo/30R7z+k=
github.com/tdewolff/font v0.0.0-20260424075104-b5eeb1e23189 h1:wP/+8oeXiF55DUy9U0BP4iDD2mNu+o7evt5QLbLHv9E=
github.com/tdewolff/font v0.0.0-20260424075104-b5eeb1e23189/go.mod h1:KjNTiF0TXOXJyWGS/0DPXRknEC20c12kc0JcCYNqtz4=
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
github.com/tdewolff/parse/v2 v2.8.12 h1:5BBjfaCv482v3nltlS0u6wH1xJaxjR6ofDrWttNvROg=
github.com/tdewolff/parse/v2 v2.8.12/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.mongodb.org/mongo-driver/v2 v2.7.0 h1:RO+zqavD2/GCL3cxOMyZhx6R9Irzr8/6gsoqx5tcY/c=
go.mongodb.org/mongo-driver/v2 v2.7.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -110,7 +42,6 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
@@ -119,13 +50,3 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/plot v0.17.0 h1:d0DwPVBe9jnEGqQBoZGl/P2M9WciJbG2CnV59C9QBT4=
gonum.org/v1/plot v0.17.0/go.mod h1:ipt2GUN1oqzr2O7wCjLDtw1ShfIYYNBp4o0O1Ez5B3Y=
modernc.org/knuth v0.5.5 h1:6lap2U/ISm8aC/4NU58ALFCRllNPaK0EZcIGY/oDgUg=
modernc.org/knuth v0.5.5/go.mod h1:e5SBb35HQBj2aFwbBO3ClPcViLY3Wi0LzaOd7c/3qMk=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
star-tex.org/x/tex v0.7.1 h1:4qGAByRyY0WQsOjtcHlxz+FgrYxz8fzxIds2Gjepp5U=
star-tex.org/x/tex v0.7.1/go.mod h1:Y3y0U7sZTltTh/CDZIx0oAtMjG7eMaTuTtvDZGdyhJo=
@@ -1,47 +0,0 @@
DejaVu Sans font license
Source: https://dejavu-fonts.github.io/
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
Bitstream Vera is a trademark of Bitstream, Inc.
DejaVu changes are in public domain.
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license ("Fonts") and associated
documentation files (the "Font Software"), to reproduce and distribute the
Font Software, including without limitation the rights to use, copy, merge,
publish, distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to the
following conditions:
The above copyright and trademark notices and this permission notice shall
be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional glyphs or characters may be added to the Fonts, only if the fonts
are renamed to names not containing either the words "Bitstream" or the word
"Vera".
This License becomes null and void to the extent applicable to Fonts or Font
Software that has been modified and distributed under the "Bitstream Vera"
names.
The Font Software may be sold as part of a larger software package but no
copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome
Foundation, and Bitstream Inc., shall not be used in advertising or
otherwise to promote the sale, use or other dealings in this Font Software
without prior written authorization from the Gnome Foundation or Bitstream
Inc., respectively.
Binary file not shown.
+10 -28
View File
@@ -12,7 +12,6 @@ import (
"time"
"github.com/go-telegram/bot/models"
"github.com/tdewolff/canvas"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/storage"
@@ -426,39 +425,22 @@ func TestWheelOfNamesBeta_RotatesOptionLabelsWithSlices(t *testing.T) {
}
}
func TestWheelOfNamesBeta_DisplayTextPreservesVietnamese(t *testing.T) {
input := "Tiếng Việt Đặng"
got := wheelBetaDisplayText(input, 32)
if got != input {
t.Fatalf("wheelBetaDisplayText() = %q, want %q", got, input)
func TestWheelOfNamesBeta_DisplayTextNormalizesVietnamese(t *testing.T) {
input := "không dấu Tiếng Việt Đặng Ơ Ư ấ ệ"
want := "khong dau Tieng Viet Dang O U a e"
got := wheelBetaDisplayText(input, 64)
if got != want {
t.Fatalf("wheelBetaDisplayText() = %q, want %q", got, want)
}
if strings.Contains(got, "?") {
t.Fatalf("wheelBetaDisplayText() replaced Vietnamese with ?: %q", got)
}
}
func TestWheelOfNamesBeta_FontUsesNormalWeight(t *testing.T) {
if got := wheelBetaTextFont.Style().Weight(); got != canvas.FontRegular {
t.Fatalf("font weight = %v, want regular non-bold face", got)
}
if wheelBetaTextFont.Style().Italic() {
t.Fatalf("font style = %v, want non-italic regular face", wheelBetaTextFont.Style())
}
}
func TestWheelOfNamesBeta_FontSupportsVietnameseGlyphs(t *testing.T) {
face := newWheelBetaTextFace(wheelBetaInkColorIndex)
for _, r := range "Tiếng Việt Đặng Ơ Ư ấ ệ" {
if r == ' ' {
continue
}
glyphs := face.Glyphs(string(r))
if len(glyphs) == 0 {
t.Fatalf("Glyphs(%q) is empty, want Vietnamese glyph support", r)
}
if glyphs[0].ID == 0 {
t.Fatalf("Glyphs(%q)[0].ID = 0, want Vietnamese glyph support", r)
}
func TestWheelOfNamesBeta_DisplayTextNormalizesDecomposedVietnamese(t *testing.T) {
got := wheelBetaDisplayText("tie\u0302\u0301ng Vie\u0323t", 32)
if got != "tieng Viet" {
t.Fatalf("wheelBetaDisplayText() = %q, want %q", got, "tieng Viet")
}
}
+33 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"image"
"image/color"
"image/draw"
"image/gif"
"math"
)
@@ -103,8 +104,38 @@ func renderWheelBetaFrameWithStatus(options []string, winner int, rotation float
}
func renderWheelBetaFrameWithCelebration(options []string, winner int, rotation float64, reveal bool, status string, celebrateStep int) *image.Paletted {
rect := image.Rect(0, 0, wheelBetaSize, wheelBetaSize)
img := image.NewPaletted(rect, wheelBetaPalette)
draw.Draw(img, rect, image.NewUniform(wheelBetaPalette[wheelBetaBackgroundColorIndex]), image.Point{}, draw.Src)
cx, cy := wheelBetaSize/2, wheelBetaSize/2
drawWheelDropShadow(img, cx, cy)
segment := 2 * math.Pi / float64(len(options))
r2 := wheelBetaRadius * wheelBetaRadius
for y := cy - wheelBetaRadius; y <= cy+wheelBetaRadius; y++ {
for x := cx - wheelBetaRadius; x <= cx+wheelBetaRadius; x++ {
dx, dy := x-cx, y-cy
if dx*dx+dy*dy > r2 {
continue
}
theta := normalizeAngle(math.Atan2(float64(dy), float64(dx)) - rotation)
idx := int(theta / segment)
colorIndex := wheelBetaSliceColorIndexes[idx%len(wheelBetaSliceColorIndexes)]
img.SetColorIndex(x, y, colorIndex)
}
}
currentIndex := currentWheelBetaIndex(len(options), rotation)
pointerColor := wheelBetaSliceColorIndexes[currentIndex%len(wheelBetaSliceColorIndexes)]
drawWheelLighting(img, cx, cy)
drawWheelRim(img, cx, cy)
drawWinnerCelebration(img, celebrateStep)
drawWheelSliceLabels(img, options, rotation)
drawCircle(img, cx, cy, 24, wheelBetaPaperColorIndex)
drawCircle(img, cx, cy, 18, wheelBetaInkColorIndex)
drawPointer(img, cx+wheelBetaRadius, cy, pointerColor)
drawCenteredText(img, "WHEELOFNAMES BETA", cy+wheelBetaRadius+34, wheelBetaInkColorIndex)
label := status
if label == "" {
label = "CURRENT"
@@ -114,7 +145,8 @@ func renderWheelBetaFrameWithCelebration(options []string, winner int, rotation
label = "RESULT"
value = wheelBetaDisplayText(options[winner], 28)
}
return palettizeWheelBetaFrame(renderWheelBetaCanvasImage(options, rotation, pointerColor, label, value, celebrateStep))
drawStatusBand(img, label, value)
return img
}
func finalWheelRotation(optionCount, winner int) float64 {
+236 -192
View File
@@ -1,121 +1,36 @@
package misc
import (
_ "embed"
"image"
"image/draw"
"image/color"
"math"
"strings"
"unicode"
"github.com/tdewolff/canvas"
"github.com/tdewolff/canvas/renderers/rasterizer"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
"golang.org/x/image/math/fixed"
"golang.org/x/text/unicode/norm"
)
const (
wheelBetaSliceLabelRadius = 64
wheelBetaFontSize = 10
)
//go:embed fonts/dejavu-sans.ttf
var wheelBetaTextFontBytes []byte
var (
wheelBetaTextFont = mustWheelBetaTextFont()
wheelBetaTextFontFamily = mustWheelBetaTextFontFamily()
)
func mustWheelBetaTextFont() *canvas.Font {
parsed, err := canvas.LoadFont(wheelBetaTextFontBytes, 0, canvas.FontRegular)
if err != nil {
panic(err)
}
return parsed
}
func mustWheelBetaTextFontFamily() *canvas.FontFamily {
family := canvas.NewFontFamily("wheel-beta")
if err := family.LoadFont(wheelBetaTextFontBytes, 0, canvas.FontRegular); err != nil {
panic(err)
}
return family
}
func newWheelBetaTextFace(colorIndex byte) *canvas.FontFace {
return wheelBetaTextFontFamily.Face(wheelBetaFontSize, wheelBetaPalette[colorIndex], canvas.FontRegular)
}
func renderWheelBetaCanvasImage(options []string, rotation float64, pointerColorIndex byte, label, value string, celebrateStep int) image.Image {
c := canvas.New(float64(wheelBetaSize), float64(wheelBetaSize))
ctx := canvas.NewContext(c)
ctx.SetCoordSystem(canvas.CartesianIV)
ctx.SetStrokeCapper(canvas.RoundCap)
ctx.SetStrokeJoiner(canvas.RoundJoin)
drawWheelBetaRect(ctx, 0, 0, float64(wheelBetaSize), float64(wheelBetaSize), wheelBetaBackgroundColorIndex)
cx := float64(wheelBetaSize) / 2
cy := float64(wheelBetaSize) / 2
drawWheelDropShadow(ctx, cx, cy)
drawWheelSlices(ctx, options, rotation)
drawWheelLighting(ctx, cx, cy)
drawWheelRim(ctx, cx, cy)
drawWinnerCelebration(ctx, celebrateStep)
drawWheelSliceLabels(ctx, options, rotation)
drawCircle(ctx, cx, cy, 24, wheelBetaPaperColorIndex)
drawCircle(ctx, cx, cy, 18, wheelBetaInkColorIndex)
drawPointer(ctx, cx+float64(wheelBetaRadius), cy, pointerColorIndex)
drawCenteredText(ctx, "WHEELOFNAMES BETA", cy+float64(wheelBetaRadius)+24, wheelBetaInkColorIndex)
drawStatusBand(ctx, label, value)
return rasterizer.Draw(c, canvas.DPMM(1), canvas.DefaultColorSpace)
}
func palettizeWheelBetaFrame(src image.Image) *image.Paletted {
rect := image.Rect(0, 0, wheelBetaSize, wheelBetaSize)
img := image.NewPaletted(rect, wheelBetaPalette)
draw.FloydSteinberg.Draw(img, rect, src, image.Point{})
return img
}
func drawWheelSlices(ctx *canvas.Context, options []string, rotation float64) {
func drawWheelSliceLabels(img *image.Paletted, options []string, rotation float64) {
if len(options) == 0 {
return
}
cx := float64(wheelBetaSize) / 2
cy := float64(wheelBetaSize) / 2
segment := 2 * math.Pi / float64(len(options))
steps := int(math.Ceil(float64(wheelBetaRadius) * segment / 4))
if steps < 8 {
steps = 8
}
for idx := range options {
start := rotation + float64(idx)*segment
path := &canvas.Path{}
path.MoveTo(cx, cy)
for step := 0; step <= steps; step++ {
angle := start + segment*float64(step)/float64(steps)
path.LineTo(cx+math.Cos(angle)*float64(wheelBetaRadius), cy+math.Sin(angle)*float64(wheelBetaRadius))
}
path.Close()
drawWheelBetaPath(ctx, path, wheelBetaSliceColorIndexes[idx%len(wheelBetaSliceColorIndexes)])
}
}
func drawWheelSliceLabels(ctx *canvas.Context, options []string, rotation float64) {
if len(options) == 0 {
return
}
cx := float64(wheelBetaSize) / 2
cy := float64(wheelBetaSize) / 2
cx, cy := wheelBetaSize/2, wheelBetaSize/2
segment := 2 * math.Pi / float64(len(options))
limit := wheelBetaSliceLabelLimit(len(options))
for idx, option := range options {
angle := rotation + (float64(idx)+0.5)*segment
centerX := cx + math.Cos(angle)*float64(wheelBetaSliceLabelRadius)
centerY := cy + math.Sin(angle)*float64(wheelBetaSliceLabelRadius)
centerX := cx + int(math.Round(math.Cos(angle)*float64(wheelBetaSliceLabelRadius)))
centerY := cy + int(math.Round(math.Sin(angle)*float64(wheelBetaSliceLabelRadius)))
text := wheelBetaDisplayText(option, limit)
drawRotatedCenteredTextAt(ctx, text, centerX, centerY, angle, wheelBetaInkColorIndex)
drawRotatedCenteredTextAt(img, text, centerX+1, centerY+1, angle, wheelBetaPaperColorIndex)
drawRotatedCenteredTextAt(img, text, centerX, centerY, angle, wheelBetaInkColorIndex)
}
}
@@ -132,130 +47,229 @@ func wheelBetaSliceLabelLimit(optionCount int) int {
}
}
func drawCircle(ctx *canvas.Context, cx, cy, radius float64, colorIndex byte) {
ctx.SetFillColor(wheelBetaPalette[colorIndex])
ctx.DrawPath(cx, cy, canvas.Circle(radius))
ctx.Fill()
func drawCircle(img *image.Paletted, cx, cy, radius int, colorIndex byte) {
r2 := radius * radius
for y := cy - radius; y <= cy+radius; y++ {
for x := cx - radius; x <= cx+radius; x++ {
dx, dy := x-cx, y-cy
if dx*dx+dy*dy <= r2 {
img.SetColorIndex(x, y, colorIndex)
}
}
}
}
func drawWheelDropShadow(ctx *canvas.Context, cx, cy float64) {
ctx.SetFillColor(wheelBetaPalette[wheelBetaShadowColorIndex])
ctx.DrawPath(cx, cy+8, canvas.Ellipse(float64(wheelBetaRadius+9), float64(wheelBetaRadius+5)))
ctx.Fill()
func drawWheelDropShadow(img *image.Paletted, cx, cy int) {
rx := wheelBetaRadius + 9
ry := wheelBetaRadius + 5
shadowCY := cy + 8
limit := rx * rx * ry * ry
for y := shadowCY - ry; y <= shadowCY+ry; y++ {
for x := cx - rx; x <= cx+rx; x++ {
dx := x - cx
dy := y - shadowCY
if dx*dx*ry*ry+dy*dy*rx*rx <= limit {
setWheelBetaPixel(img, x, y, wheelBetaShadowColorIndex)
}
}
}
}
func drawWheelLighting(ctx *canvas.Context, cx, cy float64) {
drawHighlightOval(ctx, cx-30, cy-50, 44, 18)
drawCircleOutline(ctx, cx, cy, float64(wheelBetaRadius-5), 2, wheelBetaBevelColorIndex)
drawCircleOutline(ctx, cx, cy, float64(wheelBetaRadius-8), 1, wheelBetaHighlightColorIndex)
func drawWheelLighting(img *image.Paletted, cx, cy int) {
outer := wheelBetaRadius * wheelBetaRadius
edgeStart := wheelBetaRadius - 13
edge := edgeStart * edgeStart
for y := cy - wheelBetaRadius; y <= cy+wheelBetaRadius; y++ {
for x := cx - wheelBetaRadius; x <= cx+wheelBetaRadius; x++ {
dx, dy := x-cx, y-cy
d2 := dx*dx + dy*dy
if d2 > outer || d2 < edge {
continue
}
if dx+dy > wheelBetaRadius/3 {
img.SetColorIndex(x, y, wheelBetaBevelColorIndex)
}
if dx+dy < -wheelBetaRadius {
img.SetColorIndex(x, y, wheelBetaHighlightColorIndex)
}
}
}
drawHighlightOval(img, cx-30, cy-50, 44, 18)
}
func drawHighlightOval(ctx *canvas.Context, cx, cy, rx, ry float64) {
ctx.SetFillColor(wheelBetaPalette[wheelBetaHighlightColorIndex])
ctx.DrawPath(cx, cy, canvas.Ellipse(rx, ry))
ctx.Fill()
func drawHighlightOval(img *image.Paletted, cx, cy, rx, ry int) {
limit := rx * rx * ry * ry
for y := cy - ry; y <= cy+ry; y++ {
for x := cx - rx; x <= cx+rx; x++ {
dx := x - cx
dy := y - cy
if dx*dx*ry*ry+dy*dy*rx*rx <= limit {
setWheelBetaPixel(img, x, y, wheelBetaHighlightColorIndex)
}
}
}
}
func drawWheelRim(ctx *canvas.Context, cx, cy float64) {
drawCircleOutline(ctx, cx, cy, float64(wheelBetaRadius)-1.5, 3, wheelBetaInkColorIndex)
drawCircleOutline(ctx, cx, cy, float64(wheelBetaRadius-5), 1, wheelBetaBevelColorIndex)
drawCircleOutline(ctx, cx, cy, float64(wheelBetaRadius-8), 1, wheelBetaHighlightColorIndex)
func drawWheelRim(img *image.Paletted, cx, cy int) {
drawCircleOutline(img, cx, cy, wheelBetaRadius, 3, wheelBetaInkColorIndex)
drawCircleOutline(img, cx, cy, wheelBetaRadius-5, 1, wheelBetaBevelColorIndex)
drawCircleOutline(img, cx, cy, wheelBetaRadius-8, 1, wheelBetaHighlightColorIndex)
}
func drawCircleOutline(ctx *canvas.Context, cx, cy, radius, thickness float64, colorIndex byte) {
ctx.SetFillColor(canvas.Transparent)
ctx.SetStrokeColor(wheelBetaPalette[colorIndex])
ctx.SetStrokeWidth(thickness)
ctx.DrawPath(cx, cy, canvas.Circle(radius))
ctx.Stroke()
func drawCircleOutline(img *image.Paletted, cx, cy, radius, thickness int, colorIndex byte) {
outer := radius * radius
innerRadius := radius - thickness
inner := innerRadius * innerRadius
for y := cy - radius; y <= cy+radius; y++ {
for x := cx - radius; x <= cx+radius; x++ {
dx, dy := x-cx, y-cy
d2 := dx*dx + dy*dy
if d2 <= outer && d2 >= inner {
img.SetColorIndex(x, y, colorIndex)
}
}
}
}
func drawPointer(ctx *canvas.Context, tipX, cy float64, fillColorIndex byte) {
outer := &canvas.Path{}
outer.MoveTo(tipX+2, cy)
outer.LineTo(tipX+34, cy+24)
outer.LineTo(tipX+34, cy-24)
outer.Close()
drawWheelBetaPath(ctx, outer, wheelBetaInkColorIndex)
drawWheelBetaRect(ctx, tipX-1, cy-2, tipX+5, cy+2, wheelBetaInkColorIndex)
inner := &canvas.Path{}
inner.MoveTo(tipX+4, cy)
inner.LineTo(tipX+30, cy+10)
inner.LineTo(tipX+30, cy-10)
inner.Close()
drawWheelBetaPath(ctx, inner, fillColorIndex)
func drawPointer(img *image.Paletted, tipX, cy int, fillColorIndex byte) {
for xOffset := 0; xOffset < 34; xOffset++ {
half := xOffset / 2
x := tipX + xOffset
for y := cy - half; y <= cy+half; y++ {
setWheelBetaPixel(img, x, y, wheelBetaInkColorIndex)
}
}
for xOffset := 3; xOffset < 30; xOffset++ {
half := xOffset/2 - 2
if half < 0 {
continue
}
x := tipX + xOffset
for y := cy - half; y <= cy+half; y++ {
setWheelBetaPixel(img, x, y, fillColorIndex)
}
}
}
func drawWinnerCelebration(ctx *canvas.Context, step int) {
func drawWinnerCelebration(img *image.Paletted, step int) {
if step < 0 {
return
}
cx := float64(wheelBetaSize) / 2
cy := float64(wheelBetaSize) / 2
cx, cy := wheelBetaSize/2, wheelBetaSize/2
phase := step % wheelBetaCelebrateFrames
ringRadius := 34 + phase*5
if ringRadius < wheelBetaRadius-8 {
drawCircleOutline(ctx, cx, cy, float64(ringRadius), 1, wheelBetaSparkColorIndex)
drawCircleOutline(img, cx, cy, ringRadius, 1, wheelBetaSparkColorIndex)
}
for i := 0; i < 14; i++ {
angle := (float64(i)/14)*2*math.Pi + float64(phase)*0.31
inner := float64(wheelBetaRadius + 9 + phase%3)
outer := inner + 7 + float64(phase%4)
x1 := cx + math.Cos(angle)*inner
y1 := cy + math.Sin(angle)*inner
x2 := cx + math.Cos(angle)*outer
y2 := cy + math.Sin(angle)*outer
x1 := cx + int(math.Round(math.Cos(angle)*inner))
y1 := cy + int(math.Round(math.Sin(angle)*inner))
x2 := cx + int(math.Round(math.Cos(angle)*outer))
y2 := cy + int(math.Round(math.Sin(angle)*outer))
colorIndex := wheelBetaSliceColorIndexes[(i+phase)%len(wheelBetaSliceColorIndexes)]
if i%5 == 0 {
colorIndex = wheelBetaSparkColorIndex
}
drawWheelBetaLine(ctx, x1, y1, x2, y2, 2, colorIndex)
drawSpark(ctx, x2, y2, colorIndex)
drawPalettedLine(img, x1, y1, x2, y2, colorIndex)
drawSpark(img, x2, y2, colorIndex)
}
}
func drawStatusBand(ctx *canvas.Context, label, value string) {
drawWheelBetaRect(ctx, 31, 235, float64(wheelBetaSize-25), 286, wheelBetaShadowColorIndex)
drawWheelBetaRect(ctx, 28, 230, float64(wheelBetaSize-28), 282, wheelBetaPaperColorIndex)
drawWheelBetaLine(ctx, 28, 230, float64(wheelBetaSize-28), 230, 1, wheelBetaHighlightColorIndex)
drawWheelBetaLine(ctx, 28, 281, float64(wheelBetaSize-28), 281, 1, wheelBetaBevelColorIndex)
drawCenteredText(ctx, label, 240, wheelBetaInkColorIndex)
drawCenteredText(ctx, value, 260, wheelBetaInkColorIndex)
func drawStatusBand(img *image.Paletted, label, value string) {
for y := 235; y < 286; y++ {
for x := 31; x < wheelBetaSize-25; x++ {
img.SetColorIndex(x, y, wheelBetaShadowColorIndex)
}
}
for y := 230; y < 282; y++ {
for x := 28; x < wheelBetaSize-28; x++ {
img.SetColorIndex(x, y, wheelBetaPaperColorIndex)
}
}
for x := 28; x < wheelBetaSize-28; x++ {
img.SetColorIndex(x, 230, wheelBetaHighlightColorIndex)
img.SetColorIndex(x, 281, wheelBetaBevelColorIndex)
}
drawCenteredText(img, label, 250, wheelBetaInkColorIndex)
drawCenteredText(img, value, 270, wheelBetaInkColorIndex)
}
func drawCenteredText(ctx *canvas.Context, text string, topY float64, colorIndex byte) {
drawCenteredTextAt(ctx, text, float64(wheelBetaSize)/2, topY, colorIndex)
func drawCenteredText(img *image.Paletted, text string, baselineY int, colorIndex byte) {
drawCenteredTextAt(img, text, wheelBetaSize/2, baselineY, colorIndex)
}
func drawCenteredTextAt(ctx *canvas.Context, text string, centerX, topY float64, colorIndex byte) {
face := newWheelBetaTextFace(colorIndex)
ctx.DrawText(centerX, topY, canvas.NewTextLine(face, text, canvas.Center))
func drawCenteredTextAt(img *image.Paletted, text string, centerX, baselineY int, colorIndex byte) {
face := basicfont.Face7x13
width := font.MeasureString(face, text).Ceil()
x := centerX - width/2
drawer := font.Drawer{
Dst: img,
Src: image.NewUniform(wheelBetaPalette[colorIndex]),
Face: face,
Dot: fixed.P(x, baselineY),
}
drawer.DrawString(text)
}
func drawRotatedCenteredTextAt(ctx *canvas.Context, text string, centerX, centerY, angle float64, colorIndex byte) {
face := newWheelBetaTextFace(colorIndex)
topY := centerY - face.Metrics().LineHeight/2
ctx.Push()
ctx.RotateAbout(angle*180/math.Pi, centerX, centerY)
ctx.DrawText(centerX, topY, canvas.NewTextLine(face, text, canvas.Center))
ctx.Pop()
func drawRotatedCenteredTextAt(img *image.Paletted, text string, centerX, centerY int, angle float64, colorIndex byte) {
face := basicfont.Face7x13
padding := 2
metrics := face.Metrics()
textWidth := font.MeasureString(face, text).Ceil()
textHeight := metrics.Height.Ceil()
mask := image.NewAlpha(image.Rect(0, 0, textWidth+padding*2, textHeight+padding*2))
drawer := font.Drawer{
Dst: mask,
Src: image.NewUniform(color.Alpha{A: 255}),
Face: face,
Dot: fixed.P(padding, padding+metrics.Ascent.Ceil()),
}
drawer.DrawString(text)
sourceCenterX := float64(mask.Bounds().Dx()-1) / 2
sourceCenterY := float64(mask.Bounds().Dy()-1) / 2
sin, cos := math.Sin(angle), math.Cos(angle)
for y := mask.Bounds().Min.Y; y < mask.Bounds().Max.Y; y++ {
for x := mask.Bounds().Min.X; x < mask.Bounds().Max.X; x++ {
if mask.AlphaAt(x, y).A == 0 {
continue
}
localX := float64(x) - sourceCenterX
localY := float64(y) - sourceCenterY
targetX := centerX + int(math.Round(localX*cos-localY*sin))
targetY := centerY + int(math.Round(localX*sin+localY*cos))
setWheelBetaPixel(img, targetX, targetY, colorIndex)
}
}
}
func wheelBetaDisplayText(s string, limit int) string {
var b strings.Builder
count := 0
for _, r := range s {
for _, r := range norm.NFD.String(s) {
if unicode.Is(unicode.Mn, r) {
continue
}
if count >= limit {
break
}
r = wheelBetaASCIIRune(r)
if unicode.IsControl(r) {
b.WriteByte('?')
count++
continue
}
if r < 32 || r > 126 {
b.WriteByte('?')
count++
continue
}
b.WriteRune(r)
count++
}
@@ -266,35 +280,65 @@ func wheelBetaDisplayText(s string, limit int) string {
return out
}
func drawSpark(ctx *canvas.Context, cx, cy float64, colorIndex byte) {
drawWheelBetaLine(ctx, cx-2, cy, cx+2, cy, 1, colorIndex)
drawWheelBetaLine(ctx, cx, cy-2, cx, cy+2, 1, colorIndex)
func wheelBetaASCIIRune(r rune) rune {
switch r {
case 'đ':
return 'd'
case 'Đ':
return 'D'
default:
return r
}
}
func drawWheelBetaLine(ctx *canvas.Context, x0, y0, x1, y1, width float64, colorIndex byte) {
ctx.SetFillColor(canvas.Transparent)
ctx.SetStrokeColor(wheelBetaPalette[colorIndex])
ctx.SetStrokeWidth(width)
path := &canvas.Path{}
path.MoveTo(x0, y0)
path.LineTo(x1, y1)
ctx.DrawPath(0, 0, path)
ctx.Stroke()
func drawSpark(img *image.Paletted, cx, cy int, colorIndex byte) {
for y := cy - 1; y <= cy+1; y++ {
for x := cx - 1; x <= cx+1; x++ {
if x == cx || y == cy {
setWheelBetaPixel(img, x, y, colorIndex)
}
}
}
}
func drawWheelBetaRect(ctx *canvas.Context, x0, y0, x1, y1 float64, colorIndex byte) {
path := &canvas.Path{}
path.MoveTo(x0, y0)
path.LineTo(x1, y0)
path.LineTo(x1, y1)
path.LineTo(x0, y1)
path.Close()
drawWheelBetaPath(ctx, path, colorIndex)
func drawPalettedLine(img *image.Paletted, x0, y0, x1, y1 int, colorIndex byte) {
dx := absInt(x1 - x0)
sx := -1
if x0 < x1 {
sx = 1
}
dy := -absInt(y1 - y0)
sy := -1
if y0 < y1 {
sy = 1
}
err := dx + dy
for {
setWheelBetaPixel(img, x0, y0, colorIndex)
if x0 == x1 && y0 == y1 {
return
}
e2 := 2 * err
if e2 >= dy {
err += dy
x0 += sx
}
if e2 <= dx {
err += dx
y0 += sy
}
}
}
func drawWheelBetaPath(ctx *canvas.Context, path *canvas.Path, colorIndex byte) {
ctx.SetStrokeColor(canvas.Transparent)
ctx.SetFillColor(wheelBetaPalette[colorIndex])
ctx.DrawPath(0, 0, path)
ctx.Fill()
func setWheelBetaPixel(img *image.Paletted, x, y int, colorIndex byte) {
if image.Pt(x, y).In(img.Rect) {
img.SetColorIndex(x, y, colorIndex)
}
}
func absInt(v int) int {
if v < 0 {
return -v
}
return v
}
@@ -1,45 +0,0 @@
---
phase: 1
title: Add Canvas Dependency
status: completed
effort: ''
priority: P2
dependencies: []
---
# Phase 1: Add Canvas Dependency
## Overview
Add `github.com/tdewolff/canvas` as the renderer dependency and inspect its local API surface enough to choose the smallest integration path.
## Requirements
- Functional: project builds with the new dependency available to `internal/modules/misc`.
- Non-functional: avoid GUI/game/native-runtime dependencies; keep dependency addition Go-only.
## Architecture
Dependency is used only by the wheel beta renderer. GIF encoding remains in `image/gif`; canvas is only responsible for drawing frame imagery into a Go image.
## Related Code Files
- Modify: `go.mod`
- Modify: `go.sum`
- Read: module docs/source under Go module cache as needed.
## Implementation Steps
1. Run `go get github.com/tdewolff/canvas@latest`.
2. Inspect the installed package APIs for raster image rendering and font/text support.
3. Record any API constraints directly in implementation notes or comments only if useful.
## Success Criteria
- [x] `go.mod` includes `github.com/tdewolff/canvas`.
- [x] No native runtime package such as raylib/cairo/skia is introduced.
- [x] `go test ./internal/modules/misc -run TestWheelOfNamesBeta_RenderGIFTiming -count=1` still compiles or fails only on not-yet-migrated code.
## Risk Assessment
Risk: canvas latest is pseudo-version and API may differ from docs. Mitigation: inspect local package docs/source before editing renderer.
@@ -1,55 +0,0 @@
---
phase: 2
title: Migrate Renderer
status: completed
effort: ''
priority: P1
dependencies:
- 1
---
# Phase 2: Migrate Renderer
## Overview
Move wheel beta frame drawing onto `tdewolff/canvas` while retaining the current frame-generation functions and observable command behavior.
## Requirements
- Functional: `renderWheelBetaFrame` returns frames with same size, palette expectations, pointer alignment, status band, labels, and celebration states.
- Non-functional: keep renderer headless, deterministic, and local to misc module.
## Architecture
The spin profile and GIF assembly stay unchanged. The per-frame renderer creates a canvas-backed raster image, draws the wheel using paths/text/transforms, converts the result to the existing GIF palette, then returns `*image.Paletted` for `gif.EncodeAll`.
```text
spin profile -> canvas frame renderer -> paletted frame -> image/gif.EncodeAll
```
## Related Code Files
- Modify: `internal/modules/misc/wheelofnames_beta.go`
- Modify: `internal/modules/misc/wheelofnames_beta_drawing.go`
- Modify: `internal/modules/misc/handlers_test.go` only if internal rendering tests need adaptation.
## Implementation Steps
1. Add canvas frame creation and raster-to-paletted conversion helpers.
2. Port slices, rim, pointer, highlight, status band, title/status text, labels, and celebration drawing to canvas-backed helpers.
3. Preserve current constants where possible so tests and behavior remain stable.
4. Keep `renderWheelBetaFrame`, `renderWheelBetaFrameWithStatus`, and `renderWheelBetaFrameWithCelebration` call sites intact.
5. Remove obsolete manual text-mask helpers only if no tests depend on them; otherwise update tests to target observable behavior.
## Success Criteria
- [x] Existing GIF timing/frame transition tests pass.
- [x] Existing pointer and winner slice tests pass.
- [x] Existing label placement/rotation and Vietnamese glyph tests pass.
- [x] No command registration, caption, filename, dimensions, or duration changes.
## Risk Assessment
Risk: palette conversion can shift exact color indexes and break tests. Mitigation: keep final paletted frame using `wheelBetaPalette` and adjust only tests that were over-coupled to manual-pixel internals.
Risk: canvas text APIs may require font face plumbing that is heavier than expected. Mitigation: keep embedded font bytes and use canvas text/font APIs only within misc renderer.
@@ -1,52 +0,0 @@
---
phase: 3
title: Validate Behavior
status: completed
effort: ''
priority: P1
dependencies:
- 2
---
# Phase 3: Validate Behavior
## Overview
Verify the canvas-backed renderer preserves command behavior and does not introduce lint, vet, or repo-wide test failures.
## Requirements
- Functional: all acceptance criteria from `plan.md` are verified by tests or code inspection.
- Non-functional: lint and vet pass before any commit.
## Architecture
Validation centers on existing misc tests plus repo-wide Go gates because command behavior and renderer internals share misc package files.
## Related Code Files
- Read/verify: `internal/modules/misc/handlers_test.go`
- Read/verify: `internal/modules/misc/wheelofnames_beta*.go`
- Read/verify: `go.mod`
## Implementation Steps
1. Run focused wheel beta tests first.
2. Run full misc package tests.
3. Run `go test ./...`.
4. Run `go vet ./...`.
5. Run `golangci-lint run`.
6. Review diff for public contract changes and unintended docs/command surface changes.
## Success Criteria
- [x] Focused wheel beta tests pass.
- [x] `go test ./internal/modules/misc -count=1` passes.
- [x] `go test ./...` passes.
- [x] `go vet ./...` passes.
- [x] `golangci-lint run` passes.
- [x] Diff contains no command rename, menu, storage, or Telegram payload contract changes.
## Risk Assessment
Risk: transitive dependencies increase lint/test time. Mitigation: measure via standard gates and keep changes scoped.
@@ -1,85 +0,0 @@
---
title: Wheel Beta Canvas Renderer
description: >-
Migrate /wheelofnamesbeta frame rendering from manual pixel drawing to
github.com/tdewolff/canvas while preserving Telegram behavior.
status: completed
priority: P2
branch: main
tags:
- feature
- backend
- graphics
blockedBy: []
blocks: []
created: '2026-07-06T07:48:34.819Z'
createdBy: 'ck:plan'
source: skill
---
# Wheel Beta Canvas Renderer
## Overview
Migrate `/wheelofnamesbeta` frame rendering to `github.com/tdewolff/canvas` so wheel slices, text, highlights, rim, pointer, and celebration effects are drawn through a higher-level vector renderer. Keep the existing Telegram command contract and stdlib GIF encoding.
Expected output: `/wheelofnamesbeta <options>` still sends a 320x320 animated GIF named `wheelofnamesbeta.gif`, but frames are generated through canvas-backed rendering instead of manual low-level pixel/text stamping.
Acceptance criteria:
- GIF frame count, loop count, delays, duration, width, and height stay unchanged.
- Winner/current option tracking remains aligned with the 3h pointer.
- Vietnamese option text still renders without `?` glyph replacement.
- Slice labels still rotate with their slices and appear inside the wheel.
- `/wheelofnamesbeta` send behavior and caption stay unchanged.
- Focused misc tests, `go test ./...`, `go vet ./...`, and `golangci-lint run` pass.
Scope boundary:
- Do not add `gifski`, ffmpeg, shell commands, cgo graphics stacks, or external runtime binaries.
- Do not change command names, command menu metadata, Telegram API payload shape, or spin-profile math.
- Do not refactor unrelated misc commands.
Constraints:
- Go-only/headless deployment for Coolify.
- Keep embedded DejaVu Sans font use; no runtime font discovery.
- Keep changes localized to `internal/modules/misc` and module dependency files unless tests require otherwise.
- Preserve public/internal function signatures where tests and command code depend on them.
Scout context:
- Project is a Go 1.25 Telegram bot with pluggable modules under `internal/modules`.
- Current wheel beta code lives in `internal/modules/misc/wheelofnames_beta.go`, `wheelofnames_beta_drawing.go`, `wheelofnames_beta_spin_profile.go`, and `wheelofnames_beta_command.go`.
- Existing tests in `internal/modules/misc/handlers_test.go` cover GIF timing, frame transitions, pointer alignment, label placement/rotation, Vietnamese glyph support, and Telegram send payloads.
- Prior completed plan `plans/260703-0404-wheelofnames-misc-command` is complete and non-blocking.
- Research report `plans/reports/260706-1441-wheel-beta-gif-rendering-library-research.md` recommends `tdewolff/canvas` as renderer and keeping `image/gif` first.
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Add Canvas Dependency](./phase-01-add-canvas-dependency.md) | Completed |
| 2 | [Migrate Renderer](./phase-02-migrate-renderer.md) | Completed |
| 3 | [Validate Behavior](./phase-03-validate-behavior.md) | Completed |
## Dependencies
Cross-plan dependencies: none.
Implementation dependencies:
- Add `github.com/tdewolff/canvas` via `go get`.
- Continue using stdlib `image/gif` for animation encoding.
- Continue using current embedded `internal/modules/misc/fonts/dejavu-sans.ttf`.
## Touchpoints
| File | Action |
|---|---|
| `go.mod` / `go.sum` | Add canvas module and transitive dependency metadata. |
| `internal/modules/misc/wheelofnames_beta.go` | Route frame generation through canvas-backed renderer while preserving GIF contract. |
| `internal/modules/misc/wheelofnames_beta_drawing.go` | Replace or reduce manual draw helpers with canvas equivalents where practical. |
| `internal/modules/misc/handlers_test.go` | Adjust/add tests only where rendering internals legitimately change. |
## Verification
- `go test ./internal/modules/misc -count=1`
- `go test ./...`
- `go vet ./...`
- `golangci-lint run`
@@ -1,203 +0,0 @@
---
type: research-report
created: 2026-07-06 14:41 UTC
topic: wheel beta gif rendering library options
status: complete
---
# Research Report: Wheel Beta GIF Rendering Library
## Summary
Best fit: use `github.com/tdewolff/canvas` for frame rendering, keep the current stdlib `image/gif` encoder first, and evaluate `gifski` only if GIF color quality remains poor.
Reason: this bot runs server-side as a Go Telegram bot. The rendering stack should stay deterministic, headless, and low-ops. `canvas` gives higher-level vector drawing, affine transforms, text handling, gradients, and raster output without bringing a GUI/game runtime. `gifski` is excellent for final GIF encoding quality, but it adds CLI/C library operational complexity.
Important correction: current code does not use `x/image` to encode GIFs. It uses standard `image/gif` for animation encoding and `golang.org/x/image/font/opentype` for font rasterization. Replacing direct `x/image` usage mostly means replacing our low-level text/glyph-mask drawing path.
## Method
- Scope: Go libraries suitable for rendering animated wheel GIF frames for `/wheelofnamesbeta`.
- Criteria: server deploy friction, text quality, transforms, gradients, animation frame pipeline, dependency size, license, maintenance, integration cost.
- Sources: project code, pkg.go.dev, GitHub READMEs, project homepages.
- Search limit: within `ck:research` 5-call max.
## Current State
Current wheel beta stack:
```text
spin math -> render each frame into image.Paletted -> image/gif.EncodeAll
|
+-> direct pixel loops for slices, rim, pointer
+-> x/image/opentype for title/status/label text
```
Pain points:
- Manual pixel loops make anti-aliasing and gradients awkward.
- Rotated text is custom mask stamping.
- Fixed 14-color palette restricts smooth highlights and anti-aliased text.
- Better animation feel is already mostly math; better visual output needs a higher-level renderer and possibly better quantization.
## Recommendation
### 1. Primary Renderer: `tdewolff/canvas`
Use `github.com/tdewolff/canvas` for drawing each wheel frame into a raster image.
Why:
- It is a Go vector graphics library that outputs raster formats, SVG, PDF, EPS, and more. Its README describes it as a Cairo/node-canvas-style target with path manipulation and text formatting support.
- It supports text drawing through `Context.DrawText`, view transforms through `Context.Rotate`, and raster writers including PNG/GIF via `renderers`.
- The package is current enough for this repo: `go list -m -json github.com/tdewolff/canvas@latest` returned `v0.0.0-20260617131110-529326a1955e` with Go 1.25.
- It avoids GUI/display server concerns.
Trade-offs:
- More complex API than `gg`.
- Not v1/stable-tagged on pkg.go.dev.
- Full package has many imports; use only the subpackages needed.
- It may still use Go image-related packages internally. The value is removing our direct low-level glyph/pixel renderer, not purging every image dependency.
Fit for wheel:
```text
for each frame:
c := canvas.New(widthMM, heightMM)
ctx := canvas.NewContext(c)
draw slices as paths/arcs
draw labels with text object + rotated view
draw pointer, rim, status band
rasterize to RGBA/paletted image
append frame to GIF
```
### 2. Keep `image/gif` Initially
Keep `image/gif.EncodeAll` for first migration. It is already working, tested, and simple.
Change only the frame source first:
```text
old: renderWheelBetaFrame(...) *image.Paletted
new: renderWheelBetaFrameRGBA(...) *image.RGBA
palettize frame
gif.EncodeAll(...)
```
If anti-aliasing is added, the fixed 14-color palette will limit quality. Move to a larger 128/256-color palette before introducing external encoders.
### 3. Optional Encoder Upgrade: `gifski`
Use `gifski` only if we specifically need better GIF color quality after renderer migration.
Why:
- `gifski` is designed for high-quality GIF conversion. Its docs highlight pngquant palettes, temporal dithering, lossy LZW, temporal smoothing, and denoising.
- It accepts PNG frames through CLI, and can also be compiled as a C library.
Why not first:
- Adds external binary or C/Rust/FFI complexity.
- More moving parts in Coolify/self-host deploy.
- Current animation is only 320x320 and simple; the standard GIF pipeline may be good enough with a better renderer and palette.
## Comparison
| Option | Role | Pros | Cons | Verdict |
|---|---|---|---|---|
| `tdewolff/canvas` | Vector renderer | Good transforms, paths, text, gradients, raster targets, current Go metadata | Larger API/import surface, no v1 tag | Best primary renderer |
| `fogleman/gg` | Simple 2D RGBA renderer | Very easy API, anchored text, transforms, gradients | Last tagged release 2019; still uses font/image stack; less advanced text/layout | Good fallback for quick prototype |
| `llgcode/draw2d` | Canvas-style vector renderer | Pure Go, affine transforms, text, image/PDF/SVG backends | Older API style, less compelling than `canvas` for text/layout | Acceptable but not first choice |
| `gifski` | GIF encoder | Best output quality, temporal dithering, PNG-frame input | External binary/C library operational cost | Optional phase 2 encoder |
| `raylib-go` | Game/graphics runtime | Mature 2D/game APIs and animation examples | OpenGL/window/runtime requirements, first build can be heavy, poor fit for headless bot | Avoid |
| Cairo/Skia bindings | Native renderer | Excellent rendering quality | C/C++ deps, cgo/build complexity | Avoid unless Go-native options fail |
## Implementation Path
1. Add a renderer seam:
```go
type wheelBetaFrameRenderer interface {
Render(options []string, winner int, rotation float64, reveal bool, status string, celebrateStep int) image.Image
}
```
2. Keep current renderer as baseline.
3. Add `canvas` renderer behind a small internal function, not public command behavior.
4. Preserve current animation contract:
- same frame count
- same delay array
- same final winner alignment at 3h pointer
- same Telegram `sendAnimation` behavior
- same Vietnamese glyph coverage
5. Add visual-focused tests:
- GIF decodes and has expected frame count/delay.
- Result frame differs from last spin frame.
- Text appears near expected slice label centers.
- Current/winner index still tracks pointer.
- Optional golden-ish image metrics: count non-background pixels, pointer bounds, label bounds.
6. Only after renderer looks better, decide whether encoder upgrade is needed:
- If color banding/text edges remain bad: evaluate 256-color palette and `gifski`.
- If file size too large: tune frame count, palette, or consider MP4 upload if acceptable for Telegram.
## Code Sketch
```go
func renderWheelOfNamesBetaGIF(options []string, winner int) ([]byte, error) {
frames := make([]*image.Paletted, 0, wheelBetaSpinFrames+wheelBetaHoldFrames)
delays := make([]int, 0, cap(frames))
renderer := newWheelBetaCanvasRenderer()
profile := newWheelBetaSpinProfile(len(options), winner, nil)
for i := 0; i < wheelBetaSpinFrames; i++ {
t := float64(i) / float64(wheelBetaSpinFrames-1)
rgba := renderer.Render(options, winner, profile.rotationAt(t), false, profile.statusAt(t), -1)
frames = append(frames, palettizeWheelFrame(rgba))
delays = append(delays, wheelBetaSpinDelay)
}
// hold frames unchanged
return encodeWheelGIF(frames, delays)
}
```
Keep this as an internal refactor; no Telegram command contract change.
## Security And Operations
- Prefer pure Go. Avoid spawning shell commands from message handlers unless strongly justified.
- If using `gifski` CLI later, never pass user-controlled paths or shell strings; write temp frames in a controlled temp dir and execute with `exec.Command` args.
- Watch memory: 50 frames at 320x320 RGBA is about 20 MB before GIF/palette overhead. Fine, but avoid retaining extra PNG buffers if using `gifski`.
- Avoid runtime font discovery. Continue embedding font bytes in the Go package.
## Source Notes
- `tdewolff/canvas` README: vector graphics target with raster output and text formatting; can output PNG/JPG/GIF and other formats. Source: https://github.com/tdewolff/canvas
- `tdewolff/canvas/renderers`: GIF writer accepts `canvas.Resolution`, `canvas.Colorspace`, and `image/gif.*Options`; `Write` supports `.gif`, `.png`, `.webp`, `.svg`, `.pdf`, and more. Source: https://pkg.go.dev/github.com/tdewolff/canvas/renderers
- `tdewolff/canvas` pkg metadata: latest pseudo-version published 2026-06-17, MIT, Go 1.25. Source: https://pkg.go.dev/github.com/tdewolff/canvas
- `fogleman/gg`: creates `image.RGBA` contexts, supports text drawing, anchored text, gradients, transforms, and rotated drawing helpers. Source: https://pkg.go.dev/github.com/fogleman/gg and https://github.com/fogleman/gg
- `draw2d`: supports image/PDF/OpenGL/SVG outputs, text rendering with TrueType fonts, and affine transforms. Source: https://pkg.go.dev/github.com/llgcode/draw2d and https://github.com/llgcode/draw2d
- `gifski`: uses pngquant palettes, temporal dithering, lossy LZW compression, smoothing/denoising; supports CLI and C library integration. Source: https://gif.ski/ and https://github.com/ImageOptim/gifski
- `raylib-go`: game-oriented Go bindings; includes raylib C source and has Linux graphics dependencies or embedded native libraries depending mode. Source: https://github.com/gen2brain/raylib-go
- Cairo: strong renderer but C library/bindings; supports affine transforms and antialiased text, but increases native dependency surface. Source: https://www.cairographics.org/
## Next Steps
1. Prototype `tdewolff/canvas` in a small internal spike rendering one static wheel frame to PNG/GIF.
2. Compare output against current renderer at 320x320 and maybe 640x640 downsampled.
3. If quality is clearly better and build/test cost acceptable, migrate `/wheelofnamesbeta` frame rendering.
4. Only then evaluate `gifski` or broader GIF quantization.
## Unresolved Questions
None.