1
0
mirror of https://github.com/brues-code/TwitchEmotes.git synced 2026-09-21 23:26:57 +00:00

Pack animation sheets as tall strips

ClassicAPI's texture dimension gate lifts the two limits the sheet layout was
built around. A sheet skinnier than 16:1 drawing nothing was never a client
rule -- VanillaHelpers grew the texture recycle pool to 6x6 but left the index
stride at 5, so a 32x1024 strip collided with a 64x32 bucket and was handed
back the wrong texture. And power-of-two is no longer required, since the gate
grows the decode scratch to fit.

So a run wraps into columns only when it outgrows the scratch at 32 frames, and
a sheet is sized to its frames exactly: 20 frames is a 32x640 strip rather than
64x1024. The 128 frame budget is unchanged, and older sheets still play -- the
animator reads the column count off the sheet.
This commit is contained in:
Brues
2026-09-14 13:58:06 -05:00
parent a9297cc58b
commit ce8038ed03
2 changed files with 27 additions and 30 deletions
+5 -5
View File
@@ -21,11 +21,11 @@ local function GetCurrentFrameNum(animdata)
return math.floor((TWITCHEMOTES_T * animdata.framerate) % animdata.nFrames)
end
-- Frames run row-major across however many columns the sheet is wide. Two
-- client limits force that: no texture side may exceed 1024, and a texture
-- skinnier than 16:1 doesn't draw at all -- a 32x1024 strip renders nothing,
-- while the same frames as 64x512 render fine. So a run longer than 16 frames
-- is packed in columns rather than as one tall strip.
-- Frames run row-major across however many columns the sheet is wide, since a
-- run longer than 32 frames outgrows the 1024px decode scratch as one strip.
-- Older sheets are packed more densely than that: before ClassicAPI's texture
-- dimension gate a sheet skinnier than 16:1 drew nothing, so runs wrapped at 16
-- frames. Deriving the column count from the sheet handles either.
local function GetFrameRect(animdata, framenum)
local cols = math.floor(animdata.imageWidth / animdata.frameWidth)
if cols < 1 then cols = 1 end
+22 -25
View File
@@ -7,12 +7,12 @@
Downloads the emote, converts it to a texture the 1.12 client can decode, and
registers it in Emotes.lua (and in the minimap dropdown's pack list).
Two client constraints drive the conversion:
Two constraints drive the conversion:
* Textures are capped at 1024px per side and power-of-two, so an animation
longer than 32 frames is packed row-major across several 32px columns
rather than one over-tall strip. TwitchEmotesAnimator derives the column
count from imageWidth / frameWidth.
* A sheet may not exceed 1024px per side, so an animation longer than 32
frames is packed row-major across several 32px columns rather than one
over-tall strip. TwitchEmotesAnimator derives the column count from
imageWidth / frameWidth.
* The animator plays frames at one constant rate off a ~30fps ticker, but a
GIF holds each frame for as long as it likes. The source timeline is
resampled at a constant rate instead: a long hold repeats, and a source
@@ -32,8 +32,7 @@ from PIL import Image, ImageSequence
CDN = 'https://cdn.betterttv.net/emote/%s/%s'
FRAME = 32 # cell height in the sheet; a wide emote's cell is wider
DISPLAY = 28 # rendered height in a chat line
MAX_TEXTURE = 1024 # client cap, per side
MAX_ASPECT = 16 # see layout(): skinnier than this and nothing draws
MAX_TEXTURE = 1024 # side of the decode scratch, per side
MAX_COLS = 4 # 4 * 32 = 128px wide, 128 frames at most
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -127,20 +126,19 @@ def resample(durations, budget):
return fps, indices
def pot(n):
v = 1
while v < n:
v *= 2
return v
def layout(count, cell):
"""Column count and texture size the client can actually sample.
"""Column count and texture size for `count` frames.
Two limits, both found the hard way: no side may exceed 1024, and the sheet
may not be skinnier than 16:1 - a 32x1024 strip (32:1) draws nothing at all,
while the same frames as 64x512 draw fine. So a run longer than 16 frames
goes to two columns rather than growing the strip.
ClassicAPI's dimension gate lifts 1.12's power-of-two rule and grows the
decode scratch to fit, so the sheet is sized to its frames exactly. One
limit is left - the scratch's own side, 1024 - which a run longer than 32
frames outgrows, so it wraps into further 32px columns.
(A sheet skinnier than 16:1 used to draw nothing, which forced columns much
sooner. That was never a client rule: VanillaHelpers grew the texture
recycle pool to 6x6 but left the index stride at 5, so a 32x1024 strip
collided with a 64x32 bucket and got handed back the wrong texture. The gate
rewrites both index sites to stride 6.)
A non-square cell stays single-column: the animator derives its column count
as imageWidth / frameWidth, which only holds when the cell tiles the texture
@@ -148,14 +146,13 @@ def layout(count, cell):
"""
cw, ch = cell
for cols in ((1,) if cw != FRAME else (1, 2, 4)):
width = pot(cols * cw)
width = cols * cw
rows = -(-count // cols)
height = pot(rows * ch)
if (max(width, height) <= MAX_TEXTURE and
height <= width * MAX_ASPECT and width <= height * MAX_ASPECT):
height = rows * ch
if max(width, height) <= MAX_TEXTURE:
return cols, width, height
sys.exit('%d frames of %dx%d do not fit a sheet within %dpx and %d:1'
% (count, cw, ch, MAX_TEXTURE, MAX_ASPECT))
sys.exit('%d frames of %dx%d do not fit a sheet within %dpx'
% (count, cw, ch, MAX_TEXTURE))
def write_tga(im, path):