From 94777ecbc426d863883043f3f84f10aed2b7e1dd Mon Sep 17 00:00:00 2001 From: Brues <5278969+brues-code@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:00:11 -0500 Subject: [PATCH] Add an emote installer, animate Nerdge tools/add_emote.py downloads a BetterTTV emote, converts it to a texture the 1.12 client decodes, and registers it in defaultpack, emoticons, the animation table and the minimap pack list. Two client limits shape the conversion. Textures cap at 1024px per side, so anything longer than 32 frames packs row-major across several 32px columns. And the animator plays one constant rate while a GIF holds individual frames for different lengths, so uneven delays expand into repeated frames at the integer rate that reproduces the source timing with the fewest frames - a faster rate always matches the duration more exactly but pays for it in texture size. Nerdge is the first emote through it, replacing the static art with the animated original: 17 source frames over 3.12s become 36 frames at 11fps (3.27s) in a 64x1024 sheet, keeping its hold, push, hold shape. .pkgmeta is tracked now so the release packager honours its ignore list and leaves tools out of the zip. --- .pkgmeta | 1 + Emotes.lua | 3 +- Emotes/Pepes/Nerdge.tga | Bin 4114 -> 262162 bytes tools/add_emote.py | 249 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 tools/add_emote.py diff --git a/.pkgmeta b/.pkgmeta index 006625b..1835494 100644 --- a/.pkgmeta +++ b/.pkgmeta @@ -2,3 +2,4 @@ package-as: TwitchEmotes ignore: - .luarc.json + - tools diff --git a/Emotes.lua b/Emotes.lua index fd4c2db..009b494 100644 --- a/Emotes.lua +++ b/Emotes.lua @@ -4039,7 +4039,7 @@ defaultpack={ ["peepoJail"] = "Interface\\AddOns\\TwitchEmotes\\Emotes\\Pepes\\peepoJail.tga:28:28", ["peepoNotes"] = "Interface\\AddOns\\TwitchEmotes\\Emotes\\Pepes\\peepoNotes.tga:28:28", ["Awakge"] = "Interface\\AddOns\\TwitchEmotes\\Emotes\\Pepes\\Awakge.tga:28:28", - ["Nerdge"] = "Interface\\AddOns\\TwitchEmotes\\Emotes\\Pepes\\Nerdge.tga:28:28", + ["Nerdge"] = "Interface\\AddOns\\TwitchEmotes\\Emotes\\Pepes\\Nerdge.tga:28:28:0:0:64:1024:0:32:0:32", [":shy:"]="Interface\\AddOns\\TwitchEmotes\\Emotes\\Pepes\\shy.tga:28:28:0:0:32:64:0:32:0:32", ["peepoSmash"]="Interface\\AddOns\\TwitchEmotes\\Emotes\\Pepes\\peepoSmash.tga:28:28:0:0:32:256:0:32:0:32", ["peepoBye"] = "Interface\\AddOns\\TwitchEmotes\\Emotes\\Pepes\\peepoBye.tga:28:28", @@ -9031,4 +9031,5 @@ TwitchEmotes_animation_metadata = { ["Interface\\AddOns\\TwitchEmotes\\Emotes\\Custom\\LETSGOOO.tga"] = {["nFrames"] = 11, ["frameWidth"] = 32, ["frameHeight"] = 32, ["imageWidth"]=32, ["imageHeight"]=512, ["framerate"] = 20}, ["Interface\\AddOns\\TwitchEmotes\\Emotes\\Custom\\pedro.tga"] = {["nFrames"] = 48, ["frameWidth"] = 32, ["frameHeight"] = 32, ["imageWidth"]=64, ["imageHeight"]=1024, ["framerate"] = 10}, ["Interface\\AddOns\\TwitchEmotes\\Emotes\\Pepes\\peepofeet.tga"] = {["nFrames"] = 3, ["frameWidth"] = 32, ["frameHeight"] = 32, ["imageWidth"]=32, ["imageHeight"]=128, ["framerate"] = 15}, + ["Interface\\AddOns\\TwitchEmotes\\Emotes\\Pepes\\Nerdge.tga"] = {["nFrames"] = 36, ["frameWidth"] = 32, ["frameHeight"] = 32, ["imageWidth"]=64, ["imageHeight"]=1024, ["framerate"] = 11}, } diff --git a/Emotes/Pepes/Nerdge.tga b/Emotes/Pepes/Nerdge.tga index 3b05a32f9907c7dd15e8ed50fe0b41a16e52bffe..f3349d6476e59b8db545c7e2f1305b25cbc0a2eb 100644 GIT binary patch literal 262162 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 diff --git a/tools/add_emote.py b/tools/add_emote.py new file mode 100644 index 0000000..0b2e95a --- /dev/null +++ b/tools/add_emote.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Add a BetterTTV emote to the addon. + + python tools/add_emote.py https://betterttv.com/emotes/ PepeCool + python tools/add_emote.py PepeCool --dir Pepes --pack Pepe + +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: + + * 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. + * The animator plays frames at one constant rate, but GIFs hold individual + frames for different lengths. Uneven delays are expanded into repeated + frames at whichever integer rate reproduces the original timing most + closely within the frame budget. +""" +import argparse +import os +import re +import struct +import sys +import urllib.request + +from PIL import Image, ImageSequence + +CDN = 'https://cdn.betterttv.net/emote/%s/%s' +FRAME = 32 # frame size in the sheet; emotes render at 28x28 +MAX_TEXTURE = 1024 # client cap, per side +MAX_COLS = 4 # 4 * 32 = 128px wide, 128 frames at most +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def emote_id(arg): + m = re.search(r'([0-9a-f]{24})', arg) + if not m: + sys.exit('not a BetterTTV emote id or URL: %s' % arg) + return m.group(1) + + +def download(eid): + last = None + for size in ('3x', '2x', '1x'): + try: + with urllib.request.urlopen(CDN % (eid, size), timeout=30) as r: + return r.read() + except Exception as exc: # noqa: BLE001 - try the next size + last = exc + sys.exit('could not download emote %s: %s' % (eid, last)) + + +def load_frames(blob): + """Frames as 32x32 RGBA, plus each frame's display duration in ms.""" + from io import BytesIO + im = Image.open(BytesIO(blob)) + frames, durations = [], [] + for page in ImageSequence.Iterator(im): + rgba = page.convert('RGBA') + fitted = Image.new('RGBA', (FRAME, FRAME), (0, 0, 0, 0)) + scaled = rgba.copy() + scaled.thumbnail((FRAME, FRAME), Image.LANCZOS) + fitted.paste(scaled, ((FRAME - scaled.width) // 2, + (FRAME - scaled.height) // 2)) + frames.append(fitted) + durations.append(page.info.get('duration') or 0) + return frames, durations + + +TIMING_TOLERANCE = 0.05 # 5% drift over a loop is not perceptible + + +def pick_framerate(durations, budget): + """Integer fps whose repeated frames reproduce the source timing. + + Returns (fps, repeats). Every frame is shown at least once, so a rate too + slow to represent the shortest delay simply rounds it up. Among the rates + that hold the loop length within tolerance, the one needing the fewest + frames wins - a faster rate can always match the duration more exactly, but + it pays for that in texture size. + """ + total = sum(durations) / 1000.0 + candidates = [] + for fps in range(1, 31): + repeats = [max(1, int(round(d * fps / 1000.0))) for d in durations] + if sum(repeats) > budget: + continue + error = abs(sum(repeats) / float(fps) - total) + candidates.append((error, sum(repeats), fps, repeats)) + if not candidates: + sys.exit('animation does not fit in %d frames' % budget) + tolerance = max(total * TIMING_TOLERANCE, 0.05) + within = [c for c in candidates if c[0] <= tolerance] + best = min(within or candidates, key=lambda c: (c[1], c[0])) + return best[2], best[3] + + +def layout(count): + """Smallest column count whose rows fit the texture cap.""" + for cols in (1, 2, 4): + rows = -(-count // cols) + if rows * FRAME <= MAX_TEXTURE: + height = FRAME + while height < rows * FRAME: + height *= 2 + return cols, height + sys.exit('%d frames exceed the %dpx texture cap' % (count, MAX_TEXTURE)) + + +def write_tga(im, path): + """Uncompressed 32-bit BGRA, top-down origin - what the client decodes.""" + w, h = im.size + r, g, b, a = im.split() + header = struct.pack(' name) is left alone. + src, hits = re.subn(r'(\["%s"\]\s*=\s*")Interface\\\\AddOns[^"]*(")' + % re.escape(name), lambda m: m.group(1) + spec + m.group(2), + src, count=1) + if hits != 1: + sys.exit('could not find the defaultpack row for %s' % name) + else: + # defaultpack (name -> texture) and emoticons (typed token -> name) are + # two separate tables; Emoticons_Deformat walks emoticons and indexes + # defaultpack with the same key, so both need the name. + src = insert_before(src, '\n };\n emoticons={', + '\t["%s"]="%s",' % (name, spec)) + src = insert_before(src, '\n };\n\nTwitchEmotes_animation_metadata', + '\t["%s"]="%s",' % (name, name)) + + # A sheet needs an animation entry; a static texture must not have one. + src = re.sub(r'\t\["%s"\] = \{\["nFrames"\][^\n]*\n' % re.escape(path), '', src) + if sheet_info: + entry = ('\t["%s"] = {["nFrames"] = %d, ["frameWidth"] = %d, ' + '["frameHeight"] = %d, ["imageWidth"]=%d, ["imageHeight"]=%d, ' + '["framerate"] = %d},' % + (path, nframes, FRAME, FRAME, cols * FRAME, height, fps)) + src = src.rstrip('\n') + assert src.endswith('}'), 'unexpected end of Emotes.lua' + src = src[:-1] + entry + '\n}\n' + open(emotes_lua, 'w', encoding='utf-8', newline='').write(src) + if known: + return + + # dropdown_options in TwitchEmotes.lua drives the minimap menu; keep each + # pack sorted, since the submenu pages are labelled by their first and last + # entry. + main_lua = os.path.join(ROOT, 'TwitchEmotes.lua') + main = open(main_lua, encoding='utf-8').read() + pat = re.compile(r'(\[\d+\]=\s*\{"%s"(.*?)\},\n)' % re.escape(pack)) + m = pat.search(main) + if not m: + sys.exit('no dropdown pack named %r in TwitchEmotes.lua' % pack) + names = re.findall(r'"([^"]*)"', m.group(1)) + if name in names[1:]: + sys.exit('%s is already in the %s menu' % (name, pack)) + body = sorted(names[1:] + [name], key=lambda s: s.lower()) + # the match starts at '[' so the line's existing indent is already in place + line = '[%s]= {%s},\n' % ( + re.match(r'\[(\d+)\]', m.group(1)).group(1), + ','.join('"%s"' % n for n in [names[0]] + body)) + main = main[:m.start(1)] + line + main[m.end(1):] + open(main_lua, 'w', encoding='utf-8', newline='').write(main) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('emote', help='BetterTTV emote id or URL') + ap.add_argument('name', help='chat trigger word, e.g. PepeCool') + ap.add_argument('--dir', default='Custom', help='folder under Emotes/ (default: Custom)') + ap.add_argument('--pack', default='Custom', help='minimap menu pack (default: Custom)') + ap.add_argument('--replace', action='store_true', + help='update an emote that is already registered') + ap.add_argument('--dry-run', action='store_true', help='report only, write nothing') + args = ap.parse_args() + + if not re.match(r'^[%:\w]+$', args.name): + sys.exit('name must be a plain word or :token: - got %r' % args.name) + + frames, durations = load_frames(download(emote_id(args.emote))) + tga = os.path.join(ROOT, 'Emotes', args.dir, args.name + '.tga') + + if len(frames) == 1: + sheet, info = frames[0], None + print('%s: static 32x32' % args.name) + else: + fps, repeats = pick_framerate(durations, MAX_COLS * (MAX_TEXTURE // FRAME)) + sheet, nframes, cols, height = build_sheet(frames, repeats) + info = (nframes, cols, height, fps) + print('%s: %d source frames (%.2fs) -> %d frames at %dfps (%.2fs), ' + '%dx%d sheet in %d column(s)' % + (args.name, len(frames), sum(durations) / 1000.0, nframes, fps, + nframes / float(fps), cols * FRAME, height, cols)) + + if args.dry_run: + print('dry run: would write %s' % os.path.relpath(tga, ROOT)) + return + write_tga(sheet, tga) + register(args.name, args.dir, args.pack, info, args.replace) + print('wrote %s and %s' % (os.path.relpath(tga, ROOT), + 'updated its registration' if args.replace + else 'registered it in the %s pack' % args.pack)) + + +if __name__ == '__main__': + main()