Skip to content

Instantly share code, notes, and snippets.

@motebaya
Created May 7, 2026 19:08
Show Gist options
  • Select an option

  • Save motebaya/24a5d83ba6444b6503536d94799e019e to your computer and use it in GitHub Desktop.

Select an option

Save motebaya/24a5d83ba6444b6503536d94799e019e to your computer and use it in GitHub Desktop.
a Tiktok watermark generator
"""
Generates a TikTok-branded watermark PNG (transparent background) containing:
* A TikTok musical-note logo rendered from official SVG path data,
with a chromatic-aberration (cyan + red offset) effect
* A username panel with a white-to-light-grey gradient
* A dark "FOLLOW" badge
@github.com/motebaya - 2026-03-30 06:44 PM
"""
from __future__ import annotations
import argparse
import os
import re
from typing import Optional
from PIL import Image, ImageDraw, ImageFont, ImageFilter
FONT_DIR = os.path.join(
os.path.expanduser("~"),
"AppData", "Local", "Microsoft", "Windows", "Fonts",
)
TIKTOK_SVG_PATH = (
"M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17"
" 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97"
"-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54"
" 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31"
"-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49"
".18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02"
" 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11"
" 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5"
" 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79"
".06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"
)
# Type aliases
_RGBA = tuple[int, int, int, int]
_Polygon = list[tuple[float, float]]
def create_gradient_image(
size: tuple[int, int],
colors: list[_RGBA],
direction: str = "horizontal",
) -> Image.Image:
"""Create an RGBA gradient image transitioning through multiple colour stops.
:param size: ``(width, height)`` of the output image.
:param colors: List of RGBA tuples defining colour stops.
:param direction: ``"horizontal"`` or ``"vertical"``.
:returns: New RGBA :class:`~PIL.Image.Image`.
"""
w, h = size
img = Image.new("RGBA", (w, h))
pixels = img.load()
num_segments = len(colors) - 1
length = w if direction == "horizontal" else h
for i in range(length):
t = i / max(length - 1, 1)
segment = min(int(t * num_segments), num_segments - 1)
local_t = t * num_segments - segment
c1, c2 = colors[segment], colors[segment + 1]
pixel = tuple(int(c1[k] + (c2[k] - c1[k]) * local_t) for k in range(4))
for j in range(h if direction == "horizontal" else w):
if direction == "horizontal":
pixels[i, j] = pixel
else:
pixels[j, i] = pixel
return img
def apply_rounded_mask(img: Image.Image, radius: int) -> Image.Image:
"""Return a copy of *img* with rounded corners applied via an alpha mask.
:param img: Source RGBA image.
:param radius: Corner radius in pixels.
:returns: New RGBA image with rounded corners.
"""
w, h = img.size
mask = Image.new("L", (w, h), 0)
ImageDraw.Draw(mask).rounded_rectangle([0, 0, w - 1, h - 1], radius=radius, fill=255)
result = img.copy()
result.putalpha(mask)
return result
def parse_svg_path(d_string: str) -> list[tuple[str, list[float]]]:
"""Parse an SVG path ``d`` attribute into a list of ``(command, args)`` tuples.
:param d_string: Raw SVG path data string.
:returns: Ordered list of ``(command_letter, [float, …])`` pairs.
"""
# Tokenize: split into commands and numbers
tokens = re.findall(r'[MmLlHhVvCcSsQqTtAaZz]|[-+]?[0-9]*\.?[0-9]+', d_string)
commands: list[tuple[str, list[float]]] = []
i = 0
while i < len(tokens):
if tokens[i].isalpha():
cmd = tokens[i]
i += 1
args: list[float] = []
while i < len(tokens) and not tokens[i].isalpha():
args.append(float(tokens[i]))
i += 1
commands.append((cmd, args))
else:
i += 1
return commands
def svg_path_to_polygons(
d_string: str,
scale: float = 1.0,
offset_x: float = 0,
offset_y: float = 0,
) -> list[_Polygon]:
"""Convert an SVG path ``d`` string to a list of polygon point lists.
Supports ``M``, ``L``, ``H``, ``V``, ``C``, ``S``, ``Q``, ``Z`` commands
(both absolute and relative). Curves are approximated with line segments.
:param d_string: SVG path data string.
:param scale: Uniform scale factor applied to all coordinates.
:param offset_x: Horizontal translation (pixels) applied after scaling.
:param offset_y: Vertical translation (pixels) applied after scaling.
:returns: List of polygons, each a list of ``(x, y)`` pixel tuples.
"""
def transform(x: float, y: float) -> tuple[float, float]:
return (x * scale + offset_x, y * scale + offset_y)
def bezier_cubic(
p0: tuple[float, float], p1: tuple[float, float],
p2: tuple[float, float], p3: tuple[float, float],
steps: int = 12,
) -> list[tuple[float, float]]:
"""Sample a cubic bezier as line segments."""
pts = []
for i in range(1, steps + 1):
t, mt = i / steps, 1 - i / steps
pts.append((
mt**3*p0[0] + 3*mt**2*t*p1[0] + 3*mt*t**2*p2[0] + t**3*p3[0],
mt**3*p0[1] + 3*mt**2*t*p1[1] + 3*mt*t**2*p2[1] + t**3*p3[1],
))
return pts
def bezier_quadratic(
p0: tuple[float, float], p1: tuple[float, float],
p2: tuple[float, float], steps: int = 10,
) -> list[tuple[float, float]]:
"""Sample a quadratic bezier as line segments."""
pts = []
for i in range(1, steps + 1):
t, mt = i / steps, 1 - i / steps
pts.append((
mt**2*p0[0] + 2*mt*t*p1[0] + t**2*p2[0],
mt**2*p0[1] + 2*mt*t*p1[1] + t**2*p2[1],
))
return pts
commands = parse_svg_path(d_string)
polygons: list[_Polygon] = []
current_polygon: _Polygon = []
cx = cy = sx = sy = 0.0 # current position / subpath start
last_cp: Optional[tuple[float, float]] = None # last control point for S/s
for cmd, args in commands:
if cmd == 'M':
if current_polygon:
polygons.append(current_polygon)
cx, cy = args[0], args[1]
sx, sy = cx, cy
current_polygon = [transform(cx, cy)]
# Implicit lineto for remaining pairs
j = 2
while j + 1 < len(args):
cx, cy = args[j], args[j + 1]
current_polygon.append(transform(cx, cy))
j += 2
last_cp = None
elif cmd == 'm':
if current_polygon:
polygons.append(current_polygon)
cx += args[0]; cy += args[1]
sx, sy = cx, cy
current_polygon = [transform(cx, cy)]
j = 2
while j + 1 < len(args):
cx += args[j]; cy += args[j + 1]
current_polygon.append(transform(cx, cy))
j += 2
last_cp = None
elif cmd == 'L':
j = 0
while j + 1 < len(args):
cx, cy = args[j], args[j + 1]
current_polygon.append(transform(cx, cy))
j += 2
last_cp = None
elif cmd == 'l':
j = 0
while j + 1 < len(args):
cx += args[j]; cy += args[j + 1]
current_polygon.append(transform(cx, cy))
j += 2
last_cp = None
elif cmd == 'H':
for val in args:
cx = val
current_polygon.append(transform(cx, cy))
last_cp = None
elif cmd == 'h':
for val in args:
cx += val
current_polygon.append(transform(cx, cy))
last_cp = None
elif cmd == 'V':
for val in args:
cy = val
current_polygon.append(transform(cx, cy))
last_cp = None
elif cmd == 'v':
for val in args:
cy += val
current_polygon.append(transform(cx, cy))
last_cp = None
elif cmd == 'C':
j = 0
while j + 5 < len(args):
p0 = (cx, cy)
p1 = (args[j], args[j + 1])
p2 = (args[j + 2], args[j + 3])
p3 = (args[j + 4], args[j + 5])
current_polygon.extend(transform(px, py) for px, py in bezier_cubic(p0, p1, p2, p3))
cx, cy = p3; last_cp = p2; j += 6
elif cmd == 'c':
j = 0
while j + 5 < len(args):
p0 = (cx, cy)
p1 = (cx + args[j], cy + args[j + 1])
p2 = (cx + args[j + 2], cy + args[j + 3])
p3 = (cx + args[j + 4], cy + args[j + 5])
current_polygon.extend(transform(px, py) for px, py in bezier_cubic(p0, p1, p2, p3))
last_cp = p2; cx, cy = p3; j += 6
elif cmd == 'S':
j = 0
while j + 3 < len(args):
p0 = (cx, cy)
p1 = (2*cx - last_cp[0], 2*cy - last_cp[1]) if last_cp else (cx, cy)
p2 = (args[j], args[j + 1])
p3 = (args[j + 2], args[j + 3])
current_polygon.extend(transform(px, py) for px, py in bezier_cubic(p0, p1, p2, p3))
last_cp = p2; cx, cy = p3; j += 4
elif cmd == 's':
j = 0
while j + 3 < len(args):
p0 = (cx, cy)
p1 = (2*cx - last_cp[0], 2*cy - last_cp[1]) if last_cp else (cx, cy)
p2 = (cx + args[j], cy + args[j + 1])
p3 = (cx + args[j + 2], cy + args[j + 3])
current_polygon.extend(transform(px, py) for px, py in bezier_cubic(p0, p1, p2, p3))
last_cp = p2; cx, cy = p3; j += 4
elif cmd == 'Q':
j = 0
while j + 3 < len(args):
p0 = (cx, cy)
p1 = (args[j], args[j + 1])
p2 = (args[j + 2], args[j + 3])
current_polygon.extend(transform(px, py) for px, py in bezier_quadratic(p0, p1, p2))
last_cp = p1; cx, cy = p2; j += 4
elif cmd == 'q':
j = 0
while j + 3 < len(args):
p0 = (cx, cy)
p1 = (cx + args[j], cy + args[j + 1])
p2 = (cx + args[j + 2], cy + args[j + 3])
current_polygon.extend(transform(px, py) for px, py in bezier_quadratic(p0, p1, p2))
last_cp = p1; cx, cy = p2; j += 4
elif cmd in ('Z', 'z'):
cx, cy = sx, sy
if current_polygon:
current_polygon.append(transform(sx, sy))
polygons.append(current_polygon)
current_polygon = []
last_cp = None
if current_polygon:
polygons.append(current_polygon)
return polygons
def draw_svg_path(
draw: ImageDraw.ImageDraw,
d_string: str,
scale: float,
offset_x: float,
offset_y: float,
fill_color: _RGBA,
) -> None:
"""Render an SVG path onto a PIL draw canvas as filled polygons.
:param draw: Active :class:`~PIL.ImageDraw.ImageDraw` context.
:param d_string: SVG path ``d`` attribute string.
:param scale: Uniform scale applied to all coordinates.
:param offset_x: Horizontal pixel offset applied after scaling.
:param offset_y: Vertical pixel offset applied after scaling.
:param fill_color: RGBA fill colour for the polygons.
"""
for poly in svg_path_to_polygons(d_string, scale, offset_x, offset_y):
if len(poly) >= 3:
draw.polygon(poly, fill=fill_color)
def _load_font(*names: str, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
"""Try loading fonts from *names* in order; fall back to the default font.
:param names: Font filenames relative to :data:`FONT_DIR`, tried in order.
:param size: Desired font size in points.
:returns: Loaded font object.
"""
for name in names:
try:
return ImageFont.truetype(os.path.join(FONT_DIR, name), size)
except IOError:
continue
return ImageFont.load_default()
def draw_tiktok_logo(size: int = 200) -> Image.Image:
"""Render the TikTok logo from official SVG path data with chromatic aberration.
Composites three offset copies of the path — cyan (left/down), red (right/up),
and white (centred) — over a dark gradient background.
:param size: Square canvas size in pixels.
:returns: RGBA :class:`~PIL.Image.Image` of the logo.
"""
# --- 1. Dark gradient background ---
bg_colors: list[_RGBA] = [
(30, 30, 35, 255),
(18, 18, 22, 255),
(15, 15, 18, 255),
(22, 22, 26, 255),
]
bg = apply_rounded_mask(
create_gradient_image((size, size), bg_colors, direction="vertical"),
int(size * 0.22),
)
# --- 2. Draw the TikTok note with chromatic aberration ---
margin = size * 0.18
svg_scale = (size - 2 * margin) / 24.0 # SVG viewBox is 24x24
# Each layer: (x_offset, y_offset, RGBA colour)
layers: list[tuple[float, float, _RGBA]] = [
(margin - 4, margin + 3, (37, 244, 238, 180)), # Layer 1: Cyan/aqua offset (shifted left and down)
(margin + 4, margin - 3, (254, 44, 85, 180)), # Layer 2: Red/pink offset (shifted right and up)
(margin, margin, (255, 255, 255, 255)), # Layer 3: White main (centred)
]
for ox, oy, color in layers:
layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw_svg_path(ImageDraw.Draw(layer), TIKTOK_SVG_PATH, svg_scale, ox, oy, color)
bg.alpha_composite(layer)
return bg
def draw_follow_badge(width: int = 200, height: int = 55) -> Image.Image:
"""Render a "FOLLOW" badge with TikTok dark style and semi-rounded corners.
:param width: Badge width in pixels.
:param height: Badge height in pixels.
:returns: RGBA :class:`~PIL.Image.Image` of the badge.
"""
badge_bg = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(badge_bg)
draw.rounded_rectangle(
[0, 0, width - 1, height - 1],
radius=int(height * 0.25),
fill=(40, 40, 42, 255), # Dark charcoal
)
font = _load_font("Inter_18pt-Bold.ttf", "Roboto-Bold.ttf", size=int(height * 0.42))
bbox = draw.textbbox((0, 0), "FOLLOW", font=font)
draw.text(
((width - (bbox[2] - bbox[0])) // 2, (height - (bbox[3] - bbox[1])) // 2 - bbox[1]),
"FOLLOW", fill="white", font=font,
)
return badge_bg
def draw_username_panel(
width: int,
height: int,
username: str = "",
) -> Image.Image:
"""Render the username panel with a white-to-light-grey gradient and centred text.
:param width: Panel width in pixels.
:param height: Panel height in pixels.
:param username: Optional username string to display.
:returns: RGBA :class:`~PIL.Image.Image` of the panel.
"""
panel_colors: list[_RGBA] = [
(255, 255, 255, 245),
(248, 248, 250, 240),
(235, 235, 240, 235),
(225, 225, 232, 230),
]
panel = apply_rounded_mask(
create_gradient_image((width, height), panel_colors, direction="horizontal"),
int(height * 0.18),
)
if username:
draw = ImageDraw.Draw(panel)
font = _load_font(
"Inter_24pt-SemiBold.ttf", "Roboto-Medium.ttf",
size=int(height * 0.28),
)
bbox = draw.textbbox((0, 0), username, font=font)
draw.text(
((width - (bbox[2] - bbox[0])) // 2, (height - (bbox[3] - bbox[1])) // 2 - bbox[1]),
username, fill=(50, 50, 55, 230), font=font,
)
return panel
def _measure_username_width(username: str, panel_height: int) -> int:
"""Return the pixel width needed to render *username* inside the panel.
:param username: Text to measure.
:param panel_height: Panel height used to derive the font size.
:returns: Text width in pixels, or ``0`` if *username* is empty.
"""
if not username:
return 0
font = _load_font(
"Inter_24pt-SemiBold.ttf", "Roboto-Medium.ttf",
size=int(panel_height * 0.28),
)
tmp_draw = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
bbox = tmp_draw.textbbox((0, 0), username, font=font)
return bbox[2] - bbox[0]
def create_tiktok_watermark(
username: str = "",
output_filename: str = "tiktok_watermark.png",
) -> Image.Image:
"""Compose and save the complete TikTok watermark PNG.
Combines the logo, username panel, and FOLLOW badge on a transparent canvas.
:param username: Channel name displayed on the panel (e.g. ``"@johndoe"``).
:param output_filename: Destination file path for the rendered PNG.
:returns: The composed RGBA :class:`~PIL.Image.Image`.
"""
logo_size = 200
badge_height = 55
panel_height = logo_size
min_panel_width = 400
text_padding = 100
panel_width = max(min_panel_width, _measure_username_width(username, panel_height) + text_padding)
badge_width = min(200, panel_width - 40)
gap_logo_panel = 15
badge_overlap_y = 18
shadow_blur = 12
shadow_offset = 6
padding = 30
logo_x = padding
logo_y = padding
panel_x = padding + logo_size + gap_logo_panel
panel_y = padding
badge_x = panel_x + (panel_width - badge_width) // 2
badge_y = panel_y + panel_height - badge_height + badge_overlap_y
canvas_w = panel_x + panel_width + padding
canvas_h = max(logo_y + logo_size + padding, badge_y + badge_height + padding)
canvas = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
# Drop shadows
def _drop_shadow(rect: list[int], radius: int, alpha: int, blur: int = shadow_blur) -> None:
layer = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
ImageDraw.Draw(layer).rounded_rectangle(rect, radius=radius, fill=(0, 0, 0, alpha))
canvas.alpha_composite(layer.filter(ImageFilter.GaussianBlur(blur)))
_drop_shadow(
[panel_x + 3, panel_y + shadow_offset, panel_x + panel_width + 3, panel_y + panel_height + shadow_offset],
radius=int(panel_height * 0.18), alpha=35,
)
_drop_shadow(
[logo_x + 3, logo_y + shadow_offset, logo_x + logo_size + 3, logo_y + logo_size + shadow_offset],
radius=int(logo_size * 0.22), alpha=40,
)
_drop_shadow(
[badge_x + 2, badge_y + 4, badge_x + badge_width + 2, badge_y + badge_height + 4],
radius=int(badge_height * 0.25), alpha=45, blur=8,
)
# Composite elements
for img, pos in [
(draw_username_panel(panel_width, panel_height, username), (panel_x, panel_y)),
(draw_tiktok_logo(logo_size), (logo_x, logo_y)),
(draw_follow_badge(badge_width, badge_height), (badge_x, badge_y)),
]:
canvas.paste(img, pos, img)
canvas.save(output_filename, "PNG")
print(f"✓ Saved: {output_filename} ({canvas_w}x{canvas_h})")
return canvas
if __name__ == "__main__":
create_tiktok_watermark("@khaby.lame", "tiktok_watermark.png")
@motebaya

motebaya commented May 7, 2026

Copy link
Copy Markdown
Author

result:

tiktok_watermark

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment