ulduldp commited on
Commit
2c7a9d7
·
verified ·
1 Parent(s): 3241474

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +504 -142
app.py CHANGED
@@ -1,89 +1,284 @@
1
  from flask import Flask, render_template_string, request, jsonify, send_from_directory, abort
2
- import os, uuid, subprocess, textwrap
 
 
 
3
  from werkzeug.utils import secure_filename
4
  from faster_whisper import WhisperModel
5
- from PIL import ImageFont
6
 
7
  app = Flask(__name__)
 
8
  BASE_DIR = os.path.dirname(os.path.abspath(__file__))
 
9
  UPLOAD_FOLDER = os.path.join(BASE_DIR, "uploads")
10
  OUTPUT_FOLDER = os.path.join(BASE_DIR, "static", "videos")
11
  SUBTITLE_FOLDER = os.path.join(BASE_DIR, "subtitles")
 
12
  os.makedirs(UPLOAD_FOLDER, exist_ok=True)
13
  os.makedirs(OUTPUT_FOLDER, exist_ok=True)
14
  os.makedirs(SUBTITLE_FOLDER, exist_ok=True)
15
 
16
- # Load Whisper model (CPU, fast)
17
- model = WhisperModel("tiny", device="cpu", compute_type="int8")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
- HTML = """<!DOCTYPE html>
20
- <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Photo+Audio→Video</title>
21
  <style>
22
- /* (CSS same as before) */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  </style>
24
- </head><body>
 
 
25
  <div class="container">
26
  <h1>Photo + Audio → Video</h1>
 
27
  <form id="form">
28
  <div class="upload-box">
29
  <label>Select Photo</label>
30
  <input type="file" id="image" name="image" accept="image/*" required>
 
31
  <img id="preview" class="preview">
 
32
  <label>Select Audio (mp3/wav)</label>
33
  <input type="file" name="audio" accept="audio/*" required>
34
  </div>
 
35
  <button type="submit">Generate Video</button>
36
  </form>
 
37
  <div id="loading">Generating Video...</div>
 
38
  <video id="video" controls playsinline></video>
 
39
  <div class="download-btn" id="downloadDiv">
40
  <a id="downloadBtn" download>Download Video</a>
41
  </div>
42
  </div>
 
43
  <script>
44
- // (JS same as before)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  </script>
46
- </body></html>
 
47
  """
48
 
49
- def ass_time(sec: float) -> str:
50
- """Convert seconds to H:MM:SS.CS (ASS format)."""
51
- if sec < 0: sec = 0
52
- h = int(sec // 3600); m = int((sec % 3600) // 60); s = sec % 60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  return f"{h}:{m:02d}:{s:05.2f}"
54
 
55
- def ass_escape(text: str) -> str:
56
- """Escape {\} characters for ASS subtitle text."""
57
- return text.replace("\\", "\\\\").replace("{", "\\{").replace("}", "\\}")
58
 
59
- def wrap_caption_pixel(text: str, font_path: str, font_size: int, max_width_px: int, max_lines: int = 5) -> str:
60
- """
61
- Wrap text to fit within max_width_px pixels using the specified TrueType font.
62
- Splits long words if needed and limits to max_lines.
63
- Returns text with '\\n' as line breaks (converted later to '\\N').
64
- """
65
- text = " ".join(text.strip().split())
 
 
66
  if not text:
67
- return ""
68
-
69
- # Load font
70
- if font_path:
71
- font = ImageFont.truetype(font_path, font_size)
72
- else:
73
- font = ImageFont.load_default()
74
-
75
- # Function to split a single long word
76
- def split_long(word):
77
- bbox = font.getbbox(word)
78
- word_width = bbox[2] - bbox[0]
79
- if word_width <= max_width_px:
80
  return [word]
 
81
  parts = []
82
  chunk = ""
83
  for ch in word:
84
  trial = chunk + ch
85
- trial_width = font.getbbox(trial)[2] - font.getbbox(trial)[0]
86
- if trial_width <= max_width_px:
87
  chunk = trial
88
  else:
89
  if chunk:
@@ -93,88 +288,179 @@ def wrap_caption_pixel(text: str, font_path: str, font_size: int, max_width_px:
93
  parts.append(chunk)
94
  return parts
95
 
96
- # Break text into tokens (splitting long words)
97
  tokens = []
98
  for word in text.split(" "):
99
- tokens.extend(split_long(word))
100
 
101
- # Build lines
102
  lines = []
103
  current = ""
 
104
  for token in tokens:
105
- trial_line = token if not current else f"{current} {token}"
106
- trial_width = font.getbbox(trial_line)[2] - font.getbbox(trial_line)[0]
107
- if trial_width <= max_width_px:
108
- current = trial_line
109
  else:
110
  if current:
111
  lines.append(current)
112
  current = token
 
113
  if current:
114
  lines.append(current)
115
 
116
- # Limit number of lines
117
  if len(lines) > max_lines:
118
- # Merge overflow into the last line
119
- kept = lines[:max_lines-1]
120
- rest = " ".join(lines[max_lines-1:])
121
- kept.append(rest)
122
  lines = kept
123
 
124
- return "\n".join(lines)
125
 
126
- def make_ass_subtitles(segments, ass_path):
127
  """
128
- Write ASS subtitle file with white text and solid black background.
129
  """
130
- # ASS header with one style
131
- header = """[Script Info]
132
- ScriptType: v4.00+
133
- PlayResX: 1080
134
- PlayResY: 1920
135
- WrapStyle: 2
136
- [V4+ Styles]
137
- Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
138
- Style: Default,Arial,38,&H00FFFFFF,&H00000000,&H00000000,&H00000000,0,0,0,0,100,100,0,0,3,0,0,2,120,120,220,1
139
-
140
- [Events]
141
- Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
142
- """
143
- lines = [header]
144
- font_path = None
145
- # Attempt to find a common TTF font
146
- for candidate in [
147
- "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
148
- "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf"
149
- ]:
150
- if os.path.exists(candidate):
151
- font_path = candidate
152
- break
153
 
154
- # Maximum pixel width inside frame (1080 minus horizontal margins)
155
- max_width_px = 1080 - 240 # 120 left + 120 right margins
 
 
 
 
 
 
 
 
156
 
157
- for seg in segments:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  text = seg["text"].strip()
159
  if not text:
160
  continue
161
- start = ass_time(seg["start"])
162
- end = ass_time(seg["end"])
163
- # Wrap text by pixel width
164
- wrapped = wrap_caption_pixel(text, font_path, font_size=38, max_width_px=max_width_px, max_lines=4)
165
- # Escape and replace newline with ASS newline
166
- ass_text = ass_escape(wrapped).replace("\n", r"\N")
167
- # Dialogue with overrides: primary color white, outline color black, background black
168
- dialogue = (
169
- f"Dialogue: 0,{start},{end},Default,,0,0,0,,"
170
- r"{\bord0\shad0\blur0\be0\1c&HFFFFFF&\3c&H000000&\4c&H000000&\3a&H00&\4a&H00}"
171
- f"{ass_text}\n"
172
- )
173
- lines.append(dialogue)
174
 
175
- # Write .ass file
176
- with open(ass_path, "w", encoding="utf-8") as f:
177
- f.writelines(lines)
 
 
 
 
 
 
 
 
178
 
179
  @app.route("/")
180
  def home():
@@ -182,92 +468,168 @@ def home():
182
 
183
  @app.route("/video/<path:filename>")
184
  def serve_video(filename):
185
- """Serve video file from OUTPUT_FOLDER with no caching."""
186
  file_path = os.path.join(OUTPUT_FOLDER, filename)
187
  if not os.path.exists(file_path):
188
  abort(404)
189
- response = send_from_directory(OUTPUT_FOLDER, filename, as_attachment=False, conditional=True)
 
 
 
 
 
 
190
  response.headers["Cache-Control"] = "no-store"
191
  return response
192
 
193
  @app.route("/generate", methods=["POST"])
194
  def generate():
195
- """Handle upload, transcription, subtitle generation, and video rendering."""
196
  if "image" not in request.files or "audio" not in request.files:
197
  return jsonify({"error": "Missing files"}), 400
 
198
  image = request.files["image"]
199
  audio = request.files["audio"]
 
200
  if not image.filename or not audio.filename:
201
  return jsonify({"error": "Please upload both image and audio"}), 400
202
 
203
  uid = str(uuid.uuid4())
204
- image_path = os.path.join(UPLOAD_FOLDER, f"{uid}_{secure_filename(image.filename)}")
205
- audio_path = os.path.join(UPLOAD_FOLDER, f"{uid}_{secure_filename(audio.filename)}")
 
 
 
 
206
  output_filename = f"{uid}.mp4"
207
  output_path = os.path.join(OUTPUT_FOLDER, output_filename)
208
- ass_path = os.path.join(SUBTITLE_FOLDER, f"{uid}.ass")
 
 
209
 
210
- # Save uploads
211
  image.save(image_path)
212
  audio.save(audio_path)
213
 
214
  try:
215
- # Transcribe audio with Whisper (fast VAD mode)
216
- segments_iter, info = model.transcribe(audio_path, beam_size=1, vad_filter=True)
 
 
 
 
217
  transcript = []
218
- full_text = []
219
- for seg in segments_iter:
220
- text = seg.text.strip()
221
- if not text: continue
 
 
 
222
  transcript.append({
223
- "start": round(seg.start, 2),
224
- "end": round(seg.end, 2),
225
  "text": text
226
  })
227
- full_text.append(text)
228
- # Create ASS subtitles
229
- make_ass_subtitles(transcript, ass_path)
230
-
231
- # Escape path for ffmpeg
232
- safe_ass_path = ass_path.replace("\\", "\\\\").replace(":", "\\:").replace("'", r"\'")
233
-
234
- # FFmpeg filters: scale to 1080x1920 (increase), crop to 1080x1920, overlay subtitles
235
- vf_filter = (
236
- "scale=1080:1920:force_original_aspect_ratio=increase,"
237
- "crop=1080:1920,"
238
- f"ass='{safe_ass_path}'"
239
- )
240
  cmd = [
241
- "ffmpeg", "-y",
242
- "-loop", "1", "-framerate", "1", "-i", image_path,
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  "-i", audio_path,
244
- "-vf", vf_filter,
245
- "-map", "0:v:0", "-map", "1:a:0", # select streams
246
- "-c:v", "libx264", "-preset", "ultrafast", "-crf", "20",
247
- "-pix_fmt", "yuv420p", "-r", "24",
248
- "-c:a", "aac", "-b:a", "128k",
249
- "-movflags", "+faststart", # for streaming
250
- "-shortest", output_path
251
  ]
252
- result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
253
 
254
- # Check output
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  if not os.path.exists(output_path) or os.path.getsize(output_path) == 0:
256
- return jsonify({"error": "Video file missing", "details": result.stderr.decode()}), 500
 
 
 
257
 
258
  return jsonify({
259
  "video_url": f"/video/{output_filename}",
260
  "transcript": transcript,
261
- "full_text": " ".join(full_text).strip(),
262
  "language": getattr(info, "language", None)
263
  })
264
 
265
  except subprocess.CalledProcessError as e:
266
- # FFmpeg error
267
- return jsonify({"error": "FFmpeg failed", "details": e.stderr.decode()}), 500
 
 
 
268
  except Exception as e:
269
- # General error
270
- return jsonify({"error": "Processing failed", "details": str(e)}), 500
 
 
271
 
272
  if __name__ == "__main__":
273
- app.run(host="0.0.0.0", port=7860, debug=True)
 
1
  from flask import Flask, render_template_string, request, jsonify, send_from_directory, abort
2
+ import os
3
+ import uuid
4
+ import subprocess
5
+ from PIL import Image, ImageDraw, ImageFont
6
  from werkzeug.utils import secure_filename
7
  from faster_whisper import WhisperModel
 
8
 
9
  app = Flask(__name__)
10
+
11
  BASE_DIR = os.path.dirname(os.path.abspath(__file__))
12
+
13
  UPLOAD_FOLDER = os.path.join(BASE_DIR, "uploads")
14
  OUTPUT_FOLDER = os.path.join(BASE_DIR, "static", "videos")
15
  SUBTITLE_FOLDER = os.path.join(BASE_DIR, "subtitles")
16
+
17
  os.makedirs(UPLOAD_FOLDER, exist_ok=True)
18
  os.makedirs(OUTPUT_FOLDER, exist_ok=True)
19
  os.makedirs(SUBTITLE_FOLDER, exist_ok=True)
20
 
21
+ # Fast CPU model
22
+ model = WhisperModel(
23
+ "tiny",
24
+ device="cpu",
25
+ compute_type="int8"
26
+ )
27
+
28
+ FRAME_W = 1080
29
+ FRAME_H = 1920
30
+
31
+ HTML = """
32
+ <!DOCTYPE html>
33
+ <html lang="en">
34
+ <head>
35
+ <meta charset="UTF-8">
36
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
37
+ <title>Photo + Audio To Video</title>
38
 
 
 
39
  <style>
40
+ *{
41
+ margin:0;
42
+ padding:0;
43
+ box-sizing:border-box;
44
+ font-family:Arial;
45
+ }
46
+
47
+ body{
48
+ background:#0f0f0f;
49
+ color:white;
50
+ min-height:100vh;
51
+ display:flex;
52
+ justify-content:center;
53
+ align-items:center;
54
+ padding:20px;
55
+ }
56
+
57
+ .container{
58
+ width:100%;
59
+ max-width:500px;
60
+ background:#1b1b1b;
61
+ border-radius:20px;
62
+ padding:25px;
63
+ box-shadow:0 0 20px rgba(0,0,0,0.4);
64
+ }
65
+
66
+ h1{
67
+ text-align:center;
68
+ margin-bottom:25px;
69
+ font-size:28px;
70
+ }
71
+
72
+ .upload-box{
73
+ border:2px dashed #444;
74
+ padding:20px;
75
+ border-radius:15px;
76
+ margin-bottom:20px;
77
+ }
78
+
79
+ label{
80
+ display:block;
81
+ margin-bottom:8px;
82
+ color:#ccc;
83
+ }
84
+
85
+ input{
86
+ width:100%;
87
+ padding:12px;
88
+ background:#2a2a2a;
89
+ border:none;
90
+ border-radius:10px;
91
+ color:white;
92
+ margin-bottom:15px;
93
+ }
94
+
95
+ button{
96
+ width:100%;
97
+ padding:15px;
98
+ border:none;
99
+ border-radius:12px;
100
+ background:#00aaff;
101
+ color:white;
102
+ font-size:18px;
103
+ cursor:pointer;
104
+ transition:0.3s;
105
+ }
106
+
107
+ button:hover{
108
+ opacity:0.9;
109
+ }
110
+
111
+ #loading{
112
+ display:none;
113
+ text-align:center;
114
+ margin-top:20px;
115
+ }
116
+
117
+ video{
118
+ width:100%;
119
+ margin-top:20px;
120
+ border-radius:15px;
121
+ display:none;
122
+ aspect-ratio:9/16;
123
+ background:#000;
124
+ object-fit:cover;
125
+ }
126
+
127
+ .download-btn{
128
+ display:none;
129
+ margin-top:15px;
130
+ text-align:center;
131
+ }
132
+
133
+ .download-btn a{
134
+ display:inline-block;
135
+ background:#22c55e;
136
+ color:white;
137
+ text-decoration:none;
138
+ padding:12px 20px;
139
+ border-radius:10px;
140
+ }
141
+
142
+ .preview{
143
+ margin-top:15px;
144
+ width:100%;
145
+ border-radius:15px;
146
+ display:none;
147
+ }
148
  </style>
149
+ </head>
150
+
151
+ <body>
152
  <div class="container">
153
  <h1>Photo + Audio → Video</h1>
154
+
155
  <form id="form">
156
  <div class="upload-box">
157
  <label>Select Photo</label>
158
  <input type="file" id="image" name="image" accept="image/*" required>
159
+
160
  <img id="preview" class="preview">
161
+
162
  <label>Select Audio (mp3/wav)</label>
163
  <input type="file" name="audio" accept="audio/*" required>
164
  </div>
165
+
166
  <button type="submit">Generate Video</button>
167
  </form>
168
+
169
  <div id="loading">Generating Video...</div>
170
+
171
  <video id="video" controls playsinline></video>
172
+
173
  <div class="download-btn" id="downloadDiv">
174
  <a id="downloadBtn" download>Download Video</a>
175
  </div>
176
  </div>
177
+
178
  <script>
179
+ const form = document.getElementById("form");
180
+ const loading = document.getElementById("loading");
181
+ const video = document.getElementById("video");
182
+ const downloadBtn = document.getElementById("downloadBtn");
183
+ const downloadDiv = document.getElementById("downloadDiv");
184
+ const preview = document.getElementById("preview");
185
+
186
+ document.getElementById("image").addEventListener("change", function(e){
187
+ const file = e.target.files[0];
188
+ if(file){
189
+ preview.src = URL.createObjectURL(file);
190
+ preview.style.display = "block";
191
+ }
192
+ });
193
+
194
+ form.addEventListener("submit", async (e)=>{
195
+ e.preventDefault();
196
+
197
+ loading.style.display = "block";
198
+ video.style.display = "none";
199
+ downloadDiv.style.display = "none";
200
+
201
+ const formData = new FormData(form);
202
+
203
+ try{
204
+ const response = await fetch("/generate", {
205
+ method:"POST",
206
+ body:formData
207
+ });
208
+
209
+ const data = await response.json();
210
+ loading.style.display = "none";
211
+
212
+ if(data.video_url){
213
+ video.src = data.video_url + "?t=" + new Date().getTime();
214
+ video.style.display = "block";
215
+
216
+ downloadBtn.href = data.video_url;
217
+ downloadDiv.style.display = "block";
218
+ }else{
219
+ alert(data.error || "Failed");
220
+ console.log(data.details || "");
221
+ }
222
+ }catch(err){
223
+ loading.style.display = "none";
224
+ alert("Server Error");
225
+ console.error(err);
226
+ }
227
+ });
228
  </script>
229
+ </body>
230
+ </html>
231
  """
232
 
233
+ def find_font_path():
234
+ candidates = [
235
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
236
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
237
+ "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf",
238
+ "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
239
+ "/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
240
+ "/usr/share/fonts/truetype/freefont/FreeSans.ttf",
241
+ ]
242
+ for path in candidates:
243
+ if os.path.exists(path):
244
+ return path
245
+ return None
246
+
247
+ FONT_PATH = find_font_path()
248
+
249
+ def ass_time(seconds: float) -> str:
250
+ if seconds < 0:
251
+ seconds = 0
252
+ h = int(seconds // 3600)
253
+ m = int((seconds % 3600) // 60)
254
+ s = seconds % 60
255
  return f"{h}:{m:02d}:{s:05.2f}"
256
 
257
+ def measure_text_width(font, text: str) -> int:
258
+ bbox = font.getbbox(text)
259
+ return bbox[2] - bbox[0]
260
 
261
+ def measure_text_height(font, text: str) -> int:
262
+ bbox = font.getbbox(text)
263
+ return bbox[3] - bbox[1]
264
+
265
+ def clean_text(text: str) -> str:
266
+ return " ".join(text.strip().split())
267
+
268
+ def wrap_text_by_pixels(text: str, font, max_width_px: int, max_lines: int = 4) -> list[str]:
269
+ text = clean_text(text)
270
  if not text:
271
+ return []
272
+
273
+ def split_long_word(word: str) -> list[str]:
274
+ if measure_text_width(font, word) <= max_width_px:
 
 
 
 
 
 
 
 
 
275
  return [word]
276
+
277
  parts = []
278
  chunk = ""
279
  for ch in word:
280
  trial = chunk + ch
281
+ if measure_text_width(font, trial) <= max_width_px:
 
282
  chunk = trial
283
  else:
284
  if chunk:
 
288
  parts.append(chunk)
289
  return parts
290
 
 
291
  tokens = []
292
  for word in text.split(" "):
293
+ tokens.extend(split_long_word(word))
294
 
 
295
  lines = []
296
  current = ""
297
+
298
  for token in tokens:
299
+ trial = token if not current else f"{current} {token}"
300
+ if measure_text_width(font, trial) <= max_width_px:
301
+ current = trial
 
302
  else:
303
  if current:
304
  lines.append(current)
305
  current = token
306
+
307
  if current:
308
  lines.append(current)
309
 
 
310
  if len(lines) > max_lines:
311
+ kept = lines[:max_lines - 1]
312
+ kept.append(" ".join(lines[max_lines - 1:]))
 
 
313
  lines = kept
314
 
315
+ return lines
316
 
317
+ def pick_layout(text: str):
318
  """
319
+ Try a few font sizes and pick one that fits nicely.
320
  """
321
+ if FONT_PATH and os.path.exists(FONT_PATH):
322
+ candidates = [42, 40, 38, 36, 34, 32]
323
+ max_box_width = 940
324
+ padding_x = 36
325
+ padding_y = 22
326
+ line_spacing = 10
327
+ bottom_margin = 230
328
+ radius = 20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
 
330
+ for font_size in candidates:
331
+ font = ImageFont.truetype(FONT_PATH, font_size)
332
+ lines = wrap_text_by_pixels(
333
+ text=text,
334
+ font=font,
335
+ max_width_px=max_box_width - (padding_x * 2),
336
+ max_lines=4
337
+ )
338
+ if not lines:
339
+ continue
340
 
341
+ widths = [measure_text_width(font, line) for line in lines]
342
+ heights = [measure_text_height(font, line) for line in lines]
343
+
344
+ box_w = min(max_box_width, max(widths) + padding_x * 2)
345
+ box_h = sum(heights) + line_spacing * (len(lines) - 1) + padding_y * 2
346
+
347
+ if box_h <= 420:
348
+ return {
349
+ "font": font,
350
+ "font_size": font_size,
351
+ "lines": lines,
352
+ "box_w": box_w,
353
+ "box_h": box_h,
354
+ "padding_x": padding_x,
355
+ "padding_y": padding_y,
356
+ "line_spacing": line_spacing,
357
+ "bottom_margin": bottom_margin,
358
+ "radius": radius,
359
+ }
360
+
361
+ font = ImageFont.truetype(FONT_PATH, 32)
362
+ lines = wrap_text_by_pixels(
363
+ text=text,
364
+ font=font,
365
+ max_width_px=max_box_width - (padding_x * 2),
366
+ max_lines=4
367
+ )
368
+ widths = [measure_text_width(font, line) for line in lines] if lines else [0]
369
+ heights = [measure_text_height(font, line) for line in lines] if lines else [0]
370
+ box_w = min(max_box_width, max(widths) + padding_x * 2)
371
+ box_h = sum(heights) + line_spacing * (len(lines) - 1) + padding_y * 2
372
+ return {
373
+ "font": font,
374
+ "font_size": 32,
375
+ "lines": lines,
376
+ "box_w": box_w,
377
+ "box_h": box_h,
378
+ "padding_x": padding_x,
379
+ "padding_y": padding_y,
380
+ "line_spacing": line_spacing,
381
+ "bottom_margin": bottom_margin,
382
+ "radius": radius,
383
+ }
384
+
385
+ font = ImageFont.load_default()
386
+ lines = wrap_text_by_pixels(text=text, font=font, max_width_px=900, max_lines=4)
387
+ widths = [measure_text_width(font, line) for line in lines] if lines else [0]
388
+ heights = [measure_text_height(font, line) for line in lines] if lines else [0]
389
+ box_w = min(940, max(widths) + 72)
390
+ box_h = sum(heights) + 10 * (len(lines) - 1) + 44
391
+ return {
392
+ "font": font,
393
+ "font_size": 16,
394
+ "lines": lines,
395
+ "box_w": box_w,
396
+ "box_h": box_h,
397
+ "padding_x": 36,
398
+ "padding_y": 22,
399
+ "line_spacing": 10,
400
+ "bottom_margin": 230,
401
+ "radius": 20,
402
+ }
403
+
404
+ def render_subtitle_frame(text: str, image_path: str):
405
+ layout = pick_layout(text)
406
+ font = layout["font"]
407
+ lines = layout["lines"]
408
+ box_w = layout["box_w"]
409
+ box_h = layout["box_h"]
410
+ padding_x = layout["padding_x"]
411
+ padding_y = layout["padding_y"]
412
+ line_spacing = layout["line_spacing"]
413
+ bottom_margin = layout["bottom_margin"]
414
+ radius = layout["radius"]
415
+
416
+ img = Image.new("RGBA", (FRAME_W, FRAME_H), (0, 0, 0, 0))
417
+ draw = ImageDraw.Draw(img)
418
+
419
+ x0 = int((FRAME_W - box_w) / 2)
420
+ y0 = int(FRAME_H - bottom_margin - box_h)
421
+ x1 = x0 + box_w
422
+ y1 = y0 + box_h
423
+
424
+ # Solid black background box
425
+ draw.rounded_rectangle(
426
+ [x0, y0, x1, y1],
427
+ radius=radius,
428
+ fill=(0, 0, 0, 255)
429
+ )
430
+
431
+ y = y0 + padding_y
432
+ for line in lines:
433
+ line_w = measure_text_width(font, line)
434
+ line_h = measure_text_height(font, line)
435
+ tx = int((FRAME_W - line_w) / 2)
436
+ draw.text(
437
+ (tx, y),
438
+ line,
439
+ font=font,
440
+ fill=(255, 255, 255, 255)
441
+ )
442
+ y += line_h + line_spacing
443
+
444
+ img.save(image_path)
445
+
446
+ def build_subtitle_overlays(transcript, job_dir):
447
+ overlay_specs = []
448
+ for idx, seg in enumerate(transcript):
449
  text = seg["text"].strip()
450
  if not text:
451
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
452
 
453
+ png_name = f"sub_{idx:03d}.png"
454
+ png_path = os.path.join(job_dir, png_name)
455
+ render_subtitle_frame(text, png_path)
456
+
457
+ overlay_specs.append({
458
+ "path": png_path,
459
+ "start": float(seg["start"]),
460
+ "end": float(seg["end"]),
461
+ })
462
+
463
+ return overlay_specs
464
 
465
  @app.route("/")
466
  def home():
 
468
 
469
  @app.route("/video/<path:filename>")
470
  def serve_video(filename):
 
471
  file_path = os.path.join(OUTPUT_FOLDER, filename)
472
  if not os.path.exists(file_path):
473
  abort(404)
474
+
475
+ response = send_from_directory(
476
+ OUTPUT_FOLDER,
477
+ filename,
478
+ as_attachment=False,
479
+ conditional=True
480
+ )
481
  response.headers["Cache-Control"] = "no-store"
482
  return response
483
 
484
  @app.route("/generate", methods=["POST"])
485
  def generate():
 
486
  if "image" not in request.files or "audio" not in request.files:
487
  return jsonify({"error": "Missing files"}), 400
488
+
489
  image = request.files["image"]
490
  audio = request.files["audio"]
491
+
492
  if not image.filename or not audio.filename:
493
  return jsonify({"error": "Please upload both image and audio"}), 400
494
 
495
  uid = str(uuid.uuid4())
496
+
497
+ image_name = secure_filename(image.filename)
498
+ audio_name = secure_filename(audio.filename)
499
+
500
+ image_path = os.path.join(UPLOAD_FOLDER, f"{uid}_{image_name}")
501
+ audio_path = os.path.join(UPLOAD_FOLDER, f"{uid}_{audio_name}")
502
  output_filename = f"{uid}.mp4"
503
  output_path = os.path.join(OUTPUT_FOLDER, output_filename)
504
+ job_subtitle_dir = os.path.join(SUBTITLE_FOLDER, uid)
505
+
506
+ os.makedirs(job_subtitle_dir, exist_ok=True)
507
 
 
508
  image.save(image_path)
509
  audio.save(audio_path)
510
 
511
  try:
512
+ segments_iter, info = model.transcribe(
513
+ audio_path,
514
+ beam_size=1,
515
+ vad_filter=True
516
+ )
517
+
518
  transcript = []
519
+ full_text_parts = []
520
+
521
+ for segment in segments_iter:
522
+ text = segment.text.strip()
523
+ if not text:
524
+ continue
525
+
526
  transcript.append({
527
+ "start": round(segment.start, 2),
528
+ "end": round(segment.end, 2),
529
  "text": text
530
  })
531
+ full_text_parts.append(text)
532
+
533
+ overlay_specs = build_subtitle_overlays(transcript, job_subtitle_dir)
534
+
535
+ # Inputs:
536
+ # 0 = image
537
+ # 1..n = subtitle PNG overlays
538
+ # last = audio
 
 
 
 
 
539
  cmd = [
540
+ "ffmpeg",
541
+ "-y",
542
+ "-loop", "1",
543
+ "-framerate", "1",
544
+ "-i", image_path,
545
+ ]
546
+
547
+ for spec in overlay_specs:
548
+ cmd.extend([
549
+ "-loop", "1",
550
+ "-framerate", "1",
551
+ "-i", spec["path"]
552
+ ])
553
+
554
+ cmd.extend([
555
  "-i", audio_path,
556
+ ])
557
+
558
+ filter_parts = [
559
+ "[0:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920[base]"
 
 
 
560
  ]
 
561
 
562
+ last_label = "[base]"
563
+
564
+ for idx, spec in enumerate(overlay_specs):
565
+ input_idx = idx + 1
566
+ next_label = f"[v{idx}]"
567
+ start = spec["start"]
568
+ end = spec["end"]
569
+ filter_parts.append(
570
+ f"{last_label}[{input_idx}:v]overlay=0:0:enable='between(t,{start:.2f},{end:.2f})'{next_label}"
571
+ )
572
+ last_label = next_label
573
+
574
+ if overlay_specs:
575
+ filter_complex = ";".join(filter_parts)
576
+ else:
577
+ filter_complex = "[0:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920[vout]"
578
+
579
+ if overlay_specs:
580
+ final_video_label = last_label
581
+ else:
582
+ final_video_label = "[vout]"
583
+
584
+ audio_input_index = len(overlay_specs) + 1
585
+
586
+ cmd.extend([
587
+ "-filter_complex", filter_complex,
588
+ "-map", final_video_label,
589
+ "-map", f"{audio_input_index}:a:0",
590
+ "-c:v", "libx264",
591
+ "-preset", "ultrafast",
592
+ "-crf", "20",
593
+ "-pix_fmt", "yuv420p",
594
+ "-r", "24",
595
+ "-c:a", "aac",
596
+ "-b:a", "128k",
597
+ "-movflags", "+faststart",
598
+ "-shortest",
599
+ output_path
600
+ ])
601
+
602
+ result = subprocess.run(
603
+ cmd,
604
+ stdout=subprocess.PIPE,
605
+ stderr=subprocess.PIPE,
606
+ check=True
607
+ )
608
+
609
  if not os.path.exists(output_path) or os.path.getsize(output_path) == 0:
610
+ return jsonify({
611
+ "error": "Video file not created",
612
+ "details": "FFmpeg ran but output file is missing or empty."
613
+ }), 500
614
 
615
  return jsonify({
616
  "video_url": f"/video/{output_filename}",
617
  "transcript": transcript,
618
+ "full_text": " ".join(full_text_parts).strip(),
619
  "language": getattr(info, "language", None)
620
  })
621
 
622
  except subprocess.CalledProcessError as e:
623
+ return jsonify({
624
+ "error": "FFmpeg failed",
625
+ "details": e.stderr.decode("utf-8", errors="ignore")
626
+ }), 500
627
+
628
  except Exception as e:
629
+ return jsonify({
630
+ "error": "Processing failed",
631
+ "details": str(e)
632
+ }), 500
633
 
634
  if __name__ == "__main__":
635
+ app.run(host="0.0.0.0", port=7860, debug=True)