import os, hashlib, textwrap, requests from io import BytesIO from PIL import Image, ImageDraw, ImageFont import gradio as gr # ============================== # Config / Secrets # ============================== HF_TOKEN = os.getenv("HF_TOKEN") # optional # Try these Inference API model IDs first (will skip on 404/403/5xx) INFERENCE_CANDIDATES = [ "stabilityai/stable-diffusion-2-1", "runwayml/stable-diffusion-v1-5", ] # Public Space fallback (no token). We'll DISCOVER a valid api_name at runtime. PUBLIC_SPACE_ID = "black-forest-labs/FLUX.1-schnell" # ============================== # Fonts # ============================== CANDIDATE_FONTS = [ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", ] def get_font(size: int): for p in CANDIDATE_FONTS: if os.path.exists(p): return ImageFont.truetype(p, size=int(size)) return ImageFont.load_default() # ============================== # Utils # ============================== def i(v): try: return int(round(float(v))) except Exception: return int(v) def gradient_from_prompt(prompt: str, w=768, h=768) -> Image.Image: w, h = i(w), i(h) hsh = hashlib.sha256((prompt or "meme").encode()).hexdigest() c1 = tuple(int(hsh[i:i+2], 16) for i in (0, 2, 4)) c2 = tuple(int(hsh[i:i+2], 16) for i in (6, 8, 10)) c1 = tuple(min(255, int(v*1.2)) for v in c1) c2 = tuple(min(255, int(v*1.1)) for v in c2) img = Image.new("RGB", (w, h), c1) px = img.load() for y in range(h): t = y / (h - 1) r = int(c1[0]*(1-t) + c2[0]*t) g = int(c1[1]*(1-t) + c2[1]*t) b = int(c1[2]*(1-t) + c2[2]*t) for x in range(w): px[x, y] = (r, g, b) return img def wrap_lines(draw, text, img_w, font, stroke): lines = [] max_chars = max(12, min(30, img_w // 30)) for paragraph in (text or "").split("\n"): wrapped = textwrap.wrap(paragraph, width=max_chars) lines.extend(wrapped if wrapped else [""]) heights = [draw.textbbox((0, 0), ln, font=font, stroke_width=stroke)[3] for ln in lines] return lines, heights def draw_block(draw, text, img_w, y, font, fill, stroke_fill, stroke_width, align="center"): lines, heights = wrap_lines(draw, text, img_w, font, stroke_width) curr_y = y for i, line in enumerate(lines): bbox = draw.textbbox((0, 0), line, font=font, stroke_width=stroke_width) w_line = bbox[2] - bbox[0] if align == "center": x = (img_w - w_line) / 2 elif align == "left": x = int(img_w * 0.05) else: x = img_w - int(img_w * 0.05) - w_line draw.text((x, curr_y), line, font=font, fill=fill, stroke_width=stroke_width, stroke_fill=stroke_fill) curr_y += heights[i] + int(font.size * 0.25) return curr_y, sum(heights) + (len(heights)-1) * int(font.size*0.25) # ============================== # Styles / text split # ============================== PRESETS = { "None": "", "Retro Comic": "bold comic outline, grain, high contrast, 35mm scan", "Vaporwave": "vaporwave, neon pink and cyan, miami sunset, synth grid", "Game Boy": "pixel art, 4-color green palette, dithering", "Newspaper Halftone": "b&w halftone dots, newsprint texture", "Cyberpunk Neon": "neon city at night, purple blue rim light, rain", "90s Web": "bevel buttons, gradients, clipart stars, lens flare", "Synthwave Grid": "purple/indigo sky, glowing sun, mountains, grid floor", } def smart_split_text(prompt: str): p = (prompt or "").strip() if not p: return "TOP TEXT", "BOTTOM TEXT" for sep in ["|", " - ", " — ", ":", ";"]: if sep in p: a, b = p.split(sep, 1) return a.strip().upper(), b.strip().upper() words = p.split() if len(words) > 6: mid = len(words) // 2 return " ".join(words[:mid]).upper(), " ".join(words[mid:]).upper() return p.upper(), "" # ============================== # Generators (multi-fallback) # ============================== def call_inference_api(model_id: str, prompt: str, width: int, height: int) -> Image.Image: if not HF_TOKEN: raise RuntimeError("no-token") url = f"https://api-inference.huggingface.co/models/{model_id}" headers = {"Authorization": f"Bearer {HF_TOKEN}"} payload = {"inputs": prompt, "options": {"wait_for_model": True}, "parameters": {"width": int(width), "height": int(height)}} r = requests.post(url, headers=headers, json=payload, timeout=180) if r.status_code != 200: raise RuntimeError(f"{model_id}:{r.status_code}") return Image.open(BytesIO(r.content)).convert("RGB") def call_public_space(prompt: str, width: int, height: int) -> Image.Image: """Use the FLUX public Space directly via its /infer endpoint.""" from gradio_client import Client client = Client("black-forest-labs/FLUX.1-schnell") # order: prompt, seed, randomize_seed, width, height, num_inference_steps result, _seed = client.predict( prompt, 0, # seed (0 = let Space choose unless randomize_seed=False) True, # randomize_seed int(width), int(height), 4, # num_inference_steps (keep tiny for speed on mobile) api_name="/infer" ) # result is a dict with path/url path = None if isinstance(result, dict): path = result.get("path") or result.get("url") elif isinstance(result, list) and result: item = result[0] if isinstance(item, dict): path = item.get("path") or item.get("url") else: path = item else: path = result if not path: raise RuntimeError("public-space returned empty result") from PIL import Image return Image.open(path).convert("RGB") def generate_image_auto(prompt: str, width: int, height: int): tried = [] # 1) Inference API candidates (if token present) if HF_TOKEN: for mid in INFERENCE_CANDIDATES: try: img = call_inference_api(mid, prompt, width, height) return img, f"✅ Inference API: **{mid}** (token present)" except Exception as e: tried.append(f"{mid}→{str(e)}") continue # 2) Public Space dynamic try: img = call_public_space(prompt, width, height) return img, "✅ Public Space: FLUX /infer" except Exception as e: tried.append(f"{PUBLIC_SPACE_ID}→{str(e)}") # 3) Gradient return gradient_from_prompt(prompt, w=width, h=height), f"⚠️ Fallback gradient | tried: {', '.join(tried)}" # ============================== # Core pipeline (returns image + status) # ============================== def generate_and_meme( prompt, preset_name, use_ai, width, height, font_size, stroke_width, text_color, outline_color, align, top_nudge, bottom_nudge, use_prompt_for_text, top_text_manual, bottom_text_manual ): width, height = i(width), i(height) top_nudge, bottom_nudge = i(top_nudge), i(bottom_nudge) stroke_width = i(stroke_width) base = (prompt or "").strip() style_suffix = PRESETS.get(preset_name or "None", "") gen_prompt = (base + " " + style_suffix).strip() if use_ai: img, status = generate_image_auto(gen_prompt, width, height) else: img, status = gradient_from_prompt(gen_prompt, w=width, h=height), "ℹ️ AI generator is OFF" # Text if use_prompt_for_text: top_text, bottom_text = smart_split_text(base) else: top_text = (top_text_manual or "").upper() bottom_text = (bottom_text_manual or "").upper() img = img.convert("RGB") draw = ImageDraw.Draw(img) w_img, h_img = img.size base_size = max(12, int((w_img * float(font_size)) / 100)) font = get_font(base_size) stroke = int(max(0, stroke_width)) top_y = int(h_img * 0.03) + top_nudge draw_block(draw, top_text, w_img, top_y, font, text_color, outline_color, stroke, align=align) lines, heights = wrap_lines(draw, bottom_text, w_img, font, stroke) total_bottom_h = sum(heights) + (len(heights)-1) * int(font.size*0.25) bottom_y_start = int(h_img - total_bottom_h - h_img*0.03) - bottom_nudge draw_block(draw, bottom_text, w_img, bottom_y_start, font, text_color, outline_color, stroke, align=align) return img, status # ============================== # Retro theme + CSS # ============================== THEME = gr.themes.Soft(primary_hue="indigo", secondary_hue="violet", neutral_hue="slate") CUSTOM_CSS = """ @import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap'); :root { --radius: 14px; } * { -webkit-tap-highlight-color: transparent; } body { background: radial-gradient(1200px 600px at 50% -10%, #0d1220 10%, #05060b 70%); } .gradio-container { max-width: 900px; margin: 0 auto; padding: 12px; } h2, p { text-align: center; color: #cde3ff; text-shadow: 0 0 10px rgba(80,120,255,.25); } h2 { font-family: 'Press Start 2P', system-ui, sans-serif; letter-spacing: 1px; font-size: 18px; } .crt { position: relative; border: 2px solid #2a3350; border-radius: 12px; overflow: hidden; box-shadow: 0 0 0 1px #0f1427 inset, 0 0 40px rgba(60,80,255,.25); } .crt::before { content: ""; position: absolute; inset: 0; pointer-events: none; background: repeating-linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0.05) 1px, transparent 1px, transparent 3px); mix-blend-mode: overlay; opacity: .25; } label { color: #a9b7ff !important; } .gr-button { font-weight: 800; border-radius: 12px; } """ # ============================== # App # ============================== with gr.Blocks(theme=THEME, css=CUSTOM_CSS) as demo: gr.Markdown("
One prompt → generate image → auto meme text. Style presets for instant vibes.
") with gr.Row(): with gr.Column(scale=1, elem_classes=["crt"]): prompt = gr.Textbox(label="Your idea (one prompt)", value="cat typing on a laptop at midnight") preset = gr.Dropdown(choices=list(PRESETS.keys()), value="Retro Comic", label="Style preset") use_ai = gr.Checkbox(label="Use AI image (auto-fallbacks, no key required)", value=True) with gr.Row(): width = gr.Slider(384, 1024, value=768, step=64, label="Width") height = gr.Slider(384, 1024, value=768, step=64, label="Height") gr.Markdown("### Meme Text") use_prompt_for_text = gr.Checkbox(label="Auto from prompt", value=True) top_text_manual = gr.Textbox(label="Top text (if not auto)", value="", interactive=True) bottom_text_manual = gr.Textbox(label="Bottom text (if not auto)", value="", interactive=True) align = gr.Radio(choices=["left", "center", "right"], value="center", label="Text alignment") font_size = gr.Slider(8, 24, value=10, step=1, label="Font size (% of width)") stroke_width = gr.Slider(0, 16, value=4, step=1, label="Outline thickness") with gr.Row(): text_color = gr.ColorPicker(value="#FFFFFF", label="Text color") outline_color = gr.ColorPicker(value="#000000", label="Outline color") with gr.Row(): top_nudge = gr.Slider(-300, 300, value=0, step=1, label="Top nudge (px)") bottom_nudge = gr.Slider(-300, 300, value=0, step=1, label="Bottom nudge (px)") with gr.Column(scale=1, elem_classes=["crt"]): out = gr.Image(type="pil", label="Preview / Download", height=540, show_download_button=True) status = gr.Markdown("…") generate = gr.Button("✨ Generate Image + Meme", variant="primary") inputs = [prompt, preset, use_ai, width, height, font_size, stroke_width, text_color, outline_color, align, top_nudge, bottom_nudge, use_prompt_for_text, top_text_manual, bottom_text_manual] generate.click(fn=generate_and_meme, inputs=inputs, outputs=[out, status]) for comp in [preset, use_prompt_for_text, top_text_manual, bottom_text_manual, font_size, stroke_width, text_color, outline_color, align, top_nudge, bottom_nudge]: comp.change(fn=generate_and_meme, inputs=inputs, outputs=[out, status], show_progress=False) if __name__ == "__main__": demo.launch()