Created
May 7, 2026 19:08
-
-
Save motebaya/24a5d83ba6444b6503536d94799e019e to your computer and use it in GitHub Desktop.
a Tiktok watermark generator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| 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") |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
result: