Initial commit

This commit is contained in:
Justin Oros
2026-04-13 18:03:33 -07:00
commit 73d16c9b69
26 changed files with 1660 additions and 0 deletions
BIN
View File
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
def group_into_pages(scenes, mode="normal"):
if mode == "tiny":
return [[s] for s in scenes]
pages = []
temp = []
for s in scenes:
temp.append(s)
if len(temp) == 3:
pages.append(temp)
temp = []
if temp:
pages.append(temp)
return pages
+137
View File
@@ -0,0 +1,137 @@
import re
from ai.character_memory import build_consistency_tokens
STYLES = {
"lineart": {
"positive": (
"anime illustration, manga style, black and white, monochrome, "
"clean ink linework, expressive characters, dynamic composition, "
"professional manga art, single scene, full image"
),
"negative": (
"color, colorful, coloured, vibrant, saturated, "
"multiple panels, panel borders, panel grid, split panels, "
"collage, triptych, diptych, "
"realistic, photorealistic, photograph, 3d render, "
"ugly, blurry, watermark, text, signature, lowres, "
"bad anatomy, deformed, extra limbs"
),
"cfg": 7.0,
},
"manga": {
"positive": (
"single full-page manga illustration, full-bleed scene, "
"black and white ink, screentone shading, "
"expressive faces, detailed backgrounds, "
"professional manga art, one continuous scene"
),
"negative": (
"multiple panels, panel borders, panel grid, comic layout, split panels, "
"panel dividers, gutters, multi-panel page, page layout, comic book grid, "
"collage, triptych, diptych, "
"lowres, bad anatomy, blurry, watermark, text, ugly, deformed, "
"color, coloured, western comic style"
),
"cfg": 7.5,
},
}
MOOD_MAP = {
"tense": "tense dramatic scene, characters look worried or scared",
"sad": "sad melancholy scene, characters look downcast",
"happy": "happy cheerful scene, characters smiling",
"angry": "angry confrontational scene, characters look furious",
"mysterious": "mysterious eerie scene, shadowy atmosphere",
"neutral": "calm everyday scene",
"romantic": "romantic gentle scene, soft atmosphere",
"action": "action dynamic scene, characters in motion",
"excited": "excited energetic scene, characters enthusiastic",
"fearful": "fearful tense scene, characters look afraid",
}
JUNK_NAMES = {
"he", "she", "they", "him", "her", "them", "his", "hers", "their",
"i", "me", "we", "us", "it", "you", "location", "unknown", "none",
"character", "person", "man", "woman", "boy", "girl",
"the man", "the woman", "the boy", "the girl", "the person",
"a man", "a woman", "old man", "young man", "young woman",
"his secretary", "his secretarys", "her secretary",
"narrator", "voice", "someone", "anyone", "everyone",
}
PLACEHOLDER_SETTINGS = {"location", "unknown", "none", "'location'", '"location"', ""}
POSSESSIVE_RE = re.compile(r"'s$|s'$", re.IGNORECASE)
def clean_text(s):
s = s.replace("\\u0022", "").replace("\\u0027", "")
s = s.replace('\\"', "").replace("\\'", "")
s = re.sub(r'[\"\'`]', "", s)
s = re.sub(r"\s+", " ", s).strip()
return s
def is_valid_name(name):
n = name.strip().lower()
if not n or len(n) < 2:
return False
if n in JUNK_NAMES:
return False
if POSSESSIVE_RE.search(n):
return False
return True
def build_page_prompt(parsed_scenes, style="lineart"):
style_def = STYLES.get(style, STYLES["lineart"])
if not parsed_scenes:
return style_def["positive"], style_def["negative"]
all_characters = []
visual_parts = []
settings = []
moods = []
for scene in parsed_scenes:
if isinstance(scene, str):
visual_parts.append(scene)
continue
for char in scene.get("characters", []):
char = clean_text(char)
if is_valid_name(char):
all_characters.append(char)
visual = clean_text(scene.get("visual_scene", ""))
if visual:
visual_parts.append(visual)
setting = clean_text(scene.get("setting", ""))
if setting.lower() not in PLACEHOLDER_SETTINGS:
settings.append(setting)
mood = scene.get("mood", "neutral").strip().lower()
if mood:
moods.append(mood)
unique_chars = list(dict.fromkeys(all_characters))
char_tokens = build_consistency_tokens(unique_chars)
dominant_mood = moods[0] if moods else "neutral"
mood_desc = MOOD_MAP.get(dominant_mood, MOOD_MAP["neutral"])
setting_str = settings[0][:60] if settings else ""
scene_str = visual_parts[0][:80] if visual_parts else ""
style_prefix = (
"(anime style:1.4), (manga illustration:1.3), "
"(black and white:1.3), (monochrome:1.2), "
) if style == "lineart" else ""
parts = [style_prefix + style_def["positive"], mood_desc]
if setting_str:
parts.append(setting_str)
if char_tokens:
parts.append(char_tokens)
if scene_str:
parts.append(scene_str)
return ", ".join(parts), style_def["negative"]
def get_cfg(style="lineart"):
return STYLES.get(style, STYLES["lineart"])["cfg"]
+115
View File
@@ -0,0 +1,115 @@
import textwrap
from PIL import Image, ImageDraw, ImageFont
import os
MAX_BUBBLES = 3
MAX_LINE_WIDTH = 22
BUBBLE_PADDING = 14
FONT_SIZE = 22
TAIL_SIZE = 14
def get_font(size=FONT_SIZE):
candidates = [
"/System/Library/Fonts/Helvetica.ttc",
"/System/Library/Fonts/Arial.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
]
for path in candidates:
if os.path.exists(path):
try:
return ImageFont.truetype(path, size)
except Exception:
pass
return ImageFont.load_default()
def wrap_text(text, max_width=MAX_LINE_WIDTH):
return textwrap.fill(text, max_width)
def draw_bubble(draw, x, y, w, h, tail_side="bottom"):
r = 16
draw.rounded_rectangle([x, y, x+w, y+h], radius=r, fill="white", outline="black", width=3)
if tail_side == "bottom":
tx = x + w // 2
ty = y + h
draw.polygon([
(tx - TAIL_SIZE, ty - 4),
(tx + TAIL_SIZE, ty - 4),
(tx, ty + TAIL_SIZE),
], fill="white")
draw.line([(tx - TAIL_SIZE, ty - 4), (tx, ty + TAIL_SIZE)], fill="black", width=3)
draw.line([(tx + TAIL_SIZE, ty - 4), (tx, ty + TAIL_SIZE)], fill="black", width=3)
elif tail_side == "top":
tx = x + w // 2
ty = y
draw.polygon([
(tx - TAIL_SIZE, ty + 4),
(tx + TAIL_SIZE, ty + 4),
(tx, ty - TAIL_SIZE),
], fill="white")
draw.line([(tx - TAIL_SIZE, ty + 4), (tx, ty - TAIL_SIZE)], fill="black", width=3)
draw.line([(tx + TAIL_SIZE, ty + 4), (tx, ty - TAIL_SIZE)], fill="black", width=3)
def add_speech_bubbles(img_path, dialogue):
if not dialogue:
return
entries = [d for d in dialogue if d.get("line", "").strip()][:MAX_BUBBLES]
if not entries:
return
img = Image.open(img_path).convert("RGB")
draw = ImageDraw.Draw(img)
font = get_font(FONT_SIZE)
small_font = get_font(FONT_SIZE - 6)
iw, ih = img.size
margin = 18
zones = [
ih // 8,
ih * 5 // 8,
ih // 4,
]
for i, entry in enumerate(entries):
speaker = entry.get("speaker", "").strip()
line = entry.get("line", "").strip()
if not line:
continue
wrapped = wrap_text(line)
bbox = draw.textbbox((0, 0), wrapped, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
if speaker and speaker != "?":
spk_bbox = draw.textbbox((0, 0), speaker, font=small_font)
spk_h = spk_bbox[3] - spk_bbox[1] + 4
else:
spk_h = 0
bw = min(text_w + BUBBLE_PADDING * 2, iw - margin * 2)
bh = text_h + spk_h + BUBBLE_PADDING * 2
x_offset = margin if i % 2 == 0 else iw - bw - margin
by = zones[i % len(zones)]
by = max(margin, min(by, ih - bh - margin))
tail = "bottom" if by < ih // 2 else "top"
draw_bubble(draw, x_offset, by, bw, bh, tail_side=tail)
if speaker and speaker != "?":
draw.text((x_offset + BUBBLE_PADDING, by + BUBBLE_PADDING), speaker, font=small_font, fill="#444444")
draw.text(
(x_offset + BUBBLE_PADDING, by + BUBBLE_PADDING + spk_h),
wrapped,
font=font,
fill="black",
)
img.save(img_path)