350 lines
16 KiB
Python
350 lines
16 KiB
Python
import os
|
|
import json
|
|
import shutil
|
|
import textwrap
|
|
import requests
|
|
import google.generativeai as genai
|
|
from . import utils
|
|
import config
|
|
from modules import ai
|
|
|
|
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"Analyze this generated image against the description: '{prompt}'.\nRate accuracy/relevance on a scale of 1-10.\nProvide a 1-sentence critique.\nReturn JSON: {{'score': int, 'reason': 'string'}}", img])
|
|
if folder: utils.log_usage(folder, "logic-pro", 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', {})
|
|
|
|
prompt = f"""
|
|
Write a compelling back-cover blurb (approx 150-200 words) for this book.
|
|
TITLE: {meta.get('title')}
|
|
GENRE: {meta.get('genre')}
|
|
LOGLINE: {bp.get('manual_instruction')}
|
|
PLOT: {json.dumps(bp.get('plot_beats', []))}
|
|
CHARACTERS: {json.dumps(bp.get('characters', []))}
|
|
"""
|
|
try:
|
|
response = ai.model_writer.generate_content(prompt)
|
|
utils.log_usage(folder, "writer-flash", 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):
|
|
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 feedback and feedback.strip():
|
|
utils.log("MARKETING", f"Analyzing feedback: '{feedback}'...")
|
|
analysis_prompt = f"""
|
|
User Feedback on Book Cover: "{feedback}"
|
|
Determine if the user wants to:
|
|
1. Keep the current background image but change text/layout/color (REGENERATE_LAYOUT).
|
|
2. Create a completely new background image (REGENERATE_IMAGE).
|
|
|
|
NOTE: If the feedback is generic (e.g. "regenerate", "try again") or does not explicitly mention keeping the image/changing text only, default to REGENERATE_IMAGE.
|
|
Return JSON: {{ "action": "REGENERATE_LAYOUT" or "REGENERATE_IMAGE", "instruction": "Specific instruction for the Art Director" }}
|
|
"""
|
|
try:
|
|
resp = ai.model_logic.generate_content(analysis_prompt)
|
|
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.")
|
|
|
|
design_prompt = f"""
|
|
Act as an Art Director. Design the cover for this book.
|
|
TITLE: {meta.get('title')}
|
|
GENRE: {meta.get('genre')}
|
|
TONE: {meta.get('style', {}).get('tone')}
|
|
|
|
CRITICAL INSTRUCTIONS:
|
|
1. CHARACTER APPEARANCE: Strictly adhere to the provided character descriptions (hair, eyes, race, age, clothing) in the Visual Context.
|
|
2. GENRE EXPRESSIONS: Ensure character facial expressions and body language heavily reflect the GENRE (e.g. Horror = terrified/menacing, Romance = longing/soft, Thriller = intense/alert).
|
|
|
|
{visual_context}
|
|
{f"USER FEEDBACK: {feedback}" if feedback else ""}
|
|
{f"INSTRUCTION: {design_instruction}" if design_instruction else ""}
|
|
|
|
Provide JSON output:
|
|
{{
|
|
"font_name": "Name of a popular Google Font (e.g. Roboto, Cinzel, Oswald, Playfair Display)",
|
|
"primary_color": "#HexCode (Background)",
|
|
"text_color": "#HexCode (Contrast)",
|
|
"art_prompt": "A detailed description of the cover art for an image generator. Explicitly describe characters based on the visual context."
|
|
}}
|
|
"""
|
|
try:
|
|
response = ai.model_artist.generate_content(design_prompt)
|
|
utils.log_usage(folder, "artist-flash", 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
|
|
|
|
if regenerate_image:
|
|
for i in range(1, 6):
|
|
utils.log("MARKETING", f"Generating cover art (Attempt {i}/5)...")
|
|
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:
|
|
if "resource" in str(e).lower() and ai.HAS_VERTEX:
|
|
utils.log("MARKETING", "⚠️ Imagen 3 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)
|
|
|
|
score, critique = evaluate_image_quality(attempt_path, art_prompt, ai.model_logic, 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 score > best_img_score:
|
|
best_img_score = score
|
|
best_img_path = attempt_path
|
|
|
|
if score == 10:
|
|
utils.log("MARKETING", " -> Perfect image accepted.")
|
|
break
|
|
|
|
if "scar" in critique.lower() or "deform" in critique.lower() or "blur" in critique.lower():
|
|
art_prompt += " (Ensure high quality, clear skin, no scars, sharp focus)."
|
|
|
|
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"""
|
|
Act as a Senior Book Cover Designer. Analyze this 600x900 cover art.
|
|
BOOK DETAILS: Title: {meta.get('title')}, Author: {meta.get('author')}, Genre: {meta.get('genre')}
|
|
TASK: Determine best (x, y) coordinates for Title and Author. Do NOT place text over faces.
|
|
RETURN 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_logic.generate_content([layout_prompt, img])
|
|
utils.log_usage(folder, "logic-pro", 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 this book cover layout. Is the text legible? Is the contrast good? Does it look professional? Title: {meta.get('title')}"
|
|
score, critique = evaluate_image_quality(attempt_path, eval_prompt, ai.model_logic, 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):
|
|
generate_blurb(bp, folder)
|
|
generate_cover(bp, folder, tracking) |