captionwave 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ """
2
+ captionwave — genera audio + subtítulos animados sincronizados a partir de texto.
3
+
4
+ Salidas: .ass (animado), .srt (respaldo), audio (.mp3) y emojis con tiempos.
5
+ Tú montas el video con FFmpeg/MoviePy usando esos archivos.
6
+ """
7
+
8
+ from .core import CaptionGenerator, chunk_words
9
+ from .styles import Style, PRESETS, get_style, list_styles, hex_to_ass
10
+ from .emojis import EmojiPicker
11
+
12
+ __version__ = "0.1.0"
13
+ __all__ = [
14
+ "CaptionGenerator",
15
+ "chunk_words",
16
+ "Style",
17
+ "PRESETS",
18
+ "get_style",
19
+ "list_styles",
20
+ "hex_to_ass",
21
+ "EmojiPicker",
22
+ ]
@@ -0,0 +1,209 @@
1
+ """
2
+ Escritura del archivo .ass (Advanced SubStation Alpha).
3
+
4
+ Genera subtítulos animados que libass/ffmpeg renderiza de forma nativa. La
5
+ sincronización viene de los tiempos por palabra del TTS.
6
+
7
+ Técnica de resaltado: para los estilos de "palabra activa" se emite UN evento
8
+ por palabra, dibujando la línea completa con solo la palabra en curso resaltada.
9
+ Así nunca hay dos palabras activas, la posición no se mueve y se evita una
10
+ limitación de libass al encadenar varias transiciones \\t con huecos.
11
+ """
12
+
13
+ from __future__ import annotations
14
+ from .styles import Style, hex_to_ass
15
+
16
+
17
+ # ---- utilidades de tiempo ----
18
+ def _fmt_time(seconds: float) -> str:
19
+ if seconds < 0:
20
+ seconds = 0
21
+ cs = int(round(seconds * 100))
22
+ h, cs = divmod(cs, 360000)
23
+ m, cs = divmod(cs, 6000)
24
+ s, cs = divmod(cs, 100)
25
+ return f"{h:d}:{m:02d}:{s:02d}.{cs:02d}"
26
+
27
+
28
+ def _alignment(position: str) -> int:
29
+ return {"center": 5, "lower": 2, "upper": 8}.get(position, 5)
30
+
31
+
32
+ def _txt(word: str, upper: bool) -> str:
33
+ return word.upper() if upper else word
34
+
35
+
36
+ def _word_windows(words, line_start, line_end):
37
+ """Ventana [inicio, fin) de cada palabra, continua (sin huecos)."""
38
+ n = len(words)
39
+ wins = []
40
+ for i, w in enumerate(words):
41
+ s = w["start"]
42
+ e = words[i + 1]["start"] if i + 1 < n else line_end
43
+ if e <= s:
44
+ e = s + 0.12
45
+ wins.append((i, s, e))
46
+ return wins
47
+
48
+
49
+ # ---- karaoke (usa \k nativo de libass) ----
50
+ def _line_karaoke(style: Style, words, line_start):
51
+ al = _alignment(style.position)
52
+ parts = ["{\\an%d}" % al]
53
+ cursor = 0
54
+ for w in words:
55
+ s = max(0, int(round((w["start"] - line_start) * 100)))
56
+ e = max(s + 1, int(round((w["start"] + w["dur"] - line_start) * 100)))
57
+ gap = s - cursor
58
+ if gap > 0:
59
+ parts.append("{\\k%d} " % gap)
60
+ parts.append("{\\kf%d}%s " % (max(1, e - s), _txt(w["word"], style.uppercase)))
61
+ cursor = e
62
+ return "".join(parts).rstrip()
63
+
64
+
65
+ # ---- línea completa con UNA palabra resaltada (estática + pop opcional) ----
66
+ def _render_active(words, active_idx, style, fad=(0, 0)):
67
+ al = _alignment(style.position)
68
+ base = hex_to_ass(style.base_color)
69
+ act = hex_to_ass(style.active_color)
70
+ anim = style.animation
71
+
72
+ head = "{\\an%d" % al
73
+ if fad != (0, 0):
74
+ head += "\\fad(%d,%d)" % fad
75
+ head += "}"
76
+ parts = [head]
77
+
78
+ for k, w in enumerate(words):
79
+ word = _txt(w["word"], style.uppercase)
80
+ activa = (k == active_idx)
81
+ if anim == "active_sticker":
82
+ if activa:
83
+ on = hex_to_ass(style.sticker_text_color)
84
+ stick = hex_to_ass(style.sticker_color)
85
+ blk = "{\\1c%s\\3c%s\\bord%d\\fscx70\\fscy70\\t(0,9,\\fscx100\\fscy100)}" % (
86
+ on, stick, style.sticker_bord)
87
+ else:
88
+ outline = hex_to_ass(style.outline_color)
89
+ blk = "{\\1c%s\\3c%s\\bord%d}" % (base, outline, style.outline_w)
90
+ elif anim == "active_pop":
91
+ if activa:
92
+ p = style.pop_scale
93
+ blk = "{\\1c%s\\fscx80\\fscy80\\t(0,9,\\fscx%d\\fscy%d)}" % (act, p, p)
94
+ else:
95
+ blk = "{\\1c%s\\fscx100\\fscy100}" % base
96
+ else: # active_color / fade
97
+ blk = "{\\1c%s}" % (act if activa else base)
98
+ parts.append(blk + word + " ")
99
+
100
+ return "".join(parts).rstrip()
101
+
102
+
103
+ def _line_events(ln, style):
104
+ """Devuelve lista de (start, end, text) para una línea según su animación."""
105
+ words = ln["words"]
106
+ ls, le = ln["start"], ln["end"]
107
+ anim = style.animation
108
+
109
+ if anim == "karaoke":
110
+ return [(ls, le, _line_karaoke(style, words, ls))]
111
+
112
+ wins = _word_windows(words, ls, le)
113
+ n = len(wins)
114
+ evs = []
115
+ for (i, s, e) in wins:
116
+ fad = (0, 0)
117
+ if anim == "fade":
118
+ fad = (120 if i == 0 else 0, 120 if i == n - 1 else 0)
119
+ evs.append((s, e, _render_active(words, i, style, fad=fad)))
120
+ return evs
121
+
122
+
123
+ # ---- single_word: una palabra a la vez, grande y centrada ----
124
+ def _single_word_events(style: Style, words, total):
125
+ act = hex_to_ass(style.active_color)
126
+ rows = []
127
+ n = len(words)
128
+ for i, w in enumerate(words):
129
+ s = w["start"]
130
+ e = words[i + 1]["start"] if i + 1 < n else total
131
+ if e <= s + 0.05:
132
+ e = s + 0.2
133
+ txt = ("{\\an5\\1c%s\\fscx70\\fscy70\\t(0,140,\\fscx100\\fscy100)\\fad(50,50)}%s"
134
+ % (act, _txt(w["word"], style.uppercase)))
135
+ rows.append((s, e, txt))
136
+ return rows
137
+
138
+
139
+ # ---- cabecera y estilos ----
140
+ def _header(style: Style, W: int, H: int) -> str:
141
+ primary = (hex_to_ass(style.active_color)
142
+ if style.animation == "karaoke" else hex_to_ass(style.base_color))
143
+ secondary = hex_to_ass(style.base_color)
144
+ outline = hex_to_ass(style.outline_color)
145
+ back = hex_to_ass("#000000")
146
+ bold = -1 if style.bold else 0
147
+ align = _alignment(style.position)
148
+ margin_v = 0 if style.position == "center" else style.margin_v
149
+
150
+ return (
151
+ "[Script Info]\n"
152
+ "ScriptType: v4.00+\n"
153
+ "WrapStyle: 2\n"
154
+ "ScaledBorderAndShadow: yes\n"
155
+ f"PlayResX: {W}\n"
156
+ f"PlayResY: {H}\n"
157
+ "YCbCr Matrix: TV.709\n"
158
+ "\n"
159
+ "[V4+ Styles]\n"
160
+ "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, "
161
+ "OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, "
162
+ "ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, "
163
+ "Alignment, MarginL, MarginR, MarginV, Encoding\n"
164
+ f"Style: Main,{style.font},{style.font_size},{primary},{secondary},"
165
+ f"{outline},{back},{bold},0,0,0,100,100,0,0,1,{style.outline_w},"
166
+ f"{style.shadow},{align},60,60,{margin_v},1\n"
167
+ f"Style: Emoji,{style.font},96,&H00FFFFFF,&H00FFFFFF,&H00000000,"
168
+ f"&H00000000,0,0,0,0,100,100,0,0,1,0,0,5,0,0,0,1\n"
169
+ "\n"
170
+ "[Events]\n"
171
+ "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
172
+ )
173
+
174
+
175
+ def _dialogue(start, end, text, style_name="Main", layer=0):
176
+ return f"Dialogue: {layer},{_fmt_time(start)},{_fmt_time(end)},{style_name},,0,0,0,,{text}\n"
177
+
178
+
179
+ def build_ass(lines, style: Style, resolution=(1080, 1920),
180
+ emojis=None, emoji_in_ass=True) -> str:
181
+ """Construye el contenido completo del .ass.
182
+
183
+ lines: lista de dicts {words:[{word,start,dur}], start, end, emoji?}
184
+ """
185
+ W, H = resolution
186
+ chunks = [_header(style, W, H)]
187
+
188
+ if style.animation == "single_word":
189
+ flat = [w for ln in lines for w in ln["words"]]
190
+ total = lines[-1]["end"] if lines else 0.0
191
+ for (s, e, t) in _single_word_events(style, flat, total):
192
+ chunks.append(_dialogue(s, e, t))
193
+ else:
194
+ for ln in lines:
195
+ for (s, e, t) in _line_events(ln, style):
196
+ chunks.append(_dialogue(s, e, t))
197
+
198
+ # Emoji por línea (capa superior). Ojo: libass no garantiza emoji a color
199
+ # (ver nota en emojis.py); usa emojis.json para superponer tu propio arte.
200
+ if emoji_in_ass:
201
+ cx, cy = W // 2, int(H * 0.34)
202
+ for ln in lines:
203
+ em = ln.get("emoji")
204
+ if not em:
205
+ continue
206
+ txt = "{\\an5\\pos(%d,%d)\\fad(120,120)}%s" % (cx, cy, em)
207
+ chunks.append(_dialogue(ln["start"], ln["end"], txt, style_name="Emoji", layer=2))
208
+
209
+ return "".join(chunks)
captionwave/core.py ADDED
@@ -0,0 +1,182 @@
1
+ """
2
+ API principal de captionwave.
3
+
4
+ Ejemplo mínimo:
5
+
6
+ from captionwave import CaptionGenerator
7
+
8
+ gen = CaptionGenerator(voice="es-MX-DaliaNeural", rate="+18%", style="hormozi")
9
+ r = gen.generate(
10
+ "El Sol es una estrella que contiene el 99% de la masa del sistema solar.",
11
+ out_audio="voz.mp3",
12
+ out_ass="subs.ass",
13
+ out_srt="subs.srt",
14
+ )
15
+ print(r["duration"], "segundos")
16
+
17
+ Devuelve un dict con: audio, ass, srt, duration, words, lines, emojis.
18
+ Los archivos quedan listos para que tú montes el video (FFmpeg, MoviePy, etc.).
19
+ """
20
+
21
+ from __future__ import annotations
22
+ import json
23
+ from typing import Optional, Union
24
+
25
+ from .styles import Style, get_style, list_styles # re-export
26
+ from .emojis import EmojiPicker
27
+ from . import tts as _tts
28
+ from . import ass_writer as _ass
29
+ from . import srt_writer as _srt
30
+
31
+
32
+ def chunk_words(words, max_words=3, max_chars=18, total=None):
33
+ """Agrupa palabras en líneas. Devuelve lista de dicts con start/end/words/text."""
34
+ lines = []
35
+ cur = []
36
+ for w in words:
37
+ cur.append(w)
38
+ txt = " ".join(x["word"] for x in cur)
39
+ if len(cur) >= max_words or len(txt) >= max_chars:
40
+ lines.append(cur)
41
+ cur = []
42
+ if cur:
43
+ lines.append(cur)
44
+
45
+ salida = []
46
+ for grupo in lines:
47
+ salida.append({
48
+ "words": grupo,
49
+ "start": grupo[0]["start"],
50
+ "end": grupo[-1]["start"] + grupo[-1]["dur"],
51
+ "text": " ".join(x["word"] for x in grupo),
52
+ })
53
+ # El fin de cada línea = inicio de la siguiente (subtítulos continuos)
54
+ for i in range(len(salida) - 1):
55
+ salida[i]["end"] = salida[i + 1]["start"]
56
+ if salida:
57
+ fin = total if total else (salida[-1]["end"] + 0.4)
58
+ salida[-1]["end"] = max(salida[-1]["end"], fin)
59
+ return salida
60
+
61
+
62
+ class CaptionGenerator:
63
+ def __init__(
64
+ self,
65
+ voice: str = "es-MX-DaliaNeural",
66
+ rate: str = "+0%",
67
+ style: Union[str, Style] = "hormozi",
68
+ emoji: bool = True,
69
+ emoji_max_version: float = 15.0,
70
+ emoji_in_ass: bool = True,
71
+ resolution=(1080, 1920),
72
+ ):
73
+ self.voice = voice
74
+ self.rate = rate
75
+ self.style = get_style(style)
76
+ self.use_emoji = emoji
77
+ self.emoji_in_ass = emoji_in_ass
78
+ self.resolution = resolution
79
+ self._picker = EmojiPicker(emoji_max_version) if emoji else None
80
+
81
+ # -- API principal --
82
+ def generate(
83
+ self,
84
+ text: str,
85
+ out_audio: str = "audio.mp3",
86
+ out_ass: Optional[str] = "subs.ass",
87
+ out_srt: Optional[str] = None,
88
+ out_emojis: Optional[str] = None,
89
+ intro: Optional[str] = None,
90
+ intro_rate: Optional[str] = None,
91
+ ) -> dict:
92
+ """Genera audio + subtítulos sincronizados a partir de `text`.
93
+
94
+ intro: texto opcional que se dice ANTES (p. ej. un gancho), con su propio
95
+ ritmo `intro_rate`. Sus tiempos se integran automáticamente.
96
+ """
97
+ # 1) Voz + tiempos por palabra
98
+ if intro:
99
+ parts = [(intro.strip(), intro_rate or self.rate), (text.strip(), self.rate)]
100
+ words, dur = _tts.synthesize_segments(parts, voice=self.voice, out_path=out_audio)
101
+ else:
102
+ words, dur = _tts.synthesize(text, voice=self.voice, rate=self.rate, out_path=out_audio)
103
+
104
+ if not words:
105
+ raise RuntimeError("El TTS no devolvió palabras. ¿Conexión a internet / voz válida?")
106
+
107
+ # 2) Construir subtítulos a partir de los tiempos por palabra del TTS
108
+ resultado = self.build_from_words(
109
+ words, dur, out_ass=out_ass, out_srt=out_srt, out_emojis=out_emojis,
110
+ )
111
+ resultado["audio"] = out_audio
112
+ return resultado
113
+
114
+ # -- API sin TTS (offline) --
115
+ def build_from_words(
116
+ self,
117
+ words,
118
+ duration: float,
119
+ out_ass: Optional[str] = "subs.ass",
120
+ out_srt: Optional[str] = None,
121
+ out_emojis: Optional[str] = None,
122
+ ) -> dict:
123
+ """Construye los subtítulos a partir de tiempos por palabra ya calculados.
124
+
125
+ No usa TTS ni internet: tú aportas
126
+ ``words = [{"word", "start", "dur"}, ...]`` (en segundos) y la duración
127
+ total. Útil para pruebas, pipelines offline o cuando ya tienes los
128
+ tiempos de otra fuente.
129
+
130
+ Devuelve el mismo dict que ``generate`` (con ``audio=None``).
131
+ """
132
+ if not words:
133
+ raise ValueError("`words` está vacío: no hay nada que subtitular.")
134
+
135
+ # 1) Agrupar en líneas
136
+ lines = chunk_words(words, self.style.max_words, self.style.max_chars, total=duration)
137
+
138
+ # 2) Emojis: por línea (para el .ass) y por palabra (para overlay propio)
139
+ emojis_palabra = []
140
+ if self.use_emoji and self._picker:
141
+ for i, ln in enumerate(lines):
142
+ ln["emoji"] = self._picker.for_phrase(ln["text"], i)
143
+ for w in words:
144
+ emojis_palabra.append({
145
+ "word": w["word"],
146
+ "emoji": self._picker.for_word(w["word"]),
147
+ "start": round(w["start"], 3),
148
+ "dur": round(w["dur"], 3),
149
+ })
150
+
151
+ # 3) Escribir .ass
152
+ if out_ass:
153
+ ass_text = _ass.build_ass(
154
+ lines, self.style, resolution=self.resolution,
155
+ emoji_in_ass=(self.emoji_in_ass and self.use_emoji),
156
+ )
157
+ with open(out_ass, "w", encoding="utf-8") as f:
158
+ f.write(ass_text)
159
+
160
+ # 4) Escribir .srt (opcional)
161
+ if out_srt:
162
+ with open(out_srt, "w", encoding="utf-8") as f:
163
+ f.write(_srt.build_srt(lines, uppercase=self.style.uppercase))
164
+
165
+ # 5) Escribir emojis.json (opcional, para superponer arte propio)
166
+ if out_emojis and self.use_emoji:
167
+ with open(out_emojis, "w", encoding="utf-8") as f:
168
+ json.dump(emojis_palabra, f, ensure_ascii=False, indent=2)
169
+
170
+ return {
171
+ "audio": None,
172
+ "ass": out_ass,
173
+ "srt": out_srt,
174
+ "duration": duration,
175
+ "words": words,
176
+ "lines": [
177
+ {"text": ln["text"], "start": ln["start"], "end": ln["end"],
178
+ "emoji": ln.get("emoji")}
179
+ for ln in lines
180
+ ],
181
+ "emojis": emojis_palabra,
182
+ }
captionwave/emojis.py ADDED
@@ -0,0 +1,161 @@
1
+ """
2
+ Asignación palabra -> emoji.
3
+
4
+ Importante: solo se usan emojis cuya **versión de Unicode** es lo bastante
5
+ antigua como para estar presente en iOS. Esto evita "tofus" (cuadritos) en
6
+ iPhone. El umbral se controla con `max_version` (por defecto 15.0, que cubre
7
+ ampliamente las versiones recientes de iOS).
8
+
9
+ Nota sobre la APARIENCIA "estilo Apple": este módulo entrega el CARÁCTER de
10
+ emoji correcto (un punto de código Unicode), no la imagen de Apple. La imagen
11
+ con la que se ve un emoji la pone el sistema/fuente donde se renderice:
12
+ - En un iPhone/Mac se verá con el diseño de Apple automáticamente.
13
+ - En Linux (p. ej. un servidor) se verá con Noto/Twemoji.
14
+ La librería NO incluye ni descarga el arte propietario de Apple. Si necesitas
15
+ el diseño exacto de Apple en el video, superpón tus propias imágenes usando los
16
+ tiempos del emoji que entrega `generate(...)` (campo `emojis`).
17
+ """
18
+
19
+ from __future__ import annotations
20
+ import unicodedata
21
+ import functools
22
+
23
+ import emoji as _emoji_lib
24
+
25
+
26
+ # Palabras que NO deben disparar la búsqueda de emoji.
27
+ _STOP = {
28
+ "de", "del", "la", "el", "los", "las", "con", "y", "o", "un", "una", "para",
29
+ "por", "en", "a", "al", "que", "se", "su", "sus", "sin", "sobre", "tipo",
30
+ "como", "este", "esta", "ese", "esa", "es", "son", "tiene", "tienen", "fue",
31
+ "ser", "han", "hay", "muy", "mas", "menos", "todo", "toda", "todos", "mismo",
32
+ "cada", "entre", "hasta", "cuando", "donde", "porque", "mucho", "mucha",
33
+ "uno", "dos", "tres", "cuatro", "cinco", "seis", "siete", "ocho", "nueve",
34
+ "diez", "cien", "mil", "veces", "vez", "casi", "tan", "tanto", "cerca",
35
+ }
36
+
37
+ # Diccionario curado: control fino para palabras clave frecuentes.
38
+ CURADOS = {
39
+ "estrella": "⭐", "estrellas": "⭐", "estelar": "⭐",
40
+ "galaxia": "🌌", "galaxias": "🌌", "universo": "🌌", "cosmos": "🌌",
41
+ "tierra": "🌍", "mundo": "🌍", "planeta": "🪐", "planetas": "🪐",
42
+ "agua": "💧", "liquido": "💧", "oceano": "🌊", "mar": "🌊", "ola": "🌊",
43
+ "arbol": "🌳", "arboles": "🌳", "bosque": "🌳", "selva": "🌳", "planta": "🌱",
44
+ "cometa": "☄️", "meteorito": "☄️", "asteroide": "☄️",
45
+ "luz": "💡", "brillo": "💡", "ilumina": "💡",
46
+ "energia": "⚡", "electricidad": "⚡", "rayo": "⚡", "rayos": "⚡",
47
+ "explosion": "💥", "estalla": "💥", "impacto": "💥", "choque": "💥",
48
+ "molecula": "⚛️", "atomo": "⚛️", "particula": "⚛️", "neutron": "⚛️",
49
+ "proton": "⚛️", "electron": "⚛️",
50
+ "quimica": "🧪", "reaccion": "🧪", "experimento": "🔬", "ciencia": "🔬",
51
+ "cientifico": "🔬", "microscopio": "🔬",
52
+ "celula": "🦠", "celulas": "🦠", "virus": "🦠", "bacteria": "🦠", "microbio": "🦠",
53
+ "dinosaurio": "🦕", "dinosaurios": "🦕", "fosil": "🦴",
54
+ "terremoto": "🌋", "sismo": "🌋", "volcan": "🌋",
55
+ "numero": "🔢", "numeros": "🔢", "millones": "🔢", "miles": "🔢",
56
+ "diamante": "💎", "cristal": "💎", "oro": "🪙",
57
+ "velocidad": "⚡", "veloz": "⚡", "rapido": "⚡",
58
+ "calor": "🔥", "caliente": "🔥", "fuego": "🔥",
59
+ "temperatura": "🌡️", "frio": "❄️", "congelado": "❄️", "hielo": "🧊",
60
+ "aire": "💨", "viento": "💨", "gas": "💨",
61
+ "oxigeno": "🫁", "pulmon": "🫁", "pulmones": "🫁",
62
+ "sonido": "🔊", "oido": "👂", "musica": "🎵",
63
+ "computadora": "💻", "ordenador": "💻", "datos": "💻",
64
+ "tiempo": "⏳", "reloj": "⏰", "año": "📅", "años": "📅", "siglo": "📅",
65
+ "cerebro": "🧠", "corazon": "❤️", "sangre": "🩸", "hueso": "🦴",
66
+ "sol": "☀️", "luna": "🌙", "noche": "🌙", "dia": "🌞",
67
+ "dinero": "💰", "ojo": "👀", "ojos": "👀", "mente": "🧠",
68
+ }
69
+
70
+ # Emojis de respaldo cuando no se encuentra nada relevante.
71
+ DEFAULTS = ["✨", "🤯", "💫", "👀", "🔥"]
72
+
73
+
74
+ def _norm(w: str) -> str:
75
+ """minúsculas, sin acentos (conservando ñ), solo letras."""
76
+ w = w.lower().replace("ñ", "\x00")
77
+ w = "".join(c for c in unicodedata.normalize("NFKD", w) if not unicodedata.combining(c))
78
+ w = w.replace("\x00", "ñ")
79
+ return "".join(c for c in w if c.isalpha())
80
+
81
+
82
+ def _es_bandera(ch: str) -> bool:
83
+ return any(0x1F1E6 <= ord(c) <= 0x1F1FF or c == "\U0001F3F4" for c in ch)
84
+
85
+
86
+ @functools.lru_cache(maxsize=8)
87
+ def _build_index(max_version: float):
88
+ """Construye {palabra_norm: emoji} desde la librería emoji.
89
+
90
+ Filtra por versión (compatibilidad iOS), descarta banderas y se queda con
91
+ el nombre más específico para cada palabra.
92
+ """
93
+ idx: dict[str, tuple[str, int]] = {}
94
+ try:
95
+ _emoji_lib.config.load_language("es")
96
+ except Exception:
97
+ pass
98
+ for ch, d in _emoji_lib.EMOJI_DATA.items():
99
+ if d.get("status") != _emoji_lib.STATUS["fully_qualified"]:
100
+ continue
101
+ ver = d.get("E")
102
+ if not isinstance(ver, (int, float)) or ver > max_version:
103
+ continue
104
+ if _es_bandera(ch):
105
+ continue
106
+ nombre = d.get("es") or d.get("en")
107
+ if not nombre:
108
+ continue
109
+ tokens = nombre.strip(":").split("_")
110
+ ntok = len(tokens)
111
+ for t in tokens:
112
+ k = _norm(t)
113
+ if len(k) < 3 or k in _STOP:
114
+ continue
115
+ if k not in idx or ntok < idx[k][1]:
116
+ idx[k] = (ch, ntok)
117
+ return idx
118
+
119
+
120
+ class EmojiPicker:
121
+ """Selecciona emojis para palabras y frases, limitado a iOS."""
122
+
123
+ def __init__(self, max_version: float = 15.0):
124
+ self.max_version = max_version
125
+ self._idx = _build_index(max_version)
126
+
127
+ def for_word(self, palabra: str):
128
+ """Devuelve un emoji para una sola palabra, o None si no aplica."""
129
+ k = _norm(palabra)
130
+ if not k or k in _STOP:
131
+ return None
132
+ if k in CURADOS:
133
+ return CURADOS[k]
134
+ # plurales sencillos
135
+ for suf in ("es", "s"):
136
+ base = k[:-len(suf)]
137
+ if k.endswith(suf) and len(base) >= 3 and base in CURADOS:
138
+ return CURADOS[base]
139
+ if k in self._idx:
140
+ return self._idx[k][0]
141
+ for suf in ("es", "s"):
142
+ base = k[:-len(suf)]
143
+ if k.endswith(suf) and len(base) >= 3 and base in self._idx:
144
+ return self._idx[base][0]
145
+ return None
146
+
147
+ def for_phrase(self, frase: str, fallback_idx: int = 0):
148
+ """Mejor emoji para una frase (busca en sus palabras, de larga a corta)."""
149
+ for w in sorted(frase.split(), key=len, reverse=True):
150
+ e = self.for_word(w)
151
+ if e:
152
+ return e
153
+ return DEFAULTS[fallback_idx % len(DEFAULTS)]
154
+
155
+ def is_ios_safe(self, ch: str) -> bool:
156
+ """True si el emoji está dentro de la versión permitida (iOS)."""
157
+ d = _emoji_lib.EMOJI_DATA.get(ch)
158
+ if not d:
159
+ return False
160
+ ver = d.get("E")
161
+ return isinstance(ver, (int, float)) and ver <= self.max_version
@@ -0,0 +1,32 @@
1
+ """
2
+ Escritura de .srt (respaldo de compatibilidad).
3
+
4
+ SRT no admite animaciones ni karaoke: son bloques de texto con tiempo. Sirve
5
+ para reproductores/plataformas que no aceptan .ass. La sincronización por línea
6
+ se mantiene.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ def _fmt(seconds: float) -> str:
13
+ if seconds < 0:
14
+ seconds = 0
15
+ ms = int(round(seconds * 1000))
16
+ h, ms = divmod(ms, 3600000)
17
+ m, ms = divmod(ms, 60000)
18
+ s, ms = divmod(ms, 1000)
19
+ return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
20
+
21
+
22
+ def build_srt(lines, uppercase: bool = True) -> str:
23
+ out = []
24
+ for i, ln in enumerate(lines, 1):
25
+ text = ln["text"]
26
+ if uppercase:
27
+ text = text.upper()
28
+ out.append(str(i))
29
+ out.append(f"{_fmt(ln['start'])} --> {_fmt(ln['end'])}")
30
+ out.append(text)
31
+ out.append("")
32
+ return "\n".join(out) + "\n"
captionwave/styles.py ADDED
@@ -0,0 +1,153 @@
1
+ """
2
+ Estilos de subtítulos.
3
+
4
+ Cada estilo combina:
5
+ - una `animation` (cómo se mueve/resalta la palabra activa)
6
+ - colores, fuente, tamaño, posición
7
+
8
+ Los colores se escriben en HEX normal ("#RRGGBB") y se convierten al
9
+ formato de ASS internamente. Puedes usar un preset tal cual o copiarlo y
10
+ cambiarle cualquier campo.
11
+
12
+ Animaciones disponibles:
13
+ - "karaoke" -> barrido tipo karaoke (color tenue -> color activo)
14
+ - "active_color" -> solo la palabra que se está diciendo cambia de color
15
+ - "active_pop" -> la palabra activa crece (rebote) y cambia de color
16
+ - "active_sticker" -> la palabra activa recibe un "sticker" de fondo (estilo Hormozi)
17
+ - "single_word" -> una palabra a la vez, grande y centrada
18
+ - "fade" -> la frase aparece con fade + palabra activa en color
19
+ """
20
+
21
+ from __future__ import annotations
22
+ from dataclasses import dataclass, replace
23
+ from typing import Optional
24
+
25
+
26
+ def hex_to_ass(color: str, alpha: int = 0) -> str:
27
+ """Convierte '#RRGGBB' al formato de color de ASS '&HAABBGGRR'.
28
+
29
+ alpha: 0 = opaco, 255 = totalmente transparente.
30
+ """
31
+ c = color.lstrip("#")
32
+ if len(c) == 3:
33
+ c = "".join(ch * 2 for ch in c)
34
+ r = int(c[0:2], 16)
35
+ g = int(c[2:4], 16)
36
+ b = int(c[4:6], 16)
37
+ return f"&H{alpha:02X}{b:02X}{g:02X}{r:02X}"
38
+
39
+
40
+ @dataclass
41
+ class Style:
42
+ name: str = "custom"
43
+ animation: str = "active_color" # ver lista arriba
44
+
45
+ # Tipografía
46
+ font: str = "DejaVu Sans" # nombre de la fuente (debe existir en el sistema que renderiza)
47
+ font_file: Optional[str] = None # ruta opcional al .ttf (útil para fijar la fuente al render)
48
+ font_size: int = 84
49
+ bold: bool = True
50
+
51
+ # Colores
52
+ base_color: str = "#FFFFFF" # color normal del texto
53
+ active_color: str = "#FFD23F" # color de la palabra activa
54
+ outline_color: str = "#000000" # contorno
55
+ outline_w: int = 5
56
+ shadow: int = 0
57
+
58
+ # Solo para animation="active_sticker"
59
+ sticker_color: str = "#FFD23F" # color del "sticker" de fondo
60
+ sticker_text_color: str = "#000000" # color del texto cuando está sobre el sticker
61
+ sticker_bord: int = 18 # grosor del sticker
62
+
63
+ # Solo para animation="active_pop" / "single_word"
64
+ pop_scale: int = 130 # % de escala al hacer "pop"
65
+
66
+ # Texto
67
+ uppercase: bool = True
68
+ max_words: int = 3 # máximo de palabras por línea
69
+ max_chars: int = 18 # corte adicional por longitud
70
+
71
+ # Posición: "center" (medio de la pantalla), "lower" (abajo), "upper" (arriba)
72
+ position: str = "center"
73
+ margin_v: int = 260 # margen vertical cuando position != center
74
+
75
+ def copy(self, **cambios) -> "Style":
76
+ """Devuelve una copia del estilo con los campos indicados modificados."""
77
+ return replace(self, **cambios)
78
+
79
+
80
+ # --------------------------------------------------------------------------
81
+ # PRESETS (inspirados en las opciones de revid.ai)
82
+ # --------------------------------------------------------------------------
83
+ PRESETS = {
84
+ # Clásico de Shorts/Reels: palabra activa con "sticker" amarillo.
85
+ "hormozi": Style(
86
+ name="hormozi", animation="active_sticker",
87
+ base_color="#FFFFFF", active_color="#FFD23F",
88
+ sticker_color="#FFD23F", sticker_text_color="#101010",
89
+ font_size=88, outline_w=6, uppercase=True, max_words=3,
90
+ ),
91
+ # Barrido karaoke: el texto se "llena" de color al ritmo de la voz.
92
+ "karaoke": Style(
93
+ name="karaoke", animation="karaoke",
94
+ base_color="#FFFFFF", active_color="#FFD23F",
95
+ font_size=82, outline_w=5, uppercase=True, max_words=4,
96
+ ),
97
+ # La palabra activa crece con un rebote.
98
+ "pop": Style(
99
+ name="pop", animation="active_pop",
100
+ base_color="#FFFFFF", active_color="#FFD23F",
101
+ pop_scale=134, font_size=84, outline_w=5, uppercase=True, max_words=3,
102
+ ),
103
+ # Una sola palabra a la vez, grande y centrada.
104
+ "single": Style(
105
+ name="single", animation="single_word",
106
+ base_color="#FFFFFF", active_color="#FFD23F",
107
+ pop_scale=118, font_size=120, outline_w=7, uppercase=True,
108
+ ),
109
+ # Neón: contorno de color y palabra activa cian.
110
+ "neon": Style(
111
+ name="neon", animation="active_color",
112
+ base_color="#FFFFFF", active_color="#27E1FF",
113
+ outline_color="#0066FF", outline_w=5, font_size=82, uppercase=True, max_words=3,
114
+ ),
115
+ # Sticker verde.
116
+ "green": Style(
117
+ name="green", animation="active_sticker",
118
+ base_color="#FFFFFF", active_color="#27E36B",
119
+ sticker_color="#27E36B", sticker_text_color="#08240F",
120
+ font_size=86, outline_w=6, uppercase=True, max_words=3,
121
+ ),
122
+ # "Fuego": palabra activa naranja con pop.
123
+ "fire": Style(
124
+ name="fire", animation="active_pop",
125
+ base_color="#FFFFFF", active_color="#FF5630",
126
+ pop_scale=132, font_size=84, outline_w=5, uppercase=True, max_words=3,
127
+ ),
128
+ # Limpio: parecido a unos subtítulos blancos con contorno (estilo simple).
129
+ "clean": Style(
130
+ name="clean", animation="active_color",
131
+ base_color="#FFFFFF", active_color="#FFE9A8",
132
+ outline_color="#000000", outline_w=5, font_size=78,
133
+ uppercase=True, max_words=3, position="lower",
134
+ ),
135
+ }
136
+
137
+
138
+ def get_style(estilo) -> Style:
139
+ """Acepta un nombre de preset (str) o un objeto Style y devuelve un Style."""
140
+ if isinstance(estilo, Style):
141
+ return estilo
142
+ if isinstance(estilo, str):
143
+ key = estilo.lower()
144
+ if key not in PRESETS:
145
+ disponibles = ", ".join(sorted(PRESETS))
146
+ raise ValueError(f"Estilo '{estilo}' no existe. Disponibles: {disponibles}")
147
+ return PRESETS[key].copy()
148
+ raise TypeError("estilo debe ser un nombre (str) o un objeto Style")
149
+
150
+
151
+ def list_styles() -> list[str]:
152
+ """Lista los nombres de los presets disponibles."""
153
+ return sorted(PRESETS)
captionwave/tts.py ADDED
@@ -0,0 +1,157 @@
1
+ """
2
+ Generación de voz (TTS) con tiempos por palabra.
3
+
4
+ Usa edge-tts. La clave de la sincronización es que el motor devuelve eventos
5
+ `WordBoundary` con el offset y duración EXACTOS de cada palabra dentro del audio
6
+ que se está generando. Por eso los subtítulos quedan pegados al audio: ambos
7
+ salen de la misma fuente.
8
+
9
+ Requiere conexión a internet (endpoint de Microsoft Edge TTS).
10
+ """
11
+
12
+ from __future__ import annotations
13
+ import asyncio
14
+ import os
15
+
16
+ import edge_tts
17
+
18
+
19
+ def _run_sync(coro):
20
+ """Ejecuta una corrutina de forma síncrona, también dentro de Jupyter/Colab.
21
+
22
+ En un script normal basta con ``asyncio.run``. Pero en notebooks (Google
23
+ Colab, Jupyter) ya hay un event loop corriendo y ``asyncio.run`` fallaría
24
+ con "cannot be called from a running event loop"; en ese caso la corrutina
25
+ se ejecuta en un hilo aparte con su propio loop.
26
+ """
27
+ try:
28
+ asyncio.get_running_loop()
29
+ except RuntimeError:
30
+ return asyncio.run(coro) # no hay loop activo: caso normal (script)
31
+
32
+ import threading
33
+
34
+ box = {}
35
+
36
+ def _worker():
37
+ try:
38
+ box["value"] = asyncio.run(coro)
39
+ except BaseException as exc: # se re-lanza en el hilo principal
40
+ box["error"] = exc
41
+
42
+ t = threading.Thread(target=_worker)
43
+ t.start()
44
+ t.join()
45
+ if "error" in box:
46
+ raise box["error"]
47
+ return box["value"]
48
+
49
+
50
+ def _try_duration(path: str):
51
+ """Intenta leer la duración real del MP3 con mutagen, si está instalado."""
52
+ try:
53
+ from mutagen.mp3 import MP3 # opcional
54
+ return float(MP3(path).info.length)
55
+ except Exception:
56
+ return None
57
+
58
+
59
+ def _make_communicate(text: str, voice: str, rate: str):
60
+ """Crea un Communicate pidiendo eventos por palabra (WordBoundary).
61
+
62
+ edge-tts >= 7 cambió el valor por defecto del parámetro ``boundary`` a
63
+ "SentenceBoundary"; sin "WordBoundary" no llegan los tiempos por palabra y
64
+ los subtítulos saldrían vacíos. En edge-tts < 7 ese parámetro no existe (y
65
+ el valor por defecto ya era WordBoundary), así que se omite.
66
+ """
67
+ try:
68
+ return edge_tts.Communicate(text, voice, rate=rate, boundary="WordBoundary")
69
+ except TypeError:
70
+ return edge_tts.Communicate(text, voice, rate=rate)
71
+
72
+
73
+ async def _stream_one(text: str, voice: str, rate: str, fh):
74
+ """Escribe el audio de un segmento en el handle abierto `fh` y devuelve sus palabras."""
75
+ com = _make_communicate(text, voice, rate)
76
+ words = []
77
+ async for chunk in com.stream():
78
+ if chunk["type"] == "audio":
79
+ fh.write(chunk["data"])
80
+ elif chunk["type"] == "WordBoundary":
81
+ words.append({
82
+ "word": chunk["text"],
83
+ "start": chunk["offset"] / 1e7,
84
+ "dur": max(chunk["duration"] / 1e7, 0.02),
85
+ })
86
+ return words
87
+
88
+
89
+ async def _run(parts, voice, out_path):
90
+ """parts = [(text, rate), ...] -> audio concatenado + palabras con offset acumulado."""
91
+ todas = []
92
+ cursor = 0.0
93
+ with open(out_path, "wb") as fh:
94
+ for (text, rate) in parts:
95
+ ws = await _stream_one(text, voice, rate, fh)
96
+ for w in ws:
97
+ todas.append({"word": w["word"], "start": w["start"] + cursor, "dur": w["dur"]})
98
+ # avanzar el cursor por la duración de este segmento
99
+ if ws:
100
+ cursor = todas[-1]["start"] + todas[-1]["dur"]
101
+ return todas
102
+
103
+
104
+ def synthesize(text: str, voice: str = "es-MX-DaliaNeural",
105
+ rate: str = "+0%", out_path: str = "audio.mp3"):
106
+ """Genera audio de un texto y devuelve (words, duration).
107
+
108
+ words: lista de dicts {word, start, dur} en segundos.
109
+ duration: duración total estimada del audio (s).
110
+ """
111
+ return synthesize_segments([(text, rate)], voice=voice, out_path=out_path)
112
+
113
+
114
+ def synthesize_segments(parts, voice: str = "es-MX-DaliaNeural",
115
+ out_path: str = "audio.mp3"):
116
+ """Igual que synthesize, pero une varios segmentos (p. ej. intro + cuerpo).
117
+
118
+ parts: lista de tuplas (texto, rate). Ej: [("¿Sabías que...?", "+25%"),
119
+ ("el sol es una estrella", "+18%")]
120
+ """
121
+ try:
122
+ words = _run_sync(_run(list(parts), voice, out_path))
123
+ except Exception as e:
124
+ # No dejes un .mp3 vacío/parcial tras un fallo.
125
+ try:
126
+ if os.path.exists(out_path) and os.path.getsize(out_path) == 0:
127
+ os.remove(out_path)
128
+ except OSError:
129
+ pass
130
+ raise RuntimeError(
131
+ "No se pudo generar la voz con edge-tts. Comprueba:\n"
132
+ " • que tengas conexión a internet (edge-tts usa el servicio de "
133
+ "Microsoft Edge),\n"
134
+ " • que ninguna red/proxy/firewall bloquee speech.platform.bing.com,\n"
135
+ f" • que la voz exista (voz='{voice}'; lista: 'edge-tts --list-voices').\n"
136
+ f"Detalle técnico: {type(e).__name__}: {e}"
137
+ ) from e
138
+
139
+ if not words:
140
+ dur = _try_duration(out_path) or 0.0
141
+ return words, dur
142
+
143
+ last_end = words[-1]["start"] + words[-1]["dur"]
144
+ real = _try_duration(out_path)
145
+
146
+ if real and last_end > 0.3:
147
+ # Reescalar suavemente para alinear el último subtítulo con el fin real
148
+ factor = real / last_end
149
+ if 0.85 <= factor <= 1.15:
150
+ for w in words:
151
+ w["start"] *= factor
152
+ w["dur"] = max(w["dur"] * factor, 0.02)
153
+ dur = real
154
+ else:
155
+ dur = last_end + 0.4 # colchón final
156
+
157
+ return words, dur
@@ -0,0 +1,269 @@
1
+ Metadata-Version: 2.4
2
+ Name: captionwave
3
+ Version: 0.1.0
4
+ Summary: Genera audio + subtítulos animados sincronizados (.ass/.srt) a partir de texto, con emojis compatibles con iOS.
5
+ Author-email: Wilfredo Guillén <wilfredoguillensalazar@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Fortex-GT/Captionwave
8
+ Project-URL: Repository, https://github.com/Fortex-GT/Captionwave
9
+ Project-URL: Issues, https://github.com/Fortex-GT/Captionwave/issues
10
+ Keywords: subtitles,captions,tts,ass,shorts,reels,tiktok,karaoke,emoji
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Topic :: Multimedia :: Video
22
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: edge-tts>=7.0
27
+ Requires-Dist: emoji>=2.8
28
+ Provides-Extra: duration
29
+ Requires-Dist: mutagen>=1.45; extra == "duration"
30
+ Provides-Extra: video
31
+ Requires-Dist: moviepy>=2.0; extra == "video"
32
+ Provides-Extra: test
33
+ Requires-Dist: pytest>=7; extra == "test"
34
+ Dynamic: license-file
35
+
36
+ # captionwave
37
+
38
+ Genera **audio (voz)** y **subtítulos animados sincronizados** (`.ass` + `.srt`) a partir de una variable de texto. Pensado para Shorts / Reels / TikTok estilo "¿Sabías que…?".
39
+
40
+ La librería **no arma el video final**: te entrega los archivos (audio + subtítulos + emojis con tiempos) para que tú montes el video como prefieras (FFmpeg, tu editor, MoviePy…). Así tienes control total y no reprogramas los subtítulos en cada proyecto.
41
+
42
+ - ✅ Voz con **edge-tts** y tiempos **por palabra** → el audio y los subtítulos quedan pegados *por construcción* (no se desfasan).
43
+ - ✅ Subtítulos animados en **`.ass`** (karaoke, palabra activa, "pop", sticker estilo Hormozi, palabra-por-palabra…) que **FFmpeg/libass renderiza de forma nativa** (rápido).
44
+ - ✅ `.srt` de respaldo para plataformas que no aceptan `.ass`.
45
+ - ✅ **Emojis filtrados a los que existen en iOS** (sin "tofus"/cuadritos), con sus tiempos exportados para superponer tu propio arte.
46
+
47
+ ---
48
+
49
+ ## Instalación
50
+
51
+ ```bash
52
+ pip install captionwave # una vez publicado en PyPI
53
+ ```
54
+
55
+ Mientras tanto —o en notebooks como Google Colab— instálalo desde GitHub:
56
+
57
+ ```bash
58
+ pip install "git+https://github.com/Fortex-GT/Captionwave.git"
59
+ ```
60
+
61
+ O desde el código fuente (este repositorio):
62
+
63
+ ```bash
64
+ pip install . # o, para desarrollo: pip install -e .
65
+ ```
66
+
67
+ Requisitos:
68
+ - **Conexión a internet** para la voz (edge-tts usa el servicio de Microsoft Edge). Si solo quieres los subtítulos a partir de tiempos que ya tienes, puedes trabajar sin red con `build_from_words(...)` (ver más abajo y `examples/offline_sin_internet.py`).
69
+ - **FFmpeg** instalado si vas a quemar los subtítulos en el video (`ffmpeg -version`).
70
+ - Opcional, recomendado: `pip install captionwave[duration]` (usa *mutagen* para medir con exactitud la duración del audio y alinear el último subtítulo).
71
+
72
+ ---
73
+
74
+ ## Uso rápido
75
+
76
+ ```python
77
+ from captionwave import CaptionGenerator
78
+
79
+ gen = CaptionGenerator(
80
+ voice="es-MX-DaliaNeural", # cualquier voz de edge-tts
81
+ rate="+18%", # más rápido = más dinámico
82
+ style="hormozi", # ver estilos abajo
83
+ )
84
+
85
+ r = gen.generate(
86
+ "El Sol es una estrella que contiene el 99% de la masa del sistema solar.",
87
+ out_audio="voz.mp3",
88
+ out_ass="subs.ass",
89
+ out_srt="subs.srt", # opcional
90
+ out_emojis="emojis.json", # opcional (para superponer arte de emoji)
91
+ )
92
+
93
+ print(r["duration"], "segundos")
94
+ ```
95
+
96
+ Esto crea `voz.mp3`, `subs.ass`, `subs.srt` y `emojis.json`, listos para montar.
97
+
98
+ ### Con un gancho/intro a otro ritmo
99
+
100
+ ```python
101
+ r = gen.generate(
102
+ "el sol es una estrella enorme.",
103
+ intro="¿Sabías que...?", # se dice antes
104
+ intro_rate="+5%", # el gancho un poco más pausado
105
+ out_audio="voz.mp3", out_ass="subs.ass",
106
+ )
107
+ ```
108
+
109
+ ### Sin internet (a partir de tiempos que ya tienes)
110
+
111
+ Si ya tienes los tiempos por palabra (de otra fuente o para hacer pruebas),
112
+ puedes generar los subtítulos **sin TTS ni conexión** con `build_from_words`:
113
+
114
+ ```python
115
+ from captionwave import CaptionGenerator
116
+
117
+ words = [
118
+ {"word": "El", "start": 0.00, "dur": 0.18},
119
+ {"word": "Sol", "start": 0.18, "dur": 0.34},
120
+ {"word": "es", "start": 0.52, "dur": 0.16},
121
+ {"word": "una", "start": 0.68, "dur": 0.18},
122
+ {"word": "estrella", "start": 0.86, "dur": 0.52},
123
+ ]
124
+
125
+ gen = CaptionGenerator(style="hormozi")
126
+ r = gen.build_from_words(words, duration=1.5, out_ass="subs.ass", out_srt="subs.srt")
127
+ ```
128
+
129
+ Devuelve el mismo `dict` que `generate` (con `audio=None`). Ver `examples/offline_sin_internet.py`.
130
+
131
+ ---
132
+
133
+ ## Estilos disponibles
134
+
135
+ ![Muestra de estilos](estilos_muestra.png)
136
+
137
+ *(Palabra activa "ESTRELLA" resaltada en 4 de los estilos. El resaltado avanza palabra por palabra al ritmo de la voz.)*
138
+
139
+ ```python
140
+ from captionwave import list_styles
141
+ print(list_styles())
142
+ ```
143
+
144
+ | Estilo | Animación | Descripción |
145
+ |-------------|------------------|-------------|
146
+ | `hormozi` | sticker amarillo | Palabra activa con fondo sólido (clásico de Shorts). |
147
+ | `green` | sticker verde | Igual que hormozi pero en verde. |
148
+ | `karaoke` | barrido | El texto se "llena" de color al ritmo de la voz. |
149
+ | `pop` | rebote | La palabra activa crece y cambia de color. |
150
+ | `fire` | rebote naranja | Variante de `pop` en tono fuego. |
151
+ | `neon` | color + glow | Palabra activa cian con contorno azul. |
152
+ | `single` | palabra única | Una sola palabra a la vez, grande y centrada. |
153
+ | `clean` | color suave | Subtítulo abajo, sobrio (lower third). |
154
+
155
+ ### Personalizar cualquier estilo
156
+
157
+ Cada estilo es un `Style` que puedes copiar y modificar:
158
+
159
+ ```python
160
+ from captionwave import get_style, CaptionGenerator
161
+
162
+ mi_estilo = get_style("hormozi").copy(
163
+ active_color="#FF3366",
164
+ sticker_color="#FF3366",
165
+ sticker_text_color="#FFFFFF",
166
+ font="Montserrat", # debe estar instalada en el sistema que renderiza
167
+ font_size=96,
168
+ max_words=2, # menos palabras por línea
169
+ position="lower", # "center" | "lower" | "upper"
170
+ )
171
+
172
+ gen = CaptionGenerator(style=mi_estilo, rate="+20%")
173
+ ```
174
+
175
+ Campos útiles de `Style`: `base_color`, `active_color`, `outline_color`, `outline_w`, `sticker_color`, `sticker_text_color`, `sticker_bord`, `pop_scale`, `font`, `font_size`, `uppercase`, `max_words`, `max_chars`, `position`.
176
+
177
+ ---
178
+
179
+ ## Montar el video con FFmpeg
180
+
181
+ La librería te da `voz.mp3` + `subs.ass`. Para quemar los subtítulos sobre un fondo:
182
+
183
+ **Sobre un video de fondo** (gameplay, b-roll, etc.):
184
+ ```bash
185
+ ffmpeg -i fondo.mp4 -i voz.mp3 \
186
+ -vf "scale=1080:1920,ass=subs.ass" \
187
+ -map 0:v -map 1:a -shortest salida.mp4
188
+ ```
189
+
190
+ **Sobre una imagen fija**:
191
+ ```bash
192
+ ffmpeg -loop 1 -i fondo.jpg -i voz.mp3 \
193
+ -vf "scale=1080:1920,ass=subs.ass" \
194
+ -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest salida.mp4
195
+ ```
196
+
197
+ > El `.ass` ya trae la resolución (1080×1920 por defecto). Si cambias la resolución usa `resolution=(W, H)` en `CaptionGenerator`.
198
+
199
+ ---
200
+
201
+ ## ⚠️ Sobre los emojis (léelo)
202
+
203
+ La librería elige, para cada palabra/frase, un **emoji que sí existe en iOS** (filtra por versión de Unicode; ajustable con `emoji_max_version`). Esto evita que en un iPhone aparezcan cuadritos.
204
+
205
+ Dos cosas importantes sobre la **apariencia**:
206
+
207
+ 1. **No se incluye el arte de Apple.** Los emojis de Apple son propiedad de Apple y no se pueden empaquetar/redistribuir. La librería entrega el **carácter Unicode** correcto; el diseño con el que se ve lo pone el sistema donde se reproduce (en iPhone/Mac se verá con el estilo de Apple; en Linux con Noto/Twemoji).
208
+
209
+ 2. **libass no garantiza emojis a color.** Al quemar el `.ass` con FFmpeg, los emojis suelen salir **monocromos**. Por eso, si quieres el emoji a color (y con el look de Apple), la mejor ruta es **superponerlo como imagen** en tu editor o con FFmpeg, usando los tiempos que te da la librería:
210
+
211
+ ```python
212
+ r = gen.generate("...", out_emojis="emojis.json")
213
+ for e in r["emojis"]:
214
+ print(e) # {"word": "estrella", "emoji": "⭐", "start": 1.38, "dur": 0.58}
215
+ ```
216
+
217
+ Con eso colocas tu PNG de emoji (que tú aportas) en `start` durante `dur`. Así el carácter sale de la librería y el arte lo pones tú, sin problemas de copyright.
218
+
219
+ Si prefieres incrustar el carácter directamente en el `.ass` de todas formas, está activado por defecto (`emoji_in_ass=True`); ponlo en `False` si vas a usar overlay.
220
+
221
+ ---
222
+
223
+ ## Qué devuelve `generate(...)`
224
+
225
+ Un `dict` con:
226
+
227
+ ```python
228
+ {
229
+ "audio": "voz.mp3",
230
+ "ass": "subs.ass",
231
+ "srt": "subs.srt" | None,
232
+ "duration": 6.37, # segundos
233
+ "words": [{"word","start","dur"}, ...],
234
+ "lines": [{"text","start","end","emoji"}, ...],
235
+ "emojis": [{"word","emoji","start","dur"}, ...],
236
+ }
237
+ ```
238
+
239
+ ---
240
+
241
+ ## Desarrollo y tests
242
+
243
+ ```bash
244
+ pip install -e ".[test]"
245
+ pytest
246
+ ```
247
+
248
+ Los tests **no necesitan internet** (no llaman al TTS): cubren los estilos, el
249
+ troceado en líneas, la selección de emojis y la escritura de `.ass`/`.srt`.
250
+
251
+ ---
252
+
253
+ ## Publicar tu propia copia
254
+
255
+ > **Antes de subir a PyPI, verifica que el nombre `captionwave` esté libre** en https://pypi.org. Si no lo está, renómbralo: cambia la carpeta `src/captionwave/` y el campo `name` de `pyproject.toml`.
256
+
257
+ ```bash
258
+ pip install build twine
259
+ python -m build
260
+ twine upload dist/*
261
+ ```
262
+
263
+ Para GitHub solo inicializa el repo y súbelo normal.
264
+
265
+ ---
266
+
267
+ ## Licencia
268
+
269
+ MIT © 2026 Wilfredo Guillén — ver [LICENSE](LICENSE).
@@ -0,0 +1,12 @@
1
+ captionwave/__init__.py,sha256=0ZqIVOn2XdUKWaG_lAkhC61yNHJD3IctIrQqdSYkR_w,565
2
+ captionwave/ass_writer.py,sha256=Fuc8ACXrw8gkau6XzBbqhjvIMsCMcJPLC-oLIo31IRM,7441
3
+ captionwave/core.py,sha256=w7gKhOkCGU2MsoqldhtN3tDW3e-w9KydT4mzcfSuPTM,6388
4
+ captionwave/emojis.py,sha256=eEsSaQSG080dSGfGGx8A_fO3j5ve-Bq8doljo8bNZuE,6963
5
+ captionwave/srt_writer.py,sha256=CrEnbrQ7MadyH2mKjyLdq87XIDHi26Mkb-K4TZsIlBE,873
6
+ captionwave/styles.py,sha256=uIEP5_qaMSUUKx2fLFS5GXjhWcaoF6JkkhEpBhAT0Vg,5925
7
+ captionwave/tts.py,sha256=uxQvWQot3SSDbXvWrxU5QJcQdBOvObVMqER_riYO2kM,5506
8
+ captionwave-0.1.0.dist-info/licenses/LICENSE,sha256=ib_54mIkMoqZxNXDMHLVp3AnLTwnsbZjhSDQVNsDp7A,1074
9
+ captionwave-0.1.0.dist-info/METADATA,sha256=kKtavIYW8ngCwbFa6S6MRh5Bx7LnAQvHvTu8mGn-31w,9921
10
+ captionwave-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ captionwave-0.1.0.dist-info/top_level.txt,sha256=5E9diZeqVkzuSEt6eV_e-KgMwNFaUmI1eYJPKLht4ac,12
12
+ captionwave-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wilfredo Guillén
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ captionwave