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 3b05a32..f3349d6 100644 Binary files a/Emotes/Pepes/Nerdge.tga and b/Emotes/Pepes/Nerdge.tga differ 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()