Files
bookapp/modules/marketing.py
Mike Wichers 1964c9c2a5 v1.3.0: Improve all AI prompts, refinement loops, and cover generation accuracy
story.py — write_chapter():
- Added POSITION context ("Chapter N of Total") so the AI calibrates narrative
  tension correctly (setup vs escalation vs climax/payoff)
- Moved PACING_GUIDE to sit directly after PACING metadata instead of being
  buried after 13 quality criteria items where the AI rarely reads it
- Removed duplicate pacing descriptions that appeared after QUALITY_CRITERIA

story.py — refinement loop:
- Capped critique history to last 2 entries (was accumulating all previous
  attempts, wasting tokens and confusing the model on attempt 4-5)
- Added TARGET_WORDS and BEATS constraints to the refinement prompt to prevent
  chapters from shrinking or losing plot beats during editing passes
- Restructured refinement prompt with explicit HARD_CONSTRAINTS section

story.py — check_and_propagate():
- Increased chapter context from 5000 to 12000 chars for continuity rewrites
  (was asking for a full chapter rewrite but only providing a fragment)
- Added explicit word count target to rewrite so chapters are not truncated
- Added conservative decision bias: only rewrite on genuine contradictions

story.py — plan_structure():
- Now passes TARGET_CHAPTERS, TARGET_WORDS, GENRE, and CHARACTERS to the
  structure AI — it was planning blindly without knowing the book's scale

marketing.py — generate_blurb():
- Rewrote prompt with 4-part structure: Hook → Stakes → Tension → Close
- Formats plot beats as a readable list instead of raw JSON array
- Extracts protagonist automatically for personalised blurb copy
- Added genre-tone matching, present-tense voice, and no-spoiler rule

marketing.py — generate_cover():
- Added genre-to-visual-style mapping (thriller → cinematic, fantasy → epic
  digital painting, romance → painterly, etc.)
- Art prompt instructions now enforce: no text/letters/watermarks, rule-of-thirds
  composition, explicit focal point, lighting description, colour palette
- Replaced generic image evaluation with a 5-criteria book-cover rubric:
  visual impact, genre fit, composition, quality, and clean image (no text)
- Score penalties: -3 for visible text/watermarks, -2 for blur/deformed anatomy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 10:38:36 -05:00

484 lines
23 KiB
Python

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)