I only have file-reading and skill tools in this session, so I couldn't write the file or run it. Below is the program, plus the steps to run and validate it. It's written against the skill's real GIFBuilder and validators APIs, which I read first.
Two things in the skill's code shaped the design:
save(..., optimize_for_emoji=True)throws away frames when there are more than 12. That would wreck a pulse-then-sparkle sequence. The program passesnum_colors=48directly instead and keeps all 24 frames.validate_gifonly checks dimensions, size and frame count. It doesn't check colors or the 3-second limit, so the program has its own checks for those.
make_notification_bell.py
Place it in the project root, next to .claude/skills/.
#!/usr/bin/env python3
"""128x128 Slack emoji: blue bell pulses twice, then emits three gold sparkles."""
import math
import sys
from pathlib import Path
from PIL import Image, ImageDraw
sys.path.insert(0, str(Path(__file__).parent / ".claude" / "skills" / "slack-gif-creator"))
from core.gif_builder import GIFBuilder # noqa: E402
from core.validators import validate_gif # noqa: E402
SIZE = 128
SS = 4 # supersampling factor for smooth edges
FPS = 10 # 24 frames @ 10 fps = 2.4s
NUM_FRAMES = 24
NUM_COLORS = 48
OUTPUT = "notification-bell.gif"
BG = (22, 30, 58)
BELL = (52, 132, 232)
BELL_LIGHT = (130, 190, 255)
BELL_DARK = (24, 76, 160)
RIM = (86, 156, 245)
GOLD = (255, 200, 40)
GOLD_CORE = (255, 238, 150)
BELL_CENTER = (64, 74)
PULSE_FRAMES = 7 # frames per pulse
PULSE_AMOUNT = 0.18
# (x, y, start_frame) for each sparkle
SPARKLES = [(20, 30, 12), (64, 14, 14), (108, 30, 16)]
SPARKLE_LIFE = 8
SPARKLE_RADIUS = 12
def bell(draw, scale):
cx, cy = BELL_CENTER
def p(x, y):
return ((cx + x * scale) * SS, (cy + y * scale) * SS)
def box(x1, y1, x2, y2):
return [*p(x1, y1), *p(x2, y2)]
w = max(2, round(2 * SS))
draw.ellipse(box(-4, -38, 4, -30), fill=BELL_DARK) # knob
draw.ellipse(box(-20, -32, 20, 8), fill=BELL, outline=BELL_DARK, width=w) # dome
draw.polygon([p(-20, -12), p(20, -12), p(28, 18), p(-28, 18)], fill=BELL) # skirt
draw.line([p(-20, -12), p(-28, 18)], fill=BELL_DARK, width=w)
draw.line([p(20, -12), p(28, 18)], fill=BELL_DARK, width=w)
draw.rounded_rectangle(box(-31, 16, 31, 25), radius=4 * SS, fill=RIM,
outline=BELL_DARK, width=w)
draw.line([p(-12, -22), p(-16, 6)], fill=BELL_LIGHT, width=3 * SS) # highlight
draw.ellipse(box(-7, 24, 7, 38), fill=BELL_DARK) # clapper
def sparkle(draw, x, y, r):
def star(radius, color):
pts = []
for k in range(8):
ang = math.radians(-90 + k * 45)
rad = radius if k % 2 == 0 else radius * 0.3
pts.append(((x + rad * math.cos(ang)) * SS, (y + rad * math.sin(ang)) * SS))
draw.polygon(pts, fill=color)
star(r, GOLD)
star(r * 0.5, GOLD_CORE)
def render(i):
img = Image.new("RGB", (SIZE * SS, SIZE * SS), BG)
draw = ImageDraw.Draw(img)
scale = 1.0
if i < 2 * PULSE_FRAMES:
phase = (i % PULSE_FRAMES) / PULSE_FRAMES
scale += PULSE_AMOUNT * math.sin(math.pi * phase)
bell(draw, scale)
for sx, sy, start in SPARKLES:
t = (i - start) / SPARKLE_LIFE
if 0 <= t < 1:
drift = -6 * t # float upward as it fades
sparkle(draw, sx, sy + drift, SPARKLE_RADIUS * math.sin(math.pi * t))
return img.resize((SIZE, SIZE), Image.Resampling.LANCZOS)
def main():
builder = GIFBuilder(width=SIZE, height=SIZE, fps=FPS)
for i in range(NUM_FRAMES):
builder.add_frame(render(i))
# No optimize_for_emoji: it would drop frames above 12. Colors are capped explicitly.
builder.save(OUTPUT, num_colors=NUM_COLORS, remove_duplicates=False)
validate_gif(OUTPUT, is_emoji=True, verbose=True)
if __name__ == "__main__":
main()
Run it
pip install pillow imageio numpy
python make_notification_bell.py
Validate the output
-
Skill validator. The script calls
validate_gifitself. It confirms 128x128 and reports file size, frame count and duration. Expect about 24 frames at 10 fps, so 2.4s. -
Colors and duration. The skill validator doesn't check these two limits, so run this:
from PIL import Image, ImageSequence im = Image.open("notification-bell.gif") frames = [f.convert("RGB") for f in ImageSequence.Iterator(im)] colors = set() for f in frames: colors |= {c for _, c in f.getcolors(maxcolors=128 * 128)} total = sum(f.info.get("duration", 100) for f in ImageSequence.Iterator(im)) / 1000 print(im.size, len(frames), "frames,", len(colors), "colors,", f"{total:.1f}s") assert im.size == (128, 128) and len(colors) <= 48 and total < 3 -
Visual check. Open the GIF in a browser or Quick Look. Confirm that:
- the bell pulses twice, during roughly the first 1.4s;
- three gold sparkles then pop up in sequence and fade;
- the loop restarts cleanly.
-
Slack check. Upload it through Customize Workspace → Emoji. Slack's emoji limit is 128 KB, and this should come in well under that. Check the file size to be sure.
Since I haven't run it, the sparkle positions may overlap the bell's top corners a little at peak pulse. If so, adjust the SPARKLES coordinates or PULSE_AMOUNT. If you'd like me to run it and fix anything, I'd need file-write and shell access.
Real run recorded with claude-code / claude-sonnet-5-5. Output is shown verbatim, unmodified.
하는 일
Slack에 적합한 GIF를 만들기 위한 제약 조건, 애니메이션 안내, Python 유틸리티를 제공합니다.
작동 방식
- Pillow 그리기 도구로 이미지 프레임을 만듭니다.
- 이징, 프레임 합성, 색상 축소, 중복 프레임 제거 도우미를 사용합니다.
- GIF를 저장하고 크기, 파일 용량, 프레임 수, 재생 시간 정보를 검증합니다.
적합한 경우
맞춤 Slack 이모지, 작은 반복 메시지 애니메이션, 생성된 GIF 검증.
사용자가 선택한 이미지 또는 GIF 경로를 읽고 제공한 출력 경로에 GIF를 쓰는 Python 모듈을 포함합니다. 네트워크나 자격 증명에 접근하지 않습니다. Pillow, imageio, imageio-ffmpeg, NumPy가 필요합니다. 사용 전에 생성 미디어와 출력 경로를 확인해야 합니다.