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

5 Commits
v5 ... v6

Author SHA1 Message Date
Brues 3fe9ed86b8 Mirror releases to the Gitea instance, by hand
A Gitea pull mirror replicates commits and tags only, so the mirror's Releases
page shows bare tags with nothing to download. ClassicAPI's mirror-release
workflow pushes the notes and attachments over through Gitea's API; this is
that workflow pointed at brues/TwitchEmotes.

Manual only: workflow_call is dropped, so a release does not trigger it and
nothing else calls it. Run it from the Actions UI with a tag, or with the tag
blank to backfill every release.

Needs its own MIRROR_TOKEN secret on this repo before the first run.
2026-09-14 14:19:20 -05:00
Brues 6348b85740 Draw the stats window with art 1.12 actually ships
Neither texture it asked for exists on this client, and ClassicAPI ships no
art of its own. UI-DialogBox-Background-Dark is a later addition -- 1.12 has
only the un-suffixed UI-DialogBox-Background -- and ItemSocketingFrame is TBC,
so the featured emotes' border was cropped out of a sheet whose folder isn't
there at all.

UI-Quickslot2 is the obvious stand-in for that border, but its hole is ~55% of
the texture, so a 70px emote needs a 128px ring and runs into the title above
and the caption below. It is a small backdrop frame instead, styled like the
autocomplete popup, which keeps the 86/70 sizing. The emote moves onto that
frame to draw above the backdrop; topSentTex/topSeenTex keep their names, so
the animator still finds them.
2026-09-14 14:05:29 -05:00
Brues bea278e730 Add a README 2026-09-14 13:58:06 -05:00
Brues ce8038ed03 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.
2026-09-14 13:58:06 -05:00
Brues a9297cc58b Set changelog-title 2026-09-08 17:30:48 -05:00
6 changed files with 266 additions and 38 deletions
+180
View File
@@ -0,0 +1,180 @@
name: Mirror release
# Copy a GitHub release — its notes and the files attached to it — across to
# the Gitea mirror at https://octowow.st/git/brues/TwitchEmotes.
#
# A Gitea pull mirror replicates commits and tags only. Releases are database
# records with their own attachments, so they never cross: the mirror's
# Releases page shows bare tags with nothing to download. This workflow pushes
# them over through Gitea's API.
#
# ONE-TIME SETUP: on the mirror, go to Settings -> Applications -> Generate New
# Token, tick `repository` write, and copy the token. On GitHub, add it under
# Settings -> Secrets and variables -> Actions as `MIRROR_TOKEN`. The token
# needs push rights on brues/TwitchEmotes.
#
# Manual only — nothing calls it, and a release does not trigger it. Run it from
# the Actions UI with a `tag` to mirror one release, or with `tag` left blank to
# backfill every release that exists on GitHub.
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to mirror (e.g. v2.0.0). Blank = every release.'
required: false
default: ''
permissions:
contents: read
jobs:
mirror:
runs-on: ubuntu-latest
steps:
# No checkout: the release files come from the GitHub release itself, so
# this reads the same bytes a user downloads rather than a rebuild.
- name: Push release to the mirror
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
MIRROR_TOKEN: ${{ secrets.MIRROR_TOKEN }}
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [ -z "${MIRROR_TOKEN:-}" ]; then
echo "::error::MIRROR_TOKEN is not set - see the setup note at the top of .github/workflows/mirror-release.yml"
exit 1
fi
API=https://octowow.st/git/api/v1
MIRROR=brues/TwitchEmotes
RESP=$(mktemp)
# Sends one API call. Prints the HTTP status; leaves the body in $RESP.
req() {
local method=$1 path=$2
shift 2
curl -sS -m 600 -o "$RESP" -w '%{http_code}' -X "$method" \
-H "Authorization: token $MIRROR_TOKEN" \
-H 'Accept: application/json' \
"$API/$path" "$@"
}
fail() {
echo "::error::$1"
echo "the mirror replied: $(head -c 2000 "$RESP")"
exit 1
}
# A release must hang off a tag that is already on the mirror. If the
# tag is missing the mirror has not pulled yet, so ask it to and wait.
# Attaching to a tag the mirror does not have would let its next sync
# turn our release back into a draft.
ensure_tag() {
local tag=$1 i
if [ "$(req GET "repos/$MIRROR/tags/$tag")" = 200 ]; then
return 0
fi
echo " tag $tag is not on the mirror yet - asking it to sync"
req POST "repos/$MIRROR/mirror-sync" > /dev/null || true
for i in $(seq 1 30); do
sleep 10
if [ "$(req GET "repos/$MIRROR/tags/$tag")" = 200 ]; then
echo " tag $tag arrived after $(( i * 10 ))s"
return 0
fi
done
fail "tag $tag never reached the mirror. Sync it, then run this workflow again."
}
mirror_one() {
local tag=$1 work code id f name size have_id have_size enc
work=$(mktemp -d)
echo "=== $tag ==="
gh release view "$tag" --json tagName,name,body,isDraft,isPrerelease > "$work/gh.json"
if [ "$(jq -r .isDraft "$work/gh.json")" = true ]; then
echo " still a draft on GitHub - skipped"
return 0
fi
ensure_tag "$tag"
jq '{tag_name: .tagName,
name: (.name // .tagName),
body: (.body // ""),
draft: false,
prerelease: .isPrerelease}' "$work/gh.json" > "$work/release.json"
# Gitea's tag sync leaves a bare tag record behind. Patching it
# promotes that record to a real release instead of colliding.
code=$(req GET "repos/$MIRROR/releases/tags/$tag")
if [ "$code" = 200 ]; then
id=$(jq -r .id "$RESP")
code=$(req PATCH "repos/$MIRROR/releases/$id" \
-H 'Content-Type: application/json' --data-binary @"$work/release.json")
if [ "$code" != 200 ]; then
fail "could not update release $tag on the mirror (HTTP $code)"
fi
echo " updated release #$id"
else
code=$(req POST "repos/$MIRROR/releases" \
-H 'Content-Type: application/json' --data-binary @"$work/release.json")
if [ "$code" != 201 ]; then
fail "could not create release $tag on the mirror (HTTP $code)"
fi
id=$(jq -r .id "$RESP")
echo " created release #$id"
fi
mkdir -p "$work/assets"
# Exits non-zero when the release carries no files, which is fine.
gh release download "$tag" --dir "$work/assets" --clobber || true
code=$(req GET "repos/$MIRROR/releases/$id/assets")
if [ "$code" != 200 ]; then
fail "could not list the mirror's attachments for $tag (HTTP $code)"
fi
cp "$RESP" "$work/have.json"
for f in "$work"/assets/*; do
[ -f "$f" ] || continue
name=$(basename "$f")
size=$(stat -c %s "$f")
have_id=$(jq -r --arg n "$name" 'map(select(.name == $n)) | .[0].id // ""' "$work/have.json")
have_size=$(jq -r --arg n "$name" 'map(select(.name == $n)) | .[0].size // ""' "$work/have.json")
# Same name and same size: already mirrored. A size mismatch means
# a re-cut release, so replace it - Gitea would otherwise keep both
# and serve the stale one from the older link.
if [ -n "$have_id" ] && [ "$have_size" = "$size" ]; then
echo " $name is already there"
continue
fi
if [ -n "$have_id" ]; then
req DELETE "repos/$MIRROR/releases/$id/assets/$have_id" > /dev/null
fi
enc=$(jq -rn --arg n "$name" '$n | @uri')
code=$(req POST "repos/$MIRROR/releases/$id/assets?name=$enc" -F "attachment=@$f")
if [ "$code" != 201 ]; then
fail "could not upload $name (HTTP $code)"
fi
echo " uploaded $name ($size bytes)"
done
}
if [ -n "${TAG:-}" ]; then
mirror_one "$TAG"
else
echo "no tag given - backfilling every release on GitHub"
gh release list --limit 200 --json tagName --jq '.[].tagName' > /tmp/tags.txt
# fd 3 keeps gh and curl from eating the tag list off stdin.
while read -r t <&3; do
if [ -n "$t" ]; then
mirror_one "$t"
fi
done 3< /tmp/tags.txt
fi
+1
View File
@@ -1,4 +1,5 @@
package-as: TwitchEmotes
changelog-title: TwitchEmotes
ignore:
- .luarc.json
+42
View File
@@ -0,0 +1,42 @@
[![ClassicAPI](https://img.shields.io/badge/ClassicAPI%20>=%20v1.14.0-Required-purple.svg)](https://github.com/brues-code/ClassicAPI)
# TwitchEmotes
Twitch, BetterTTV, FrankerFaceZ and Discord emotes in World of Warcraft 1.12.1.
Type `Kappa`, `PepeLaugh` or `:skull:` in chat and it comes out as the emote — for you and for
everyone else running the addon. Over 4,000 codes across 28 packs, 122 of them animated, rendered
inline in chat, chat bubbles and mail.
Originally written for retail by **Ren**; backported to vanilla by **Brues**.
## Features
- **Emotes everywhere** — say, yell, guild, officer, whisper, party, raid, battleground, channels
and in-game mail. Each channel can be toggled on its own.
- **Animated emotes** at ~30fps, in chat, in the picker, and over chat bubbles.
- **Autocomplete** — type `:` plus two characters for a ranked popup (prefix, then substring, then
fuzzy). Tab cycles the suggestions, space or a click accepts one.
- **Emote picker** on the minimap button, grouped by pack. Mark the packs you use as favourites and
hide the rest.
- **Clickable emotes** — hover an emote in chat to see its code, shift-click to put it in your
edit box.
- **Usage statistics** — how often you have sent and seen each emote (shift-click the minimap
button).
Minimap button: click for the picker, shift-click for statistics, right-click for options.
## Requirements
[ClassicAPI](https://github.com/brues-code/ClassicAPI) is required. The 1.12 client cannot draw
cropped inline textures on its own, which is what animated emotes and sprite sheets need. Without
the DLL loaded the addon disables itself and points you at the download.
## Adding an emote
`tools/add_emote.py` takes a BetterTTV emote, converts it to a texture the 1.12 client can decode,
and registers it:
```
python tools/add_emote.py https://betterttv.com/emotes/<id> PepeCool
```
+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
+16 -8
View File
@@ -49,8 +49,13 @@ local WIN_H = 516
local FALLBACK_TEX = "Interface\\AddOns\\TwitchEmotes\\Emotes\\1337.tga"
local BORDER_TEX = "Interface\\ItemSocketingFrame\\UI-EngineeringSockets"
local BORDER = { left = 0.015625, right = 0.6875, top = 0.41210938, bottom = 0.49609375 }
-- Same panel styling as the autocomplete popup.
local FEATURED_BACKDROP = {
bgFile = "Interface\\Buttons\\WHITE8X8",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
tile = true, tileSize = 8, edgeSize = 8,
insets = { left = 3, right = 3, top = 3, bottom = 3 },
}
local sentKeys = {}
local seenKeys = {}
@@ -184,15 +189,18 @@ local function makeFeatured(f, label, centerX)
title:SetPoint("CENTER", f, "TOPLEFT", centerX, -52)
title:SetText(label)
local border = f:CreateTexture(nil, "ARTWORK")
border:SetTexture(BORDER_TEX)
-- a frame rather than a texture, so the emote can sit on its OVERLAY layer
-- above the backdrop
local border = CreateFrame("Frame", nil, f)
border:SetSize(86, 86)
border:SetTexCoord(BORDER.left, BORDER.right, BORDER.top, BORDER.bottom)
border:SetPoint("CENTER", f, "TOPLEFT", centerX, -108)
border:SetBackdrop(FEATURED_BACKDROP)
border:SetBackdropColor(0.05, 0.05, 0.05, 0.95)
border:SetBackdropBorderColor(1, 1, 1, 1)
local tex = f:CreateTexture(nil, "OVERLAY")
local tex = border:CreateTexture(nil, "OVERLAY")
tex:SetSize(70, 70)
tex:SetPoint("CENTER", f, "TOPLEFT", centerX, -108)
tex:SetPoint("CENTER", border, "CENTER", 0, 0)
local cap = f:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
cap:SetPoint("CENTER", f, "TOPLEFT", centerX, -160)
@@ -212,7 +220,7 @@ local function buildWindow()
f:SetScript("OnDragStop", f.StopMovingOrSizing)
f:SetFrameStrata("HIGH")
f:SetBackdrop({
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
tile = true, tileSize = 32, edgeSize = 32,
insets = { left = 11, right = 12, top = 12, bottom = 11 },
+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):