Spaces:
Running on Zero
Running on Zero
SupPepper commited on
Commit ·
67ee91c
1
Parent(s): efe487f
gitignore
Browse files- .gitignore +1 -0
- scripts/make_trailer.py +0 -459
.gitignore
CHANGED
|
@@ -34,3 +34,4 @@ memory/
|
|
| 34 |
uv.lock
|
| 35 |
frontend/music/*.mp3
|
| 36 |
/trailer
|
|
|
|
|
|
| 34 |
uv.lock
|
| 35 |
frontend/music/*.mp3
|
| 36 |
/trailer
|
| 37 |
+
/scripts/trailer
|
scripts/make_trailer.py
DELETED
|
@@ -1,459 +0,0 @@
|
|
| 1 |
-
"""Build the Ephemeral Hearts demo trailer from real generated game assets.
|
| 2 |
-
|
| 3 |
-
Composes 1920x1080 stills with PIL (title cards, backdrop shots, in-game scene
|
| 4 |
-
mockups, a relations-graph shot, an ending shot), animates each with ffmpeg
|
| 5 |
-
(Ken Burns zoom/pan), chains them with crossfades, and scores the result with
|
| 6 |
-
three of the game's music tracks (joyful -> dramatic -> romantic) to showcase
|
| 7 |
-
the dynamic-soundtrack feature.
|
| 8 |
-
|
| 9 |
-
Usage: uv run python scripts/make_trailer.py
|
| 10 |
-
Output: trailer/ephemeral_hearts_trailer.mp4 (1280x720, ~48 s)
|
| 11 |
-
"""
|
| 12 |
-
|
| 13 |
-
from __future__ import annotations
|
| 14 |
-
|
| 15 |
-
import subprocess
|
| 16 |
-
import sys
|
| 17 |
-
from pathlib import Path
|
| 18 |
-
|
| 19 |
-
import numpy as np
|
| 20 |
-
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
| 21 |
-
|
| 22 |
-
ROOT = Path(__file__).resolve().parent.parent
|
| 23 |
-
CACHE = ROOT / ".cache" / "images"
|
| 24 |
-
MUSIC = ROOT / "frontend" / "music"
|
| 25 |
-
OUT = ROOT / "trailer"
|
| 26 |
-
STILLS = OUT / "stills"
|
| 27 |
-
CLIPS = OUT / "clips"
|
| 28 |
-
|
| 29 |
-
W, H = 1920, 1080 # still resolution (rendered down to 720p)
|
| 30 |
-
FPS = 25
|
| 31 |
-
XFADE = 0.5 # crossfade between clips
|
| 32 |
-
MUSIC_XF = 1.5 # crossfade between music tracks
|
| 33 |
-
|
| 34 |
-
BG_DARK = (12, 9, 20)
|
| 35 |
-
GOLD = (232, 193, 114)
|
| 36 |
-
INK = (236, 232, 242)
|
| 37 |
-
GREY = (186, 180, 198)
|
| 38 |
-
RED = (212, 116, 116)
|
| 39 |
-
GREEN = (126, 204, 160)
|
| 40 |
-
AMBER = (226, 172, 96)
|
| 41 |
-
|
| 42 |
-
FONTS = Path("C:/Windows/Fonts")
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def font(name: str, size: int) -> ImageFont.FreeTypeFont:
|
| 46 |
-
return ImageFont.truetype(str(FONTS / name), size)
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
# --------------------------------------------------------------------------- #
|
| 50 |
-
# PIL helpers
|
| 51 |
-
# --------------------------------------------------------------------------- #
|
| 52 |
-
|
| 53 |
-
def dark_canvas() -> Image.Image:
|
| 54 |
-
img = Image.new("RGB", (W, H), BG_DARK)
|
| 55 |
-
glow = Image.new("L", (W, H), 0)
|
| 56 |
-
d = ImageDraw.Draw(glow)
|
| 57 |
-
d.ellipse([W // 2 - 620, H // 2 - 330, W // 2 + 620, H // 2 + 330], fill=46)
|
| 58 |
-
glow = glow.filter(ImageFilter.GaussianBlur(180))
|
| 59 |
-
tint = Image.new("RGB", (W, H), (52, 40, 28))
|
| 60 |
-
return Image.composite(tint, img, glow)
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def draw_tracked(d, center_xy, text, fnt, fill, tracking=0):
|
| 64 |
-
widths = [d.textlength(c, font=fnt) for c in text]
|
| 65 |
-
total = sum(widths) + tracking * (len(text) - 1)
|
| 66 |
-
x = center_xy[0] - total / 2
|
| 67 |
-
asc, desc = fnt.getmetrics()
|
| 68 |
-
y = center_xy[1] - (asc + desc) / 2
|
| 69 |
-
for c, w in zip(text, widths):
|
| 70 |
-
d.text((x, y), c, font=fnt, fill=fill)
|
| 71 |
-
x += w + tracking
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
def card(lines: list[tuple[str, str, int, tuple]], rule=True, icon=None) -> Image.Image:
|
| 75 |
-
"""Text card: (text, font_file, size, color) per line, vertically centered."""
|
| 76 |
-
img = dark_canvas()
|
| 77 |
-
d = ImageDraw.Draw(img)
|
| 78 |
-
gap = 34
|
| 79 |
-
fnts = [font(ff, size) for _, ff, size, _ in lines]
|
| 80 |
-
heights = [sum(f.getmetrics()) for f in fnts]
|
| 81 |
-
block = sum(heights) + gap * (len(lines) - 1)
|
| 82 |
-
y = (H - block) / 2
|
| 83 |
-
if icon == "mic":
|
| 84 |
-
_mic(d, W // 2, int(y) - 150)
|
| 85 |
-
y += 20
|
| 86 |
-
elif rule:
|
| 87 |
-
d.rectangle([W // 2 - 60, y - 56, W // 2 + 60, y - 50], fill=GOLD)
|
| 88 |
-
for (text, _, _, color), f, h in zip(lines, fnts, heights):
|
| 89 |
-
d.text((W / 2, y + h / 2), text, font=f, fill=color, anchor="mm")
|
| 90 |
-
y += h + gap
|
| 91 |
-
return img
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
def _mic(d: ImageDraw.ImageDraw, cx: int, cy: int):
|
| 95 |
-
"""Small golden microphone glyph."""
|
| 96 |
-
d.rounded_rectangle([cx - 26, cy - 60, cx + 26, cy + 28], radius=26, fill=GOLD)
|
| 97 |
-
d.arc([cx - 52, cy - 24, cx + 52, cy + 62], 0, 180, fill=GOLD, width=8)
|
| 98 |
-
d.line([cx, cy + 62, cx, cy + 92], fill=GOLD, width=8)
|
| 99 |
-
d.line([cx - 30, cy + 96, cx + 30, cy + 96], fill=GOLD, width=8)
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def title_card(subtitle: str, extra: list[tuple[str, str, int, tuple]] | None = None) -> Image.Image:
|
| 103 |
-
img = dark_canvas()
|
| 104 |
-
d = ImageDraw.Draw(img)
|
| 105 |
-
cy = H // 2 - (60 if extra else 20)
|
| 106 |
-
draw_tracked(d, (W // 2, cy - 40), "EPHEMERAL HEARTS", font("georgiab.ttf", 116), GOLD, tracking=16)
|
| 107 |
-
d.rectangle([W // 2 - 360, cy + 58, W // 2 + 360, cy + 61], fill=(120, 100, 70))
|
| 108 |
-
d.text((W / 2, cy + 116), subtitle, font=font("georgiai.ttf", 46), fill=GREY, anchor="mm")
|
| 109 |
-
if extra:
|
| 110 |
-
y = cy + 230
|
| 111 |
-
for text, ff, size, color in extra:
|
| 112 |
-
d.text((W / 2, y), text, font=font(ff, size), fill=color, anchor="mm")
|
| 113 |
-
y += size + 34
|
| 114 |
-
return img
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
def prep_backdrop(name: str, v_bias: float = 0.45) -> Image.Image:
|
| 118 |
-
img = Image.open(CACHE / name).convert("RGB").resize((W, W), Image.LANCZOS)
|
| 119 |
-
ytop = int((W - H) * v_bias)
|
| 120 |
-
return img.crop((0, ytop, W, ytop + H))
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
def load_sprite(name: str) -> Image.Image:
|
| 124 |
-
"""Sprites in cache already carry a rembg alpha channel."""
|
| 125 |
-
img = Image.open(CACHE / name)
|
| 126 |
-
if img.mode == "RGBA" and img.getchannel("A").getextrema()[0] == 0:
|
| 127 |
-
return img
|
| 128 |
-
# fallback: flood-fill white background from borders
|
| 129 |
-
rgb = np.asarray(img.convert("RGB")).astype(np.int16)
|
| 130 |
-
near = (rgb > 255 - 28).all(axis=2)
|
| 131 |
-
seed = np.zeros_like(near)
|
| 132 |
-
seed[0, :], seed[-1, :], seed[:, 0], seed[:, -1] = near[0, :], near[-1, :], near[:, 0], near[:, -1]
|
| 133 |
-
prev = -1
|
| 134 |
-
while seed.sum() != prev:
|
| 135 |
-
prev = seed.sum()
|
| 136 |
-
grown = seed.copy()
|
| 137 |
-
grown[1:, :] |= seed[:-1, :]
|
| 138 |
-
grown[:-1, :] |= seed[1:, :]
|
| 139 |
-
grown[:, 1:] |= seed[:, :-1]
|
| 140 |
-
grown[:, :-1] |= seed[:, 1:]
|
| 141 |
-
seed = grown & near
|
| 142 |
-
alpha = Image.fromarray(np.where(seed, 0, 255).astype(np.uint8))
|
| 143 |
-
alpha = alpha.filter(ImageFilter.MinFilter(3)).filter(ImageFilter.GaussianBlur(1.2))
|
| 144 |
-
out = img.convert("RGBA")
|
| 145 |
-
out.putalpha(alpha)
|
| 146 |
-
return out
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
def heart(d: ImageDraw.ImageDraw, cx: int, cy: int, s: int, color):
|
| 150 |
-
d.ellipse([cx - s, cy - s, cx, cy], fill=color)
|
| 151 |
-
d.ellipse([cx, cy - s, cx + s, cy], fill=color)
|
| 152 |
-
d.polygon([(cx - s, cy - s // 3), (cx + s, cy - s // 3), (cx, cy + s)], fill=color)
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
def scene(bg_name: str, sprite_name: str, speaker: str, line1: str, line2: str,
|
| 156 |
-
sprite_h: int = 940, sprite_cx: int = 1290, v_bias: float = 0.45,
|
| 157 |
-
delta: int | None = None) -> Image.Image:
|
| 158 |
-
"""In-game look: backdrop + sprite + VN dialogue box (+ relationship delta)."""
|
| 159 |
-
img = prep_backdrop(bg_name, v_bias).convert("RGBA")
|
| 160 |
-
shade = Image.new("L", (W, H), 0)
|
| 161 |
-
ImageDraw.Draw(shade).rectangle([0, H - 480, W, H], fill=110)
|
| 162 |
-
shade = shade.filter(ImageFilter.GaussianBlur(120))
|
| 163 |
-
img = Image.composite(Image.new("RGBA", (W, H), (8, 6, 14, 255)), img, shade)
|
| 164 |
-
|
| 165 |
-
sp = load_sprite(sprite_name)
|
| 166 |
-
ratio = sprite_h / sp.height
|
| 167 |
-
sp = sp.resize((int(sp.width * ratio), sprite_h), Image.LANCZOS)
|
| 168 |
-
img.alpha_composite(sp, (sprite_cx - sp.width // 2, H - sprite_h + 30))
|
| 169 |
-
|
| 170 |
-
box = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
| 171 |
-
bd = ImageDraw.Draw(box)
|
| 172 |
-
bd.rounded_rectangle([110, 770, 1810, 1040], radius=28,
|
| 173 |
-
fill=(12, 9, 20, 214), outline=(*GOLD, 210), width=3)
|
| 174 |
-
img.alpha_composite(box)
|
| 175 |
-
|
| 176 |
-
d = ImageDraw.Draw(img)
|
| 177 |
-
d.text((170, 800), speaker, font=font("georgiab.ttf", 46), fill=GOLD)
|
| 178 |
-
d.text((170, 878), line1, font=font("georgiai.ttf", 40), fill=INK)
|
| 179 |
-
d.text((170, 940), line2, font=font("georgiai.ttf", 40), fill=INK)
|
| 180 |
-
if delta is not None:
|
| 181 |
-
dx, dy = 1620, 700
|
| 182 |
-
d.text((dx, dy), f"+{delta}", font=font("georgiab.ttf", 58), fill=GOLD,
|
| 183 |
-
stroke_width=4, stroke_fill=(12, 9, 20))
|
| 184 |
-
heart(d, dx + 118, dy + 30, 26, RED)
|
| 185 |
-
return img.convert("RGB")
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
# --------------------------------------------------------------------------- #
|
| 189 |
-
# Relations graph + ending shots
|
| 190 |
-
# --------------------------------------------------------------------------- #
|
| 191 |
-
|
| 192 |
-
def face_node(img: Image.Image, sprite_name: str, cx: int, cy: int, name: str,
|
| 193 |
-
crop_rel: tuple[float, float, float, float], r: int = 115):
|
| 194 |
-
sp = Image.open(CACHE / sprite_name).convert("RGBA")
|
| 195 |
-
w, h = sp.size
|
| 196 |
-
box = (int(w * crop_rel[0]), int(h * crop_rel[1]), int(w * crop_rel[2]), int(h * crop_rel[3]))
|
| 197 |
-
face = sp.crop(box).resize((2 * r, 2 * r), Image.LANCZOS)
|
| 198 |
-
disc = Image.new("RGBA", (2 * r, 2 * r), (30, 26, 44, 255))
|
| 199 |
-
disc.alpha_composite(face)
|
| 200 |
-
mask = Image.new("L", (2 * r, 2 * r), 0)
|
| 201 |
-
ImageDraw.Draw(mask).ellipse([0, 0, 2 * r, 2 * r], fill=255)
|
| 202 |
-
img.paste(disc, (cx - r, cy - r), mask)
|
| 203 |
-
d = ImageDraw.Draw(img)
|
| 204 |
-
d.ellipse([cx - r, cy - r, cx + r, cy + r], outline=GOLD, width=6)
|
| 205 |
-
d.text((cx, cy + r + 46), name, font=font("georgiab.ttf", 44), fill=INK,
|
| 206 |
-
anchor="mm", stroke_width=3, stroke_fill=BG_DARK)
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
def qbezier(p0, c, p1, n=60):
|
| 210 |
-
pts = []
|
| 211 |
-
for i in range(n + 1):
|
| 212 |
-
t = i / n
|
| 213 |
-
x = (1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * c[0] + t ** 2 * p1[0]
|
| 214 |
-
y = (1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * c[1] + t ** 2 * p1[1]
|
| 215 |
-
pts.append((x, y))
|
| 216 |
-
return pts
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
def arc_label(d: ImageDraw.ImageDraw, p0, c, p1, color, label):
|
| 220 |
-
pts = qbezier(p0, c, p1)
|
| 221 |
-
d.line(pts, fill=color, width=7, joint="curve")
|
| 222 |
-
# arrowhead
|
| 223 |
-
(x1, y1), (x2, y2) = pts[-4], pts[-1]
|
| 224 |
-
vx, vy = x2 - x1, y2 - y1
|
| 225 |
-
ln = (vx * vx + vy * vy) ** 0.5 or 1
|
| 226 |
-
vx, vy = vx / ln, vy / ln
|
| 227 |
-
px, py = -vy, vx
|
| 228 |
-
d.polygon([(x2, y2), (x2 - 30 * vx + 14 * px, y2 - 30 * vy + 14 * py),
|
| 229 |
-
(x2 - 30 * vx - 14 * px, y2 - 30 * vy - 14 * py)], fill=color)
|
| 230 |
-
# label at curve midpoint
|
| 231 |
-
mx, my = pts[len(pts) // 2]
|
| 232 |
-
d.text((mx, my - 8), label, font=font("georgiaz.ttf", 42), fill=color,
|
| 233 |
-
anchor="mm", stroke_width=5, stroke_fill=BG_DARK)
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
def relations_still() -> Image.Image:
|
| 237 |
-
img = dark_canvas()
|
| 238 |
-
d = ImageDraw.Draw(img)
|
| 239 |
-
draw_tracked(d, (W // 2, 130), "BETWEEN THEM", font("georgiab.ttf", 58), GOLD, tracking=12)
|
| 240 |
-
d.text((W / 2, 212), "the characters grow bonds of their own - and act on them",
|
| 241 |
-
font=font("georgiai.ttf", 40), fill=GREY, anchor="mm")
|
| 242 |
-
|
| 243 |
-
A = (520, 560) # Hana
|
| 244 |
-
B = (1400, 560) # Ren
|
| 245 |
-
C = (960, 880) # Yuki
|
| 246 |
-
# arcs first, under the nodes
|
| 247 |
-
arc_label(d, (B[0] - 80, B[1] - 60), (W // 2, 320), (A[0] + 95, A[1] - 55), RED, "jealousy")
|
| 248 |
-
arc_label(d, (A[0] + 40, A[1] + 110), (640, 830), (C[0] - 125, C[1] - 25), GREEN, "camaraderie")
|
| 249 |
-
arc_label(d, (C[0] + 120, C[1] - 30), (1290, 830), (B[0] - 35, B[1] + 110), AMBER, "rivalry")
|
| 250 |
-
|
| 251 |
-
face_node(img, "sprite_001.tender_1c2d9ce96e14.png", *A, "Hana", (0.30, 0.04, 0.70, 0.44))
|
| 252 |
-
face_node(img, "sprite_002.neutral_1cdbb592ab0a.png", *B, "Ren", (0.32, 0.02, 0.68, 0.38))
|
| 253 |
-
face_node(img, "sprite_003.neutral_38f6b226c42e.png", *C, "Yuki", (0.31, 0.01, 0.69, 0.39))
|
| 254 |
-
return img
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
def ending_still() -> Image.Image:
|
| 258 |
-
bg = prep_backdrop("bg_the_cherry_blossom_courtyard_11bfbdeafe4e.png", 0.4)
|
| 259 |
-
bg = bg.filter(ImageFilter.GaussianBlur(3)).convert("RGBA")
|
| 260 |
-
veil = Image.new("RGBA", (W, H), (10, 8, 18, 158))
|
| 261 |
-
bg.alpha_composite(veil)
|
| 262 |
-
d = ImageDraw.Draw(bg)
|
| 263 |
-
draw_tracked(d, (W // 2, H // 2 - 150), "A WARM ENDING", font("georgiab.ttf", 96), GOLD, tracking=20)
|
| 264 |
-
d.rectangle([W // 2 - 280, H // 2 - 64, W // 2 + 280, H // 2 - 61], fill=(170, 145, 100))
|
| 265 |
-
d.text((W / 2, H / 2 + 14), "Beneath the falling petals, she finally says it -",
|
| 266 |
-
font=font("georgiai.ttf", 46), fill=INK, anchor="mm")
|
| 267 |
-
d.text((W / 2, H / 2 + 84), "and the whole world seems to hold its breath.",
|
| 268 |
-
font=font("georgiai.ttf", 46), fill=INK, anchor="mm")
|
| 269 |
-
d.text((W / 2, H - 110), "win their heart or break it - every ending is written and illustrated live",
|
| 270 |
-
font=font("georgia.ttf", 34), fill=GREY, anchor="mm")
|
| 271 |
-
return bg.convert("RGB")
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
# --------------------------------------------------------------------------- #
|
| 275 |
-
# ffmpeg helpers
|
| 276 |
-
# --------------------------------------------------------------------------- #
|
| 277 |
-
|
| 278 |
-
def run(cmd: list[str]):
|
| 279 |
-
r = subprocess.run(cmd, capture_output=True, text=True)
|
| 280 |
-
if r.returncode != 0:
|
| 281 |
-
print(r.stderr[-3000:], file=sys.stderr)
|
| 282 |
-
sys.exit(f"ffmpeg failed: {' '.join(cmd[:8])} …")
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
def make_clip(still: Path, out: Path, dur: float, motion: str):
|
| 286 |
-
frames = int(dur * FPS)
|
| 287 |
-
if motion == "static":
|
| 288 |
-
vf = f"scale=1280:720,fps={FPS},format=yuv420p"
|
| 289 |
-
else:
|
| 290 |
-
if motion == "in":
|
| 291 |
-
zp = (f"zoompan=z='min(zoom+0.0009,1.13)'"
|
| 292 |
-
f":x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
|
| 293 |
-
f":d={frames}:s=1280x720:fps={FPS}")
|
| 294 |
-
elif motion == "out":
|
| 295 |
-
zp = (f"zoompan=z='if(lte(on,1),1.13,max(1.13-0.0009*on,1.0))'"
|
| 296 |
-
f":x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
|
| 297 |
-
f":d={frames}:s=1280x720:fps={FPS}")
|
| 298 |
-
else: # pan
|
| 299 |
-
zp = (f"zoompan=z=1.10"
|
| 300 |
-
f":x='(iw-iw/zoom)*on/{frames}':y='ih/2-(ih/zoom/2)'"
|
| 301 |
-
f":d={frames}:s=1280x720:fps={FPS}")
|
| 302 |
-
vf = "scale=3840:-2," + zp + ",format=yuv420p"
|
| 303 |
-
run(["ffmpeg", "-y", "-loop", "1", "-framerate", str(FPS), "-t", f"{dur}",
|
| 304 |
-
"-i", str(still), "-vf", vf,
|
| 305 |
-
"-c:v", "libx264", "-preset", "fast", "-crf", "18", str(out)])
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
# --------------------------------------------------------------------------- #
|
| 309 |
-
# Storyboard
|
| 310 |
-
# --------------------------------------------------------------------------- #
|
| 311 |
-
|
| 312 |
-
def build_stills() -> list[tuple[str, float, str, str | None]]:
|
| 313 |
-
"""[(filename, duration, motion, music_cue)] - music_cue marks track switches."""
|
| 314 |
-
shots: list[tuple[str, float, str, str | None]] = []
|
| 315 |
-
|
| 316 |
-
def save(img, name, dur, motion, cue=None):
|
| 317 |
-
img.save(STILLS / name)
|
| 318 |
-
shots.append((name, dur, motion, cue))
|
| 319 |
-
|
| 320 |
-
# -- hooks ---------------------------------------------------------------
|
| 321 |
-
save(card([("No script.", "georgiab.ttf", 84, INK)], rule=False),
|
| 322 |
-
"00_hook1.png", 1.8, "static", cue="joyful")
|
| 323 |
-
save(card([("No branches.", "georgiab.ttf", 84, INK)], rule=False),
|
| 324 |
-
"01_hook2.png", 1.8, "static")
|
| 325 |
-
save(card([("Nothing is pre-written.", "georgiab.ttf", 78, GOLD)], rule=False),
|
| 326 |
-
"02_hook3.png", 2.4, "static")
|
| 327 |
-
save(title_card("an AI-improvised anime visual novel"),
|
| 328 |
-
"03_title.png", 3.8, "static")
|
| 329 |
-
|
| 330 |
-
# -- world montage ---------------------------------------------------------
|
| 331 |
-
save(prep_backdrop("bg_sakura_academy_courtyard_e9ee918cd6b4.png"),
|
| 332 |
-
"04_bg_sakura.png", 2.4, "in")
|
| 333 |
-
save(prep_backdrop("bg_seashell_cove_22a17fda1006.png"),
|
| 334 |
-
"05_bg_cove.png", 2.4, "pan")
|
| 335 |
-
save(prep_backdrop("bg_glowspore_glade_3497325c4038.png", 0.5),
|
| 336 |
-
"06_bg_glade.png", 2.4, "out")
|
| 337 |
-
save(card([("Every scene is painted", "georgia.ttf", 62, INK),
|
| 338 |
-
("the moment you arrive", "georgia.ttf", 62, INK)]),
|
| 339 |
-
"07_card_scenes.png", 2.6, "static")
|
| 340 |
-
|
| 341 |
-
# -- characters ------------------------------------------------------------
|
| 342 |
-
save(scene("bg_the_cherry_blossom_courtyard_11bfbdeafe4e.png",
|
| 343 |
-
"sprite_001.tender_1c2d9ce96e14.png",
|
| 344 |
-
"Hana",
|
| 345 |
-
"You came... I was hoping you would.",
|
| 346 |
-
"The cherry trees bloomed early this year - almost like they knew."),
|
| 347 |
-
"08_scene1.png", 4.6, "in")
|
| 348 |
-
save(card([("Every character is improvised -", "georgia.ttf", 62, INK),
|
| 349 |
-
("moods, secrets, feelings", "georgiai.ttf", 62, GOLD)]),
|
| 350 |
-
"09_card_chars.png", 2.6, "static")
|
| 351 |
-
save(scene("bg_after_school_rooftop_f8a0f52182b3.png",
|
| 352 |
-
"sprite_002.neutral_1cdbb592ab0a.png",
|
| 353 |
-
"Ren",
|
| 354 |
-
"Don't give me that look. I was not waiting for you...",
|
| 355 |
-
"I just happened to be here after class. Obviously.",
|
| 356 |
-
sprite_h=900, sprite_cx=1330, v_bias=0.4, delta=12),
|
| 357 |
-
"10_scene2.png", 4.6, "in")
|
| 358 |
-
save(card([("Speak with your voice -", "georgia.ttf", 62, INK),
|
| 359 |
-
("they listen, and remember", "georgiai.ttf", 62, GOLD)], icon="mic"),
|
| 360 |
-
"11_card_voice.png", 2.6, "static")
|
| 361 |
-
|
| 362 |
-
# -- relations -------------------------------------------------------------
|
| 363 |
-
save(card([("And between them...", "georgiai.ttf", 72, INK)], rule=False),
|
| 364 |
-
"12_card_between.png", 2.2, "static", cue="dramatic")
|
| 365 |
-
save(relations_still(),
|
| 366 |
-
"13_relations.png", 4.8, "in")
|
| 367 |
-
|
| 368 |
-
# -- endings ---------------------------------------------------------------
|
| 369 |
-
save(card([("Win their heart...", "georgia.ttf", 66, INK),
|
| 370 |
-
("or lose everything", "georgiai.ttf", 66, RED)]),
|
| 371 |
-
"14_card_win.png", 2.6, "static", cue="romantic")
|
| 372 |
-
save(ending_still(),
|
| 373 |
-
"15_ending.png", 4.6, "in")
|
| 374 |
-
|
| 375 |
-
# -- tech + final ------------------------------------------------------------
|
| 376 |
-
save(card([("Three small AIs. One living dream.", "georgiab.ttf", 64, INK),
|
| 377 |
-
("Qwen3-14B · SDXL-Turbo + LoRA · Whisper", "georgia.ttf", 44, GOLD),
|
| 378 |
-
("~18B parameters - everything generated live", "georgia.ttf", 36, GREY)]),
|
| 379 |
-
"16_card_tech.png", 3.4, "static")
|
| 380 |
-
save(title_card("an AI-improvised anime visual novel",
|
| 381 |
-
extra=[("Play it now on Hugging Face Spaces", "georgia.ttf", 44, INK),
|
| 382 |
-
("Build Small Hackathon - June 2026", "georgia.ttf", 34, GREY)]),
|
| 383 |
-
"17_final.png", 6.0, "static")
|
| 384 |
-
return shots
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
def main():
|
| 388 |
-
STILLS.mkdir(parents=True, exist_ok=True)
|
| 389 |
-
CLIPS.mkdir(parents=True, exist_ok=True)
|
| 390 |
-
|
| 391 |
-
print("[1/3] composing stills…")
|
| 392 |
-
shots = build_stills()
|
| 393 |
-
|
| 394 |
-
print("[2/3] animating clips…")
|
| 395 |
-
clip_paths = []
|
| 396 |
-
for name, dur, motion, _ in shots:
|
| 397 |
-
out = CLIPS / (Path(name).stem + ".mp4")
|
| 398 |
-
make_clip(STILLS / name, out, dur, motion)
|
| 399 |
-
clip_paths.append((out, dur))
|
| 400 |
-
print(f" {name} ({dur}s, {motion})")
|
| 401 |
-
|
| 402 |
-
print("[3/3] assembling with crossfades + 3-track score…")
|
| 403 |
-
n = len(clip_paths)
|
| 404 |
-
durs = [d for _, d in clip_paths]
|
| 405 |
-
total = sum(durs) - XFADE * (n - 1)
|
| 406 |
-
|
| 407 |
-
# clip start offsets in the final timeline
|
| 408 |
-
offsets = [0.0]
|
| 409 |
-
for i in range(1, n):
|
| 410 |
-
offsets.append(offsets[i - 1] + durs[i - 1] - XFADE)
|
| 411 |
-
|
| 412 |
-
# music segment boundaries from the cue marks
|
| 413 |
-
cues = [(offsets[i], shots[i][3]) for i in range(n) if shots[i][3]]
|
| 414 |
-
tracks = [c[1] for c in cues] # joyful, dramatic, romantic
|
| 415 |
-
bounds = [c[0] for c in cues[1:]] + [total] # switch times + end
|
| 416 |
-
|
| 417 |
-
inputs: list[str] = []
|
| 418 |
-
for p, _ in clip_paths:
|
| 419 |
-
inputs += ["-i", str(p)]
|
| 420 |
-
for t in tracks:
|
| 421 |
-
inputs += ["-i", str(MUSIC / f"{t}.mp3")]
|
| 422 |
-
|
| 423 |
-
fc = []
|
| 424 |
-
last = "[0:v]"
|
| 425 |
-
for i in range(1, n):
|
| 426 |
-
out_lbl = f"[v{i}]"
|
| 427 |
-
fc.append(f"{last}[{i}:v]xfade=transition=fade:duration={XFADE}:offset={offsets[i]:.3f}{out_lbl}")
|
| 428 |
-
last = out_lbl
|
| 429 |
-
fc.append(f"{last}fade=t=out:st={total - 1.0:.3f}:d=1.0[vout]")
|
| 430 |
-
|
| 431 |
-
# audio: trim each track to its segment (+ crossfade halves), chain acrossfades
|
| 432 |
-
seg_start = 0.0
|
| 433 |
-
for k, t in enumerate(tracks):
|
| 434 |
-
seg = bounds[k] - seg_start
|
| 435 |
-
pad = (MUSIC_XF / 2) if k in (0, len(tracks) - 1) else MUSIC_XF
|
| 436 |
-
ln = seg + pad
|
| 437 |
-
fc.append(f"[{n + k}:a]atrim=0:{ln:.3f},asetpts=PTS-STARTPTS[a{k}]")
|
| 438 |
-
seg_start = bounds[k]
|
| 439 |
-
last_a = "[a0]"
|
| 440 |
-
for k in range(1, len(tracks)):
|
| 441 |
-
out_lbl = f"[ax{k}]"
|
| 442 |
-
fc.append(f"{last_a}[a{k}]acrossfade=d={MUSIC_XF}{out_lbl}")
|
| 443 |
-
last_a = out_lbl
|
| 444 |
-
fc.append(f"{last_a}afade=t=in:st=0:d=0.6,afade=t=out:st={total - 3.0:.3f}:d=3.0[aout]")
|
| 445 |
-
|
| 446 |
-
final = OUT / "ephemeral_hearts_trailer.mp4"
|
| 447 |
-
run(["ffmpeg", "-y", *inputs,
|
| 448 |
-
"-filter_complex", ";".join(fc),
|
| 449 |
-
"-map", "[vout]", "-map", "[aout]",
|
| 450 |
-
"-c:v", "libx264", "-preset", "slow", "-crf", "18",
|
| 451 |
-
"-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart",
|
| 452 |
-
"-t", f"{total:.3f}",
|
| 453 |
-
str(final)])
|
| 454 |
-
print(f"\ndone -> {final} ({total:.1f}s)")
|
| 455 |
-
print("music cues:", list(zip(tracks, [0.0] + bounds[:-1])))
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
if __name__ == "__main__":
|
| 459 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|