1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
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)
|