Replaced monolithic modules/ package with a clean architecture:
- core/ config.py, utils.py
- ai/ models.py (ResilientModel), setup.py (init_models)
- story/ planner.py, writer.py, editor.py, style_persona.py, bible_tracker.py
- marketing/ cover.py, blurb.py, fonts.py, assets.py
- export/ exporter.py
- web/ app.py (Flask factory), db.py, helpers.py, tasks.py, routes/{auth,project,run,persona,admin}.py
- cli/ engine.py (run_generation), wizard.py (BookWizard)
Flask routes split into 5 Blueprints; all templates updated with blueprint-
prefixed url_for() calls. Dockerfile and docker-compose updated to use
web.app entry point and new package paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
366 lines
17 KiB
Python
366 lines
17 KiB
Python
import os
|
|
import sys
|
|
import json
|
|
import shutil
|
|
import textwrap
|
|
import subprocess
|
|
from core import utils
|
|
from ai import models as ai_models
|
|
from marketing.fonts import download_font
|
|
|
|
try:
|
|
from PIL import Image, ImageDraw, ImageFont, ImageStat
|
|
HAS_PIL = True
|
|
except ImportError:
|
|
HAS_PIL = False
|
|
|
|
|
|
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_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', {})
|
|
|
|
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"
|
|
|
|
regenerate_image = True
|
|
design_instruction = ""
|
|
|
|
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_models.model_logic.generate_content(analysis_prompt)
|
|
utils.log_usage(folder, ai_models.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')
|
|
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_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}")
|