From 59355909f2de9236af8168a26c70bcf6caa3b285 Mon Sep 17 00:00:00 2001 From: Elvis Claros Castro Date: Sat, 26 Sep 2026 20:21:47 -0300 Subject: Import video pipeline as it was --- scenes/tiktok.py | 156 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 scenes/tiktok.py (limited to 'scenes/tiktok.py') diff --git a/scenes/tiktok.py b/scenes/tiktok.py new file mode 100644 index 0000000..7e2ad2d --- /dev/null +++ b/scenes/tiktok.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +"""Base comun para los videos verticales: formato 9:16, subtitulos, +barra de progreso y un sistema de 'beats' sincronizados con el audio.""" +import json, os +from contextlib import contextmanager +from manim import * + +# --- formato TikTok 1080x1920 ------------------------------------------------- +if os.environ.get("DRAFT"): + config.pixel_width, config.pixel_height = 540, 960 +else: + config.pixel_width, config.pixel_height = 1080, 1920 +config.frame_width = 9.0 +config.frame_height = 16.0 +config.frame_rate = 30 +config.background_color = "#0B0F1A" + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +FONT = "Roboto" +AMBAR = "#FFD166" +VERDE = "#06D6A0" +ROSA = "#EF476F" +CELESTE = "#4CC9F0" +GRIS = "#8D99AE" + +GAP = 0.30 # silencio entre segmentos +SUB_Y = -4.25 # los subtitulos van arriba de la UI de TikTok +TOP_Y = 6.45 + + +class _Beat: + """Acumula el tiempo usado dentro de un segmento para poder rellenar el resto.""" + def __init__(self, scene, floor): + self.s, self.floor, self.used = scene, floor, 0.0 + + def play(self, *anims, run_time=1.0, **kw): + self.s.play(*anims, run_time=run_time, **kw) + self.used += run_time + + def wait(self, t): + if t > 0.02: + self.s.wait(t) + self.used += t + + def add(self, *m): + self.s.add(*m) + + def remove(self, *m): + self.s.remove(*m) + + def close(self): + rest = self.floor - self.used + if rest > 0.02: + self.s.wait(rest) + self.used += rest + if GAP > 0.02: + self.s.wait(GAP) + self.used += GAP + return self.used + + +class TikTok(Scene): + NAME = "" + + # -- infraestructura ------------------------------------------------------ + def prepare(self): + with open(os.path.join(ROOT, "audio", self.NAME, "segments.json"), encoding="utf-8") as f: + self.data = json.load(f) + self.segs = self.data["segments"] + self.timeline, self.clock = [], 0.0 + self._sub = None + self._expected = sum(s["dur"] + GAP for s in self.segs) + self.backdrop() + self._progress_bar() + + def backdrop(self): + dots = VGroup(*[ + Dot(radius=0.035, color="#243050", fill_opacity=0.55).move_to([x, y, 0]) + for x in np.arange(-4.2, 4.3, 1.05) + for y in np.arange(-7.5, 7.6, 1.05) + ]) + self.add(dots) + return dots + + def _progress_bar(self): + w = config.frame_width + track = Rectangle(width=w, height=0.075, stroke_width=0, + fill_color="#1B2233", fill_opacity=1).move_to(UP * (TOP_Y + 1.15)) + bar = Rectangle(width=0.002, height=0.075, stroke_width=0, + fill_color=AMBAR, fill_opacity=1).move_to(track.get_left(), LEFT) + self.elapsed = 0.0 + total, left, y = self._expected, -w / 2, track.get_y() + + def upd(m, dt): + self.elapsed += dt + frac = min(self.elapsed / total, 1.0) + nw = max(frac * w, 0.002) + m.stretch_to_fit_width(nw) + m.move_to([left + nw / 2, y, 0]) + + bar.add_updater(upd) + self.add(track, bar) + + def subtitle(self, text): + """Cambia el subtitulo sin consumir tiempo de animacion.""" + if self._sub: + self.remove(self._sub) + t = Text(text, font=FONT, font_size=44, weight=BOLD, color=WHITE, + line_spacing=0.8).move_to(UP * SUB_Y) + if t.width > 8.0: + t.scale(8.0 / t.width) + box = RoundedRectangle(corner_radius=0.18, stroke_width=0, + fill_color="#000000", fill_opacity=0.55, + width=t.width + 0.55, height=t.height + 0.42).move_to(t) + self._sub = VGroup(box, t) + self.add(self._sub) + + @contextmanager + def beat(self, i, sub=True): + """Bloque sincronizado con el segmento i de audio.""" + if sub: + self.subtitle(self.segs[i]["sub"]) + b = _Beat(self, self.segs[i]["dur"]) + yield b + used = b.close() + self.timeline.append({"i": i, "start": round(self.clock, 3), + "dur": round(used, 3), "audio": self.segs[i]["dur"]}) + self.clock += used + + def finish(self): + if self._sub: + self.play(FadeOut(self._sub), run_time=0.4) + self.clock += 0.4 + self.wait(0.5) + self.clock += 0.5 + os.makedirs(os.path.join(ROOT, "out"), exist_ok=True) + with open(os.path.join(ROOT, "out", f"{self.NAME}_timeline.json"), "w") as f: + json.dump({"name": self.NAME, "gap": GAP, "total": round(self.clock, 3), + "beats": self.timeline}, f, indent=2) + + # -- helpers visuales ----------------------------------------------------- + def chip(self, text, color=AMBAR): + t = Text(text, font=FONT, font_size=34, weight=BOLD, color="#0B0F1A") + box = RoundedRectangle(corner_radius=0.3, stroke_width=0, fill_color=color, + fill_opacity=1, width=t.width + 0.7, height=t.height + 0.45) + g = VGroup(box, t).move_to(UP * TOP_Y) + return g + + def fit(self, m, w=8.3): + if m.width > w: + m.scale(w / m.width) + return m + + def big(self, text, size=76, color=WHITE): + return Text(text, font=FONT, font_size=size, weight=BOLD, color=color) -- cgit v1.2.3