File size: 18,793 Bytes
70923cd
6567ae9
906ec90
 
 
d83b57c
70923cd
906ec90
70923cd
 
d83b57c
 
906ec90
70923cd
906ec90
70923cd
 
 
906ec90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70923cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
906ec90
70923cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
906ec90
 
 
70923cd
906ec90
70923cd
 
906ec90
ded38ea
906ec90
 
ded38ea
 
6567ae9
906ec90
 
 
70923cd
 
906ec90
 
 
 
 
 
 
70923cd
 
906ec90
 
 
 
 
70923cd
 
7be90a2
906ec90
70923cd
 
906ec90
 
 
 
70923cd
906ec90
 
 
 
 
 
 
70923cd
 
906ec90
 
70923cd
c841491
b32dded
70923cd
de205c3
b32dded
70923cd
 
 
 
906ec90
 
70923cd
906ec90
 
 
 
70923cd
906ec90
 
70923cd
906ec90
70923cd
 
906ec90
b32dded
70923cd
906ec90
de205c3
906ec90
de205c3
b32dded
70923cd
906ec90
 
 
 
 
 
70923cd
906ec90
 
b32dded
906ec90
b32dded
70923cd
 
 
 
 
 
de205c3
70923cd
906ec90
70923cd
906ec90
 
 
70923cd
906ec90
70923cd
 
906ec90
 
 
 
 
 
 
 
70923cd
906ec90
 
70923cd
 
 
 
906ec90
 
70923cd
 
906ec90
 
 
70923cd
 
 
906ec90
 
70923cd
 
 
 
906ec90
 
70923cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
906ec90
 
70923cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
906ec90
70923cd
 
 
 
906ec90
70923cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
906ec90
70923cd
 
 
906ec90
70923cd
 
 
906ec90
 
 
 
 
 
 
 
 
 
 
 
70923cd
906ec90
70923cd
906ec90
 
 
 
 
 
70923cd
906ec90
 
70923cd
 
 
 
 
906ec90
 
70923cd
 
906ec90
 
 
70923cd
 
906ec90
70923cd
 
906ec90
70923cd
906ec90
 
70923cd
 
906ec90
 
70923cd
 
906ec90
70923cd
906ec90
 
 
 
 
 
70923cd
 
906ec90
 
b32dded
70923cd
906ec90
 
70923cd
906ec90
 
 
 
 
 
 
 
 
70923cd
2a111b0
906ec90
 
70923cd
906ec90
 
 
 
 
 
 
 
 
 
 
 
70923cd
 
906ec90
70923cd
 
 
906ec90
 
 
70923cd
 
906ec90
 
 
 
70923cd
d83b57c
 
70923cd
1
2
3
4
5
6
7
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
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
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
# app.py
import asyncio
import os
import re
import shutil
import uuid
import html
from pathlib import Path
from typing import Optional, Tuple

import gradio as gr

# -------------------------
# Config
# -------------------------
BASE_DIR = Path(".")
UPLOAD_FOLDER = BASE_DIR / "uploads"
CONVERTED_FOLDER = BASE_DIR / "converted"
UPLOAD_FOLDER.mkdir(exist_ok=True)
CONVERTED_FOLDER.mkdir(exist_ok=True)

ALLOWED_EXTENSIONS = {
    ".3g2", ".3gp", ".3gpp", ".avi", ".cavs", ".dv", ".dvr", ".flv",
    ".m2ts", ".m4v", ".mkv", ".mod", ".mov", ".mp4", ".mpeg", ".mpg",
    ".mts", ".mxf", ".ogg", ".rm", ".rmvb", ".swf", ".ts", ".vob",
    ".webm", ".wmv", ".wtv", ".ogv", ".opus", ".aac", ".ac3", ".aif",
    ".aifc", ".aiff", ".amr", ".au", ".caf", ".dss", ".flac", ".m4a",
    ".m4b", ".mp3", ".oga", ".voc", ".wav", ".weba", ".wma"
}

VIDEO_BASE_OPTS = ["-crf", "63", "-c:v", "libx264", "-tune", "zerolatency"]
ACCEL = "auto"

FFMPEG_TIME_RE = re.compile(r"time=(\d+):(\d+):(\d+\.\d+)")

# Ensure fdkaac executable bit if it exists in cwd
FDKAAC_PATH = Path("./fdkaac")
try:
    if FDKAAC_PATH.exists():
        FDKAAC_PATH.chmod(FDKAAC_PATH.stat().st_mode | 0o111)
except Exception:
    pass

# -------------------------
# Helpers
# -------------------------
def is_audio_file(path: str) -> bool:
    ext = Path(path).suffix.lower()
    return ext in {".mp3", ".m4a", ".wav", ".aac", ".oga", ".ogg"}

def preview_html(percent: float, step: str, media_src: Optional[str] = None) -> str:
    """Return HTML containing media preview (video or audio) with overlayed progress/step."""
    pct = max(0.0, min(100.0, percent))
    esc_step = html.escape(str(step))
    media_tag = ""
    if media_src:
        esc_src = html.escape(str(media_src))
        if is_audio_file(media_src):
            media_tag = f'<audio src="{esc_src}" controls style="width:100%;display:block;"></audio>'
        else:
            media_tag = f'<video src="{esc_src}" controls style="width:100%;height:auto;display:block;"></video>'
    else:
        media_tag = (
            '<div style="width:100%;height:320px;display:flex;align-items:center;justify-content:center;'
            'background:#0b0b0b;color:#fff;font-size:16px;">Preview will appear here once available</div>'
        )

    return f'''
    <div style="position:relative;border-radius:8px;overflow:hidden;border:1px solid #ddd;">
      {media_tag}
      <div style="position:absolute;left:8px;right:8px;bottom:12px;pointer-events:none;">
        <div style="background:rgba(0,0,0,0.52);padding:10px;border-radius:8px;color:#fff;font-family:system-ui,Segoe UI,Roboto,Arial;">
          <div style="font-size:13px;margin-bottom:8px;"><strong>{esc_step}</strong></div>
          <div style="height:12px;background:#222;border-radius:6px;overflow:hidden;">
            <div style="width:{pct:.2f}%;height:100%;background:linear-gradient(90deg,#21d4fd,#b721ff);"></div>
          </div>
          <div style="font-size:12px;margin-top:6px;color:#ddd;">{pct:.1f}%</div>
        </div>
      </div>
    </div>
    '''

async def run_command_capture(cmd, cwd=None, env=None) -> Tuple[str, str, int]:
    """Run command to completion, capture stdout/stderr."""
    proc = await asyncio.create_subprocess_exec(
        *cmd,
        cwd=cwd,
        env=env or os.environ.copy(),
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    stdout, stderr = await proc.communicate()
    return stdout.decode(errors="ignore"), stderr.decode(errors="ignore"), proc.returncode

async def get_duration_seconds(path: Path) -> Optional[float]:
    """Get duration using ffprobe (seconds) or None if unknown."""
    cmd = [
        "ffprobe", "-v", "error",
        "-show_entries", "format=duration",
        "-of", "default=noprint_wrappers=1:nokey=1",
        str(path)
    ]
    stdout, stderr, rc = await run_command_capture(cmd)
    if rc != 0 or not stdout:
        return None
    try:
        return float(stdout.strip())
    except Exception:
        return None

def which_cmd(name: str) -> Optional[str]:
    return shutil.which(name)

# -------------------------
# Converter generator
# streams tuples: (preview_html_str, download_path_or_None)
# -------------------------
async def convert_stream(
    use_youtube: bool,
    youtube_url: str,
    video_file,  # gr.File
    downscale: bool,
    faster: bool,
    use_mp3: bool,
    audio_only: bool,
    custom_bitrate: bool,
    video_bitrate: float
):
    # initial
    yield preview_html(0.0, "Starting..."), None

    temp_files = []
    input_path: Optional[Path] = None

    try:
        # SOURCE
        if use_youtube:
            if not youtube_url:
                yield preview_html(0.0, "Error: YouTube URL required."), None
                return
            if not which_cmd("yt-dlp"):
                yield preview_html(0.0, "yt-dlp not found on server. Please upload the file manually."), None
                return

            yield preview_html(1.0, "Attempting YouTube download..."), None
            out_uuid = uuid.uuid4().hex
            out_template = str(UPLOAD_FOLDER / f"{out_uuid}.%(ext)s")
            ytdlp_cmd = ["yt-dlp", "-f", "b", "-o", out_template, youtube_url]
            stdout, stderr, rc = await run_command_capture(ytdlp_cmd)
            combined = (stdout or "") + "\n" + (stderr or "")
            if rc != 0:
                if "Video unavailable" in combined or "This video is unavailable" in combined:
                    yield preview_html(0.0, "Video unavailable (removed/private/age-restricted)."), None
                    return
                yield preview_html(0.0, "Could not download from YouTube from this server (cloud host blocked). Please upload manually."), None
                return
            files = list(UPLOAD_FOLDER.glob(f"{out_uuid}.*"))
            if not files:
                yield preview_html(0.0, "Download completed but file not found."), None
                return
            input_path = files[0]
            temp_files.append(input_path)
        else:
            if not video_file:
                yield preview_html(0.0, "No video provided. Upload or use YouTube URL."), None
                return
            try:
                ext = Path(video_file.name).suffix.lower()
            except Exception:
                ext = None
            if ext not in ALLOWED_EXTENSIONS:
                yield preview_html(0.0, f"Unsupported file type: {ext}"), None
                return
            input_path = UPLOAD_FOLDER / f"{uuid.uuid4().hex}{ext}"
            shutil.copy2(video_file.name, input_path)
            temp_files.append(input_path)

        yield preview_html(1.0, "Probing duration..."), None
        total_seconds = await get_duration_seconds(input_path)
        if not total_seconds:
            yield preview_html(0.0, "Warning: duration unknown; progress will be step-based."), None

        # AUDIO-ONLY PATH
        if audio_only:
            # MP3 branch
            if use_mp3:
                step = "Converting to MP3..."
                out_audio = CONVERTED_FOLDER / f"{uuid.uuid4().hex}.mp3"
                ffmpeg_cmd = [
                    "ffmpeg", "-y", "-i", str(input_path),
                    "-ac", "1", "-ar", "24000", "-b:a", "8k", str(out_audio)
                ]
                yield preview_html(2.0, step), None
                if total_seconds:
                    proc = await asyncio.create_subprocess_exec(*ffmpeg_cmd, stderr=asyncio.subprocess.PIPE)
                    last = 0.0
                    while True:
                        line = await proc.stderr.readline()
                        if not line:
                            break
                        txt = line.decode(errors="ignore")
                        m = FFMPEG_TIME_RE.search(txt)
                        if m:
                            hh, mm, ss = m.groups()
                            current = int(hh) * 3600 + int(mm) * 60 + float(ss)
                            pct = (current / total_seconds) * 100.0
                            if pct - last >= 0.5:
                                last = pct
                                yield preview_html(pct, step), None
                    await proc.wait()
                    if proc.returncode != 0:
                        yield preview_html(0.0, "ffmpeg failed while encoding MP3."), None
                        return
                else:
                    stdout, stderr, rc = await run_command_capture(ffmpeg_cmd)
                    if rc != 0:
                        yield preview_html(0.0, "ffmpeg failed while encoding MP3."), None
                        return
                yield preview_html(100.0, "MP3 conversion finished.", media_src=str(out_audio)), str(out_audio)
                return

            # AAC via fdkaac branch (audio-only)
            # Ensure fdkaac exists
            if not FDKAAC_PATH.exists():
                yield preview_html(0.0, "fdkaac not found at ./fdkaac β€” please add it to the app folder and make executable."), None
                return

            step = "Preparing WAV for fdkaac..."
            yield preview_html(2.0, step), None
            wav_tmp = CONVERTED_FOLDER / f"{uuid.uuid4().hex}.wav"
            aac_out = CONVERTED_FOLDER / f"{uuid.uuid4().hex}.m4a"

            # Generate WAV (low sample rate)
            ffmpeg_wav_cmd = ["ffmpeg", "-y", "-i", str(input_path), "-ac", "1", "-ar", "8000", str(wav_tmp)]
            stdout, stderr, rc = await run_command_capture(ffmpeg_wav_cmd)
            if rc != 0:
                yield preview_html(0.0, "ffmpeg failed to produce WAV for fdkaac."), None
                try:
                    wav_tmp.unlink()
                except Exception:
                    pass
                return

            # Run fdkaac to produce m4a
            fdkaac_cmd = [
                "./fdkaac", "-b", "1k", "-C", "-f", "2", "-G", "1", "-w", "8000",
                "-o", str(aac_out), str(wav_tmp)
            ]
            yield preview_html(5.0, "Encoding AAC with fdkaac..."), None
            stdout, stderr, rc = await run_command_capture(fdkaac_cmd)
            try:
                wav_tmp.unlink()
            except Exception:
                pass
            if rc != 0:
                yield preview_html(0.0, f"fdkaac failed: {stderr[:300] if stderr else 'no output'}"), None
                return

            yield preview_html(100.0, "AAC (fdkaac) audio ready.", media_src=str(aac_out)), str(aac_out)
            return

        # ----------------------
        # FULL VIDEO FLOW
        # ----------------------
        # 1) Audio encode (via fdkaac or mp3)
        out_audio = CONVERTED_FOLDER / f"{uuid.uuid4().hex}.m4a"
        if use_mp3:
            ffmpeg_audio_cmd = ["ffmpeg", "-y", "-i", str(input_path), "-ac", "1", "-ar", "8000", "-c:a", "libmp3lame", "-b:a", "8k", str(out_audio)]
            yield preview_html(2.0, "Encoding audio (MP3)..."), None
            if total_seconds:
                proc = await asyncio.create_subprocess_exec(*ffmpeg_audio_cmd, stderr=asyncio.subprocess.PIPE)
                last = 0.0
                while True:
                    line = await proc.stderr.readline()
                    if not line:
                        break
                    txt = line.decode(errors="ignore")
                    m = FFMPEG_TIME_RE.search(txt)
                    if m:
                        hh, mm, ss = m.groups()
                        current = int(hh) * 3600 + int(mm) * 60 + float(ss)
                        pct = (current / total_seconds) * 100.0 * 0.20
                        if pct - last >= 0.5:
                            last = pct
                            yield preview_html(pct, "Encoding audio (MP3)..."), None
                await proc.wait()
                if proc.returncode != 0:
                    yield preview_html(0.0, "ffmpeg audio encoding failed."), None
                    return
            else:
                stdout, stderr, rc = await run_command_capture(ffmpeg_audio_cmd)
                if rc != 0:
                    yield preview_html(0.0, "ffmpeg audio encoding failed."), None
                    return
        else:
            # Use fdkaac path for AAC
            if not FDKAAC_PATH.exists():
                yield preview_html(0.0, "fdkaac not found at ./fdkaac β€” please add it to the app folder and make executable."), None
                return

            # 1a: Create WAV
            yield preview_html(2.0, "Generating WAV for fdkaac..."), None
            wav_tmp = CONVERTED_FOLDER / f"{uuid.uuid4().hex}.wav"
            ffmpeg_wav_cmd = ["ffmpeg", "-y", "-i", str(input_path), "-ac", "1", "-ar", "8000", str(wav_tmp)]
            stdout, stderr, rc = await run_command_capture(ffmpeg_wav_cmd)
            if rc != 0:
                yield preview_html(0.0, "ffmpeg failed generating WAV for fdkaac."), None
                try:
                    wav_tmp.unlink()
                except Exception:
                    pass
                return

            # 1b: Run fdkaac
            aac_tmp = CONVERTED_FOLDER / f"{uuid.uuid4().hex}.m4a"
            fdkaac_cmd = [
                "./fdkaac", "-b", "1k", "-C", "-f", "2", "-G", "1", "-w", "8000",
                "-o", str(aac_tmp), str(wav_tmp)
            ]
            yield preview_html(6.0, "Encoding AAC with fdkaac..."), None
            stdout, stderr, rc = await run_command_capture(fdkaac_cmd)
            try:
                wav_tmp.unlink()
            except Exception:
                pass
            if rc != 0:
                yield preview_html(0.0, f"fdkaac failed: {stderr[:300] if stderr else 'no output'}"), None
                return
            out_audio = aac_tmp

        # 2) Video encode
        step_video = "Encoding video track..."
        yield preview_html(20.0, step_video), None
        out_video = CONVERTED_FOLDER / f"{uuid.uuid4().hex}.mp4"
        ffmpeg_video_cmd = ["ffmpeg", "-y", "-hwaccel", ACCEL, "-i", str(input_path)]
        if downscale:
            ffmpeg_video_cmd += ["-vf", "scale=-2:144"]
        if custom_bitrate and video_bitrate:
            ffmpeg_video_cmd += ["-b:v", f"{int(video_bitrate)}k"]
        else:
            ffmpeg_video_cmd += VIDEO_BASE_OPTS
        if faster:
            ffmpeg_video_cmd += ["-preset", "ultrafast"]
        ffmpeg_video_cmd += ["-an", str(out_video)]

        if total_seconds:
            proc = await asyncio.create_subprocess_exec(*ffmpeg_video_cmd, stderr=asyncio.subprocess.PIPE)
            last = 20.0
            while True:
                line = await proc.stderr.readline()
                if not line:
                    break
                txt = line.decode(errors="ignore")
                m = FFMPEG_TIME_RE.search(txt)
                if m:
                    hh, mm, ss = m.groups()
                    current = int(hh) * 3600 + int(mm) * 60 + float(ss)
                    pct_video = (current / total_seconds) * 100.0
                    combined = 20.0 + (pct_video * 0.70)
                    if combined - last >= 0.5:
                        last = combined
                        yield preview_html(combined, step_video), None
            await proc.wait()
            if proc.returncode != 0:
                yield preview_html(0.0, "ffmpeg video encoding failed."), None
                return
        else:
            stdout, stderr, rc = await run_command_capture(ffmpeg_video_cmd)
            if rc != 0:
                yield preview_html(0.0, "ffmpeg video encoding failed."), None
                return

        # 3) Merge audio + video
        yield preview_html(90.0, "Merging audio & video..."), None
        merged_out = CONVERTED_FOLDER / f"{uuid.uuid4().hex}.mp4"
        merge_cmd = ["ffmpeg", "-y", "-i", str(out_video), "-i", str(out_audio), "-c", "copy", str(merged_out)]
        stdout, stderr, rc = await run_command_capture(merge_cmd)
        if rc != 0:
            # fallback re-encode audio into AAC during merge
            merge_cmd = ["ffmpeg", "-y", "-i", str(out_video), "-i", str(out_audio), "-c:v", "copy", "-c:a", "aac", str(merged_out)]
            stdout, stderr, rc = await run_command_capture(merge_cmd)
            if rc != 0:
                yield preview_html(0.0, "Merging audio and video failed."), None
                return

        # cleanup intermediate audio/video
        for p in (out_audio, out_video):
            try:
                p.unlink()
            except Exception:
                pass

        # final
        yield preview_html(100.0, "Conversion complete!", media_src=str(merged_out)), str(merged_out)
        return

    except Exception as e:
        yield preview_html(0.0, f"Error: {e}"), None
        return
    finally:
        # remove any temp files downloaded
        for p in temp_files:
            try:
                p.unlink()
            except Exception:
                pass

# -------------------------
# Gradio UI
# -------------------------
with gr.Blocks(title="Low Quality Video Inator (fdkaac)") as demo:
    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.")

    with gr.Row():
        use_youtube = gr.Checkbox(label="Use YouTube URL (server will try to download first)", value=False)
        youtube_url = gr.Textbox(label="YouTube URL", placeholder="https://youtube.com/...", lines=1)

    video_file = gr.File(label="Upload Video File", file_types=list(ALLOWED_EXTENSIONS), file_count="single")

    gr.Markdown("### Conversion Settings")
    with gr.Row():
        downscale = gr.Checkbox(label="Downscale to 144p", value=False)
        faster = gr.Checkbox(label="Faster encoding (lower quality)", value=False)
    with gr.Row():
        use_mp3 = gr.Checkbox(label="Use MP3 audio", value=False)
        audio_only = gr.Checkbox(label="Audio Only", value=False)
    with gr.Row():
        custom_bitrate = gr.Checkbox(label="Use custom video bitrate (kbps)", value=False)
        video_bitrate = gr.Number(label="Video bitrate (kbps)", value=64, visible=False)

    def toggle_bitrate(v):
        return gr.update(visible=v)
    custom_bitrate.change(toggle_bitrate, inputs=[custom_bitrate], outputs=[video_bitrate])

    convert_btn = gr.Button("Convert Now", variant="primary")

    preview_html_el = gr.HTML("<div>Ready. Preview will appear here.</div>", label="Preview")
    download_file = gr.File(label="Download Result")

    convert_btn.click(
        fn=convert_stream,
        inputs=[use_youtube, youtube_url, video_file, downscale, faster, use_mp3, audio_only, custom_bitrate, video_bitrate],
        outputs=[preview_html_el, download_file]
    )

demo.launch(share=False)