AI-powered Meme Generator

 """

AI-powered Meme Generator

- Pick a local image or image URL

- Generate captions with OpenAI (optional)

- Render meme-style text (top/bottom) on the image and save


Dependencies:

  pip install openai pillow requests python-dotenv

"""


import os

import textwrap

import requests

from io import BytesIO

from typing import List, Optional


from PIL import Image, ImageDraw, ImageFont, ImageOps


# Optional: OpenAI

try:

    import openai

    OPENAI_OK = True

except Exception:

    OPENAI_OK = False


# Optional .env loader

try:

    from dotenv import load_dotenv

    load_dotenv()

except Exception:

    pass


# If OPENAI_API_KEY in env, set key for openai package

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")

if OPENAI_API_KEY and OPENAI_OK:

    openai.api_key = OPENAI_API_KEY


# ---------------------------

# Config

# ---------------------------

DEFAULT_FONT = None  # if None, use PIL default (or set path to .ttf)

FONT_SIZE_RATIO = 0.07  # fraction of image height to use as font size for main caption

STROKE_WIDTH_RATIO = 0.008  # stroke width relative to image height

TOP_BOTTOM_MARGIN_RATIO = 0.03  # vertical margin as fraction of image height

MAX_LINES = 3  # maximum lines of text for top/bottom each


# Some offline caption templates (fallback)

OFFLINE_TEMPLATES = [

    "When you finally fix the bug and the build passes",

    "Me: I'll sleep early tonight\nAlso me at 3 AM:",

    "That feeling when coffee kicks in",

    "POV: You open the fridge and forget what you wanted",

    "When your code works on the first run",

    "Expectation vs Reality",

    "When someone says 'just restart it'",

    "When the meeting could have been an email",

    "I don't always test my code, but when I do, I do it in production",

    "When you say you'll 'quickly' refactor"

]


# ---------------------------

# Utilities: Image loading

# ---------------------------

def load_image_from_path_or_url(path_or_url: str) -> Image.Image:

    """Load image from filesystem path or HTTP(S) URL."""

    if path_or_url.startswith("http://") or path_or_url.startswith("https://"):

        resp = requests.get(path_or_url, timeout=15)

        resp.raise_for_status()

        return Image.open(BytesIO(resp.content)).convert("RGBA")

    else:

        return Image.open(path_or_url).convert("RGBA")


# ---------------------------

# AI caption generation

# ---------------------------

def generate_captions_ai(prompt_context: str, n: int = 6, engine: str = "gpt-4") -> List[str]:

    """

    Use OpenAI to generate meme caption suggestions.

    - prompt_context: short description of the image or theme

    - n: number of suggestions

    """

    if not OPENAI_API_KEY or not OPENAI_OK:

        raise RuntimeError("OpenAI SDK not available or OPENAI_API_KEY not set.")

    # Compose a concise system/user prompt to ask for short, meme-style captions.

    system = (

        "You are a creative meme caption generator. "

        "Given a short description of an image or theme, produce short, humorous, internet-style captions. "

        "Return an array of captions without numbering. Keep each caption to 1-2 lines. Avoid offensive content."

    )

    user = (

        f"Image description / theme: {prompt_context}\n\n"

        "Produce exactly {} short caption suggestions (1-2 lines each). Use casual, meme-friendly tone."

        .format(n)

    )

    # Use Chat Completions API (ChatCompletion) if available

    try:

        resp = openai.ChatCompletion.create(

            model="gpt-4",

            messages=[

                {"role": "system", "content": system},

                {"role": "user", "content": user}

            ],

            max_tokens=400,

            temperature=0.8,

            n=1,

        )

        content = resp["choices"][0]["message"]["content"].strip()

        # Try to split into lines or bullets

        captions = []

        for line in content.splitlines():

            line = line.strip()

            if not line:

                continue

            # strip bullet numbers

            if line[0].isdigit() and (line[1:3] == "." or line[1:2] == ")"):

                line = line.split(".", 1)[-1].strip()

            if line.startswith("-") or line.startswith("•"):

                line = line[1:].strip()

            captions.append(line)

        # If we have fewer than requested, split by ';' or ' / '

        if len(captions) < n:

            parts = [p.strip() for p in content.replace("\n", ";").split(";") if p.strip()]

            captions = parts[:n]

        return captions[:n]

    except Exception as e:

        # fallback: raise to caller

        raise RuntimeError(f"OpenAI request failed: {e}")


# ---------------------------

# Text rendering on image (classic meme style)

# ---------------------------

def select_font(size_px: int) -> ImageFont.FreeTypeFont:

    """Try to load Impact-like font or fallback to default PIL font."""

    # Common Impact paths (Windows). You can ship an included ttf with your blog repo.

    paths = [

        "Impact.ttf",  # local copy

        "/usr/share/fonts/truetype/impact/Impact.ttf",

        "/usr/share/fonts/truetype/msttcorefonts/Impact.ttf",

        "/Library/Fonts/Impact.ttf",

        "/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",

    ]

    if DEFAULT_FONT:

        paths.insert(0, DEFAULT_FONT)

    for p in paths:

        try:

            if os.path.exists(p):

                return ImageFont.truetype(p, size_px)

        except Exception:

            continue

    # Fallback

    return ImageFont.load_default()


def draw_text_with_stroke(draw: ImageDraw.Draw, position, text, font, fill="white", stroke_fill="black", stroke_width=2, align="center"):

    """Draw text with stroke (outline) for good readability."""

    x, y = position

    # Pillow >=8 supports stroke parameters; but to be robust we do manual strokes around offsets

    try:

        draw.text((x, y), text, font=font, fill=fill, stroke_width=stroke_width, stroke_fill=stroke_fill, anchor="ms", align=align)

    except TypeError:

        # manual stroke (8 neighbors)

        for dx in range(-stroke_width, stroke_width+1):

            for dy in range(-stroke_width, stroke_width+1):

                if dx == 0 and dy == 0:

                    continue

                draw.text((x+dx, y+dy), text, font=font, fill=stroke_fill, anchor="ms", align=align)

        draw.text((x, y), text, font=font, fill=fill, anchor="ms", align=align)


def wrap_text_for_width(text: str, font: ImageFont.FreeTypeFont, max_width: int) -> List[str]:

    """Wrap text into lines that fit into max_width using the provided font."""

    lines = []

    # try naive wrap

    wrapper = textwrap.TextWrapper(width=60)

    words = text.split()

    if not words:

        return []

    # greedy algorithm: keep adding words until width exceeded

    cur = words[0]

    for w in words[1:]:

        test = cur + " " + w

        if font.getsize(test)[0] <= max_width:

            cur = test

        else:

            lines.append(cur)

            cur = w

    lines.append(cur)

    # If there are still too many lines, try to compress line breaks (reduce number of lines)

    return lines


def render_meme(image: Image.Image, top_text: Optional[str], bottom_text: Optional[str], out_path: str):

    """

    Render top and/or bottom text on the image in meme style and save to out_path.

    - top_text and bottom_text can be multi-line; function will wrap to fit.

    """

    img = image.convert("RGBA")

    w, h = img.size

    draw = ImageDraw.Draw(img)


    # Decide font sizes relative to image height

    font_size = max(14, int(h * FONT_SIZE_RATIO))

    stroke_w = max(1, int(h * STROKE_WIDTH_RATIO))

    font = select_font(font_size)


    # available text width is image width minus margins

    max_text_width = int(w * 0.92)


    def draw_block(text, y_anchor):

        if not text:

            return

        # Wrap into lines

        lines = []

        # naive wrapping tries smaller font if too many lines

        words = text.splitlines() if "\n" in text else [text]

        # flatten multiple lines and re-wrap each

        combined = " ".join(l.strip() for l in words if l.strip())

        # iterative approach: try to wrap and if too many lines, reduce font

        curr_font = font

        for attempt in range(4):

            # greedily wrap to fit

            lines = wrap_text_for_width(combined, curr_font, max_text_width)

            if len(lines) <= MAX_LINES:

                break

            # reduce font size and retry

            fs = max(12, int(curr_font.size * 0.9))

            curr_font = select_font(fs)

        # compute total height for block

        line_heights = [curr_font.getsize(line)[1] for line in lines]

        block_h = sum(line_heights) + (len(lines)-1) * 4


        # starting y depending on anchor ("top" or "bottom")

        if y_anchor == "top":

            y = int(h * TOP_BOTTOM_MARGIN_RATIO) + curr_font.getsize(lines[0])[1]//2

        else:  # bottom

            y = h - int(h * TOP_BOTTOM_MARGIN_RATIO) - block_h + curr_font.getsize(lines[0])[1]//2


        # draw each line centered

        for line in lines:

            x = w // 2

            draw_text_with_stroke(draw, (x, y), line, font=curr_font, fill="white", stroke_fill="black", stroke_width=stroke_w, align="center")

            y += curr_font.getsize(line)[1] + 4


    draw_block(top_text, "top")

    draw_block(bottom_text, "bottom")


    # Convert back and save

    final = img.convert("RGB")

    final.save(out_path, quality=95)

    return out_path


# ---------------------------

# Small CLI / Interactive prompt

# ---------------------------

def choose_from_list(prompt: str, options: List[str]) -> int:

    """Let user pick an index from options on the console."""

    print(prompt)

    for i, o in enumerate(options, start=1):

        print(f"{i}. {o}")

    while True:

        try:

            sel = input("Choose number (or 'c' to cancel): ").strip()

            if sel.lower() == "c":

                return -1

            idx = int(sel)-1

            if 0 <= idx < len(options):

                return idx

        except Exception:

            pass

        print("Invalid choice. Try again.")


def main_interactive():

    print("=== AI-Powered Meme Generator ===")

    print("Enter local image path, or an image URL (http/https).")

    path = input("Image path or URL: ").strip()

    if not path:

        print("No image provided — exiting.")

        return


    try:

        img = load_image_from_path_or_url(path)

    except Exception as e:

        print("Failed to load image:", e)

        return


    use_ai = False

    if OPENAI_API_KEY and OPENAI_OK:

        ans = input("Generate AI captions? (y/N): ").strip().lower()

        use_ai = ans == "y"

    else:

        print("OpenAI not configured — using offline caption templates.")


    captions = []

    if use_ai:

        desc = input("Describe the image/theme briefly (or press Enter to auto-describe): ").strip()

        if not desc:

            desc = "A funny photo suitable for memes (describe notable objects / people / mood)."

        try:

            captions = generate_captions_ai(desc, n=8)

        except Exception as e:

            print("AI caption generation failed:", e)

            print("Falling back to offline templates.")

            captions = OFFLINE_TEMPLATES.copy()

    else:

        captions = OFFLINE_TEMPLATES.copy()


    # Show captions and pick or let user type

    idx = choose_from_list("Choose a caption or pick 0 to enter your own:", ["[Type custom caption]"] + captions)

    if idx == -1:

        print("Cancelled.")

        return

    if idx == 0:

        custom = input("Enter custom caption (use '\\n' for line break): ")

        chosen = custom

    else:

        chosen = captions[idx-1]


    # Choose top/bottom or both

    pos_choice = input("Place caption at (1) Top, (2) Bottom, (3) Both? [2]: ").strip() or "2"

    if pos_choice not in ("1","2","3"):

        pos_choice = "2"

    if pos_choice == "1":

        top_text = chosen

        bottom_text = None

    elif pos_choice == "2":

        top_text = None

        bottom_text = chosen

    else:

        # Ask for a second caption or reuse

        second_idx = choose_from_list("Pick another caption for the other position or type custom:", ["[Type custom]"] + captions)

        if second_idx == -1:

            print("Cancelled.")

            return

        if second_idx == 0:

            second = input("Second caption: ")

        else:

            second = captions[second_idx-1]

        top_text = chosen

        bottom_text = second


    # Save file

    out_name = input("Output filename (default: meme_output.jpg): ").strip() or "meme_output.jpg"

    try:

        out_path = render_meme(img, top_text, bottom_text, out_name)

        print("Saved meme to", out_path)

    except Exception as e:

        print("Failed to render meme:", e)


if __name__ == "__main__":

    main_interactive()


No comments: