import os import sys import json import shutil import textwrap import subprocess import requests from . import utils import config from modules import ai from rich.prompt import Confirm try: from PIL import Image, ImageDraw, ImageFont, ImageStat HAS_PIL = True except ImportError: HAS_PIL = False def download_font(font_name): """Attempts to download a Google Font from GitHub.""" if not font_name: font_name = "Roboto" if not os.path.exists(config.FONTS_DIR): os.makedirs(config.FONTS_DIR) # Handle CSS-style lists (e.g. "Roboto, sans-serif") if "," in font_name: font_name = font_name.split(",")[0].strip() # Handle filenames provided by AI if font_name.lower().endswith(('.ttf', '.otf')): font_name = os.path.splitext(font_name)[0] font_name = font_name.strip().strip("'").strip('"') for suffix in ["-Regular", " Regular", " regular", "Regular", " Bold", " Italic"]: if font_name.endswith(suffix): font_name = font_name[:-len(suffix)] font_name = font_name.strip() clean_name = font_name.replace(" ", "").lower() font_filename = f"{clean_name}.ttf" font_path = os.path.join(config.FONTS_DIR, font_filename) if os.path.exists(font_path) and os.path.getsize(font_path) > 1000: utils.log("ASSETS", f"Using cached font: {font_path}") return font_path utils.log("ASSETS", f"Downloading font: {font_name}...") compact_name = font_name.replace(" ", "") title_compact = "".join(x.title() for x in font_name.split()) patterns = [ f"static/{title_compact}-Regular.ttf", f"{title_compact}-Regular.ttf", f"{title_compact}[wght].ttf", f"{title_compact}[wdth,wght].ttf", f"static/{compact_name}-Regular.ttf", f"{compact_name}-Regular.ttf", f"{title_compact}-Regular.otf", ] headers = {"User-Agent": "Mozilla/5.0 (BookApp/1.0)"} for license_type in ["ofl", "apache", "ufl"]: base_url = f"https://github.com/google/fonts/raw/main/{license_type}/{clean_name}" for pattern in patterns: try: r = requests.get(f"{base_url}/{pattern}", headers=headers, timeout=5) if r.status_code == 200 and len(r.content) > 1000: with open(font_path, 'wb') as f: f.write(r.content) utils.log("ASSETS", f"✅ Downloaded {font_name} to {font_path}") return font_path except Exception: continue if clean_name != "roboto": utils.log("ASSETS", f"⚠️ Font '{font_name}' not found. Falling back to Roboto.") return download_font("Roboto") return None def evaluate_image_quality(image_path, prompt, model, folder=None): if not HAS_PIL: return None, "PIL not installed" try: img = Image.open(image_path) response = model.generate_content([f""" ROLE: Art Critic TASK: Analyze generated image against prompt. PROMPT: '{prompt}' OUTPUT_FORMAT (JSON): {{ "score": int (1-10), "reason": "string" }} """, img]) model_name = getattr(model, 'name', "logic-pro") if folder: utils.log_usage(folder, model_name, response.usage_metadata) data = json.loads(utils.clean_json(response.text)) return data.get('score'), data.get('reason') except Exception as e: return None, str(e) def generate_blurb(bp, folder): utils.log("MARKETING", "Generating blurb...") meta = bp.get('book_metadata', {}) # Format beats as a readable list, not raw JSON beats = bp.get('plot_beats', []) beats_text = "\n".join(f" - {b}" for b in beats[:6]) if beats else " - (no beats provided)" # Format protagonist for the blurb chars = bp.get('characters', []) protagonist = next((c for c in chars if 'protagonist' in c.get('role', '').lower()), None) protagonist_desc = f"{protagonist['name']} — {protagonist.get('description', '')}" if protagonist else "the protagonist" prompt = f""" ROLE: Marketing Copywriter TASK: Write a compelling back-cover blurb for a {meta.get('genre', 'fiction')} novel. BOOK DETAILS: - TITLE: {meta.get('title')} - GENRE: {meta.get('genre')} - AUDIENCE: {meta.get('target_audience', 'General')} - PROTAGONIST: {protagonist_desc} - LOGLINE: {bp.get('manual_instruction', '(none)')} - KEY PLOT BEATS: {beats_text} BLURB STRUCTURE: 1. HOOK (1-2 sentences): Open with the protagonist's world and the inciting disruption. Make it urgent. 2. STAKES (2-3 sentences): Raise the central conflict. What does the protagonist stand to lose? 3. TENSION (1-2 sentences): Hint at the impossible choice or escalating danger without revealing the resolution. 4. HOOK CLOSE (1 sentence): End with a tantalising question or statement that demands the reader open the book. RULES: - 150-200 words total. - DO NOT reveal the ending or resolution. - Match the genre's marketing tone ({meta.get('genre', 'fiction')}: e.g. thriller = urgent/terse, romance = emotionally charged, fantasy = epic/wondrous, horror = dread-laden). - Use present tense for the blurb voice. - No "Blurb:", no title prefix, no labels — marketing copy only. """ try: response = ai.model_writer.generate_content(prompt) utils.log_usage(folder, ai.model_writer.name, response.usage_metadata) blurb = response.text with open(os.path.join(folder, "blurb.txt"), "w") as f: f.write(blurb) with open(os.path.join(folder, "back_cover.txt"), "w") as f: f.write(blurb) except: utils.log("MARKETING", "Failed to generate blurb.") def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False): if not HAS_PIL: utils.log("MARKETING", "Pillow not installed. Skipping image cover.") return utils.log("MARKETING", "Generating cover...") meta = bp.get('book_metadata', {}) series = bp.get('series_metadata', {}) orientation = meta.get('style', {}).get('page_orientation', 'Portrait') ar = "3:4" if orientation == "Landscape": ar = "4:3" elif orientation == "Square": ar = "1:1" visual_context = "" if tracking: visual_context = "IMPORTANT VISUAL CONTEXT:\n" if 'events' in tracking: visual_context += f"Key Events/Themes: {json.dumps(tracking['events'][-5:])}\n" if 'characters' in tracking: visual_context += f"Character Appearances: {json.dumps(tracking['characters'])}\n" # Feedback Analysis regenerate_image = True design_instruction = "" # If existing art exists and no feedback provided, preserve it (Keep Cover feature) if os.path.exists(os.path.join(folder, "cover_art.png")) and not feedback: regenerate_image = False if feedback and feedback.strip(): utils.log("MARKETING", f"Analyzing feedback: '{feedback}'...") analysis_prompt = f""" ROLE: Design Assistant TASK: Analyze user feedback on cover. FEEDBACK: "{feedback}" DECISION: 1. Keep the current background image but change text/layout/color (REGENERATE_LAYOUT). 2. Create a completely new background image (REGENERATE_IMAGE). OUTPUT_FORMAT (JSON): {{ "action": "REGENERATE_LAYOUT" or "REGENERATE_IMAGE", "instruction": "Specific instruction for Art Director" }} """ try: resp = ai.model_logic.generate_content(analysis_prompt) utils.log_usage(folder, ai.model_logic.name, resp.usage_metadata) decision = json.loads(utils.clean_json(resp.text)) if decision.get('action') == 'REGENERATE_LAYOUT': regenerate_image = False utils.log("MARKETING", "Feedback indicates keeping image. Regenerating layout only.") design_instruction = decision.get('instruction', feedback) except: utils.log("MARKETING", "Feedback analysis failed. Defaulting to full regeneration.") genre = meta.get('genre', 'Fiction') tone = meta.get('style', {}).get('tone', 'Balanced') # Map genre to visual style suggestion genre_style_map = { 'thriller': 'dark, cinematic, high-contrast photography style', 'mystery': 'moody, atmospheric, noir-inspired painting', 'romance': 'warm, painterly, soft-focus illustration', 'fantasy': 'epic digital painting, rich colours, mythic scale', 'science fiction': 'sharp digital art, cool palette, futuristic', 'horror': 'unsettling, dark atmospheric painting, desaturated', 'historical fiction': 'classical oil painting style, period-accurate', 'young adult': 'vibrant illustrated style, bold colours', } suggested_style = genre_style_map.get(genre.lower(), 'professional digital illustration or photography') design_prompt = f""" ROLE: Art Director TASK: Design a professional book cover for an AI image generator. BOOK: - TITLE: {meta.get('title')} - GENRE: {genre} - TONE: {tone} - SUGGESTED_VISUAL_STYLE: {suggested_style} VISUAL_CONTEXT (characters and key themes from the story): {visual_context if visual_context else "Use genre conventions."} USER_FEEDBACK: {feedback if feedback else "None"} DESIGN_INSTRUCTION: {design_instruction if design_instruction else "Create a compelling, genre-appropriate cover."} COVER_ART_RULES: - The art_prompt must produce an image with NO text, no letters, no numbers, no watermarks, no UI elements, no logos. - Describe a clear FOCAL POINT (e.g. the protagonist, a dramatic scene, a symbolic object). - Use RULE OF THIRDS composition — leave visual space at top and/or bottom for the title and author text to be overlaid. - Describe LIGHTING that reinforces the tone (e.g. "harsh neon backlight" for thriller, "golden hour" for romance). - Describe the COLOUR PALETTE explicitly (e.g. "deep crimson and shadow-black", "soft rose gold and cream"). - Characters must match their descriptions from VISUAL_CONTEXT if present. OUTPUT_FORMAT (JSON only, no markdown): {{ "font_name": "Name of a Google Font suited to the genre (e.g. Cinzel for fantasy, Oswald for thriller, Playfair Display for romance)", "primary_color": "#HexCode (dominant background/cover colour)", "text_color": "#HexCode (high contrast against primary_color)", "art_prompt": "Detailed {suggested_style} image generation prompt. Begin with the style. Describe composition, focal point, lighting, colour palette, and any characters. End with: No text, no letters, no watermarks, photorealistic/painted quality, 8k detail." }} """ try: response = ai.model_artist.generate_content(design_prompt) utils.log_usage(folder, ai.model_artist.name, response.usage_metadata) design = json.loads(utils.clean_json(response.text)) bg_color = design.get('primary_color', '#252570') text_color = design.get('text_color', '#FFFFFF') art_prompt = design.get('art_prompt', f"Cover art for {meta.get('title')}") with open(os.path.join(folder, "cover_art_prompt.txt"), "w") as f: f.write(art_prompt) img = None image_generated = False width, height = 600, 900 best_img_score = 0 best_img_path = None MAX_IMG_ATTEMPTS = 3 if regenerate_image: for i in range(1, MAX_IMG_ATTEMPTS + 1): utils.log("MARKETING", f"Generating cover art (Attempt {i}/{MAX_IMG_ATTEMPTS})...") try: if not ai.model_image: raise ImportError("No Image Generation Model available.") status = "success" try: result = ai.model_image.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar) except Exception as e: err_lower = str(e).lower() # Try fast imagen variant before falling back to legacy if ai.HAS_VERTEX and ("resource" in err_lower or "quota" in err_lower): try: utils.log("MARKETING", "⚠️ Imagen 3 failed. Trying Imagen 3 Fast...") fb_model = ai.VertexImageModel.from_pretrained("imagen-3.0-fast-generate-001") result = fb_model.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar) status = "success_fast" except Exception: utils.log("MARKETING", "⚠️ Imagen 3 Fast failed. Trying Imagen 2...") fb_model = ai.VertexImageModel.from_pretrained("imagegeneration@006") result = fb_model.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar) status = "success_fallback" else: raise e attempt_path = os.path.join(folder, f"cover_art_attempt_{i}.png") result.images[0].save(attempt_path) utils.log_usage(folder, "imagen", image_count=1) cover_eval_criteria = ( f"Book cover art for a {genre} novel titled '{meta.get('title')}'.\n\n" f"Evaluate STRICTLY as a professional book cover on these criteria:\n" f"1. VISUAL IMPACT: Is the image immediately arresting and compelling?\n" f"2. GENRE FIT: Does the visual style, mood, and palette match {genre}?\n" f"3. COMPOSITION: Is there a clear focal point? Are top/bottom areas usable for title/author text?\n" f"4. QUALITY: Is the image sharp, detailed, and free of deformities or blurring?\n" f"5. CLEAN IMAGE: Are there absolutely NO text, watermarks, letters, or UI artifacts?\n" f"Score 1-10. Deduct 3 points if any text/watermarks are visible. " f"Deduct 2 if the image is blurry or has deformed anatomy." ) score, critique = evaluate_image_quality(attempt_path, cover_eval_criteria, ai.model_writer, folder) if score is None: score = 0 utils.log("MARKETING", f" -> Image Score: {score}/10. Critique: {critique}") utils.log_image_attempt(folder, "cover", art_prompt, f"cover_art_{i}.png", status, score=score, critique=critique) if interactive: try: if os.name == 'nt': os.startfile(attempt_path) elif sys.platform == 'darwin': subprocess.call(('open', attempt_path)) else: subprocess.call(('xdg-open', attempt_path)) except: pass if Confirm.ask(f"Accept cover attempt {i} (Score: {score})?", default=True): best_img_path = attempt_path break else: utils.log("MARKETING", "User rejected cover. Retrying...") continue # Only keep as best if score meets minimum quality bar if score >= 5 and score > best_img_score: best_img_score = score best_img_path = attempt_path elif best_img_path is None and score > 0: # Accept even low-quality image if we have nothing else best_img_score = score best_img_path = attempt_path if score >= 9: utils.log("MARKETING", " -> High quality image accepted.") break # Refine prompt based on critique keywords prompt_additions = [] critique_lower = critique.lower() if critique else "" if "scar" in critique_lower or "deform" in critique_lower: prompt_additions.append("perfect anatomy, no deformities") if "blur" in critique_lower or "blurry" in critique_lower: prompt_additions.append("sharp focus, highly detailed") if "text" in critique_lower or "letter" in critique_lower: prompt_additions.append("no text, no letters, no watermarks") if prompt_additions: art_prompt += f". ({', '.join(prompt_additions)})" except Exception as e: utils.log("MARKETING", f"Image generation failed: {e}") if "quota" in str(e).lower(): break if best_img_path and os.path.exists(best_img_path): final_art_path = os.path.join(folder, "cover_art.png") if best_img_path != final_art_path: shutil.copy(best_img_path, final_art_path) img = Image.open(final_art_path).resize((width, height)).convert("RGB") image_generated = True else: utils.log("MARKETING", "Falling back to solid color cover.") img = Image.new('RGB', (width, height), color=bg_color) utils.log_image_attempt(folder, "cover", art_prompt, "cover.png", "fallback_solid") else: # Load existing art final_art_path = os.path.join(folder, "cover_art.png") if os.path.exists(final_art_path): utils.log("MARKETING", "Using existing cover art (Layout update only).") img = Image.open(final_art_path).resize((width, height)).convert("RGB") else: utils.log("MARKETING", "Existing art not found. Forcing regeneration.") # Fallback to solid color if we were supposed to reuse but couldn't find it img = Image.new('RGB', (width, height), color=bg_color) font_path = download_font(design.get('font_name') or 'Arial') best_layout_score = 0 best_layout_path = None base_layout_prompt = f""" ROLE: Graphic Designer TASK: Determine text layout coordinates for a 600x900 cover. METADATA: - TITLE: {meta.get('title')} - AUTHOR: {meta.get('author')} - GENRE: {meta.get('genre')} CONSTRAINT: Do NOT place text over faces. OUTPUT_FORMAT (JSON): {{ "title": {{ "x": Int, "y": Int, "font_size": Int, "font_name": "String", "color": "#Hex" }}, "author": {{ "x": Int, "y": Int, "font_size": Int, "font_name": "String", "color": "#Hex" }} }} """ if feedback: base_layout_prompt += f"\nUSER FEEDBACK: {feedback}\nAdjust layout/colors accordingly." layout_prompt = base_layout_prompt for attempt in range(1, 6): utils.log("MARKETING", f"Designing text layout (Attempt {attempt}/5)...") try: response = ai.model_writer.generate_content([layout_prompt, img]) utils.log_usage(folder, ai.model_writer.name, response.usage_metadata) layout = json.loads(utils.clean_json(response.text)) if isinstance(layout, list): layout = layout[0] if layout else {} except Exception as e: utils.log("MARKETING", f"Layout generation failed: {e}") continue img_copy = img.copy() draw = ImageDraw.Draw(img_copy) def draw_element(key, text_override=None): elem = layout.get(key) if not elem: return if isinstance(elem, list): elem = elem[0] if elem else {} text = text_override if text_override else elem.get('text') if not text: return f_name = elem.get('font_name') or 'Arial' f_path = download_font(f_name) try: if f_path: font = ImageFont.truetype(f_path, elem.get('font_size', 40)) else: raise IOError("Font not found") except: font = ImageFont.load_default() x, y = elem.get('x', 300), elem.get('y', 450) color = elem.get('color') or '#FFFFFF' avg_char_w = font.getlength("A") wrap_w = int(550 / avg_char_w) if avg_char_w > 0 else 20 lines = textwrap.wrap(text, width=wrap_w) line_heights = [] for l in lines: bbox = draw.textbbox((0, 0), l, font=font) line_heights.append(bbox[3] - bbox[1] + 10) total_h = sum(line_heights) current_y = y - (total_h // 2) for i, line in enumerate(lines): bbox = draw.textbbox((0, 0), line, font=font) lx = x - ((bbox[2] - bbox[0]) / 2) draw.text((lx, current_y), line, font=font, fill=color) current_y += line_heights[i] draw_element('title', meta.get('title')) draw_element('author', meta.get('author')) attempt_path = os.path.join(folder, f"cover_layout_attempt_{attempt}.png") img_copy.save(attempt_path) # Evaluate Layout eval_prompt = f""" Analyze the text layout for the book title '{meta.get('title')}'. CHECKLIST: 1. Is the text legible against the background? 2. Is the contrast sufficient? 3. Does it look professional? """ score, critique = evaluate_image_quality(attempt_path, eval_prompt, ai.model_writer, folder) if score is None: score = 0 utils.log("MARKETING", f" -> Layout Score: {score}/10. Critique: {critique}") if score > best_layout_score: best_layout_score = score best_layout_path = attempt_path if score == 10: utils.log("MARKETING", " -> Perfect layout accepted.") break layout_prompt = base_layout_prompt + f"\nCRITIQUE OF PREVIOUS ATTEMPT: {critique}\nAdjust position/color to fix this." if best_layout_path: shutil.copy(best_layout_path, os.path.join(folder, "cover.png")) except Exception as e: utils.log("MARKETING", f"Cover generation failed: {e}") def create_marketing_assets(bp, folder, tracking=None, interactive=False): generate_blurb(bp, folder) generate_cover(bp, folder, tracking, interactive=interactive)