aboutsummaryrefslogtreecommitdiffstats
path: root/scenes/tiktok.py
diff options
context:
space:
mode:
authorElvis Claros Castro <elvis@claros.ar>2026-09-26 20:21:47 -0300
committerElvis Claros Castro <elvis@claros.ar>2026-09-26 20:21:47 -0300
commit59355909f2de9236af8168a26c70bcf6caa3b285 (patch)
tree686186e2086f81aa22ad25e78eb29fca3cbadc9a /scenes/tiktok.py
download100cia-videos-59355909f2de9236af8168a26c70bcf6caa3b285.tar.gz
100cia-videos-59355909f2de9236af8168a26c70bcf6caa3b285.zip
Import video pipeline as it was
Diffstat (limited to 'scenes/tiktok.py')
-rw-r--r--scenes/tiktok.py156
1 files changed, 156 insertions, 0 deletions
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)