From 3a42d1a339b468136ef6f3efb895e1429f858087 Mon Sep 17 00:00:00 2001 From: Mike Wichers Date: Sun, 22 Feb 2026 22:24:27 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20Rebuild=20cover=20pipeline=20with=20ful?= =?UTF-8?q?l=20evaluate=E2=86=92critique=E2=86=92refine=E2=86=92retry=20qu?= =?UTF-8?q?ality=20gates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major changes to marketing/cover.py: - Split evaluate_image_quality() into two purpose-built functions: * evaluate_cover_art(): 5-rubric scoring (visual impact, genre fit, composition, quality, clean image) with auto-fail for visible text (score capped at 4) and deductions for deformed anatomy * evaluate_cover_layout(): 5-rubric scoring (legibility, typography, placement, professional polish, genre signal) with auto-fail for illegible title (capped at 4) - Added validate_art_prompt(): pre-validates the Imagen prompt before generation — strips accidental text instructions, ensures focal point + rule-of-thirds + genre fit - Added _build_visual_context(): extracts protagonist/antagonist descriptions and key themes from tracking data into structured visual context for the art director prompt - Score thresholds raised to match chapter pipeline: ART_PASSING=7, ART_AUTO_ACCEPT=8, LAYOUT_PASSING=7 (was: art>=5 or >0, layout breaks only at ==10) - Critique-driven art prompt refinement between attempts: full LLM rewrite of the Imagen prompt using the evaluator's actionable feedback (not just keyword appending) - Layout loop now breaks early at score>=7 (was: only at ==10, so never) - Design prompt strengthened with explicit character/visual context and NO TEXT clause Co-Authored-By: Claude Sonnet 4.6 --- marketing/cover.py | 749 ++++++++++++++++++++++++++++----------------- 1 file changed, 469 insertions(+), 280 deletions(-) diff --git a/marketing/cover.py b/marketing/cover.py index bc1c941..6306438 100644 --- a/marketing/cover.py +++ b/marketing/cover.py @@ -14,27 +14,187 @@ try: except ImportError: HAS_PIL = False +# Score gates (mirrors chapter writing pipeline thresholds) +ART_SCORE_AUTO_ACCEPT = 8 # Stop retrying — image is excellent +ART_SCORE_PASSING = 7 # Acceptable; keep as best candidate +LAYOUT_SCORE_PASSING = 7 # Accept layout and stop retrying -def evaluate_image_quality(image_path, prompt, model, folder=None): - if not HAS_PIL: return None, "PIL not installed" + +# --------------------------------------------------------------------------- +# Evaluation helpers +# --------------------------------------------------------------------------- + +def evaluate_cover_art(image_path, genre, title, model, folder=None): + """Score generated cover art against a professional book-cover rubric. + + Returns (score: int | None, critique: str). + Auto-fail conditions: + - Any visible text/watermarks → score capped at 4 + - Blurry or deformed anatomy → deduct 2 points + """ + 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) + prompt = f""" + ROLE: Professional Book Cover Art Critic + TASK: Score this AI-generated cover art for a {genre} novel titled '{title}'. + SCORING RUBRIC (1-10): + 1. VISUAL IMPACT: Is the image immediately arresting? Does it demand attention on a shelf? + 2. GENRE FIT: Does the visual style, mood, and colour palette unmistakably signal {genre}? + 3. COMPOSITION: Is there a clear focal point? Are the top or bottom thirds usable for title/author text overlay? + 4. TECHNICAL QUALITY: Sharp, detailed, free of deformities, blurring, or AI artefacts? + 5. CLEAN IMAGE: Absolutely NO text, letters, numbers, watermarks, logos, or UI elements? + + SCORING SCALE: + - 9-10: Masterclass cover art, ready for a major publisher + - 7-8: Professional quality, genre-appropriate, minor flaws only + - 5-6: Usable but generic or has one significant flaw + - 1-4: Unusable — major artefacts, wrong genre, deformed figures, or visible text + + AUTO-FAIL RULES (apply before scoring): + - If ANY text, letters, watermarks or UI elements are visible → score CANNOT exceed 4. State this explicitly. + - If figures have deformed anatomy or blurring → deduct 2 from your final score. + + OUTPUT_FORMAT (JSON): {{"score": int, "critique": "Specific issues citing what to fix in the next attempt.", "actionable": "One concrete change to the image prompt that would improve the next attempt."}} + """ + response = model.generate_content([prompt, img]) + model_name = getattr(model, 'name', "logic") + if folder: + utils.log_usage(folder, model_name, response.usage_metadata) + data = json.loads(utils.clean_json(response.text)) + score = data.get('score') + critique = data.get('critique', '') + if data.get('actionable'): + critique += f" FIX: {data['actionable']}" + return score, critique + except Exception as e: + return None, str(e) + + +def evaluate_cover_layout(image_path, title, author, genre, font_name, model, folder=None): + """Score the finished cover (art + text overlay) as a professional book cover. + + Returns (score: int | None, critique: str). + """ + if not HAS_PIL: + return None, "PIL not installed" + try: + img = Image.open(image_path) + prompt = f""" + ROLE: Graphic Design Critic + TASK: Score this finished book cover for '{title}' by {author} ({genre}). + + SCORING RUBRIC (1-10): + 1. LEGIBILITY: Is the title instantly readable? High contrast against the background? + 2. TYPOGRAPHY: Does the font '{font_name}' suit the {genre} genre? Is sizing proportional? + 3. PLACEMENT: Is the title placed where it doesn't obscure the focal point? Is the author name readable? + 4. PROFESSIONAL POLISH: Does this look like a published, commercially-viable cover? + 5. GENRE SIGNAL: At a glance, does the whole cover (art + text) correctly signal {genre}? + + SCORING SCALE: + - 9-10: Indistinguishable from a professional published cover + - 7-8: Strong cover, minor refinement would help + - 5-6: Passable but text placement or contrast needs work + - 1-4: Unusable — unreadable text, clashing colours, or amateurish layout + + AUTO-FAIL: If the title text is illegible (low contrast, obscured, or missing) → score CANNOT exceed 4. + + OUTPUT_FORMAT (JSON): {{"score": int, "critique": "Specific layout issues.", "actionable": "One change to position, colour, or font size that would fix the worst problem."}} + """ + response = model.generate_content([prompt, img]) + model_name = getattr(model, 'name', "logic") + if folder: + utils.log_usage(folder, model_name, response.usage_metadata) + data = json.loads(utils.clean_json(response.text)) + score = data.get('score') + critique = data.get('critique', '') + if data.get('actionable'): + critique += f" FIX: {data['actionable']}" + return score, critique + except Exception as e: + return None, str(e) + + +# --------------------------------------------------------------------------- +# Art prompt pre-validation +# --------------------------------------------------------------------------- + +def validate_art_prompt(art_prompt, meta, model, folder=None): + """Pre-validate and improve the image generation prompt before calling Imagen. + + Checks for: accidental text instructions, vague focal point, missing composition + guidance, and genre mismatch. Returns improved prompt or original on failure. + """ + genre = meta.get('genre', 'Fiction') + title = meta.get('title', 'Untitled') + + check_prompt = f""" + ROLE: Art Director + TASK: Review and improve this image generation prompt for a {genre} book cover titled '{title}'. + + CURRENT_PROMPT: + {art_prompt} + + CHECK FOR AND FIX: + 1. Any instruction to render text, letters, or the title? → Remove it (text is overlaid separately). + 2. Is there a specific, memorable FOCAL POINT described? → Add one if missing. + 3. Does the colour palette and style match {genre} conventions? → Correct if off. + 4. Is RULE OF THIRDS composition mentioned (space at top/bottom for title overlay)? → Add if missing. + 5. Does it end with "No text, no letters, no watermarks"? → Ensure this is present. + + Return the improved prompt under 200 words. + + OUTPUT_FORMAT (JSON): {{"improved_prompt": "..."}} + """ + try: + resp = model.generate_content(check_prompt) + if folder: + utils.log_usage(folder, model.name, resp.usage_metadata) + data = json.loads(utils.clean_json(resp.text)) + improved = data.get('improved_prompt', '').strip() + if improved and len(improved) > 50: + utils.log("MARKETING", " -> Art prompt validated and improved.") + return improved + except Exception as e: + utils.log("MARKETING", f" -> Art prompt validation failed: {e}. Using original.") + return art_prompt + + +# --------------------------------------------------------------------------- +# Visual context helper +# --------------------------------------------------------------------------- + +def _build_visual_context(bp, tracking): + """Extract structured visual context: protagonist, antagonist, key themes.""" + lines = [] + chars = bp.get('characters', []) + protagonist = next((c for c in chars if 'protagonist' in c.get('role', '').lower()), None) + if protagonist: + lines.append(f"PROTAGONIST: {protagonist.get('name')} — {protagonist.get('description', '')[:200]}") + antagonist = next((c for c in chars if 'antagonist' in c.get('role', '').lower()), None) + if antagonist: + lines.append(f"ANTAGONIST: {antagonist.get('name')} — {antagonist.get('description', '')[:150]}") + if tracking and tracking.get('characters'): + for name, data in list(tracking['characters'].items())[:2]: + desc = ', '.join(data.get('descriptors', []))[:120] + if desc: + lines.append(f"CHARACTER VISUAL ({name}): {desc}") + if tracking and tracking.get('events'): + recent = [e for e in tracking['events'][-3:] if isinstance(e, str)] + if recent: + lines.append(f"KEY THEMES/EVENTS: {'; '.join(recent)[:200]}") + return "\n".join(lines) if lines else "" + + +# --------------------------------------------------------------------------- +# Main entry point +# --------------------------------------------------------------------------- def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False): if not HAS_PIL: - utils.log("MARKETING", "Pillow not installed. Skipping image cover.") + utils.log("MARKETING", "Pillow not installed. Skipping cover.") return utils.log("MARKETING", "Generating cover...") @@ -45,13 +205,7 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False): 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" + visual_context = _build_visual_context(bp, tracking) regenerate_image = True design_instruction = "" @@ -60,18 +214,15 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False): regenerate_image = False if feedback and feedback.strip(): - utils.log("MARKETING", f"Analyzing feedback: '{feedback}'...") + utils.log("MARKETING", f"Analysing feedback: '{feedback}'...") analysis_prompt = f""" ROLE: Design Assistant - TASK: Analyze user feedback on cover. - + TASK: Analyse user feedback on a book 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" }} + 1. Keep the background image; change only text/layout/colour → REGENERATE_LAYOUT + 2. Create a completely new background image → REGENERATE_IMAGE + OUTPUT_FORMAT (JSON): {{"action": "REGENERATE_LAYOUT" or "REGENERATE_IMAGE", "instruction": "Specific instruction for the Art Director."}} """ try: resp = ai_models.model_logic.generate_content(analysis_prompt) @@ -79,24 +230,24 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False): 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.") + utils.log("MARKETING", "Feedback: keeping image, regenerating layout only.") design_instruction = decision.get('instruction', feedback) - except: + except Exception: utils.log("MARKETING", "Feedback analysis failed. Defaulting to full regeneration.") genre = meta.get('genre', 'Fiction') tone = meta.get('style', {}).get('tone', 'Balanced') 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', + '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') + suggested_style = genre_style_map.get(genre.lower(), 'professional digital illustration') design_prompt = f""" ROLE: Art Director @@ -108,258 +259,296 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False): - 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."} + VISUAL_CONTEXT (characters and themes from the finished story — use these): + {visual_context if visual_context else "Use strong 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. + - The art_prompt MUST produce an image with ABSOLUTELY NO text, letters, numbers, watermarks, UI elements, or logos. Text is overlaid separately. + - Describe a specific, memorable FOCAL POINT (e.g. protagonist mid-action, a symbolic object, a dramatic landscape). + - Use RULE OF THIRDS composition — preserve visual space at top AND bottom for title/author text overlay. + - Describe LIGHTING that reinforces the tone (e.g. "harsh neon backlight", "golden hour", "cold winter dawn"). + - Specify the COLOUR PALETTE explicitly (e.g. "deep crimson and shadow-black", "soft rose gold and ivory cream"). + - If characters are described in VISUAL_CONTEXT, their appearance MUST match those descriptions exactly. + - End the art_prompt with: "No text, no letters, no watermarks, no UI elements. {suggested_style} quality, 8k detail." - OUTPUT_FORMAT (JSON only, no markdown): + OUTPUT_FORMAT (JSON only, no markdown wrapper): {{ - "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)", + "font_name": "One Google Font suited to {genre} (e.g. Cinzel for fantasy, Oswald for thriller, Playfair Display for romance)", + "primary_color": "#HexCode", "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." + "art_prompt": "Detailed image generation prompt. Style → Focal point → Composition → Lighting → Colour palette → Characters (if any). End with the NO TEXT clause." }} """ try: response = ai_models.model_artist.generate_content(design_prompt) utils.log_usage(folder, ai_models.model_artist.name, response.usage_metadata) design = json.loads(utils.clean_json(response.text)) - - bg_color = design.get('primary_color', '#252570') - - 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 - 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_models.model_image: raise ImportError("No Image Generation Model available.") - - status = "success" - try: - result = ai_models.model_image.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar) - except Exception as e: - err_lower = str(e).lower() - if ai_models.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_models.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_models.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_models.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 - - from rich.prompt import Confirm - 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 - - 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: - best_img_score = score - best_img_path = attempt_path - - if score >= 9: - utils.log("MARKETING", " -> High quality image accepted.") - break - - 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") - 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: - 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.") - 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_models.model_writer.generate_content([layout_prompt, img]) - utils.log_usage(folder, ai_models.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 idx, 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[idx] - - 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) - - 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_models.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}") + utils.log("MARKETING", f"Cover design failed: {e}") + return + + bg_color = design.get('primary_color', '#252570') + art_prompt = design.get('art_prompt', f"Cover art for {meta.get('title')}") + font_name = design.get('font_name') or 'Playfair Display' + + # Pre-validate and improve the art prompt before handing to Imagen + art_prompt = validate_art_prompt(art_prompt, meta, ai_models.model_logic, folder) + with open(os.path.join(folder, "cover_art_prompt.txt"), "w") as f: + f.write(art_prompt) + + img = None + width, height = 600, 900 + + # ----------------------------------------------------------------------- + # Phase 1: Art generation loop (evaluate → critique → refine → retry) + # ----------------------------------------------------------------------- + best_art_score = 0 + best_art_path = None + current_art_prompt = art_prompt + MAX_ART_ATTEMPTS = 3 + + if regenerate_image: + for attempt in range(1, MAX_ART_ATTEMPTS + 1): + utils.log("MARKETING", f"Generating cover art (Attempt {attempt}/{MAX_ART_ATTEMPTS})...") + attempt_path = os.path.join(folder, f"cover_art_attempt_{attempt}.png") + gen_status = "success" + + try: + if not ai_models.model_image: + raise ImportError("No image generation model available.") + + try: + result = ai_models.model_image.generate_images( + prompt=current_art_prompt, number_of_images=1, aspect_ratio=ar) + except Exception as img_err: + err_lower = str(img_err).lower() + if ai_models.HAS_VERTEX and ("resource" in err_lower or "quota" in err_lower): + try: + utils.log("MARKETING", "⚠️ Imagen 3 failed. Trying Imagen 3 Fast...") + fb = ai_models.VertexImageModel.from_pretrained("imagen-3.0-fast-generate-001") + result = fb.generate_images(prompt=current_art_prompt, number_of_images=1, aspect_ratio=ar) + gen_status = "success_fast" + except Exception: + utils.log("MARKETING", "⚠️ Imagen 3 Fast failed. Trying Imagen 2...") + fb = ai_models.VertexImageModel.from_pretrained("imagegeneration@006") + result = fb.generate_images(prompt=current_art_prompt, number_of_images=1, aspect_ratio=ar) + gen_status = "success_fallback" + else: + raise img_err + + result.images[0].save(attempt_path) + utils.log_usage(folder, "imagen", image_count=1) + + score, critique = evaluate_cover_art( + attempt_path, genre, meta.get('title', ''), ai_models.model_logic, folder) + if score is None: + score = 0 + utils.log("MARKETING", f" -> Art Score: {score}/10. Critique: {critique}") + utils.log_image_attempt(folder, "cover", current_art_prompt, + f"cover_art_attempt_{attempt}.png", gen_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 Exception: + pass + from rich.prompt import Confirm + if Confirm.ask(f"Accept cover art attempt {attempt} (score {score})?", default=True): + best_art_path = attempt_path + best_art_score = score + break + else: + utils.log("MARKETING", "User rejected art. Regenerating...") + continue + + # Track best image — prefer passing threshold; keep first usable as fallback + if score >= ART_SCORE_PASSING and score > best_art_score: + best_art_score = score + best_art_path = attempt_path + elif best_art_path is None and score > 0: + best_art_score = score + best_art_path = attempt_path + + if score >= ART_SCORE_AUTO_ACCEPT: + utils.log("MARKETING", " -> High-quality art accepted early.") + break + + # Critique-driven prompt refinement for next attempt + if attempt < MAX_ART_ATTEMPTS and critique: + refine_req = f""" + ROLE: Art Director + TASK: Rewrite the image prompt to fix the critique below. Keep under 200 words. + + CRITIQUE: {critique} + ORIGINAL_PROMPT: {current_art_prompt} + + RULES: + - Preserve genre style, focal point, and colour palette unless explicitly criticised. + - If text/watermarks were visible: reinforce "absolutely no text, no letters, no watermarks." + - If anatomy was deformed: add "perfect anatomy, professional figure illustration." + - If blurry: add "tack-sharp focus, highly detailed." + + OUTPUT_FORMAT (JSON): {{"improved_prompt": "..."}} + """ + try: + rr = ai_models.model_logic.generate_content(refine_req) + utils.log_usage(folder, ai_models.model_logic.name, rr.usage_metadata) + rd = json.loads(utils.clean_json(rr.text)) + improved = rd.get('improved_prompt', '').strip() + if improved and len(improved) > 50: + current_art_prompt = improved + utils.log("MARKETING", " -> Art prompt refined for next attempt.") + except Exception: + pass + + except Exception as e: + utils.log("MARKETING", f"Image generation attempt {attempt} failed: {e}") + if "quota" in str(e).lower(): + break + + if best_art_path and os.path.exists(best_art_path): + final_art_path = os.path.join(folder, "cover_art.png") + if best_art_path != final_art_path: + shutil.copy(best_art_path, final_art_path) + img = Image.open(final_art_path).resize((width, height)).convert("RGB") + utils.log("MARKETING", f" -> Best art: {best_art_score}/10.") + else: + utils.log("MARKETING", "⚠️ No usable art generated. Falling back to solid colour cover.") + img = Image.new('RGB', (width, height), color=bg_color) + utils.log_image_attempt(folder, "cover", art_prompt, "cover.png", "fallback_solid") + else: + 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. Using solid colour fallback.") + img = Image.new('RGB', (width, height), color=bg_color) + + if img is None: + utils.log("MARKETING", "Cover generation aborted — no image available.") + return + + font_path = download_font(font_name) + + # ----------------------------------------------------------------------- + # Phase 2: Text layout loop (evaluate → critique → adjust → retry) + # ----------------------------------------------------------------------- + best_layout_score = 0 + best_layout_path = None + + base_layout_prompt = f""" + ROLE: Graphic Designer + TASK: Determine precise text layout coordinates for a 600×900 book cover image. + + BOOK: + - TITLE: {meta.get('title')} + - AUTHOR: {meta.get('author', 'Unknown')} + - GENRE: {genre} + - FONT: {font_name} + - TEXT_COLOR: {design.get('text_color', '#FFFFFF')} + + PLACEMENT RULES: + - Title in top third OR bottom third (not centre — that obscures the focal art). + - Author name in the opposite zone, or just below the title. + - Font sizes: title ~60-80px, author ~28-36px for a 600px-wide canvas. + - Do NOT place text over faces or the primary focal point. + - Coordinates are the CENTER of the text block (x=300 is horizontal centre). + + {f"USER FEEDBACK: {feedback}. Adjust placement/colour accordingly." if feedback else ""} + + OUTPUT_FORMAT (JSON): + {{ + "title": {{"x": Int, "y": Int, "font_size": Int, "font_name": "{font_name}", "color": "#Hex"}}, + "author": {{"x": Int, "y": Int, "font_size": Int, "font_name": "{font_name}", "color": "#Hex"}} + }} + """ + + layout_prompt = base_layout_prompt + MAX_LAYOUT_ATTEMPTS = 5 + + for attempt in range(1, MAX_LAYOUT_ATTEMPTS + 1): + utils.log("MARKETING", f"Designing text layout (Attempt {attempt}/{MAX_LAYOUT_ATTEMPTS})...") + try: + resp = ai_models.model_writer.generate_content([layout_prompt, img]) + utils.log_usage(folder, ai_models.model_writer.name, resp.usage_metadata) + layout = json.loads(utils.clean_json(resp.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 font_name + f_p = download_font(f_name) + try: + fnt = ImageFont.truetype(f_p, elem.get('font_size', 40)) if f_p else ImageFont.load_default() + except Exception: + fnt = ImageFont.load_default() + x, y = elem.get('x', 300), elem.get('y', 450) + color = elem.get('color') or design.get('text_color', '#FFFFFF') + avg_w = fnt.getlength("A") + wrap_w = int(550 / avg_w) if avg_w > 0 else 20 + lines = textwrap.wrap(text, width=wrap_w) + line_heights = [] + for ln in lines: + bbox = draw.textbbox((0, 0), ln, font=fnt) + line_heights.append(bbox[3] - bbox[1] + 10) + total_h = sum(line_heights) + current_y = y - (total_h // 2) + for idx, ln in enumerate(lines): + bbox = draw.textbbox((0, 0), ln, font=fnt) + lx = x - ((bbox[2] - bbox[0]) / 2) + draw.text((lx, current_y), ln, font=fnt, fill=color) + current_y += line_heights[idx] + + 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) + + score, critique = evaluate_cover_layout( + attempt_path, meta.get('title', ''), meta.get('author', ''), genre, font_name, + ai_models.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 >= LAYOUT_SCORE_PASSING: + utils.log("MARKETING", f" -> Layout accepted (score {score} ≥ {LAYOUT_SCORE_PASSING}).") + break + + if attempt < MAX_LAYOUT_ATTEMPTS: + layout_prompt = (base_layout_prompt + + f"\n\nCRITIQUE OF ATTEMPT {attempt}: {critique}\n" + + "Adjust coordinates, font_size, or color to fix these issues exactly.") + + if best_layout_path: + shutil.copy(best_layout_path, os.path.join(folder, "cover.png")) + utils.log("MARKETING", f"Cover saved. Best layout score: {best_layout_score}/10.") + else: + utils.log("MARKETING", "⚠️ No layout produced. Cover not saved.")