Compare commits
2 Commits
4f2449f79b
...
ff5093a5f9
| Author | SHA1 | Date | |
|---|---|---|---|
| ff5093a5f9 | |||
| 3a42d1a339 |
@@ -57,6 +57,8 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
candidate_persona = style_persona.create_initial_persona(bp, folder)
|
candidate_persona = style_persona.create_initial_persona(bp, folder)
|
||||||
is_valid, p_score = style_persona.validate_persona(bp, candidate_persona, folder)
|
is_valid, p_score = style_persona.validate_persona(bp, candidate_persona, folder)
|
||||||
if is_valid or persona_attempt == max_persona_attempts:
|
if is_valid or persona_attempt == max_persona_attempts:
|
||||||
|
if not is_valid:
|
||||||
|
utils.log("SYSTEM", f" ⚠️ Persona accepted after {max_persona_attempts} attempts despite low score ({p_score}/10). Voice drift risk elevated.")
|
||||||
bp['book_metadata']['author_details'] = candidate_persona
|
bp['book_metadata']['author_details'] = candidate_persona
|
||||||
break
|
break
|
||||||
utils.log("SYSTEM", f" -> Persona attempt {persona_attempt}/{max_persona_attempts} scored {p_score}/10. Regenerating...")
|
utils.log("SYSTEM", f" -> Persona attempt {persona_attempt}/{max_persona_attempts} scored {p_score}/10. Regenerating...")
|
||||||
@@ -268,7 +270,8 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
with open(chars_track_path, "w") as f: json.dump(tracking['characters'], f, indent=2)
|
with open(chars_track_path, "w") as f: json.dump(tracking['characters'], f, indent=2)
|
||||||
with open(warn_track_path, "w") as f: json.dump(tracking.get('content_warnings', []), f, indent=2)
|
with open(warn_track_path, "w") as f: json.dump(tracking.get('content_warnings', []), f, indent=2)
|
||||||
|
|
||||||
# Update Lore Index (Item 8: RAG-Lite)
|
# Update Lore Index (Item 8: RAG-Lite) — every 3 chapters (lore is stable after ch 1-3)
|
||||||
|
if i == 0 or i % 3 == 0:
|
||||||
tracking['lore'] = bible_tracker.update_lore_index(folder, txt, tracking.get('lore', {}))
|
tracking['lore'] = bible_tracker.update_lore_index(folder, txt, tracking.get('lore', {}))
|
||||||
with open(lore_track_path, "w") as f: json.dump(tracking['lore'], f, indent=2)
|
with open(lore_track_path, "w") as f: json.dump(tracking['lore'], f, indent=2)
|
||||||
|
|
||||||
@@ -276,10 +279,12 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
current_story_state = story_state.update_story_state(txt, ch['chapter_number'], current_story_state, folder)
|
current_story_state = story_state.update_story_state(txt, ch['chapter_number'], current_story_state, folder)
|
||||||
|
|
||||||
# Exp 5: Mid-gen Consistency Snapshot (every 10 chapters)
|
# Exp 5: Mid-gen Consistency Snapshot (every 10 chapters)
|
||||||
|
# Sample: first 2 + last 8 chapters to keep token cost bounded regardless of book length
|
||||||
if len(ms) > 0 and len(ms) % 10 == 0:
|
if len(ms) > 0 and len(ms) % 10 == 0:
|
||||||
utils.log("EDITOR", f"--- Mid-gen consistency check after chapter {ch['chapter_number']} ({len(ms)} written) ---")
|
utils.log("EDITOR", f"--- Mid-gen consistency check after chapter {ch['chapter_number']} ({len(ms)} written) ---")
|
||||||
try:
|
try:
|
||||||
consistency = story_editor.analyze_consistency(bp, ms, folder)
|
ms_sample = (ms[:2] + ms[-8:]) if len(ms) > 10 else ms
|
||||||
|
consistency = story_editor.analyze_consistency(bp, ms_sample, folder)
|
||||||
issues = consistency.get('issues', [])
|
issues = consistency.get('issues', [])
|
||||||
if issues:
|
if issues:
|
||||||
for issue in issues:
|
for issue in issues:
|
||||||
|
|||||||
@@ -23,18 +23,27 @@ PRICING_CACHE = {}
|
|||||||
# --- Token Estimation & Truncation Utilities ---
|
# --- Token Estimation & Truncation Utilities ---
|
||||||
|
|
||||||
def estimate_tokens(text):
|
def estimate_tokens(text):
|
||||||
"""Estimate token count using a 4-chars-per-token heuristic (no external libs required)."""
|
"""Estimate token count using a 3.5-chars-per-token heuristic (more accurate than /4)."""
|
||||||
if not text:
|
if not text:
|
||||||
return 0
|
return 0
|
||||||
return max(1, len(text) // 4)
|
return max(1, int(len(text) / 3.5))
|
||||||
|
|
||||||
def truncate_to_tokens(text, max_tokens):
|
def truncate_to_tokens(text, max_tokens, keep_head=False):
|
||||||
"""Truncate text to approximately max_tokens, keeping the most recent (tail) content."""
|
"""Truncate text to approximately max_tokens.
|
||||||
|
|
||||||
|
keep_head=False (default): keep the most recent (tail) content — good for 'story so far'.
|
||||||
|
keep_head=True: keep first third + last two thirds — good for context that needs both
|
||||||
|
the opening framing and the most recent events.
|
||||||
|
"""
|
||||||
if not text:
|
if not text:
|
||||||
return text
|
return text
|
||||||
max_chars = max_tokens * 4
|
max_chars = int(max_tokens * 3.5)
|
||||||
if len(text) <= max_chars:
|
if len(text) <= max_chars:
|
||||||
return text
|
return text
|
||||||
|
if keep_head:
|
||||||
|
head_chars = max_chars // 3
|
||||||
|
tail_chars = max_chars - head_chars
|
||||||
|
return text[:head_chars] + "\n[...]\n" + text[-tail_chars:]
|
||||||
return text[-max_chars:]
|
return text[-max_chars:]
|
||||||
|
|
||||||
# --- In-Memory AI Response Cache ---
|
# --- In-Memory AI Response Cache ---
|
||||||
@@ -126,7 +135,14 @@ def log(phase, msg):
|
|||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
def load_json(path):
|
def load_json(path):
|
||||||
return json.load(open(path, 'r')) if os.path.exists(path) else None
|
if not os.path.exists(path):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(path, 'r', encoding='utf-8', errors='replace') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, OSError, ValueError) as e:
|
||||||
|
log("SYSTEM", f"⚠️ Failed to load JSON from {path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def create_default_personas():
|
def create_default_personas():
|
||||||
# Persona data is now stored in the Persona DB table; ensure the directory exists for sample files.
|
# Persona data is now stored in the Persona DB table; ensure the directory exists for sample files.
|
||||||
@@ -153,11 +169,13 @@ def log_image_attempt(folder, img_type, prompt, filename, status, error=None, sc
|
|||||||
data = []
|
data = []
|
||||||
if os.path.exists(log_path):
|
if os.path.exists(log_path):
|
||||||
try:
|
try:
|
||||||
with open(log_path, 'r') as f: data = json.load(f)
|
with open(log_path, 'r', encoding='utf-8') as f:
|
||||||
except:
|
data = json.load(f)
|
||||||
pass
|
except (json.JSONDecodeError, OSError):
|
||||||
|
data = [] # Corrupted log — start fresh rather than crash
|
||||||
data.append(entry)
|
data.append(entry)
|
||||||
with open(log_path, 'w') as f: json.dump(data, f, indent=2)
|
with open(log_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
def get_run_folder(base_name):
|
def get_run_folder(base_name):
|
||||||
if not os.path.exists(base_name): os.makedirs(base_name)
|
if not os.path.exists(base_name): os.makedirs(base_name)
|
||||||
@@ -218,9 +236,10 @@ def log_usage(folder, model_label, usage_metadata=None, image_count=0):
|
|||||||
|
|
||||||
if usage_metadata:
|
if usage_metadata:
|
||||||
try:
|
try:
|
||||||
input_tokens = usage_metadata.prompt_token_count
|
input_tokens = usage_metadata.prompt_token_count or 0
|
||||||
output_tokens = usage_metadata.candidates_token_count
|
output_tokens = usage_metadata.candidates_token_count or 0
|
||||||
except: pass
|
except AttributeError:
|
||||||
|
pass # usage_metadata shape varies by model; tokens stay 0
|
||||||
|
|
||||||
cost = calculate_cost(model_label, input_tokens, output_tokens, image_count)
|
cost = calculate_cost(model_label, input_tokens, output_tokens, image_count)
|
||||||
|
|
||||||
|
|||||||
@@ -44,8 +44,24 @@ def generate_blurb(bp, folder):
|
|||||||
try:
|
try:
|
||||||
response = ai_models.model_writer.generate_content(prompt)
|
response = ai_models.model_writer.generate_content(prompt)
|
||||||
utils.log_usage(folder, ai_models.model_writer.name, response.usage_metadata)
|
utils.log_usage(folder, ai_models.model_writer.name, response.usage_metadata)
|
||||||
blurb = response.text
|
blurb = response.text.strip()
|
||||||
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)
|
# Trim to 220 words if model overshot the 150-200 word target
|
||||||
except:
|
words = blurb.split()
|
||||||
utils.log("MARKETING", "Failed to generate blurb.")
|
if len(words) > 220:
|
||||||
|
blurb = " ".join(words[:220])
|
||||||
|
# End at the last sentence boundary within those 220 words
|
||||||
|
for end_ch in ['.', '!', '?']:
|
||||||
|
last_sent = blurb.rfind(end_ch)
|
||||||
|
if last_sent > len(blurb) // 2:
|
||||||
|
blurb = blurb[:last_sent + 1]
|
||||||
|
break
|
||||||
|
utils.log("MARKETING", f" -> Blurb trimmed to {len(blurb.split())} words.")
|
||||||
|
|
||||||
|
with open(os.path.join(folder, "blurb.txt"), "w", encoding='utf-8') as f:
|
||||||
|
f.write(blurb)
|
||||||
|
with open(os.path.join(folder, "back_cover.txt"), "w", encoding='utf-8') as f:
|
||||||
|
f.write(blurb)
|
||||||
|
utils.log("MARKETING", f" -> Blurb: {len(blurb.split())} words.")
|
||||||
|
except Exception as e:
|
||||||
|
utils.log("MARKETING", f"Failed to generate blurb: {e}")
|
||||||
|
|||||||
@@ -14,27 +14,187 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_PIL = False
|
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:
|
try:
|
||||||
img = Image.open(image_path)
|
img = Image.open(image_path)
|
||||||
response = model.generate_content([f"""
|
prompt = f"""
|
||||||
ROLE: Art Critic
|
ROLE: Professional Book Cover Art Critic
|
||||||
TASK: Analyze generated image against prompt.
|
TASK: Score this AI-generated cover art for a {genre} novel titled '{title}'.
|
||||||
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)
|
|
||||||
|
|
||||||
|
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):
|
def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
||||||
if not HAS_PIL:
|
if not HAS_PIL:
|
||||||
utils.log("MARKETING", "Pillow not installed. Skipping image cover.")
|
utils.log("MARKETING", "Pillow not installed. Skipping cover.")
|
||||||
return
|
return
|
||||||
|
|
||||||
utils.log("MARKETING", "Generating cover...")
|
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"
|
if orientation == "Landscape": ar = "4:3"
|
||||||
elif orientation == "Square": ar = "1:1"
|
elif orientation == "Square": ar = "1:1"
|
||||||
|
|
||||||
visual_context = ""
|
visual_context = _build_visual_context(bp, tracking)
|
||||||
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
|
regenerate_image = True
|
||||||
design_instruction = ""
|
design_instruction = ""
|
||||||
@@ -60,18 +214,15 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
|||||||
regenerate_image = False
|
regenerate_image = False
|
||||||
|
|
||||||
if feedback and feedback.strip():
|
if feedback and feedback.strip():
|
||||||
utils.log("MARKETING", f"Analyzing feedback: '{feedback}'...")
|
utils.log("MARKETING", f"Analysing feedback: '{feedback}'...")
|
||||||
analysis_prompt = f"""
|
analysis_prompt = f"""
|
||||||
ROLE: Design Assistant
|
ROLE: Design Assistant
|
||||||
TASK: Analyze user feedback on cover.
|
TASK: Analyse user feedback on a book cover.
|
||||||
|
|
||||||
FEEDBACK: "{feedback}"
|
FEEDBACK: "{feedback}"
|
||||||
|
|
||||||
DECISION:
|
DECISION:
|
||||||
1. Keep the current background image but change text/layout/color (REGENERATE_LAYOUT).
|
1. Keep the background image; change only text/layout/colour → REGENERATE_LAYOUT
|
||||||
2. Create a completely new background image (REGENERATE_IMAGE).
|
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."}}
|
||||||
OUTPUT_FORMAT (JSON): {{ "action": "REGENERATE_LAYOUT" or "REGENERATE_IMAGE", "instruction": "Specific instruction for Art Director" }}
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
resp = ai_models.model_logic.generate_content(analysis_prompt)
|
resp = ai_models.model_logic.generate_content(analysis_prompt)
|
||||||
@@ -79,9 +230,9 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
|||||||
decision = json.loads(utils.clean_json(resp.text))
|
decision = json.loads(utils.clean_json(resp.text))
|
||||||
if decision.get('action') == 'REGENERATE_LAYOUT':
|
if decision.get('action') == 'REGENERATE_LAYOUT':
|
||||||
regenerate_image = False
|
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)
|
design_instruction = decision.get('instruction', feedback)
|
||||||
except:
|
except Exception:
|
||||||
utils.log("MARKETING", "Feedback analysis failed. Defaulting to full regeneration.")
|
utils.log("MARKETING", "Feedback analysis failed. Defaulting to full regeneration.")
|
||||||
|
|
||||||
genre = meta.get('genre', 'Fiction')
|
genre = meta.get('genre', 'Fiction')
|
||||||
@@ -92,11 +243,11 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
|||||||
'romance': 'warm, painterly, soft-focus illustration',
|
'romance': 'warm, painterly, soft-focus illustration',
|
||||||
'fantasy': 'epic digital painting, rich colours, mythic scale',
|
'fantasy': 'epic digital painting, rich colours, mythic scale',
|
||||||
'science fiction': 'sharp digital art, cool palette, futuristic',
|
'science fiction': 'sharp digital art, cool palette, futuristic',
|
||||||
'horror': 'unsettling, dark atmospheric painting, desaturated',
|
'horror': 'unsettling dark atmospheric painting, desaturated',
|
||||||
'historical fiction': 'classical oil painting style, period-accurate',
|
'historical fiction':'classical oil painting style, period-accurate',
|
||||||
'young adult': 'vibrant illustrated style, bold colours',
|
'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"""
|
design_prompt = f"""
|
||||||
ROLE: Art Director
|
ROLE: Art Director
|
||||||
@@ -108,186 +259,228 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
|||||||
- TONE: {tone}
|
- TONE: {tone}
|
||||||
- SUGGESTED_VISUAL_STYLE: {suggested_style}
|
- SUGGESTED_VISUAL_STYLE: {suggested_style}
|
||||||
|
|
||||||
VISUAL_CONTEXT (characters and key themes from the story):
|
VISUAL_CONTEXT (characters and themes from the finished story — use these):
|
||||||
{visual_context if visual_context else "Use genre conventions."}
|
{visual_context if visual_context else "Use strong genre conventions."}
|
||||||
|
|
||||||
USER_FEEDBACK: {feedback if feedback else "None"}
|
USER_FEEDBACK: {feedback if feedback else "None"}
|
||||||
DESIGN_INSTRUCTION: {design_instruction if design_instruction else "Create a compelling, genre-appropriate cover."}
|
DESIGN_INSTRUCTION: {design_instruction if design_instruction else "Create a compelling, genre-appropriate cover."}
|
||||||
|
|
||||||
COVER_ART_RULES:
|
COVER_ART_RULES:
|
||||||
- The art_prompt must produce an image with NO text, no letters, no numbers, no watermarks, no UI elements, no logos.
|
- The art_prompt MUST produce an image with ABSOLUTELY NO text, letters, numbers, watermarks, UI elements, or logos. Text is overlaid separately.
|
||||||
- Describe a clear FOCAL POINT (e.g. the protagonist, a dramatic scene, a symbolic object).
|
- Describe a specific, memorable FOCAL POINT (e.g. protagonist mid-action, a symbolic object, a dramatic landscape).
|
||||||
- Use RULE OF THIRDS composition — leave visual space at top and/or bottom for the title and author text to be overlaid.
|
- 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" for thriller, "golden hour" for romance).
|
- Describe LIGHTING that reinforces the tone (e.g. "harsh neon backlight", "golden hour", "cold winter dawn").
|
||||||
- Describe the COLOUR PALETTE explicitly (e.g. "deep crimson and shadow-black", "soft rose gold and cream").
|
- Specify the COLOUR PALETTE explicitly (e.g. "deep crimson and shadow-black", "soft rose gold and ivory cream").
|
||||||
- Characters must match their descriptions from VISUAL_CONTEXT if present.
|
- 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)",
|
"font_name": "One Google Font suited to {genre} (e.g. Cinzel for fantasy, Oswald for thriller, Playfair Display for romance)",
|
||||||
"primary_color": "#HexCode (dominant background/cover colour)",
|
"primary_color": "#HexCode",
|
||||||
"text_color": "#HexCode (high contrast against primary_color)",
|
"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:
|
try:
|
||||||
response = ai_models.model_artist.generate_content(design_prompt)
|
response = ai_models.model_artist.generate_content(design_prompt)
|
||||||
utils.log_usage(folder, ai_models.model_artist.name, response.usage_metadata)
|
utils.log_usage(folder, ai_models.model_artist.name, response.usage_metadata)
|
||||||
design = json.loads(utils.clean_json(response.text))
|
design = json.loads(utils.clean_json(response.text))
|
||||||
|
except Exception as e:
|
||||||
|
utils.log("MARKETING", f"Cover design failed: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
bg_color = design.get('primary_color', '#252570')
|
bg_color = design.get('primary_color', '#252570')
|
||||||
|
|
||||||
art_prompt = design.get('art_prompt', f"Cover art for {meta.get('title')}")
|
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:
|
with open(os.path.join(folder, "cover_art_prompt.txt"), "w") as f:
|
||||||
f.write(art_prompt)
|
f.write(art_prompt)
|
||||||
|
|
||||||
img = None
|
img = None
|
||||||
width, height = 600, 900
|
width, height = 600, 900
|
||||||
|
|
||||||
best_img_score = 0
|
# -----------------------------------------------------------------------
|
||||||
best_img_path = None
|
# 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
|
||||||
|
|
||||||
MAX_IMG_ATTEMPTS = 3
|
|
||||||
if regenerate_image:
|
if regenerate_image:
|
||||||
for i in range(1, MAX_IMG_ATTEMPTS + 1):
|
for attempt in range(1, MAX_ART_ATTEMPTS + 1):
|
||||||
utils.log("MARKETING", f"Generating cover art (Attempt {i}/{MAX_IMG_ATTEMPTS})...")
|
utils.log("MARKETING", f"Generating cover art (Attempt {attempt}/{MAX_ART_ATTEMPTS})...")
|
||||||
try:
|
attempt_path = os.path.join(folder, f"cover_art_attempt_{attempt}.png")
|
||||||
if not ai_models.model_image: raise ImportError("No Image Generation Model available.")
|
gen_status = "success"
|
||||||
|
|
||||||
status = "success"
|
|
||||||
try:
|
try:
|
||||||
result = ai_models.model_image.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar)
|
if not ai_models.model_image:
|
||||||
except Exception as e:
|
raise ImportError("No image generation model available.")
|
||||||
err_lower = str(e).lower()
|
|
||||||
|
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):
|
if ai_models.HAS_VERTEX and ("resource" in err_lower or "quota" in err_lower):
|
||||||
try:
|
try:
|
||||||
utils.log("MARKETING", "⚠️ Imagen 3 failed. Trying Imagen 3 Fast...")
|
utils.log("MARKETING", "⚠️ Imagen 3 failed. Trying Imagen 3 Fast...")
|
||||||
fb_model = ai_models.VertexImageModel.from_pretrained("imagen-3.0-fast-generate-001")
|
fb = 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)
|
result = fb.generate_images(prompt=current_art_prompt, number_of_images=1, aspect_ratio=ar)
|
||||||
status = "success_fast"
|
gen_status = "success_fast"
|
||||||
except Exception:
|
except Exception:
|
||||||
utils.log("MARKETING", "⚠️ Imagen 3 Fast failed. Trying Imagen 2...")
|
utils.log("MARKETING", "⚠️ Imagen 3 Fast failed. Trying Imagen 2...")
|
||||||
fb_model = ai_models.VertexImageModel.from_pretrained("imagegeneration@006")
|
fb = ai_models.VertexImageModel.from_pretrained("imagegeneration@006")
|
||||||
result = fb_model.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar)
|
result = fb.generate_images(prompt=current_art_prompt, number_of_images=1, aspect_ratio=ar)
|
||||||
status = "success_fallback"
|
gen_status = "success_fallback"
|
||||||
else:
|
else:
|
||||||
raise e
|
raise img_err
|
||||||
|
|
||||||
attempt_path = os.path.join(folder, f"cover_art_attempt_{i}.png")
|
|
||||||
result.images[0].save(attempt_path)
|
result.images[0].save(attempt_path)
|
||||||
utils.log_usage(folder, "imagen", image_count=1)
|
utils.log_usage(folder, "imagen", image_count=1)
|
||||||
|
|
||||||
cover_eval_criteria = (
|
score, critique = evaluate_cover_art(
|
||||||
f"Book cover art for a {genre} novel titled '{meta.get('title')}'.\n\n"
|
attempt_path, genre, meta.get('title', ''), ai_models.model_logic, folder)
|
||||||
f"Evaluate STRICTLY as a professional book cover on these criteria:\n"
|
if score is None:
|
||||||
f"1. VISUAL IMPACT: Is the image immediately arresting and compelling?\n"
|
score = 0
|
||||||
f"2. GENRE FIT: Does the visual style, mood, and palette match {genre}?\n"
|
utils.log("MARKETING", f" -> Art Score: {score}/10. Critique: {critique}")
|
||||||
f"3. COMPOSITION: Is there a clear focal point? Are top/bottom areas usable for title/author text?\n"
|
utils.log_image_attempt(folder, "cover", current_art_prompt,
|
||||||
f"4. QUALITY: Is the image sharp, detailed, and free of deformities or blurring?\n"
|
f"cover_art_attempt_{attempt}.png", gen_status,
|
||||||
f"5. CLEAN IMAGE: Are there absolutely NO text, watermarks, letters, or UI artifacts?\n"
|
score=score, critique=critique)
|
||||||
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:
|
if interactive:
|
||||||
try:
|
try:
|
||||||
if os.name == 'nt': os.startfile(attempt_path)
|
if os.name == 'nt': os.startfile(attempt_path)
|
||||||
elif sys.platform == 'darwin': subprocess.call(('open', attempt_path))
|
elif sys.platform == 'darwin': subprocess.call(('open', attempt_path))
|
||||||
else: subprocess.call(('xdg-open', attempt_path))
|
else: subprocess.call(('xdg-open', attempt_path))
|
||||||
except: pass
|
except Exception:
|
||||||
|
pass
|
||||||
from rich.prompt import Confirm
|
from rich.prompt import Confirm
|
||||||
if Confirm.ask(f"Accept cover attempt {i} (Score: {score})?", default=True):
|
if Confirm.ask(f"Accept cover art attempt {attempt} (score {score})?", default=True):
|
||||||
best_img_path = attempt_path
|
best_art_path = attempt_path
|
||||||
|
best_art_score = score
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
utils.log("MARKETING", "User rejected cover. Retrying...")
|
utils.log("MARKETING", "User rejected art. Regenerating...")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if score >= 5 and score > best_img_score:
|
# Track best image — prefer passing threshold; keep first usable as fallback
|
||||||
best_img_score = score
|
if score >= ART_SCORE_PASSING and score > best_art_score:
|
||||||
best_img_path = attempt_path
|
best_art_score = score
|
||||||
elif best_img_path is None and score > 0:
|
best_art_path = attempt_path
|
||||||
best_img_score = score
|
elif best_art_path is None and score > 0:
|
||||||
best_img_path = attempt_path
|
best_art_score = score
|
||||||
|
best_art_path = attempt_path
|
||||||
|
|
||||||
if score >= 9:
|
if score >= ART_SCORE_AUTO_ACCEPT:
|
||||||
utils.log("MARKETING", " -> High quality image accepted.")
|
utils.log("MARKETING", " -> High-quality art accepted early.")
|
||||||
break
|
break
|
||||||
|
|
||||||
prompt_additions = []
|
# Critique-driven prompt refinement for next attempt
|
||||||
critique_lower = critique.lower() if critique else ""
|
if attempt < MAX_ART_ATTEMPTS and critique:
|
||||||
if "scar" in critique_lower or "deform" in critique_lower:
|
refine_req = f"""
|
||||||
prompt_additions.append("perfect anatomy, no deformities")
|
ROLE: Art Director
|
||||||
if "blur" in critique_lower or "blurry" in critique_lower:
|
TASK: Rewrite the image prompt to fix the critique below. Keep under 200 words.
|
||||||
prompt_additions.append("sharp focus, highly detailed")
|
|
||||||
if "text" in critique_lower or "letter" in critique_lower:
|
CRITIQUE: {critique}
|
||||||
prompt_additions.append("no text, no letters, no watermarks")
|
ORIGINAL_PROMPT: {current_art_prompt}
|
||||||
if prompt_additions:
|
|
||||||
art_prompt += f". ({', '.join(prompt_additions)})"
|
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:
|
except Exception as e:
|
||||||
utils.log("MARKETING", f"Image generation failed: {e}")
|
utils.log("MARKETING", f"Image generation attempt {attempt} failed: {e}")
|
||||||
if "quota" in str(e).lower(): break
|
if "quota" in str(e).lower():
|
||||||
|
break
|
||||||
|
|
||||||
if best_img_path and os.path.exists(best_img_path):
|
if best_art_path and os.path.exists(best_art_path):
|
||||||
final_art_path = os.path.join(folder, "cover_art.png")
|
final_art_path = os.path.join(folder, "cover_art.png")
|
||||||
if best_img_path != final_art_path:
|
if best_art_path != final_art_path:
|
||||||
shutil.copy(best_img_path, final_art_path)
|
shutil.copy(best_art_path, final_art_path)
|
||||||
img = Image.open(final_art_path).resize((width, height)).convert("RGB")
|
img = Image.open(final_art_path).resize((width, height)).convert("RGB")
|
||||||
|
utils.log("MARKETING", f" -> Best art: {best_art_score}/10.")
|
||||||
else:
|
else:
|
||||||
utils.log("MARKETING", "Falling back to solid color cover.")
|
utils.log("MARKETING", "⚠️ No usable art generated. Falling back to solid colour cover.")
|
||||||
img = Image.new('RGB', (width, height), color=bg_color)
|
img = Image.new('RGB', (width, height), color=bg_color)
|
||||||
utils.log_image_attempt(folder, "cover", art_prompt, "cover.png", "fallback_solid")
|
utils.log_image_attempt(folder, "cover", art_prompt, "cover.png", "fallback_solid")
|
||||||
else:
|
else:
|
||||||
final_art_path = os.path.join(folder, "cover_art.png")
|
final_art_path = os.path.join(folder, "cover_art.png")
|
||||||
if os.path.exists(final_art_path):
|
if os.path.exists(final_art_path):
|
||||||
utils.log("MARKETING", "Using existing cover art (Layout update only).")
|
utils.log("MARKETING", "Using existing cover art (layout update only).")
|
||||||
img = Image.open(final_art_path).resize((width, height)).convert("RGB")
|
img = Image.open(final_art_path).resize((width, height)).convert("RGB")
|
||||||
else:
|
else:
|
||||||
utils.log("MARKETING", "Existing art not found. Forcing regeneration.")
|
utils.log("MARKETING", "Existing art not found. Using solid colour fallback.")
|
||||||
img = Image.new('RGB', (width, height), color=bg_color)
|
img = Image.new('RGB', (width, height), color=bg_color)
|
||||||
|
|
||||||
font_path = download_font(design.get('font_name') or 'Arial')
|
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_score = 0
|
||||||
best_layout_path = None
|
best_layout_path = None
|
||||||
|
|
||||||
base_layout_prompt = f"""
|
base_layout_prompt = f"""
|
||||||
ROLE: Graphic Designer
|
ROLE: Graphic Designer
|
||||||
TASK: Determine text layout coordinates for a 600x900 cover.
|
TASK: Determine precise text layout coordinates for a 600×900 book cover image.
|
||||||
|
|
||||||
METADATA:
|
BOOK:
|
||||||
- TITLE: {meta.get('title')}
|
- TITLE: {meta.get('title')}
|
||||||
- AUTHOR: {meta.get('author')}
|
- AUTHOR: {meta.get('author', 'Unknown')}
|
||||||
- GENRE: {meta.get('genre')}
|
- GENRE: {genre}
|
||||||
|
- FONT: {font_name}
|
||||||
|
- TEXT_COLOR: {design.get('text_color', '#FFFFFF')}
|
||||||
|
|
||||||
CONSTRAINT: Do NOT place text over faces.
|
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):
|
OUTPUT_FORMAT (JSON):
|
||||||
{{
|
{{
|
||||||
"title": {{ "x": Int, "y": Int, "font_size": Int, "font_name": "String", "color": "#Hex" }},
|
"title": {{"x": Int, "y": Int, "font_size": Int, "font_name": "{font_name}", "color": "#Hex"}},
|
||||||
"author": {{ "x": Int, "y": Int, "font_size": Int, "font_name": "String", "color": "#Hex" }}
|
"author": {{"x": Int, "y": Int, "font_size": Int, "font_name": "{font_name}", "color": "#Hex"}}
|
||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if feedback:
|
|
||||||
base_layout_prompt += f"\nUSER FEEDBACK: {feedback}\nAdjust layout/colors accordingly."
|
|
||||||
|
|
||||||
layout_prompt = base_layout_prompt
|
layout_prompt = base_layout_prompt
|
||||||
|
MAX_LAYOUT_ATTEMPTS = 5
|
||||||
|
|
||||||
for attempt in range(1, 6):
|
for attempt in range(1, MAX_LAYOUT_ATTEMPTS + 1):
|
||||||
utils.log("MARKETING", f"Designing text layout (Attempt {attempt}/5)...")
|
utils.log("MARKETING", f"Designing text layout (Attempt {attempt}/{MAX_LAYOUT_ATTEMPTS})...")
|
||||||
try:
|
try:
|
||||||
response = ai_models.model_writer.generate_content([layout_prompt, img])
|
resp = ai_models.model_writer.generate_content([layout_prompt, img])
|
||||||
utils.log_usage(folder, ai_models.model_writer.name, response.usage_metadata)
|
utils.log_usage(folder, ai_models.model_writer.name, resp.usage_metadata)
|
||||||
layout = json.loads(utils.clean_json(response.text))
|
layout = json.loads(utils.clean_json(resp.text))
|
||||||
if isinstance(layout, list): layout = layout[0] if layout else {}
|
if isinstance(layout, list):
|
||||||
|
layout = layout[0] if layout else {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
utils.log("MARKETING", f"Layout generation failed: {e}")
|
utils.log("MARKETING", f"Layout generation failed: {e}")
|
||||||
continue
|
continue
|
||||||
@@ -297,37 +490,34 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
|||||||
|
|
||||||
def draw_element(key, text_override=None):
|
def draw_element(key, text_override=None):
|
||||||
elem = layout.get(key)
|
elem = layout.get(key)
|
||||||
if not elem: return
|
if not elem:
|
||||||
if isinstance(elem, list): elem = elem[0] if elem else {}
|
return
|
||||||
|
if isinstance(elem, list):
|
||||||
|
elem = elem[0] if elem else {}
|
||||||
text = text_override if text_override else elem.get('text')
|
text = text_override if text_override else elem.get('text')
|
||||||
if not text: return
|
if not text:
|
||||||
|
return
|
||||||
f_name = elem.get('font_name') or 'Arial'
|
f_name = elem.get('font_name') or font_name
|
||||||
f_path = download_font(f_name)
|
f_p = download_font(f_name)
|
||||||
try:
|
try:
|
||||||
if f_path: font = ImageFont.truetype(f_path, elem.get('font_size', 40))
|
fnt = ImageFont.truetype(f_p, elem.get('font_size', 40)) if f_p else ImageFont.load_default()
|
||||||
else: raise IOError("Font not found")
|
except Exception:
|
||||||
except: font = ImageFont.load_default()
|
fnt = ImageFont.load_default()
|
||||||
|
|
||||||
x, y = elem.get('x', 300), elem.get('y', 450)
|
x, y = elem.get('x', 300), elem.get('y', 450)
|
||||||
color = elem.get('color') or '#FFFFFF'
|
color = elem.get('color') or design.get('text_color', '#FFFFFF')
|
||||||
|
avg_w = fnt.getlength("A")
|
||||||
avg_char_w = font.getlength("A")
|
wrap_w = int(550 / avg_w) if avg_w > 0 else 20
|
||||||
wrap_w = int(550 / avg_char_w) if avg_char_w > 0 else 20
|
|
||||||
lines = textwrap.wrap(text, width=wrap_w)
|
lines = textwrap.wrap(text, width=wrap_w)
|
||||||
|
|
||||||
line_heights = []
|
line_heights = []
|
||||||
for l in lines:
|
for ln in lines:
|
||||||
bbox = draw.textbbox((0, 0), l, font=font)
|
bbox = draw.textbbox((0, 0), ln, font=fnt)
|
||||||
line_heights.append(bbox[3] - bbox[1] + 10)
|
line_heights.append(bbox[3] - bbox[1] + 10)
|
||||||
|
|
||||||
total_h = sum(line_heights)
|
total_h = sum(line_heights)
|
||||||
current_y = y - (total_h // 2)
|
current_y = y - (total_h // 2)
|
||||||
|
for idx, ln in enumerate(lines):
|
||||||
for idx, line in enumerate(lines):
|
bbox = draw.textbbox((0, 0), ln, font=fnt)
|
||||||
bbox = draw.textbbox((0, 0), line, font=font)
|
|
||||||
lx = x - ((bbox[2] - bbox[0]) / 2)
|
lx = x - ((bbox[2] - bbox[0]) / 2)
|
||||||
draw.text((lx, current_y), line, font=font, fill=color)
|
draw.text((lx, current_y), ln, font=fnt, fill=color)
|
||||||
current_y += line_heights[idx]
|
current_y += line_heights[idx]
|
||||||
|
|
||||||
draw_element('title', meta.get('title'))
|
draw_element('title', meta.get('title'))
|
||||||
@@ -336,30 +526,29 @@ def generate_cover(bp, folder, tracking=None, feedback=None, interactive=False):
|
|||||||
attempt_path = os.path.join(folder, f"cover_layout_attempt_{attempt}.png")
|
attempt_path = os.path.join(folder, f"cover_layout_attempt_{attempt}.png")
|
||||||
img_copy.save(attempt_path)
|
img_copy.save(attempt_path)
|
||||||
|
|
||||||
eval_prompt = f"""
|
score, critique = evaluate_cover_layout(
|
||||||
Analyze the text layout for the book title '{meta.get('title')}'.
|
attempt_path, meta.get('title', ''), meta.get('author', ''), genre, font_name,
|
||||||
CHECKLIST:
|
ai_models.model_writer, folder
|
||||||
1. Is the text legible against the background?
|
)
|
||||||
2. Is the contrast sufficient?
|
if score is None:
|
||||||
3. Does it look professional?
|
score = 0
|
||||||
"""
|
|
||||||
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}")
|
utils.log("MARKETING", f" -> Layout Score: {score}/10. Critique: {critique}")
|
||||||
|
|
||||||
if score > best_layout_score:
|
if score > best_layout_score:
|
||||||
best_layout_score = score
|
best_layout_score = score
|
||||||
best_layout_path = attempt_path
|
best_layout_path = attempt_path
|
||||||
|
|
||||||
if score == 10:
|
if score >= LAYOUT_SCORE_PASSING:
|
||||||
utils.log("MARKETING", " -> Perfect layout accepted.")
|
utils.log("MARKETING", f" -> Layout accepted (score {score} ≥ {LAYOUT_SCORE_PASSING}).")
|
||||||
break
|
break
|
||||||
|
|
||||||
layout_prompt = base_layout_prompt + f"\nCRITIQUE OF PREVIOUS ATTEMPT: {critique}\nAdjust position/color to fix this."
|
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:
|
if best_layout_path:
|
||||||
shutil.copy(best_layout_path, os.path.join(folder, "cover.png"))
|
shutil.copy(best_layout_path, os.path.join(folder, "cover.png"))
|
||||||
|
utils.log("MARKETING", f"Cover saved. Best layout score: {best_layout_score}/10.")
|
||||||
except Exception as e:
|
else:
|
||||||
utils.log("MARKETING", f"Cover generation failed: {e}")
|
utils.log("MARKETING", "⚠️ No layout produced. Cover not saved.")
|
||||||
|
|||||||
@@ -42,14 +42,20 @@ def download_font(font_name):
|
|||||||
base_url = f"https://github.com/google/fonts/raw/main/{license_type}/{clean_name}"
|
base_url = f"https://github.com/google/fonts/raw/main/{license_type}/{clean_name}"
|
||||||
for pattern in patterns:
|
for pattern in patterns:
|
||||||
try:
|
try:
|
||||||
r = requests.get(f"{base_url}/{pattern}", headers=headers, timeout=5)
|
r = requests.get(f"{base_url}/{pattern}", headers=headers, timeout=6)
|
||||||
if r.status_code == 200 and len(r.content) > 1000:
|
if r.status_code == 200 and len(r.content) > 1000:
|
||||||
with open(font_path, 'wb') as f: f.write(r.content)
|
with open(font_path, 'wb') as f:
|
||||||
|
f.write(r.content)
|
||||||
utils.log("ASSETS", f"✅ Downloaded {font_name} to {font_path}")
|
utils.log("ASSETS", f"✅ Downloaded {font_name} to {font_path}")
|
||||||
return font_path
|
return font_path
|
||||||
except Exception: continue
|
except requests.exceptions.Timeout:
|
||||||
|
utils.log("ASSETS", f" Font download timeout for {font_name} ({pattern}). Trying next...")
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
if clean_name != "roboto":
|
if clean_name != "roboto":
|
||||||
utils.log("ASSETS", f"⚠️ Font '{font_name}' not found. Falling back to Roboto.")
|
utils.log("ASSETS", f"⚠️ Font '{font_name}' not found on Google Fonts. Falling back to Roboto.")
|
||||||
return download_font("Roboto")
|
return download_font("Roboto")
|
||||||
|
utils.log("ASSETS", "⚠️ Roboto fallback also failed. PIL will use built-in default font.")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ def merge_selected_changes(original, draft, selected_keys):
|
|||||||
original['project_metadata'][field] = draft['project_metadata'][field]
|
original['project_metadata'][field] = draft['project_metadata'][field]
|
||||||
|
|
||||||
elif parts[0] == 'char' and len(parts) >= 2:
|
elif parts[0] == 'char' and len(parts) >= 2:
|
||||||
|
try:
|
||||||
idx = int(parts[1])
|
idx = int(parts[1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
utils.log("SYSTEM", f"⚠️ Skipping malformed bible merge key: '{key}'")
|
||||||
|
continue
|
||||||
if idx < len(draft['characters']):
|
if idx < len(draft['characters']):
|
||||||
if idx < len(original['characters']):
|
if idx < len(original['characters']):
|
||||||
original['characters'][idx] = draft['characters'][idx]
|
original['characters'][idx] = draft['characters'][idx]
|
||||||
@@ -27,7 +31,11 @@ def merge_selected_changes(original, draft, selected_keys):
|
|||||||
original['characters'].append(draft['characters'][idx])
|
original['characters'].append(draft['characters'][idx])
|
||||||
|
|
||||||
elif parts[0] == 'book' and len(parts) >= 2:
|
elif parts[0] == 'book' and len(parts) >= 2:
|
||||||
|
try:
|
||||||
book_num = int(parts[1])
|
book_num = int(parts[1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
utils.log("SYSTEM", f"⚠️ Skipping malformed bible merge key: '{key}'")
|
||||||
|
continue
|
||||||
orig_book = next((b for b in original['books'] if b['book_number'] == book_num), None)
|
orig_book = next((b for b in original['books'] if b['book_number'] == book_num), None)
|
||||||
draft_book = next((b for b in draft['books'] if b['book_number'] == book_num), None)
|
draft_book = next((b for b in draft['books'] if b['book_number'] == book_num), None)
|
||||||
|
|
||||||
@@ -42,7 +50,11 @@ def merge_selected_changes(original, draft, selected_keys):
|
|||||||
orig_book['manual_instruction'] = draft_book['manual_instruction']
|
orig_book['manual_instruction'] = draft_book['manual_instruction']
|
||||||
|
|
||||||
elif len(parts) == 4 and parts[2] == 'beat':
|
elif len(parts) == 4 and parts[2] == 'beat':
|
||||||
|
try:
|
||||||
beat_idx = int(parts[3])
|
beat_idx = int(parts[3])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
utils.log("SYSTEM", f"⚠️ Skipping malformed beat merge key: '{key}'")
|
||||||
|
continue
|
||||||
if beat_idx < len(draft_book['plot_beats']):
|
if beat_idx < len(draft_book['plot_beats']):
|
||||||
while len(orig_book['plot_beats']) <= beat_idx:
|
while len(orig_book['plot_beats']) <= beat_idx:
|
||||||
orig_book['plot_beats'].append("")
|
orig_book['plot_beats'].append("")
|
||||||
@@ -153,7 +165,8 @@ def harvest_metadata(bp, folder, full_manuscript):
|
|||||||
if valid_chars:
|
if valid_chars:
|
||||||
utils.log("HARVESTER", f"Found {len(valid_chars)} new chars.")
|
utils.log("HARVESTER", f"Found {len(valid_chars)} new chars.")
|
||||||
bp['characters'].extend(valid_chars)
|
bp['characters'].extend(valid_chars)
|
||||||
except: pass
|
except Exception as e:
|
||||||
|
utils.log("HARVESTER", f"⚠️ Metadata harvest failed: {e}")
|
||||||
return bp
|
return bp
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -362,12 +362,18 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None,
|
|||||||
utils.log("WRITER", f"⚠️ Failed Ch {chap['chapter_number']}: {e}")
|
utils.log("WRITER", f"⚠️ Failed Ch {chap['chapter_number']}: {e}")
|
||||||
return f"## Chapter {chap['chapter_number']} Failed\n\nError: {e}"
|
return f"## Chapter {chap['chapter_number']} Failed\n\nError: {e}"
|
||||||
|
|
||||||
# Exp 7: Two-Pass Drafting — Polish the rough draft with the logic (Pro) model
|
# Exp 7: Two-Pass Drafting — Polish rough draft with the logic (Pro) model before evaluation.
|
||||||
# before evaluation. Produces cleaner prose with fewer rewrite cycles.
|
# Skip when local filter-word heuristic shows draft is already clean (saves ~8K tokens/chapter).
|
||||||
if current_text:
|
_guidelines_for_polish = get_style_guidelines()
|
||||||
utils.log("WRITER", f" -> Two-pass polish (Pro model)...")
|
_fw_set = set(_guidelines_for_polish['filter_words'])
|
||||||
guidelines = get_style_guidelines()
|
_draft_word_list = current_text.lower().split() if current_text else []
|
||||||
fw_list = '", "'.join(guidelines['filter_words'])
|
_fw_hit_count = sum(1 for w in _draft_word_list if w in _fw_set)
|
||||||
|
_fw_density = _fw_hit_count / max(len(_draft_word_list), 1)
|
||||||
|
_skip_polish = _fw_density < 0.012 # < ~1 filter word per 83 words → draft already clean
|
||||||
|
|
||||||
|
if current_text and not _skip_polish:
|
||||||
|
utils.log("WRITER", f" -> Two-pass polish (Pro model, FW density {_fw_density:.3f})...")
|
||||||
|
fw_list = '", "'.join(_guidelines_for_polish['filter_words'])
|
||||||
polish_prompt = f"""
|
polish_prompt = f"""
|
||||||
ROLE: Senior Fiction Editor
|
ROLE: Senior Fiction Editor
|
||||||
TASK: Polish this rough draft into publication-ready prose.
|
TASK: Polish this rough draft into publication-ready prose.
|
||||||
@@ -379,6 +385,9 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None,
|
|||||||
TARGET_WORDS: ~{est_words}
|
TARGET_WORDS: ~{est_words}
|
||||||
BEATS (must all be covered): {json.dumps(chap.get('beats', []))}
|
BEATS (must all be covered): {json.dumps(chap.get('beats', []))}
|
||||||
|
|
||||||
|
CONTINUITY (maintain seamless flow from previous chapter):
|
||||||
|
{prev_context_block if prev_context_block else "First chapter — no prior context."}
|
||||||
|
|
||||||
POLISH_CHECKLIST:
|
POLISH_CHECKLIST:
|
||||||
1. FILTER_REMOVAL: Remove all filter words [{fw_list}] — rewrite each to show the sensation directly.
|
1. FILTER_REMOVAL: Remove all filter words [{fw_list}] — rewrite each to show the sensation directly.
|
||||||
2. DEEP_POV: Ensure the reader is inside the POV character's experience at all times — no external narration.
|
2. DEEP_POV: Ensure the reader is inside the POV character's experience at all times — no external narration.
|
||||||
@@ -404,6 +413,8 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None,
|
|||||||
current_text = polished
|
current_text = polished
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
utils.log("WRITER", f" -> Polish pass failed: {e}. Proceeding with raw draft.")
|
utils.log("WRITER", f" -> Polish pass failed: {e}. Proceeding with raw draft.")
|
||||||
|
elif current_text:
|
||||||
|
utils.log("WRITER", f" -> Draft clean (FW density {_fw_density:.3f}). Skipping polish pass.")
|
||||||
|
|
||||||
# Reduced from 3 → 2 attempts since polish pass already refines prose before evaluation
|
# Reduced from 3 → 2 attempts since polish pass already refines prose before evaluation
|
||||||
max_attempts = 2
|
max_attempts = 2
|
||||||
|
|||||||
Reference in New Issue
Block a user