SupPepper commited on
Commit
67ee91c
·
1 Parent(s): efe487f

gitignore

Browse files
Files changed (2) hide show
  1. .gitignore +1 -0
  2. 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()