Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
@@ -1,225 +1,491 @@
|
|
1 |
-
|
2 |
import asyncio
|
3 |
-
import
|
|
|
|
|
4 |
import uuid
|
5 |
import glob
|
6 |
-
import
|
|
|
7 |
import gradio as gr
|
8 |
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
34 |
*cmd,
|
|
|
|
|
35 |
stdout=asyncio.subprocess.PIPE,
|
36 |
stderr=asyncio.subprocess.PIPE,
|
37 |
-
env=env
|
38 |
)
|
39 |
-
stdout, stderr = await
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
# Convert to WAV, then AAC via fdkaac
|
60 |
-
audio_wav = os.path.join(CONVERTED_FOLDER, f"{uuid.uuid4()}.wav")
|
61 |
-
audio_output = os.path.join(CONVERTED_FOLDER, f"{uuid.uuid4()}.m4a")
|
62 |
-
await run_subprocess([
|
63 |
-
'ffmpeg', '-y', '-i', input_path, '-ac', '1', '-ar', '8000', audio_wav
|
64 |
-
])
|
65 |
-
await run_subprocess([
|
66 |
-
'./fdkaac', '-b', '1k', '-C', '-f', '2', '-G', '1', '-w', '8000',
|
67 |
-
'-o', audio_output, audio_wav
|
68 |
-
], use_fdkaac=True)
|
69 |
-
os.remove(audio_wav)
|
70 |
-
return audio_output, None
|
71 |
-
|
72 |
-
# Video conversion paths
|
73 |
-
if use_mp3:
|
74 |
-
output_video = os.path.join(CONVERTED_FOLDER, f"{uuid.uuid4()}.mp4")
|
75 |
-
ffmpeg_video_cmd = [
|
76 |
-
'ffmpeg', '-y', '-hwaccel', accel, '-i', input_path
|
77 |
-
]
|
78 |
-
|
79 |
-
if custom_bitrate:
|
80 |
-
ffmpeg_video_cmd += ['-b:v', f"{int(video_bitrate)}k"]
|
81 |
-
else:
|
82 |
-
ffmpeg_video_cmd += video_base_opts
|
83 |
-
|
84 |
-
if faster:
|
85 |
-
ffmpeg_video_cmd.extend(['-preset', 'ultrafast'])
|
86 |
-
|
87 |
-
ffmpeg_video_cmd += [
|
88 |
-
'-c:a', 'libmp3lame', '-b:a', '8k', '-ar', '24000', '-ac', '1',
|
89 |
-
output_video
|
90 |
]
|
91 |
-
await
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
if custom_bitrate:
|
112 |
-
video_cmd += ['-b:v', f"{int(video_bitrate)}k"]
|
113 |
-
else:
|
114 |
-
video_cmd += video_base_opts
|
115 |
-
if faster:
|
116 |
-
video_cmd.extend(['-preset', 'ultrafast'])
|
117 |
-
video_cmd += ['-an', video_output]
|
118 |
-
await run_subprocess(video_cmd)
|
119 |
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
124 |
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
130 |
|
131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
132 |
|
133 |
-
async def process_conversion(use_youtube, youtube_url, video_file, downscale, faster, use_mp3, audio_only, custom_bitrate, video_bitrate):
|
134 |
try:
|
135 |
if use_youtube:
|
136 |
if not youtube_url:
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
144 |
if not files:
|
145 |
-
|
|
|
|
|
146 |
input_path = files[0]
|
|
|
|
|
147 |
else:
|
148 |
if not video_file:
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
154 |
shutil.copy2(video_file.name, input_path)
|
|
|
155 |
|
156 |
-
|
157 |
-
|
158 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
159 |
|
|
|
160 |
if audio_only:
|
161 |
-
|
162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
163 |
except Exception as e:
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
205 |
-
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
inputs=[
|
218 |
-
|
219 |
-
downscale, faster, use_mp3, audio_only,
|
220 |
-
custom_bitrate, video_bitrate
|
221 |
-
],
|
222 |
-
outputs=[video_preview, file_download]
|
223 |
)
|
224 |
|
225 |
-
|
|
|
|
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 |
+
|
20 |
+
ALLOWED_EXTENSIONS = {
|
21 |
+
".3g2", ".3gp", ".3gpp", ".avi", ".cavs", ".dv", ".dvr", ".flv",
|
22 |
+
".m2ts", ".m4v", ".mkv", ".mod", ".mov", ".mp4", ".mpeg", ".mpg",
|
23 |
+
".mts", ".mxf", ".ogg", ".rm", ".rmvb", ".swf", ".ts", ".vob",
|
24 |
+
".webm", ".wmv", ".wtv", ".ogv", ".opus", ".aac", ".ac3", ".aif",
|
25 |
+
".aifc", ".aiff", ".amr", ".au", ".caf", ".dss", ".flac", ".m4a",
|
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,
|
59 |
+
env=env or os.environ.copy(),
|
60 |
stdout=asyncio.subprocess.PIPE,
|
61 |
stderr=asyncio.subprocess.PIPE,
|
|
|
62 |
)
|
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,
|
136 |
+
audio_only: bool,
|
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:
|
248 |
+
line = await proc.stderr.readline()
|
249 |
+
if not line:
|
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()
|
441 |
+
except Exception:
|
442 |
+
pass
|
443 |
+
|
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")
|
458 |
+
|
459 |
+
gr.Markdown("### Conversion Settings")
|
460 |
+
with gr.Row():
|
461 |
+
downscale = gr.Checkbox(label="Downscale to 144p", value=False)
|
462 |
+
faster = gr.Checkbox(label="Faster encoding (lower quality)", value=False)
|
463 |
+
with gr.Row():
|
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(share=False, prevent_thread_lock=True)
|