forked from kamilstanuch/Autocrop-vertical
-
Notifications
You must be signed in to change notification settings - Fork 747
Expand file tree
/
Copy pathsubtitles.py
More file actions
507 lines (416 loc) · 18.5 KB
/
Copy pathsubtitles.py
File metadata and controls
507 lines (416 loc) · 18.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
import os
import re
import subprocess
import sys
from ffmpeg_utils import video_encode_args, QUALITY
_STDIO_CONFIGURED = False
# Shared faster-whisper config so both transcription paths (this module and
# main.transcribe_video) behave identically. "small" is meaningfully better at
# German than "base" without being much slower on CPU.
DEFAULT_WHISPER_MODEL = "small"
def get_whisper_config():
"""Return the faster-whisper model config, overridable via env vars."""
return {
"model_size": os.environ.get("WHISPER_MODEL", DEFAULT_WHISPER_MODEL),
"device": os.environ.get("WHISPER_DEVICE", "cpu"),
"compute_type": os.environ.get("WHISPER_COMPUTE", "int8"),
}
# Decode params shared by both transcription paths. condition_on_previous_text
# is off to avoid repetition/hallucination loops; vad_filter drops silence.
WHISPER_TRANSCRIBE_PARAMS = {
"beam_size": 5,
"vad_filter": True,
"condition_on_previous_text": False,
"word_timestamps": True,
}
def merge_continuation_words(words):
"""Merge faster-whisper continuation fragments into their base word.
faster-whisper marks a word boundary with a LEADING SPACE on each token.
Compound-word fragments (e.g. "-Kanal.", ".200") arrive WITHOUT a leading
space and belong to the preceding word. Without merging, "YouTube" and
"-Kanal." get space-joined into "YouTube -Kanal." or split across subtitle
blocks. We concatenate such fragments onto the previous word and extend its
end time. Normal words keep their leading space, so real word boundaries
(e.g. "ich habe") are never glued together.
Returns a new list; the input dicts are not mutated.
"""
merged = []
for word in words:
text = word.get("word", "")
if merged and isinstance(text, str) and text and not text.startswith(" "):
prev = merged[-1]
prev["word"] = f"{prev.get('word', '')}{text}"
if word.get("end") is not None:
prev["end"] = word["end"]
else:
merged.append(dict(word))
return merged
def _configure_stdio():
global _STDIO_CONFIGURED
if _STDIO_CONFIGURED:
return
_STDIO_CONFIGURED = True
for stream_name in ("stdout", "stderr"):
stream = getattr(sys, stream_name, None)
if not stream or not hasattr(stream, "reconfigure"):
continue
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
def _log(message):
_configure_stdio()
stream = sys.stdout
text = str(message)
try:
stream.write(text + "\n")
except UnicodeEncodeError:
encoding = getattr(stream, "encoding", None) or "utf-8"
safe_text = text.encode(encoding, errors="replace").decode(encoding, errors="replace")
stream.write(safe_text + "\n")
stream.flush()
def _escape_ffmpeg_filter_value(value):
"""Escape a path/value for use inside a quoted FFmpeg filter argument."""
return value.replace('\\', '/').replace(':', '\\:').replace("'", "\\'")
def _normalize_subtitle_word(value):
return " ".join(str(value or "").split())
def transcribe_audio(video_path):
"""
Transcribe audio from a video file via the configured ASR backend.
Returns transcript in the same format as main.py for compatibility.
"""
# Lazy import: transcribe_backends imports helpers from this module.
from transcribe_backends import transcribe_media
_log(f"🎙️ Transcribing audio from: {video_path}")
transcript = transcribe_media(video_path)
_log(f"✅ Transcription complete. Language: {transcript['language']}")
return transcript
def generate_srt_from_video(video_path, output_path, max_chars=20, max_duration=2.0,
style="classic", **style_opts):
"""
Transcribe a video and generate a subtitle file directly (SRT, or karaoke
ASS when style="karaoke"). Used for dubbed videos without a transcript.
"""
transcript = transcribe_audio(video_path)
# Get video duration to use as clip_end
import cv2
cap = cv2.VideoCapture(video_path)
fps = cap.get(cv2.CAP_PROP_FPS)
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
duration = frame_count / fps if fps else 0
cap.release()
if style == "karaoke":
return generate_ass(transcript, 0, duration, output_path, max_chars, max_duration, **style_opts)
return generate_srt(transcript, 0, duration, output_path, max_chars, max_duration)
def _collect_word_blocks(transcript, clip_start, clip_end, max_chars=20, max_duration=2.0):
"""
Flatten transcript words for a clip range and group them into short blocks
suitable for vertical video. Returns a list of blocks; each block is a list
of {'word', 'start', 'end'} dicts with times relative to the clip.
Continuation fragments are merged defensively here too, because transcripts
from old jobs on disk store unmerged tokens (the leading space is still
present, so the boundary signal survives).
"""
flat_words = []
for segment in transcript.get('segments', []):
flat_words.extend(segment.get('words', []))
flat_words = merge_continuation_words(flat_words)
words = []
for word_info in flat_words:
if word_info.get('end', 0) > clip_start and word_info.get('start', 0) < clip_end:
cleaned_word = _normalize_subtitle_word(word_info.get('word', ''))
if not cleaned_word:
continue
words.append({
'word': cleaned_word,
'start': max(0, word_info['start'] - clip_start),
'end': max(0, word_info['end'] - clip_start),
})
blocks = []
current_block = []
block_start = None
for word in words:
if not current_block:
current_block = [word]
block_start = word['start']
continue
current_text_len = sum(len(w['word']) + 1 for w in current_block)
duration = word['end'] - block_start
if current_text_len + len(word['word']) > max_chars or duration > max_duration:
blocks.append(current_block)
current_block = [word]
block_start = word['start']
else:
current_block.append(word)
if current_block:
blocks.append(current_block)
return blocks
def generate_srt(transcript, clip_start, clip_end, output_path, max_chars=20, max_duration=2.0):
"""
Generates an SRT file from the transcript for a specific time range.
Groups words into short lines suitable for vertical video.
"""
blocks = _collect_word_blocks(transcript, clip_start, clip_end, max_chars, max_duration)
if not blocks:
return False
srt_content = ""
for index, block in enumerate(blocks, 1):
text = " ".join(w['word'] for w in block).strip()
srt_content += format_srt_block(index, block[0]['start'], block[-1]['end'], text)
# Write UTF-8 with BOM so Windows/FFmpeg subtitle readers reliably detect Unicode text.
with open(output_path, 'w', encoding='utf-8-sig') as f:
f.write(srt_content)
return True
def _ass_time(seconds):
"""Format seconds as ASS timestamp H:MM:SS.cc (centiseconds)."""
seconds = max(0, seconds)
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
centis = int(round((seconds - int(seconds)) * 100))
if centis >= 100:
centis = 99
return f"{hours}:{minutes:02d}:{secs:02d}.{centis:02d}"
def _hex_to_ass_inline_color(hex_color, fallback="FFFFFF"):
"""Convert #RRGGBB to the &HBBGGRR& form used by inline \\c override tags."""
hex_digits = str(hex_color or "").lstrip('#')
if not _HEX_COLOR_RE.match(hex_digits):
hex_digits = fallback
r = hex_digits[0:2]
g = hex_digits[2:4]
b = hex_digits[4:6]
return f"&H{b}{g}{r}&".upper()
def _escape_ass_text(text):
"""Neutralize characters that would start ASS override blocks."""
return str(text).replace('\\', '/').replace('{', '(').replace('}', ')')
def _dim_hex_color(hex_color, opacity, fallback="FFFFFF"):
"""Fully-opaque 'dimmed' variant of a color (scaled toward black).
Dimming via alpha looks muddy in ASS: libass draws the outline as a
filled shape UNDER the fill, so a semi-transparent white fill blends
with its own black outline into dark grey. Scaling the RGB instead
keeps the text crisp on every player."""
hex_digits = str(hex_color or "").lstrip('#')
if not _HEX_COLOR_RE.match(hex_digits):
hex_digits = fallback
# Gentle curve: even strong dimming stays a readable light silver, matching
# the airy look of browser-alpha dimming over bright video.
factor = 0.5 + 0.5 * _clamp_number(opacity, 0.05, 1.0, 1.0)
r = min(255, round(int(hex_digits[0:2], 16) * factor))
g = min(255, round(int(hex_digits[2:4], 16) * factor))
b = min(255, round(int(hex_digits[4:6], 16) * factor))
return f"{r:02X}{g:02X}{b:02X}"
def generate_ass(transcript, clip_start, clip_end, output_path,
max_chars=20, max_duration=2.0, alignment='bottom',
fontsize=16, font_name="Verdana", font_color="#FFFFFF",
border_color="#000000", border_width=2,
highlight_color="#FFD700", bg_color="#000000", bg_opacity=0.0,
effect="none", base_opacity=1.0, uppercase=False):
"""
Generates a karaoke-style ASS file: each block is shown like the SRT path,
but the currently spoken word is rendered in highlight_color (modern
TikTok/CapCut caption look). One dialogue event per word, back to back, so
the highlight moves with the audio without flicker.
effect: "none" | "glow" (neon shine around the active word) |
"pop" (active word scales up) | "box" (thick colored outline).
base_opacity: opacity of the non-active words — dimmed base text is the
modern captioneer look (e.g. 0.4).
"""
blocks = _collect_word_blocks(transcript, clip_start, clip_end, max_chars, max_duration)
if not blocks:
return False
# Match the SRT burn path: PlayResY 288 keeps font sizes consistent.
final_fontsize = int(_clamp_number(fontsize, 10, 200, 16) * 0.85)
if final_fontsize < 10:
final_fontsize = 10
align_map = {'top': 8, 'middle': 5, 'bottom': 2}
ass_alignment = align_map.get(str(alignment).lower(), 2)
safe_font = _sanitize_font_name(font_name)
base_opacity = _clamp_number(base_opacity, 0.05, 1.0, 1.0)
# Dim inactive words via a fully-opaque scaled color (NOT alpha — see
# _dim_hex_color); the active word overrides the color inline.
primary_colour = hex_to_ass_color(_dim_hex_color(font_color, base_opacity), 1.0)
bg_opacity = _clamp_number(bg_opacity, 0.0, 1.0, 0.0)
border_width = _clamp_number(border_width, 0, 10, 2)
if bg_opacity > 0:
border_style = 3
outline_colour = hex_to_ass_color(bg_color, bg_opacity, fallback="000000")
outline_width = 1
else:
border_style = 1
outline_colour = hex_to_ass_color(border_color, 1.0, fallback="000000")
outline_width = max(1, int(border_width))
back_colour = hex_to_ass_color("#000000", 0.0)
highlight_inline = _hex_to_ass_inline_color(highlight_color, fallback="FFD700")
# Inline override tags for the active word; {\r} after it resets to the
# (dimmed) style so the rest of the block stays untouched.
if effect == "glow":
glow_bord = max(3, int(outline_width) + 2)
active_prefix = (f"{{\\c&HFFFFFF&\\3c{highlight_inline}"
f"\\bord{glow_bord}\\blur4}}")
elif effect == "box":
box_bord = max(4, int(outline_width) + 3)
active_prefix = (f"{{\\c&HFFFFFF&\\3c{highlight_inline}"
f"\\bord{box_bord}\\blur0}}")
elif effect == "pop":
active_prefix = (f"{{\\c{highlight_inline}"
f"\\fscx75\\fscy75\\t(0,120,\\fscx112\\fscy112)}}")
else:
active_prefix = f"{{\\c{highlight_inline}}}"
header = (
"[Script Info]\n"
"ScriptType: v4.00+\n"
"PlayResY: 288\n"
"WrapStyle: 0\n"
"ScaledBorderAndShadow: yes\n"
"\n"
"[V4+ Styles]\n"
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, "
"OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, "
"ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, "
"Alignment, MarginL, MarginR, MarginV, Encoding\n"
f"Style: Default,{safe_font},{final_fontsize},{primary_colour},{primary_colour},"
f"{outline_colour},{back_colour},1,0,0,0,100,100,0,0,{border_style},"
f"{outline_width},0,{ass_alignment},10,10,25,1\n"
"\n"
"[Events]\n"
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
)
events = []
for block in blocks:
for i, word in enumerate(block):
# Event runs until the next word starts (no flicker in gaps);
# the last word holds until the block ends.
ev_start = block[0]['start'] if i == 0 else word['start']
ev_end = block[i + 1]['start'] if i < len(block) - 1 else block[-1]['end']
if ev_end <= ev_start:
continue
parts = []
for j, other in enumerate(block):
text = _escape_ass_text(other['word'])
if uppercase:
text = text.upper()
if j == i:
parts.append(f"{active_prefix}{text}{{\\r}}")
else:
parts.append(text)
events.append(
f"Dialogue: 0,{_ass_time(ev_start)},{_ass_time(ev_end)},Default,,0,0,0,,{' '.join(parts)}"
)
if not events:
return False
with open(output_path, 'w', encoding='utf-8-sig') as f:
f.write(header + "\n".join(events) + "\n")
return True
def format_srt_block(index, start, end, text):
def format_time(seconds):
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
millis = int((seconds - int(seconds)) * 1000)
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
return f"{index}\n{format_time(start)} --> {format_time(end)}\n{text}\n\n"
_HEX_COLOR_RE = re.compile(r'^[0-9A-Fa-f]{6}$')
_FONT_NAME_RE = re.compile(r'[^A-Za-z0-9 _-]')
def hex_to_ass_color(hex_color, opacity=1.0, fallback="FFFFFF"):
"""Convert #RRGGBB to ASS &HAABBGGRR format. opacity: 0.0=transparent, 1.0=opaque.
Invalid hex (e.g. "#GGGGGG", None, wrong length) falls back to `fallback`
instead of raising, so a bad color from the client can't 500 the request.
"""
hex_digits = str(hex_color or "").lstrip('#')
if not _HEX_COLOR_RE.match(hex_digits):
hex_digits = fallback
opacity = _clamp_number(opacity, 0.0, 1.0, 1.0)
r = int(hex_digits[0:2], 16)
g = int(hex_digits[2:4], 16)
b = int(hex_digits[4:6], 16)
alpha = round((1.0 - opacity) * 255)
return f"&H{alpha:02X}{b:02X}{g:02X}{r:02X}"
def _clamp_number(value, lo, hi, default):
"""Coerce value to float and clamp to [lo, hi]; use default if not numeric."""
try:
num = float(value)
except (TypeError, ValueError):
num = float(default)
return max(lo, min(hi, num))
def _sanitize_font_name(name):
"""Strip anything but [A-Za-z0-9 _-] so the font name can't inject extra
ASS override fields (commas/braces/backslashes) into force_style."""
cleaned = _FONT_NAME_RE.sub('', str(name or '')).strip()
return cleaned or "Verdana"
def burn_subtitles(video_path, srt_path, output_path, alignment=2, fontsize=16,
font_name="Verdana", font_color="#FFFFFF",
border_color="#000000", border_width=2,
bg_color="#000000", bg_opacity=0.0):
"""
Burns subtitles into the video using FFmpeg.
Supports two modes:
- Outline mode (bg_opacity=0): Text with colored outline/border
- Box mode (bg_opacity>0): Text with semi-transparent background box
"""
# Position mapping
ass_alignment = 2
align_lower = str(alignment).lower()
if align_lower == 'top':
ass_alignment = 6
elif align_lower == 'middle':
ass_alignment = 10
elif align_lower == 'bottom':
ass_alignment = 2
# Font size scaling for ASS virtual resolution (PlayResY=288 default)
# For vertical 1080x1920 video, we need larger text for readability
final_fontsize = int(_clamp_number(fontsize, 10, 200, 16) * 0.85)
if final_fontsize < 10:
final_fontsize = 10
safe_font_name = _sanitize_font_name(font_name)
bg_opacity = _clamp_number(bg_opacity, 0.0, 1.0, 0.0)
border_width = _clamp_number(border_width, 0, 10, 2)
# Path handling for FFmpeg filter syntax
safe_srt_path = _escape_ffmpeg_filter_value(srt_path)
# Convert colors to ASS format and build style
primary_colour = hex_to_ass_color(font_color, 1.0)
if bg_opacity > 0:
# Box mode: opaque background box
border_style = 3
outline_colour = hex_to_ass_color(bg_color, bg_opacity, fallback="000000")
outline_width = 1
else:
# Outline mode: text border/outline
border_style = 1
outline_colour = hex_to_ass_color(border_color, 1.0, fallback="000000")
outline_width = max(1, int(border_width))
back_colour = hex_to_ass_color("#000000", 0.0)
style_string = (
f"Alignment={ass_alignment},"
f"Fontname={safe_font_name},"
f"Fontsize={final_fontsize},"
f"PrimaryColour={primary_colour},"
f"OutlineColour={outline_colour},"
f"BackColour={back_colour},"
f"BorderStyle={border_style},"
f"Outline={outline_width},"
f"Shadow=0,"
f"MarginV=25,"
f"Bold=1"
)
if str(srt_path).lower().endswith('.ass'):
# ASS files (karaoke style) carry their own styles; force_style would
# override the per-word color tags.
vf = f"ass='{safe_srt_path}'"
else:
vf = f"subtitles='{safe_srt_path}':charenc=UTF-8:force_style='{style_string}'"
cmd = [
'ffmpeg', '-y',
'-i', video_path,
'-vf', vf,
'-c:a', 'copy',
*video_encode_args(QUALITY),
'-movflags', '+faststart',
output_path
]
_log(f"🎬 Burning subtitles: {' '.join(cmd)}")
result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
if result.returncode != 0:
stderr_text = result.stderr.decode(errors='replace')
_log(f"❌ FFmpeg Subtitle Error: {stderr_text}")
raise Exception(f"FFmpeg failed: {stderr_text}")
return True