RandomPersonRR commited on
Commit
70923cd
·
verified ·
1 Parent(s): 20c5a76

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +238 -283
app.py CHANGED
@@ -1,19 +1,21 @@
1
- # low_quality_video_inator.py
2
  import asyncio
3
  import os
4
  import re
5
  import shutil
6
  import uuid
7
- import glob
8
- import subprocess
9
  from pathlib import Path
 
 
10
  import gradio as gr
11
 
12
  # -------------------------
13
- # Config / constants
14
  # -------------------------
15
- UPLOAD_FOLDER = Path("uploads")
16
- CONVERTED_FOLDER = Path("converted")
 
17
  UPLOAD_FOLDER.mkdir(exist_ok=True)
18
  CONVERTED_FOLDER.mkdir(exist_ok=True)
19
 
@@ -26,33 +28,60 @@ ALLOWED_EXTENSIONS = {
26
  ".m4b", ".mp3", ".oga", ".voc", ".wav", ".weba", ".wma"
27
  }
28
 
29
- # FFmpeg defaults
30
  VIDEO_BASE_OPTS = ["-crf", "63", "-c:v", "libx264", "-tune", "zerolatency"]
31
  ACCEL = "auto"
32
 
33
- # Regex to find ffmpeg "time=HH:MM:SS.mmm" in stderr
34
  FFMPEG_TIME_RE = re.compile(r"time=(\d+):(\d+):(\d+\.\d+)")
35
 
36
- # Helper HTML progress bar generator (friendly and portable)
37
- def progress_html(percent: float, step: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  pct = max(0.0, min(100.0, percent))
39
- bar = f"""
40
- <div style="font-family:system-ui,Segoe UI,Roboto,Arial;margin-bottom:6px;">
41
- <div style="font-size:14px;margin-bottom:4px;"><strong>Step:</strong> {gr.utils.html.escape(step)}</div>
42
- <div style="background:#eee;border-radius:6px;overflow:hidden;border:1px solid #ddd;">
43
- <div style="width:{pct:.2f}%;padding:8px 0;text-align:center;white-space:nowrap;">
44
- <span style="color:#111;font-weight:600;">{pct:.1f}%</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  </div>
46
  </div>
47
  </div>
48
- """
49
- return bar
50
 
51
- # -------------------------
52
- # Async process helpers
53
- # -------------------------
54
- async def run_command_capture(cmd, cwd=None, env=None):
55
- """Run command to completion, return (stdout, stderr, returncode)."""
56
  proc = await asyncio.create_subprocess_exec(
57
  *cmd,
58
  cwd=cwd,
@@ -63,73 +92,33 @@ async def run_command_capture(cmd, cwd=None, env=None):
63
  stdout, stderr = await proc.communicate()
64
  return stdout.decode(errors="ignore"), stderr.decode(errors="ignore"), proc.returncode
65
 
66
- async def get_duration_seconds(path: Path) -> float | None:
67
- """Get media duration using ffprobe. Returns seconds or None."""
68
  cmd = [
69
  "ffprobe", "-v", "error",
70
- "-select_streams", "v:0",
71
  "-show_entries", "format=duration",
72
  "-of", "default=noprint_wrappers=1:nokey=1",
73
  str(path)
74
  ]
75
  stdout, stderr, rc = await run_command_capture(cmd)
76
- if rc != 0:
77
- # try generic format probing
78
- cmd2 = [
79
- "ffprobe", "-v", "error",
80
- "-show_entries", "format=duration",
81
- "-of", "default=noprint_wrappers=1:nokey=1",
82
- str(path),
83
- ]
84
- stdout, stderr, rc = await run_command_capture(cmd2)
85
- if rc != 0:
86
- return None
87
  try:
88
  return float(stdout.strip())
89
  except Exception:
90
  return None
91
 
92
- async def stream_ffmpeg_with_progress(cmd, total_seconds, step_name, yield_fn):
93
- """
94
- Run ffmpeg command and stream progress updates via yield_fn(percent, step_name).
95
- yield_fn is a coroutine function that accepts (percent: float, step_text: str)
96
- """
97
- # Start process, read stderr line-by-line
98
- proc = await asyncio.create_subprocess_exec(
99
- *cmd,
100
- stdout=asyncio.subprocess.PIPE,
101
- stderr=asyncio.subprocess.PIPE,
102
- env=os.environ.copy()
103
- )
104
-
105
- last_percent = 0.0
106
- # Read stderr bytes as they arrive
107
- while True:
108
- line = await proc.stderr.readline()
109
- if not line:
110
- break
111
- text = line.decode(errors="ignore").strip()
112
- # parse time=HH:MM:SS.ms
113
- m = FFMPEG_TIME_RE.search(text)
114
- if m and total_seconds and total_seconds > 0.0:
115
- hh, mm, ss = m.groups()
116
- current = int(hh) * 3600 + int(mm) * 60 + float(ss)
117
- percent = (current / total_seconds) * 100.0
118
- # Only yield when percent changed enough to avoid UI spam
119
- if percent - last_percent >= 0.5:
120
- last_percent = percent
121
- await yield_fn(percent, step_name)
122
- # Optionally we can yield textual debug lines (not required)
123
- await proc.wait()
124
- return proc.returncode
125
 
126
  # -------------------------
127
- # Conversion logic (async generator feeding Gradio)
 
128
  # -------------------------
129
  async def convert_stream(
130
  use_youtube: bool,
131
  youtube_url: str,
132
- video_file, # gr.File-like arg when uploaded
133
  downscale: bool,
134
  faster: bool,
135
  use_mp3: bool,
@@ -137,111 +126,72 @@ async def convert_stream(
137
  custom_bitrate: bool,
138
  video_bitrate: float
139
  ):
140
- # Convenience small helper to push progress to UI (Gradio generator will yield)
141
- async def push(percent: float, step_text: str):
142
- yield progress_html(percent, step_text), step_text, None, None
143
-
144
- # Another helper to yield final outputs
145
- def final_yield(percent, step_text, preview_path, download_path):
146
- # Gradio generator expects plain values, not coroutine - so we return these
147
- return progress_html(percent, step_text), step_text, str(preview_path) if preview_path else None, str(download_path) if download_path else None
148
-
149
- # Start
150
- step = "Starting..."
151
- yield progress_html(0.0, step), step, None, None
152
 
153
- # Choose input source
154
- input_path: Path | None = None
155
  temp_files = []
 
156
 
157
  try:
 
158
  if use_youtube:
159
  if not youtube_url:
160
- step = "Error: YouTube URL required."
161
- yield progress_html(0.0, step), step, None, None
 
 
162
  return
163
 
164
- step = "Attempting to download from YouTube (yt-dlp)..."
165
- yield progress_html(1.0, step), step, None, None
166
-
167
- # Build output template
168
  out_uuid = uuid.uuid4().hex
169
  out_template = str(UPLOAD_FOLDER / f"{out_uuid}.%(ext)s")
170
  ytdlp_cmd = ["yt-dlp", "-f", "b", "-o", out_template, youtube_url]
171
-
172
- # Run and capture stderr to inspect errors
173
  stdout, stderr, rc = await run_command_capture(ytdlp_cmd)
 
174
  if rc != 0:
175
- # Inspect stderr for "Video unavailable"
176
- combined = (stdout or "") + "\n" + (stderr or "")
177
  if "Video unavailable" in combined or "This video is unavailable" in combined:
178
- step = "Error: Video unavailable on YouTube (removed / private / blocked by uploader)."
179
- yield progress_html(0.0, step), step, None, None
180
- return
181
- else:
182
- # Assume server/cloud IP is blocked by YouTube; instruct user to upload
183
- step = (
184
- "Could not download from YouTube from this server. "
185
- "This is commonly caused by YouTube blocking cloud host IPs. "
186
- "Please upload the file manually using the 'Upload Video File' control, or try again from another network."
187
- )
188
- yield progress_html(0.0, step), step, None, None
189
  return
190
- # find the downloaded file
 
191
  files = list(UPLOAD_FOLDER.glob(f"{out_uuid}.*"))
192
  if not files:
193
- step = "Download completed but file not found."
194
- yield progress_html(0.0, step), step, None, None
195
  return
196
  input_path = files[0]
197
  temp_files.append(input_path)
198
-
199
  else:
200
  if not video_file:
201
- step = "No video provided. Upload a file or use a YouTube URL."
202
- yield progress_html(0.0, step), step, None, None
203
  return
204
- # gr.File's .name gives a local temp path in many deployments
205
  try:
206
  ext = Path(video_file.name).suffix.lower()
207
  except Exception:
208
  ext = None
209
  if ext not in ALLOWED_EXTENSIONS:
210
- step = f"Unsupported file type: {ext}"
211
- yield progress_html(0.0, step), step, None, None
212
  return
213
  input_path = UPLOAD_FOLDER / f"{uuid.uuid4().hex}{ext}"
214
  shutil.copy2(video_file.name, input_path)
215
  temp_files.append(input_path)
216
 
217
- # We now have input_path
218
- step = "Probing file duration..."
219
- yield progress_html(2.0, step), step, None, None
220
- total = await get_duration_seconds(input_path)
221
- if not total:
222
- # Might still proceed but progress can't show percent; we will show step-based progress
223
- step = "Warning: could not read duration; progress will be step-based."
224
- yield progress_html(0.0, step), step, None, None
225
- total = None
226
-
227
- # Handle audio-only or full conversion
228
  if audio_only:
 
229
  if use_mp3:
230
- step = "Converting audio to MP3..."
231
- yield progress_html(1.0, step), step, None, None
232
  out_audio = CONVERTED_FOLDER / f"{uuid.uuid4().hex}.mp3"
233
- # ffmpeg low bitrate mp3 example:
234
  ffmpeg_cmd = [
235
  "ffmpeg", "-y", "-i", str(input_path),
236
- "-ac", "1", "-ar", "24000", "-b:a", "8k",
237
- str(out_audio)
238
  ]
239
- # If we have duration, stream progress parsing
240
- if total:
241
- async def _yield_fn(p, s):
242
- yield progress_html(p, s), s, None, None
243
- # Because run_ffmpeg_with_progress expects a coroutine yield_fn, but Gradio expects
244
- # yields from this generator directly, implement manual streaming:
245
  proc = await asyncio.create_subprocess_exec(*ffmpeg_cmd, stderr=asyncio.subprocess.PIPE)
246
  last = 0.0
247
  while True:
@@ -250,191 +200,205 @@ async def convert_stream(
250
  break
251
  txt = line.decode(errors="ignore")
252
  m = FFMPEG_TIME_RE.search(txt)
253
- if m and total:
254
  hh, mm, ss = m.groups()
255
  current = int(hh) * 3600 + int(mm) * 60 + float(ss)
256
- percent = (current / total) * 100.0
257
- if percent - last >= 0.5:
258
- last = percent
259
- yield progress_html(percent, step), step, None, None
260
  await proc.wait()
261
  if proc.returncode != 0:
262
- raise RuntimeError("ffmpeg audio conversion failed")
 
263
  else:
264
- # fallback synchronous run
265
  stdout, stderr, rc = await run_command_capture(ffmpeg_cmd)
266
  if rc != 0:
267
- raise RuntimeError(f"ffmpeg failed: {stderr[:200]}")
268
- # Done
269
- yield final_yield(100.0, "Audio conversion finished.", out_audio, out_audio)
270
  return
271
- else:
272
- # AAC path (use ffmpeg's aac if fdkaac missing)
273
- step = "Extracting audio and encoding AAC..."
274
- yield progress_html(1.0, step), step, None, None
275
- out_audio = CONVERTED_FOLDER / f"{uuid.uuid4().hex}.m4a"
276
- ffmpeg_cmd = [
277
- "ffmpeg", "-y", "-i", str(input_path),
278
- "-ac", "1", "-ar", "8000", "-c:a", "aac", "-b:a", "12k",
279
- str(out_audio)
280
- ]
281
- # stream progress if possible
282
- if total:
283
- proc = await asyncio.create_subprocess_exec(*ffmpeg_cmd, stderr=asyncio.subprocess.PIPE)
284
- last = 0.0
285
- while True:
286
- line = await proc.stderr.readline()
287
- if not line:
288
- break
289
- txt = line.decode(errors="ignore")
290
- m = FFMPEG_TIME_RE.search(txt)
291
- if m and total:
292
- hh, mm, ss = m.groups()
293
- current = int(hh) * 3600 + int(mm) * 60 + float(ss)
294
- percent = (current / total) * 100.0
295
- if percent - last >= 0.5:
296
- last = percent
297
- yield progress_html(percent, step), step, None, None
298
- await proc.wait()
299
- if proc.returncode != 0:
300
- raise RuntimeError("ffmpeg aac audio encoding failed")
301
- else:
302
- stdout, stderr, rc = await run_command_capture(ffmpeg_cmd)
303
- if rc != 0:
304
- raise RuntimeError(f"ffmpeg failed: {stderr[:200]}")
305
 
306
- yield final_yield(100.0, "Audio conversion finished.", out_audio, out_audio)
 
 
 
307
  return
308
 
309
- # ---------- Video (with audio) flow ----------
310
- # 1) encode audio (unless we're merging in single pass). We'll encode audio then encode video then merge.
311
- step = "Encoding audio track..."
312
- yield progress_html(1.0, step), step, None, None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  out_audio = CONVERTED_FOLDER / f"{uuid.uuid4().hex}.m4a"
314
- ffmpeg_audio_cmd = [
315
- "ffmpeg", "-y", "-i", str(input_path),
316
- "-ac", "1", "-ar", "8000", "-c:a"
317
- ]
318
  if use_mp3:
319
- ffmpeg_audio_cmd += ["libmp3lame", "-b:a", "8k", str(out_audio)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  else:
321
- ffmpeg_audio_cmd += ["aac", "-b:a", "12k", str(out_audio)]
 
 
 
322
 
323
- if total:
324
- proc = await asyncio.create_subprocess_exec(*ffmpeg_audio_cmd, stderr=asyncio.subprocess.PIPE)
325
- last = 0.0
326
- while True:
327
- line = await proc.stderr.readline()
328
- if not line:
329
- break
330
- txt = line.decode(errors="ignore")
331
- m = FFMPEG_TIME_RE.search(txt)
332
- if m and total:
333
- hh, mm, ss = m.groups()
334
- current = int(hh) * 3600 + int(mm) * 60 + float(ss)
335
- percent = (current / total) * 100.0 * 0.20 # audio is 20% of total pipeline (visual weighting)
336
- if percent - last >= 0.5:
337
- last = percent
338
- yield progress_html(percent, step), step, None, None
339
- await proc.wait()
340
- if proc.returncode != 0:
341
- raise RuntimeError("ffmpeg audio encoding failed")
342
- else:
343
- stdout, stderr, rc = await run_command_capture(ffmpeg_audio_cmd)
 
 
 
 
344
  if rc != 0:
345
- raise RuntimeError("ffmpeg audio encoding failed (no duration)")
 
 
346
 
347
- # 2) encode video-only
348
- step = "Encoding video track..."
349
- yield progress_html(5.0, step), step, None, None
350
  out_video = CONVERTED_FOLDER / f"{uuid.uuid4().hex}.mp4"
351
  ffmpeg_video_cmd = ["ffmpeg", "-y", "-hwaccel", ACCEL, "-i", str(input_path)]
352
-
353
- # Downscale if requested
354
  if downscale:
355
  ffmpeg_video_cmd += ["-vf", "scale=-2:144"]
356
-
357
- # Bitrate handling
358
  if custom_bitrate and video_bitrate:
359
  ffmpeg_video_cmd += ["-b:v", f"{int(video_bitrate)}k"]
360
  else:
361
  ffmpeg_video_cmd += VIDEO_BASE_OPTS
362
-
363
  if faster:
364
  ffmpeg_video_cmd += ["-preset", "ultrafast"]
365
-
366
  ffmpeg_video_cmd += ["-an", str(out_video)]
367
 
368
- if total:
369
- # We'll treat video encoding as 70% of pipeline
370
  proc = await asyncio.create_subprocess_exec(*ffmpeg_video_cmd, stderr=asyncio.subprocess.PIPE)
371
- last = 0.0
372
  while True:
373
  line = await proc.stderr.readline()
374
  if not line:
375
  break
376
  txt = line.decode(errors="ignore")
377
  m = FFMPEG_TIME_RE.search(txt)
378
- if m and total:
379
  hh, mm, ss = m.groups()
380
  current = int(hh) * 3600 + int(mm) * 60 + float(ss)
381
- percent_video = (current / total) * 100.0
382
- # Map video portion to a weighted chunk (20% done already for audio, video 70% here)
383
- combined_percent = 20.0 + (percent_video * 0.70)
384
- if combined_percent - last >= 0.5:
385
- last = combined_percent
386
- yield progress_html(combined_percent, step), step, None, None
387
  await proc.wait()
388
  if proc.returncode != 0:
389
- raise RuntimeError("ffmpeg video encoding failed")
 
390
  else:
391
  stdout, stderr, rc = await run_command_capture(ffmpeg_video_cmd)
392
  if rc != 0:
393
- raise RuntimeError("ffmpeg video encoding failed (no duration)")
 
394
 
395
- # 3) merge audio+video (fast copy)
396
- step = "Merging audio and video..."
397
- yield progress_html(95.0, step), step, None, None
398
  merged_out = CONVERTED_FOLDER / f"{uuid.uuid4().hex}.mp4"
399
- merge_cmd = [
400
- "ffmpeg", "-y",
401
- "-i", str(out_video),
402
- "-i", str(out_audio),
403
- "-c", "copy",
404
- str(merged_out)
405
- ]
406
  stdout, stderr, rc = await run_command_capture(merge_cmd)
407
  if rc != 0:
408
- # sometimes copy fails due to container specifics; fallback to re-encoding mux
409
- merge_cmd = [
410
- "ffmpeg", "-y",
411
- "-i", str(out_video),
412
- "-i", str(out_audio),
413
- "-c:v", "copy", "-c:a", "aac",
414
- str(merged_out)
415
- ]
416
  stdout, stderr, rc = await run_command_capture(merge_cmd)
417
  if rc != 0:
418
- raise RuntimeError("Merging audio and video failed")
 
419
 
420
- # cleanup temp pieces
421
  for p in (out_audio, out_video):
422
  try:
423
  p.unlink()
424
  except Exception:
425
  pass
426
 
427
- # Done
428
- step = "Finished"
429
- yield final_yield(100.0, "Conversion complete!", merged_out, merged_out)
430
  return
431
 
432
  except Exception as e:
433
- err = f"Error: {e}"
434
- yield progress_html(0.0, err), err, None, None
435
  return
436
  finally:
437
- # remove temporary download files if any
438
  for p in temp_files:
439
  try:
440
  p.unlink()
@@ -444,14 +408,11 @@ async def convert_stream(
444
  # -------------------------
445
  # Gradio UI
446
  # -------------------------
447
- with gr.Blocks(title="Low Quality Video Inator", css="""
448
- .gradio-container { font-family: system-ui, 'Segoe UI', Roboto, Arial; }
449
- """) as demo:
450
-
451
- gr.Markdown("# Low Quality Video Inator\nUpload a video file or paste a YouTube URL. The interface will stream a step-aware progress bar while ffmpeg runs.")
452
 
453
  with gr.Row():
454
- use_youtube = gr.Checkbox(label="Use YouTube URL (try server download first)", value=False)
455
  youtube_url = gr.Textbox(label="YouTube URL", placeholder="https://youtube.com/...", lines=1)
456
 
457
  video_file = gr.File(label="Upload Video File", file_types=list(ALLOWED_EXTENSIONS), file_count="single")
@@ -464,28 +425,22 @@ with gr.Blocks(title="Low Quality Video Inator", css="""
464
  use_mp3 = gr.Checkbox(label="Use MP3 audio", value=False)
465
  audio_only = gr.Checkbox(label="Audio Only", value=False)
466
  with gr.Row():
467
- custom_bitrate = gr.Checkbox(label="Use Custom Video Bitrate (kbps)", value=False)
468
- video_bitrate = gr.Number(label="Video Bitrate (kbps)", value=64, visible=False)
469
 
470
- # Toggle visibility of bitrate input
471
- def toggle_bitrate_checkbox(use: bool):
472
- return gr.update(visible=use)
473
- custom_bitrate.change(toggle_bitrate_checkbox, inputs=[custom_bitrate], outputs=[video_bitrate])
474
 
475
  convert_btn = gr.Button("Convert Now", variant="primary")
476
 
477
- with gr.Column():
478
- progress_area = gr.HTML("<div>Ready.</div>", label="Progress")
479
- step_text = gr.Textbox(value="Idle", label="Current Step", interactive=False)
480
- preview = gr.Video(label="Preview Output")
481
- download_file = gr.File(label="Download Result")
482
 
483
- # Hook up generator -> UI
484
  convert_btn.click(
485
  fn=convert_stream,
486
  inputs=[use_youtube, youtube_url, video_file, downscale, faster, use_mp3, audio_only, custom_bitrate, video_bitrate],
487
- outputs=[progress_area, step_text, preview, download_file]
488
  )
489
 
490
- # Safety / hosting configuration: no flagging UI
491
- demo.launch()
 
1
+ # app.py
2
  import asyncio
3
  import os
4
  import re
5
  import shutil
6
  import uuid
7
+ import html
 
8
  from pathlib import Path
9
+ from typing import Optional, Tuple
10
+
11
  import gradio as gr
12
 
13
  # -------------------------
14
+ # Config
15
  # -------------------------
16
+ BASE_DIR = Path(".")
17
+ UPLOAD_FOLDER = BASE_DIR / "uploads"
18
+ CONVERTED_FOLDER = BASE_DIR / "converted"
19
  UPLOAD_FOLDER.mkdir(exist_ok=True)
20
  CONVERTED_FOLDER.mkdir(exist_ok=True)
21
 
 
28
  ".m4b", ".mp3", ".oga", ".voc", ".wav", ".weba", ".wma"
29
  }
30
 
 
31
  VIDEO_BASE_OPTS = ["-crf", "63", "-c:v", "libx264", "-tune", "zerolatency"]
32
  ACCEL = "auto"
33
 
 
34
  FFMPEG_TIME_RE = re.compile(r"time=(\d+):(\d+):(\d+\.\d+)")
35
 
36
+ # Ensure fdkaac executable bit if it exists in cwd
37
+ FDKAAC_PATH = Path("./fdkaac")
38
+ try:
39
+ if FDKAAC_PATH.exists():
40
+ FDKAAC_PATH.chmod(FDKAAC_PATH.stat().st_mode | 0o111)
41
+ except Exception:
42
+ pass
43
+
44
+ # -------------------------
45
+ # Helpers
46
+ # -------------------------
47
+ def is_audio_file(path: str) -> bool:
48
+ ext = Path(path).suffix.lower()
49
+ return ext in {".mp3", ".m4a", ".wav", ".aac", ".oga", ".ogg"}
50
+
51
+ def preview_html(percent: float, step: str, media_src: Optional[str] = None) -> str:
52
+ """Return HTML containing media preview (video or audio) with overlayed progress/step."""
53
  pct = max(0.0, min(100.0, percent))
54
+ esc_step = html.escape(str(step))
55
+ media_tag = ""
56
+ if media_src:
57
+ esc_src = html.escape(str(media_src))
58
+ if is_audio_file(media_src):
59
+ media_tag = f'<audio src="{esc_src}" controls style="width:100%;display:block;"></audio>'
60
+ else:
61
+ media_tag = f'<video src="{esc_src}" controls style="width:100%;height:auto;display:block;"></video>'
62
+ else:
63
+ media_tag = (
64
+ '<div style="width:100%;height:320px;display:flex;align-items:center;justify-content:center;'
65
+ 'background:#0b0b0b;color:#fff;font-size:16px;">Preview will appear here once available</div>'
66
+ )
67
+
68
+ return f'''
69
+ <div style="position:relative;border-radius:8px;overflow:hidden;border:1px solid #ddd;">
70
+ {media_tag}
71
+ <div style="position:absolute;left:8px;right:8px;bottom:12px;pointer-events:none;">
72
+ <div style="background:rgba(0,0,0,0.52);padding:10px;border-radius:8px;color:#fff;font-family:system-ui,Segoe UI,Roboto,Arial;">
73
+ <div style="font-size:13px;margin-bottom:8px;"><strong>{esc_step}</strong></div>
74
+ <div style="height:12px;background:#222;border-radius:6px;overflow:hidden;">
75
+ <div style="width:{pct:.2f}%;height:100%;background:linear-gradient(90deg,#21d4fd,#b721ff);"></div>
76
+ </div>
77
+ <div style="font-size:12px;margin-top:6px;color:#ddd;">{pct:.1f}%</div>
78
  </div>
79
  </div>
80
  </div>
81
+ '''
 
82
 
83
+ async def run_command_capture(cmd, cwd=None, env=None) -> Tuple[str, str, int]:
84
+ """Run command to completion, capture stdout/stderr."""
 
 
 
85
  proc = await asyncio.create_subprocess_exec(
86
  *cmd,
87
  cwd=cwd,
 
92
  stdout, stderr = await proc.communicate()
93
  return stdout.decode(errors="ignore"), stderr.decode(errors="ignore"), proc.returncode
94
 
95
+ async def get_duration_seconds(path: Path) -> Optional[float]:
96
+ """Get duration using ffprobe (seconds) or None if unknown."""
97
  cmd = [
98
  "ffprobe", "-v", "error",
 
99
  "-show_entries", "format=duration",
100
  "-of", "default=noprint_wrappers=1:nokey=1",
101
  str(path)
102
  ]
103
  stdout, stderr, rc = await run_command_capture(cmd)
104
+ if rc != 0 or not stdout:
105
+ return None
 
 
 
 
 
 
 
 
 
106
  try:
107
  return float(stdout.strip())
108
  except Exception:
109
  return None
110
 
111
+ def which_cmd(name: str) -> Optional[str]:
112
+ return shutil.which(name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
  # -------------------------
115
+ # Converter generator
116
+ # streams tuples: (preview_html_str, download_path_or_None)
117
  # -------------------------
118
  async def convert_stream(
119
  use_youtube: bool,
120
  youtube_url: str,
121
+ video_file, # gr.File
122
  downscale: bool,
123
  faster: bool,
124
  use_mp3: bool,
 
126
  custom_bitrate: bool,
127
  video_bitrate: float
128
  ):
129
+ # initial
130
+ yield preview_html(0.0, "Starting..."), None
 
 
 
 
 
 
 
 
 
 
131
 
 
 
132
  temp_files = []
133
+ input_path: Optional[Path] = None
134
 
135
  try:
136
+ # SOURCE
137
  if use_youtube:
138
  if not youtube_url:
139
+ yield preview_html(0.0, "Error: YouTube URL required."), None
140
+ return
141
+ if not which_cmd("yt-dlp"):
142
+ yield preview_html(0.0, "yt-dlp not found on server. Please upload the file manually."), None
143
  return
144
 
145
+ yield preview_html(1.0, "Attempting YouTube download..."), None
 
 
 
146
  out_uuid = uuid.uuid4().hex
147
  out_template = str(UPLOAD_FOLDER / f"{out_uuid}.%(ext)s")
148
  ytdlp_cmd = ["yt-dlp", "-f", "b", "-o", out_template, youtube_url]
 
 
149
  stdout, stderr, rc = await run_command_capture(ytdlp_cmd)
150
+ combined = (stdout or "") + "\n" + (stderr or "")
151
  if rc != 0:
 
 
152
  if "Video unavailable" in combined or "This video is unavailable" in combined:
153
+ yield preview_html(0.0, "Video unavailable (removed/private/age-restricted)."), None
 
 
 
 
 
 
 
 
 
 
154
  return
155
+ yield preview_html(0.0, "Could not download from YouTube from this server (cloud host blocked). Please upload manually."), None
156
+ return
157
  files = list(UPLOAD_FOLDER.glob(f"{out_uuid}.*"))
158
  if not files:
159
+ yield preview_html(0.0, "Download completed but file not found."), None
 
160
  return
161
  input_path = files[0]
162
  temp_files.append(input_path)
 
163
  else:
164
  if not video_file:
165
+ yield preview_html(0.0, "No video provided. Upload or use YouTube URL."), None
 
166
  return
 
167
  try:
168
  ext = Path(video_file.name).suffix.lower()
169
  except Exception:
170
  ext = None
171
  if ext not in ALLOWED_EXTENSIONS:
172
+ yield preview_html(0.0, f"Unsupported file type: {ext}"), None
 
173
  return
174
  input_path = UPLOAD_FOLDER / f"{uuid.uuid4().hex}{ext}"
175
  shutil.copy2(video_file.name, input_path)
176
  temp_files.append(input_path)
177
 
178
+ yield preview_html(1.0, "Probing duration..."), None
179
+ total_seconds = await get_duration_seconds(input_path)
180
+ if not total_seconds:
181
+ yield preview_html(0.0, "Warning: duration unknown; progress will be step-based."), None
182
+
183
+ # AUDIO-ONLY PATH
 
 
 
 
 
184
  if audio_only:
185
+ # MP3 branch
186
  if use_mp3:
187
+ step = "Converting to MP3..."
 
188
  out_audio = CONVERTED_FOLDER / f"{uuid.uuid4().hex}.mp3"
 
189
  ffmpeg_cmd = [
190
  "ffmpeg", "-y", "-i", str(input_path),
191
+ "-ac", "1", "-ar", "24000", "-b:a", "8k", str(out_audio)
 
192
  ]
193
+ yield preview_html(2.0, step), None
194
+ if total_seconds:
 
 
 
 
195
  proc = await asyncio.create_subprocess_exec(*ffmpeg_cmd, stderr=asyncio.subprocess.PIPE)
196
  last = 0.0
197
  while True:
 
200
  break
201
  txt = line.decode(errors="ignore")
202
  m = FFMPEG_TIME_RE.search(txt)
203
+ if m:
204
  hh, mm, ss = m.groups()
205
  current = int(hh) * 3600 + int(mm) * 60 + float(ss)
206
+ pct = (current / total_seconds) * 100.0
207
+ if pct - last >= 0.5:
208
+ last = pct
209
+ yield preview_html(pct, step), None
210
  await proc.wait()
211
  if proc.returncode != 0:
212
+ yield preview_html(0.0, "ffmpeg failed while encoding MP3."), None
213
+ return
214
  else:
 
215
  stdout, stderr, rc = await run_command_capture(ffmpeg_cmd)
216
  if rc != 0:
217
+ yield preview_html(0.0, "ffmpeg failed while encoding MP3."), None
218
+ return
219
+ yield preview_html(100.0, "MP3 conversion finished.", media_src=str(out_audio)), str(out_audio)
220
  return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
 
222
+ # AAC via fdkaac branch (audio-only)
223
+ # Ensure fdkaac exists
224
+ if not FDKAAC_PATH.exists():
225
+ yield preview_html(0.0, "fdkaac not found at ./fdkaac — please add it to the app folder and make executable."), None
226
  return
227
 
228
+ step = "Preparing WAV for fdkaac..."
229
+ yield preview_html(2.0, step), None
230
+ wav_tmp = CONVERTED_FOLDER / f"{uuid.uuid4().hex}.wav"
231
+ aac_out = CONVERTED_FOLDER / f"{uuid.uuid4().hex}.m4a"
232
+
233
+ # Generate WAV (low sample rate)
234
+ ffmpeg_wav_cmd = ["ffmpeg", "-y", "-i", str(input_path), "-ac", "1", "-ar", "8000", str(wav_tmp)]
235
+ stdout, stderr, rc = await run_command_capture(ffmpeg_wav_cmd)
236
+ if rc != 0:
237
+ yield preview_html(0.0, "ffmpeg failed to produce WAV for fdkaac."), None
238
+ try:
239
+ wav_tmp.unlink()
240
+ except Exception:
241
+ pass
242
+ return
243
+
244
+ # Run fdkaac to produce m4a
245
+ fdkaac_cmd = [
246
+ "./fdkaac", "-b", "1k", "-C", "-f", "2", "-G", "1", "-w", "8000",
247
+ "-o", str(aac_out), str(wav_tmp)
248
+ ]
249
+ yield preview_html(5.0, "Encoding AAC with fdkaac..."), None
250
+ stdout, stderr, rc = await run_command_capture(fdkaac_cmd)
251
+ try:
252
+ wav_tmp.unlink()
253
+ except Exception:
254
+ pass
255
+ if rc != 0:
256
+ yield preview_html(0.0, f"fdkaac failed: {stderr[:300] if stderr else 'no output'}"), None
257
+ return
258
+
259
+ yield preview_html(100.0, "AAC (fdkaac) audio ready.", media_src=str(aac_out)), str(aac_out)
260
+ return
261
+
262
+ # ----------------------
263
+ # FULL VIDEO FLOW
264
+ # ----------------------
265
+ # 1) Audio encode (via fdkaac or mp3)
266
  out_audio = CONVERTED_FOLDER / f"{uuid.uuid4().hex}.m4a"
 
 
 
 
267
  if use_mp3:
268
+ ffmpeg_audio_cmd = ["ffmpeg", "-y", "-i", str(input_path), "-ac", "1", "-ar", "8000", "-c:a", "libmp3lame", "-b:a", "8k", str(out_audio)]
269
+ yield preview_html(2.0, "Encoding audio (MP3)..."), None
270
+ if total_seconds:
271
+ proc = await asyncio.create_subprocess_exec(*ffmpeg_audio_cmd, stderr=asyncio.subprocess.PIPE)
272
+ last = 0.0
273
+ while True:
274
+ line = await proc.stderr.readline()
275
+ if not line:
276
+ break
277
+ txt = line.decode(errors="ignore")
278
+ m = FFMPEG_TIME_RE.search(txt)
279
+ if m:
280
+ hh, mm, ss = m.groups()
281
+ current = int(hh) * 3600 + int(mm) * 60 + float(ss)
282
+ pct = (current / total_seconds) * 100.0 * 0.20
283
+ if pct - last >= 0.5:
284
+ last = pct
285
+ yield preview_html(pct, "Encoding audio (MP3)..."), None
286
+ await proc.wait()
287
+ if proc.returncode != 0:
288
+ yield preview_html(0.0, "ffmpeg audio encoding failed."), None
289
+ return
290
+ else:
291
+ stdout, stderr, rc = await run_command_capture(ffmpeg_audio_cmd)
292
+ if rc != 0:
293
+ yield preview_html(0.0, "ffmpeg audio encoding failed."), None
294
+ return
295
  else:
296
+ # Use fdkaac path for AAC
297
+ if not FDKAAC_PATH.exists():
298
+ yield preview_html(0.0, "fdkaac not found at ./fdkaac — please add it to the app folder and make executable."), None
299
+ return
300
 
301
+ # 1a: Create WAV
302
+ yield preview_html(2.0, "Generating WAV for fdkaac..."), None
303
+ wav_tmp = CONVERTED_FOLDER / f"{uuid.uuid4().hex}.wav"
304
+ ffmpeg_wav_cmd = ["ffmpeg", "-y", "-i", str(input_path), "-ac", "1", "-ar", "8000", str(wav_tmp)]
305
+ stdout, stderr, rc = await run_command_capture(ffmpeg_wav_cmd)
306
+ if rc != 0:
307
+ yield preview_html(0.0, "ffmpeg failed generating WAV for fdkaac."), None
308
+ try:
309
+ wav_tmp.unlink()
310
+ except Exception:
311
+ pass
312
+ return
313
+
314
+ # 1b: Run fdkaac
315
+ aac_tmp = CONVERTED_FOLDER / f"{uuid.uuid4().hex}.m4a"
316
+ fdkaac_cmd = [
317
+ "./fdkaac", "-b", "1k", "-C", "-f", "2", "-G", "1", "-w", "8000",
318
+ "-o", str(aac_tmp), str(wav_tmp)
319
+ ]
320
+ yield preview_html(6.0, "Encoding AAC with fdkaac..."), None
321
+ stdout, stderr, rc = await run_command_capture(fdkaac_cmd)
322
+ try:
323
+ wav_tmp.unlink()
324
+ except Exception:
325
+ pass
326
  if rc != 0:
327
+ yield preview_html(0.0, f"fdkaac failed: {stderr[:300] if stderr else 'no output'}"), None
328
+ return
329
+ out_audio = aac_tmp
330
 
331
+ # 2) Video encode
332
+ step_video = "Encoding video track..."
333
+ yield preview_html(20.0, step_video), None
334
  out_video = CONVERTED_FOLDER / f"{uuid.uuid4().hex}.mp4"
335
  ffmpeg_video_cmd = ["ffmpeg", "-y", "-hwaccel", ACCEL, "-i", str(input_path)]
 
 
336
  if downscale:
337
  ffmpeg_video_cmd += ["-vf", "scale=-2:144"]
 
 
338
  if custom_bitrate and video_bitrate:
339
  ffmpeg_video_cmd += ["-b:v", f"{int(video_bitrate)}k"]
340
  else:
341
  ffmpeg_video_cmd += VIDEO_BASE_OPTS
 
342
  if faster:
343
  ffmpeg_video_cmd += ["-preset", "ultrafast"]
 
344
  ffmpeg_video_cmd += ["-an", str(out_video)]
345
 
346
+ if total_seconds:
 
347
  proc = await asyncio.create_subprocess_exec(*ffmpeg_video_cmd, stderr=asyncio.subprocess.PIPE)
348
+ last = 20.0
349
  while True:
350
  line = await proc.stderr.readline()
351
  if not line:
352
  break
353
  txt = line.decode(errors="ignore")
354
  m = FFMPEG_TIME_RE.search(txt)
355
+ if m:
356
  hh, mm, ss = m.groups()
357
  current = int(hh) * 3600 + int(mm) * 60 + float(ss)
358
+ pct_video = (current / total_seconds) * 100.0
359
+ combined = 20.0 + (pct_video * 0.70)
360
+ if combined - last >= 0.5:
361
+ last = combined
362
+ yield preview_html(combined, step_video), None
 
363
  await proc.wait()
364
  if proc.returncode != 0:
365
+ yield preview_html(0.0, "ffmpeg video encoding failed."), None
366
+ return
367
  else:
368
  stdout, stderr, rc = await run_command_capture(ffmpeg_video_cmd)
369
  if rc != 0:
370
+ yield preview_html(0.0, "ffmpeg video encoding failed."), None
371
+ return
372
 
373
+ # 3) Merge audio + video
374
+ yield preview_html(90.0, "Merging audio & video..."), None
 
375
  merged_out = CONVERTED_FOLDER / f"{uuid.uuid4().hex}.mp4"
376
+ merge_cmd = ["ffmpeg", "-y", "-i", str(out_video), "-i", str(out_audio), "-c", "copy", str(merged_out)]
 
 
 
 
 
 
377
  stdout, stderr, rc = await run_command_capture(merge_cmd)
378
  if rc != 0:
379
+ # fallback re-encode audio into AAC during merge
380
+ merge_cmd = ["ffmpeg", "-y", "-i", str(out_video), "-i", str(out_audio), "-c:v", "copy", "-c:a", "aac", str(merged_out)]
 
 
 
 
 
 
381
  stdout, stderr, rc = await run_command_capture(merge_cmd)
382
  if rc != 0:
383
+ yield preview_html(0.0, "Merging audio and video failed."), None
384
+ return
385
 
386
+ # cleanup intermediate audio/video
387
  for p in (out_audio, out_video):
388
  try:
389
  p.unlink()
390
  except Exception:
391
  pass
392
 
393
+ # final
394
+ yield preview_html(100.0, "Conversion complete!", media_src=str(merged_out)), str(merged_out)
 
395
  return
396
 
397
  except Exception as e:
398
+ yield preview_html(0.0, f"Error: {e}"), None
 
399
  return
400
  finally:
401
+ # remove any temp files downloaded
402
  for p in temp_files:
403
  try:
404
  p.unlink()
 
408
  # -------------------------
409
  # Gradio UI
410
  # -------------------------
411
+ with gr.Blocks(title="Low Quality Video Inator (fdkaac)") as demo:
412
+ gr.Markdown("# Low Quality Video Inator\nUpload a file or paste a YouTube URL. The app streams a step-aware overlayed progress bar while encoding.\n\n**Note:** put `fdkaac` binary in the same folder as this app and make it executable (`chmod +x fdkaac`).")
 
 
 
413
 
414
  with gr.Row():
415
+ use_youtube = gr.Checkbox(label="Use YouTube URL (server will try to download first)", value=False)
416
  youtube_url = gr.Textbox(label="YouTube URL", placeholder="https://youtube.com/...", lines=1)
417
 
418
  video_file = gr.File(label="Upload Video File", file_types=list(ALLOWED_EXTENSIONS), file_count="single")
 
425
  use_mp3 = gr.Checkbox(label="Use MP3 audio", value=False)
426
  audio_only = gr.Checkbox(label="Audio Only", value=False)
427
  with gr.Row():
428
+ custom_bitrate = gr.Checkbox(label="Use custom video bitrate (kbps)", value=False)
429
+ video_bitrate = gr.Number(label="Video bitrate (kbps)", value=64, visible=False)
430
 
431
+ def toggle_bitrate(v):
432
+ return gr.update(visible=v)
433
+ custom_bitrate.change(toggle_bitrate, inputs=[custom_bitrate], outputs=[video_bitrate])
 
434
 
435
  convert_btn = gr.Button("Convert Now", variant="primary")
436
 
437
+ preview_html_el = gr.HTML("<div>Ready. Preview will appear here.</div>", label="Preview")
438
+ download_file = gr.File(label="Download Result")
 
 
 
439
 
 
440
  convert_btn.click(
441
  fn=convert_stream,
442
  inputs=[use_youtube, youtube_url, video_file, downscale, faster, use_mp3, audio_only, custom_bitrate, video_bitrate],
443
+ outputs=[preview_html_el, download_file]
444
  )
445
 
446
+ demo.launch(share=False)