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.
- captionwave/__init__.py +22 -0
- captionwave/ass_writer.py +209 -0
- captionwave/core.py +182 -0
- captionwave/emojis.py +161 -0
- captionwave/srt_writer.py +32 -0
- captionwave/styles.py +153 -0
- captionwave/tts.py +157 -0
- captionwave-0.1.0.dist-info/METADATA +269 -0
- captionwave-0.1.0.dist-info/RECORD +12 -0
- captionwave-0.1.0.dist-info/WHEEL +5 -0
- captionwave-0.1.0.dist-info/licenses/LICENSE +21 -0
- captionwave-0.1.0.dist-info/top_level.txt +1 -0
captionwave/__init__.py
ADDED
|
@@ -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
|
+

|
|
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,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
|