ulduldp commited on
Commit
da9ee87
·
verified ·
1 Parent(s): 62e66d3

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +136 -230
app.py CHANGED
@@ -2,22 +2,23 @@ from flask import Flask, render_template_string, request, jsonify
2
  import os
3
  import uuid
4
  import subprocess
5
- import tempfile
6
  from werkzeug.utils import secure_filename
7
  from faster_whisper import WhisperModel
8
- from PIL import Image, ImageDraw, ImageFont, ImageFilter
9
 
10
  app = Flask(__name__)
11
 
12
  UPLOAD_FOLDER = "uploads"
13
  OUTPUT_FOLDER = "static/videos"
 
14
 
15
  os.makedirs(UPLOAD_FOLDER, exist_ok=True)
16
  os.makedirs(OUTPUT_FOLDER, exist_ok=True)
 
17
 
18
- # Load Whisper once
19
  model = WhisperModel(
20
- "base",
21
  device="cpu",
22
  compute_type="int8"
23
  )
@@ -29,6 +30,7 @@ HTML = """
29
  <meta charset="UTF-8">
30
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
31
  <title>Photo + Audio To Video</title>
 
32
  <style>
33
  *{
34
  margin:0;
@@ -36,6 +38,7 @@ HTML = """
36
  box-sizing:border-box;
37
  font-family:Arial;
38
  }
 
39
  body{
40
  background:#0f0f0f;
41
  color:white;
@@ -45,6 +48,7 @@ body{
45
  align-items:center;
46
  padding:20px;
47
  }
 
48
  .container{
49
  width:100%;
50
  max-width:500px;
@@ -53,22 +57,26 @@ body{
53
  padding:25px;
54
  box-shadow:0 0 20px rgba(0,0,0,0.4);
55
  }
 
56
  h1{
57
  text-align:center;
58
  margin-bottom:25px;
59
  font-size:28px;
60
  }
 
61
  .upload-box{
62
  border:2px dashed #444;
63
  padding:20px;
64
  border-radius:15px;
65
  margin-bottom:20px;
66
  }
 
67
  label{
68
  display:block;
69
  margin-bottom:8px;
70
  color:#ccc;
71
  }
 
72
  input{
73
  width:100%;
74
  padding:12px;
@@ -78,6 +86,7 @@ input{
78
  color:white;
79
  margin-bottom:15px;
80
  }
 
81
  button{
82
  width:100%;
83
  padding:15px;
@@ -89,14 +98,17 @@ button{
89
  cursor:pointer;
90
  transition:0.3s;
91
  }
 
92
  button:hover{
93
  opacity:0.9;
94
  }
 
95
  #loading{
96
  display:none;
97
  text-align:center;
98
  margin-top:20px;
99
  }
 
100
  video{
101
  width:100%;
102
  margin-top:20px;
@@ -106,11 +118,13 @@ video{
106
  background:#000;
107
  object-fit:cover;
108
  }
 
109
  .download-btn{
110
  display:none;
111
  margin-top:15px;
112
  text-align:center;
113
  }
 
114
  .download-btn a{
115
  display:inline-block;
116
  background:#22c55e;
@@ -119,6 +133,7 @@ video{
119
  padding:12px 20px;
120
  border-radius:10px;
121
  }
 
122
  .preview{
123
  margin-top:15px;
124
  width:100%;
@@ -127,12 +142,17 @@ video{
127
  }
128
  </style>
129
  </head>
 
130
  <body>
 
131
  <div class="container">
 
132
  <h1>Photo + Audio → Video</h1>
133
 
134
  <form id="form">
 
135
  <div class="upload-box">
 
136
  <label>Select Photo</label>
137
  <input type="file" id="image" name="image" accept="image/*" required>
138
 
@@ -140,9 +160,11 @@ video{
140
 
141
  <label>Select Audio (mp3/wav)</label>
142
  <input type="file" name="audio" accept="audio/*" required>
 
143
  </div>
144
 
145
  <button type="submit">Generate Video</button>
 
146
  </form>
147
 
148
  <div id="loading">Generating Video...</div>
@@ -152,6 +174,7 @@ video{
152
  <div class="download-btn" id="downloadDiv">
153
  <a id="downloadBtn" download>Download Video</a>
154
  </div>
 
155
  </div>
156
 
157
  <script>
@@ -163,6 +186,7 @@ const downloadDiv = document.getElementById("downloadDiv");
163
  const preview = document.getElementById("preview");
164
 
165
  document.getElementById("image").addEventListener("change", function(e){
 
166
  const file = e.target.files[0];
167
 
168
  if(file){
@@ -172,6 +196,7 @@ document.getElementById("image").addEventListener("change", function(e){
172
  });
173
 
174
  form.addEventListener("submit", async (e)=>{
 
175
  e.preventDefault();
176
 
177
  loading.style.display = "block";
@@ -181,9 +206,10 @@ form.addEventListener("submit", async (e)=>{
181
  const formData = new FormData(form);
182
 
183
  try{
 
184
  const response = await fetch("/generate", {
185
- method: "POST",
186
- body: formData
187
  });
188
 
189
  const data = await response.json();
@@ -191,228 +217,122 @@ form.addEventListener("submit", async (e)=>{
191
  loading.style.display = "none";
192
 
193
  if(data.video_url){
 
194
  video.src = data.video_url + "?t=" + new Date().getTime();
195
  video.style.display = "block";
196
 
197
  downloadBtn.href = data.video_url;
198
  downloadDiv.style.display = "block";
 
199
  }else{
200
  alert(data.error || "Failed");
201
  }
202
 
203
  }catch(err){
 
204
  loading.style.display = "none";
205
  alert("Server Error");
 
206
  }
 
207
  });
208
  </script>
 
209
  </body>
210
  </html>
211
  """
212
 
213
- # Reel resolution
214
- VIDEO_W = 1080
215
- VIDEO_H = 1920
216
-
217
- # Caption styling
218
- FONT_SIZE = 58
219
- BOX_RADIUS = 32
220
- BOX_PADDING_X = 45
221
- BOX_PADDING_Y = 28
222
- BOX_MARGIN_BOTTOM = 190
223
- BOX_MARGIN_X = 80
224
-
225
-
226
- def find_font():
227
- candidates = [
228
- "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
229
- "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
230
- "C:\\Windows\\Fonts\\arialbd.ttf",
231
- "C:\\Windows\\Fonts\\arial.ttf",
232
- ]
233
-
234
- for p in candidates:
235
- if os.path.exists(p):
236
- return p
237
-
238
- raise FileNotFoundError("No font found")
239
-
240
-
241
- def wrap_text(text, font, max_width):
242
- dummy = Image.new("RGBA", (10, 10))
243
- draw = ImageDraw.Draw(dummy)
244
-
245
- words = text.split()
246
- lines = []
247
-
248
- if not words:
249
- return [""]
250
-
251
- current = words[0]
252
-
253
- for word in words[1:]:
254
- test = current + " " + word
255
 
256
- bbox = draw.textbbox((0, 0), test, font=font)
257
- width = bbox[2] - bbox[0]
258
 
259
- if width <= max_width:
260
- current = test
261
- else:
262
- lines.append(current)
263
- current = word
264
 
265
- lines.append(current)
266
 
267
- return lines
268
 
 
 
 
 
269
 
270
- def draw_rounded_rect(draw, xy, radius, fill):
271
- draw.rounded_rectangle(xy, radius=radius, fill=fill)
272
 
 
273
 
274
- def make_caption_png(text, out_path):
275
- font = ImageFont.truetype(find_font(), FONT_SIZE)
276
-
277
- max_text_width = VIDEO_W - (2 * BOX_MARGIN_X) - (2 * BOX_PADDING_X)
278
-
279
- lines = wrap_text(text, font, max_text_width)
280
-
281
- measure_img = Image.new("RGBA", (10, 10))
282
- measure_draw = ImageDraw.Draw(measure_img)
283
-
284
- line_data = []
285
-
286
- for line in lines:
287
- bbox = measure_draw.textbbox((0, 0), line, font=font)
288
-
289
- line_w = bbox[2] - bbox[0]
290
- line_h = bbox[3] - bbox[1]
291
-
292
- line_data.append((line, line_w, line_h))
293
-
294
- text_w = max([x[1] for x in line_data]) if line_data else 0
295
-
296
- line_gap = 12
297
-
298
- text_h = sum([x[2] for x in line_data]) + line_gap * (len(line_data)-1)
299
-
300
- box_w = text_w + (BOX_PADDING_X * 2)
301
- box_h = text_h + (BOX_PADDING_Y * 2)
302
-
303
- img = Image.new("RGBA", (VIDEO_W, VIDEO_H), (0,0,0,0))
304
- draw = ImageDraw.Draw(img)
305
-
306
- x1 = (VIDEO_W - box_w) // 2
307
- y2 = VIDEO_H - BOX_MARGIN_BOTTOM
308
- y1 = y2 - box_h
309
- x2 = x1 + box_w
310
-
311
- # Solid black rounded background
312
- draw_rounded_rect(
313
- draw,
314
- (x1, y1, x2, y2),
315
- BOX_RADIUS,
316
- (0,0,0,240)
317
  )
318
 
319
- # Glow layer
320
- glow = Image.new("RGBA", (VIDEO_W, VIDEO_H), (0,0,0,0))
321
- glow_draw = ImageDraw.Draw(glow)
322
-
323
- current_y = y1 + BOX_PADDING_Y
324
-
325
- for line, line_w, line_h in line_data:
326
- tx = (VIDEO_W - line_w) // 2
327
-
328
- # glow
329
- for dx, dy in [
330
- (-3,0),(3,0),(0,-3),(0,3),
331
- (-2,-2),(-2,2),(2,-2),(2,2)
332
- ]:
333
- glow_draw.text(
334
- (tx+dx, current_y+dy),
335
- line,
336
- font=font,
337
- fill=(255,255,255,90)
338
- )
339
-
340
- current_y += line_h + line_gap
341
 
342
- glow = glow.filter(ImageFilter.GaussianBlur(4))
 
 
 
 
 
343
 
344
- img = Image.alpha_composite(img, glow)
 
345
 
346
- # Main crisp white text
347
- draw = ImageDraw.Draw(img)
348
 
349
- current_y = y1 + BOX_PADDING_Y
350
-
351
- for line, line_w, line_h in line_data:
352
- tx = (VIDEO_W - line_w) // 2
353
-
354
- draw.text(
355
- (tx, current_y),
356
- line,
357
- font=font,
358
- fill=(255,255,255,255),
359
- stroke_width=4,
360
- stroke_fill=(0,0,0,255)
361
- )
362
 
363
- current_y += line_h + line_gap
364
 
365
- img.save(out_path)
366
 
 
 
367
 
368
- def build_filter_complex(transcript):
369
- base = (
370
- "[0:v]"
371
- "scale=1080:1920:force_original_aspect_ratio=increase,"
372
- "crop=1080:1920,"
373
- "zoompan=z='min(zoom+0.0008,1.10)':"
374
- "x='iw/2-(iw/zoom/2)':"
375
- "y='ih/2-(ih/zoom/2)':"
376
- "d=999999:s=1080x1920:fps=30,"
377
- "format=rgba"
378
- "[base]"
379
- )
380
 
381
- parts = [base]
 
382
 
383
- last = "[base]"
 
384
 
385
- for idx, seg in enumerate(transcript, start=2):
386
- start = f"{seg['start']:.2f}"
387
- end = f"{seg['end']:.2f}"
388
 
389
- out = f"[v{idx}]"
390
-
391
- parts.append(
392
- f"{last}[{idx}:v]overlay=0:0:enable='between(t,{start},{end})'{out}"
393
  )
394
 
395
- last = out
396
-
397
- return ";".join(parts), last
398
 
 
 
399
 
400
  @app.route("/")
401
  def home():
402
  return render_template_string(HTML)
403
 
404
-
405
  @app.route("/generate", methods=["POST"])
406
  def generate():
407
 
408
  if "image" not in request.files or "audio" not in request.files:
409
- return jsonify({"error": "Missing files"})
410
 
411
  image = request.files["image"]
412
  audio = request.files["audio"]
413
 
414
  if not image.filename or not audio.filename:
415
- return jsonify({"error": "Please upload both image and audio"})
416
 
417
  uid = str(uuid.uuid4())
418
 
@@ -436,14 +356,20 @@ def generate():
436
  output_filename
437
  )
438
 
 
 
 
 
 
439
  image.save(image_path)
440
  audio.save(audio_path)
441
 
442
  try:
443
- # Transcribe
 
444
  segments_iter, info = model.transcribe(
445
  audio_path,
446
- beam_size=5,
447
  vad_filter=True
448
  )
449
 
@@ -451,6 +377,7 @@ def generate():
451
  full_text_parts = []
452
 
453
  for segment in segments_iter:
 
454
  text = segment.text.strip()
455
 
456
  if not text:
@@ -464,73 +391,50 @@ def generate():
464
 
465
  full_text_parts.append(text)
466
 
467
- with tempfile.TemporaryDirectory() as tmpdir:
468
-
469
- caption_paths = []
470
-
471
- for i, seg in enumerate(transcript, start=1):
472
-
473
- caption_path = os.path.join(
474
- tmpdir,
475
- f"caption_{i:04d}.png"
476
- )
477
 
478
- make_caption_png(
479
- seg["text"],
480
- caption_path
481
- )
482
-
483
- caption_paths.append(caption_path)
484
-
485
- cmd = [
486
- "ffmpeg",
487
- "-y",
488
-
489
- "-loop", "1",
490
- "-i", image_path,
491
 
492
- "-i", audio_path
493
- ]
 
 
 
 
494
 
495
- for p in caption_paths:
496
- cmd += [
497
- "-loop", "1",
498
- "-i", p
499
- ]
500
 
501
- filter_complex, last_video = build_filter_complex(transcript)
 
502
 
503
- filter_script = os.path.join(
504
- tmpdir,
505
- "filter.txt"
506
- )
507
 
508
- with open(filter_script, "w", encoding="utf-8") as f:
509
- f.write(filter_complex)
510
 
511
- cmd += [
512
- "-filter_complex_script", filter_script,
513
 
514
- "-map", last_video,
515
- "-map", "1:a?",
516
 
517
- "-c:v", "libx264",
518
- "-pix_fmt", "yuv420p",
519
 
520
- "-c:a", "aac",
521
- "-b:a", "192k",
522
 
523
- "-shortest",
524
 
525
- output_path
526
- ]
527
 
528
- subprocess.run(
529
- cmd,
530
- stdout=subprocess.PIPE,
531
- stderr=subprocess.PIPE,
532
- check=True
533
- )
534
 
535
  return jsonify({
536
  "video_url": f"/static/videos/{output_filename}",
@@ -542,17 +446,19 @@ def generate():
542
  except subprocess.CalledProcessError as e:
543
 
544
  return jsonify({
545
- "error": "FFmpeg failed",
546
- "details": e.stderr.decode("utf-8", errors="ignore")
 
 
 
547
  })
548
 
549
  except Exception as e:
550
 
551
  return jsonify({
552
- "error": "Processing failed",
553
  "details": str(e)
554
  })
555
 
556
-
557
  if __name__ == "__main__":
558
  app.run(host="0.0.0.0", port=7860)
 
2
  import os
3
  import uuid
4
  import subprocess
5
+ import textwrap
6
  from werkzeug.utils import secure_filename
7
  from faster_whisper import WhisperModel
 
8
 
9
  app = Flask(__name__)
10
 
11
  UPLOAD_FOLDER = "uploads"
12
  OUTPUT_FOLDER = "static/videos"
13
+ SUBTITLE_FOLDER = "subtitles"
14
 
15
  os.makedirs(UPLOAD_FOLDER, exist_ok=True)
16
  os.makedirs(OUTPUT_FOLDER, exist_ok=True)
17
+ os.makedirs(SUBTITLE_FOLDER, exist_ok=True)
18
 
19
+ # Smallest & fastest Whisper model
20
  model = WhisperModel(
21
+ "tiny",
22
  device="cpu",
23
  compute_type="int8"
24
  )
 
30
  <meta charset="UTF-8">
31
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
32
  <title>Photo + Audio To Video</title>
33
+
34
  <style>
35
  *{
36
  margin:0;
 
38
  box-sizing:border-box;
39
  font-family:Arial;
40
  }
41
+
42
  body{
43
  background:#0f0f0f;
44
  color:white;
 
48
  align-items:center;
49
  padding:20px;
50
  }
51
+
52
  .container{
53
  width:100%;
54
  max-width:500px;
 
57
  padding:25px;
58
  box-shadow:0 0 20px rgba(0,0,0,0.4);
59
  }
60
+
61
  h1{
62
  text-align:center;
63
  margin-bottom:25px;
64
  font-size:28px;
65
  }
66
+
67
  .upload-box{
68
  border:2px dashed #444;
69
  padding:20px;
70
  border-radius:15px;
71
  margin-bottom:20px;
72
  }
73
+
74
  label{
75
  display:block;
76
  margin-bottom:8px;
77
  color:#ccc;
78
  }
79
+
80
  input{
81
  width:100%;
82
  padding:12px;
 
86
  color:white;
87
  margin-bottom:15px;
88
  }
89
+
90
  button{
91
  width:100%;
92
  padding:15px;
 
98
  cursor:pointer;
99
  transition:0.3s;
100
  }
101
+
102
  button:hover{
103
  opacity:0.9;
104
  }
105
+
106
  #loading{
107
  display:none;
108
  text-align:center;
109
  margin-top:20px;
110
  }
111
+
112
  video{
113
  width:100%;
114
  margin-top:20px;
 
118
  background:#000;
119
  object-fit:cover;
120
  }
121
+
122
  .download-btn{
123
  display:none;
124
  margin-top:15px;
125
  text-align:center;
126
  }
127
+
128
  .download-btn a{
129
  display:inline-block;
130
  background:#22c55e;
 
133
  padding:12px 20px;
134
  border-radius:10px;
135
  }
136
+
137
  .preview{
138
  margin-top:15px;
139
  width:100%;
 
142
  }
143
  </style>
144
  </head>
145
+
146
  <body>
147
+
148
  <div class="container">
149
+
150
  <h1>Photo + Audio → Video</h1>
151
 
152
  <form id="form">
153
+
154
  <div class="upload-box">
155
+
156
  <label>Select Photo</label>
157
  <input type="file" id="image" name="image" accept="image/*" required>
158
 
 
160
 
161
  <label>Select Audio (mp3/wav)</label>
162
  <input type="file" name="audio" accept="audio/*" required>
163
+
164
  </div>
165
 
166
  <button type="submit">Generate Video</button>
167
+
168
  </form>
169
 
170
  <div id="loading">Generating Video...</div>
 
174
  <div class="download-btn" id="downloadDiv">
175
  <a id="downloadBtn" download>Download Video</a>
176
  </div>
177
+
178
  </div>
179
 
180
  <script>
 
186
  const preview = document.getElementById("preview");
187
 
188
  document.getElementById("image").addEventListener("change", function(e){
189
+
190
  const file = e.target.files[0];
191
 
192
  if(file){
 
196
  });
197
 
198
  form.addEventListener("submit", async (e)=>{
199
+
200
  e.preventDefault();
201
 
202
  loading.style.display = "block";
 
206
  const formData = new FormData(form);
207
 
208
  try{
209
+
210
  const response = await fetch("/generate", {
211
+ method:"POST",
212
+ body:formData
213
  });
214
 
215
  const data = await response.json();
 
217
  loading.style.display = "none";
218
 
219
  if(data.video_url){
220
+
221
  video.src = data.video_url + "?t=" + new Date().getTime();
222
  video.style.display = "block";
223
 
224
  downloadBtn.href = data.video_url;
225
  downloadDiv.style.display = "block";
226
+
227
  }else{
228
  alert(data.error || "Failed");
229
  }
230
 
231
  }catch(err){
232
+
233
  loading.style.display = "none";
234
  alert("Server Error");
235
+
236
  }
237
+
238
  });
239
  </script>
240
+
241
  </body>
242
  </html>
243
  """
244
 
245
+ def ass_time(seconds: float) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
 
247
+ if seconds < 0:
248
+ seconds = 0
249
 
250
+ h = int(seconds // 3600)
251
+ m = int((seconds % 3600) // 60)
252
+ s = seconds % 60
 
 
253
 
254
+ return f"{h}:{m:02d}:{s:05.2f}"
255
 
256
+ def ass_escape(text: str) -> str:
257
 
258
+ text = text.replace("\\", "\\\\")
259
+ text = text.replace("{", "\\{")
260
+ text = text.replace("}", "\\}")
261
+ text = text.replace("\n", " ")
262
 
263
+ return text
 
264
 
265
+ def escape_ffmpeg_path(path: str) -> str:
266
 
267
+ return (
268
+ path
269
+ .replace("\\", "\\\\")
270
+ .replace(":", "\\:")
271
+ .replace("'", r"\'")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  )
273
 
274
+ def make_ass_subtitles(segments, ass_path):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
 
276
+ header = """[Script Info]
277
+ ScriptType: v4.00+
278
+ PlayResX: 1080
279
+ PlayResY: 1920
280
+ ScaledBorderAndShadow: yes
281
+ WrapStyle: 2
282
 
283
+ [V4+ Styles]
284
+ Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
285
 
286
+ Style: Default,DejaVu Sans,60,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,3,0,0,2,80,80,140,1
 
287
 
288
+ [Events]
289
+ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
290
+ """
 
 
 
 
 
 
 
 
 
 
291
 
292
+ lines = [header]
293
 
294
+ for seg in segments:
295
 
296
+ start = ass_time(seg["start"])
297
+ end = ass_time(seg["end"])
298
 
299
+ text = seg["text"].strip()
 
 
 
 
 
 
 
 
 
 
 
300
 
301
+ if not text:
302
+ continue
303
 
304
+ # auto wrap
305
+ wrapped = textwrap.fill(text, width=22)
306
 
307
+ wrapped = ass_escape(wrapped)
308
+ wrapped = wrapped.replace("\n", r"\N")
 
309
 
310
+ dialogue = (
311
+ f"Dialogue: 0,{start},{end},Default,,0,0,0,,"
312
+ r"{\bord0\shad0\1c&HFFFFFF&\3c&H000000&\4c&H000000&}"
313
+ f"{wrapped}\n"
314
  )
315
 
316
+ lines.append(dialogue)
 
 
317
 
318
+ with open(ass_path, "w", encoding="utf-8") as f:
319
+ f.writelines(lines)
320
 
321
  @app.route("/")
322
  def home():
323
  return render_template_string(HTML)
324
 
 
325
  @app.route("/generate", methods=["POST"])
326
  def generate():
327
 
328
  if "image" not in request.files or "audio" not in request.files:
329
+ return jsonify({"error":"Missing files"})
330
 
331
  image = request.files["image"]
332
  audio = request.files["audio"]
333
 
334
  if not image.filename or not audio.filename:
335
+ return jsonify({"error":"Please upload both image and audio"})
336
 
337
  uid = str(uuid.uuid4())
338
 
 
356
  output_filename
357
  )
358
 
359
+ ass_path = os.path.join(
360
+ SUBTITLE_FOLDER,
361
+ f"{uid}.ass"
362
+ )
363
+
364
  image.save(image_path)
365
  audio.save(audio_path)
366
 
367
  try:
368
+
369
+ # Faster transcription
370
  segments_iter, info = model.transcribe(
371
  audio_path,
372
+ beam_size=1,
373
  vad_filter=True
374
  )
375
 
 
377
  full_text_parts = []
378
 
379
  for segment in segments_iter:
380
+
381
  text = segment.text.strip()
382
 
383
  if not text:
 
391
 
392
  full_text_parts.append(text)
393
 
394
+ make_ass_subtitles(transcript, ass_path)
 
 
 
 
 
 
 
 
 
395
 
396
+ safe_ass_path = escape_ffmpeg_path(
397
+ os.path.abspath(ass_path)
398
+ )
 
 
 
 
 
 
 
 
 
 
399
 
400
+ # Low CPU video processing
401
+ vf = (
402
+ "scale=1080:1920:force_original_aspect_ratio=increase,"
403
+ "crop=1080:1920,"
404
+ f"subtitles='{safe_ass_path}'"
405
+ )
406
 
407
+ cmd = [
408
+ "ffmpeg",
409
+ "-y",
 
 
410
 
411
+ "-loop", "1",
412
+ "-i", image_path,
413
 
414
+ "-i", audio_path,
 
 
 
415
 
416
+ "-vf", vf,
 
417
 
418
+ "-c:v", "libx264",
 
419
 
420
+ "-preset", "ultrafast",
 
421
 
422
+ "-pix_fmt", "yuv420p",
 
423
 
424
+ "-c:a", "aac",
425
+ "-b:a", "128k",
426
 
427
+ "-shortest",
428
 
429
+ output_path
430
+ ]
431
 
432
+ subprocess.run(
433
+ cmd,
434
+ stdout=subprocess.PIPE,
435
+ stderr=subprocess.PIPE,
436
+ check=True
437
+ )
438
 
439
  return jsonify({
440
  "video_url": f"/static/videos/{output_filename}",
 
446
  except subprocess.CalledProcessError as e:
447
 
448
  return jsonify({
449
+ "error":"FFmpeg failed",
450
+ "details": e.stderr.decode(
451
+ "utf-8",
452
+ errors="ignore"
453
+ )
454
  })
455
 
456
  except Exception as e:
457
 
458
  return jsonify({
459
+ "error":"Processing failed",
460
  "details": str(e)
461
  })
462
 
 
463
  if __name__ == "__main__":
464
  app.run(host="0.0.0.0", port=7860)