Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
@@ -1,19 +1,21 @@
|
|
1 |
-
#
|
2 |
import asyncio
|
3 |
import os
|
4 |
import re
|
5 |
import shutil
|
6 |
import uuid
|
7 |
-
import
|
8 |
-
import subprocess
|
9 |
from pathlib import Path
|
|
|
|
|
10 |
import gradio as gr
|
11 |
|
12 |
# -------------------------
|
13 |
-
# Config
|
14 |
# -------------------------
|
15 |
-
|
16 |
-
|
|
|
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 |
-
#
|
37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
38 |
pct = max(0.0, min(100.0, percent))
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
45 |
</div>
|
46 |
</div>
|
47 |
</div>
|
48 |
-
|
49 |
-
return bar
|
50 |
|
51 |
-
|
52 |
-
|
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
|
67 |
-
"""Get
|
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 |
-
|
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 |
-
|
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 |
-
#
|
|
|
128 |
# -------------------------
|
129 |
async def convert_stream(
|
130 |
use_youtube: bool,
|
131 |
youtube_url: str,
|
132 |
-
video_file, # gr.File
|
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 |
-
#
|
141 |
-
|
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 |
-
|
161 |
-
|
|
|
|
|
162 |
return
|
163 |
|
164 |
-
|
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 |
-
|
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 |
-
|
|
|
191 |
files = list(UPLOAD_FOLDER.glob(f"{out_uuid}.*"))
|
192 |
if not files:
|
193 |
-
|
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 |
-
|
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 |
-
|
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 |
-
|
218 |
-
|
219 |
-
|
220 |
-
|
221 |
-
|
222 |
-
|
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
|
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 |
-
|
240 |
-
if
|
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
|
254 |
hh, mm, ss = m.groups()
|
255 |
current = int(hh) * 3600 + int(mm) * 60 + float(ss)
|
256 |
-
|
257 |
-
if
|
258 |
-
last =
|
259 |
-
yield
|
260 |
await proc.wait()
|
261 |
if proc.returncode != 0:
|
262 |
-
|
|
|
263 |
else:
|
264 |
-
# fallback synchronous run
|
265 |
stdout, stderr, rc = await run_command_capture(ffmpeg_cmd)
|
266 |
if rc != 0:
|
267 |
-
|
268 |
-
|
269 |
-
yield
|
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 |
-
|
|
|
|
|
|
|
307 |
return
|
308 |
|
309 |
-
|
310 |
-
|
311 |
-
|
312 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
320 |
else:
|
321 |
-
|
|
|
|
|
|
|
322 |
|
323 |
-
|
324 |
-
|
325 |
-
|
326 |
-
|
327 |
-
|
328 |
-
|
329 |
-
|
330 |
-
|
331 |
-
|
332 |
-
|
333 |
-
|
334 |
-
|
335 |
-
|
336 |
-
|
337 |
-
|
338 |
-
|
339 |
-
|
340 |
-
|
341 |
-
|
342 |
-
|
343 |
-
stdout, stderr, rc = await run_command_capture(
|
|
|
|
|
|
|
|
|
344 |
if rc != 0:
|
345 |
-
|
|
|
|
|
346 |
|
347 |
-
# 2) encode
|
348 |
-
|
349 |
-
yield
|
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
|
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 =
|
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
|
379 |
hh, mm, ss = m.groups()
|
380 |
current = int(hh) * 3600 + int(mm) * 60 + float(ss)
|
381 |
-
|
382 |
-
|
383 |
-
|
384 |
-
|
385 |
-
|
386 |
-
yield progress_html(combined_percent, step), step, None, None
|
387 |
await proc.wait()
|
388 |
if proc.returncode != 0:
|
389 |
-
|
|
|
390 |
else:
|
391 |
stdout, stderr, rc = await run_command_capture(ffmpeg_video_cmd)
|
392 |
if rc != 0:
|
393 |
-
|
|
|
394 |
|
395 |
-
# 3)
|
396 |
-
|
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 |
-
#
|
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 |
-
|
|
|
419 |
|
420 |
-
# cleanup
|
421 |
for p in (out_audio, out_video):
|
422 |
try:
|
423 |
p.unlink()
|
424 |
except Exception:
|
425 |
pass
|
426 |
|
427 |
-
#
|
428 |
-
|
429 |
-
yield final_yield(100.0, "Conversion complete!", merged_out, merged_out)
|
430 |
return
|
431 |
|
432 |
except Exception as e:
|
433 |
-
|
434 |
-
yield progress_html(0.0, err), err, None, None
|
435 |
return
|
436 |
finally:
|
437 |
-
# remove
|
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"
|
448 |
-
.
|
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
|
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
|
468 |
-
video_bitrate = gr.Number(label="Video
|
469 |
|
470 |
-
|
471 |
-
|
472 |
-
|
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 |
-
|
478 |
-
|
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=[
|
488 |
)
|
489 |
|
490 |
-
|
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)
|
|