webm-mp4-gif / app.py
broadfield-dev's picture
Update app.py
8c0fdf5 verified
"""
Speed Reader β€” WebM Converter
Flask app for HuggingFace Spaces (Docker SDK).
Endpoints:
GET / β†’ health check / status page
POST /convert β†’ multipart upload: file=<webm>, format=mp4|gif, fps=15
returns the converted file as a binary download
"""
import io
import os
import subprocess
import tempfile
from pathlib import Path
from flask import Flask, request, send_file, jsonify
from flask_cors import CORS
app = Flask(__name__)
CORS(app, resources={r"/*": {"origins": "*"}})
# ── Health check ──────────────────────────────────────────────────────────────
@app.route("/", methods=["GET"])
def index():
return jsonify({
"status": "ok",
"service": "Speed Reader β€” WebM Converter",
"endpoints": {
"POST /convert": {
"params": {
"file": "WebM file (multipart)",
"format": "'mp4' or 'gif'",
"fps": "GIF frame rate, default 15 (ignored for mp4)"
},
"returns": "Converted file download"
}
}
})
# ── Conversion endpoint ───────────────────────────────────────────────────────
@app.route("/convert", methods=["POST"])
def convert():
if "file" not in request.files:
return jsonify({"error": "No file uploaded. Send as multipart field 'file'."}), 400
uploaded = request.files["file"]
fmt = request.form.get("format", "mp4").lower().strip()
fps = request.form.get("fps", "15")
if fmt not in ("mp4", "gif"):
return jsonify({"error": f"Unsupported format '{fmt}'. Use 'mp4' or 'gif'."}), 400
try:
fps = max(5, min(30, int(fps)))
except ValueError:
fps = 15
# ── Write upload to temp dir, run ffmpeg, read result into memory ─────────
#
# IMPORTANT: send_file is called AFTER the with-block so the tempdir is
# already deleted by then. We must read the output file into a BytesIO
# buffer while we are still inside the with-block.
#
buf = None
with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir)
src = tmp / "input.webm"
out = tmp / f"output.{fmt}"
uploaded.save(str(src))
if src.stat().st_size == 0:
return jsonify({"error": "Uploaded file is empty."}), 400
try:
if fmt == "mp4":
_run([
"ffmpeg", "-y",
"-i", str(src),
"-c:v", "libx264",
"-preset", "fast",
"-crf", "22",
"-pix_fmt", "yuv420p",
"-movflags", "+faststart",
"-an",
str(out)
])
else: # gif β€” two-pass palette encode
palette = tmp / "palette.png"
# Pass 1: build optimised palette from the full video
_run([
"ffmpeg", "-y",
"-i", str(src),
"-vf", f"fps={fps},scale=iw:ih:flags=lanczos,palettegen=stats_mode=diff",
str(palette)
])
# Pass 2: encode GIF using palette
# [0:v] is required to tell ffmpeg which stream feeds the filter
_run([
"ffmpeg", "-y",
"-i", str(src),
"-i", str(palette),
"-filter_complex",
f"[0:v]fps={fps},scale=iw:ih:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer",
str(out)
])
except subprocess.CalledProcessError as e:
return jsonify({"error": f"ffmpeg failed:\n{e.stderr[-800:]}"}), 500
if not out.exists() or out.stat().st_size == 0:
return jsonify({"error": "Conversion produced an empty file."}), 500
# Read into memory NOW, while the tempdir still exists
buf = io.BytesIO(out.read_bytes())
# tempdir is deleted here β€” but buf is safely in memory
buf.seek(0)
mime = "video/mp4" if fmt == "mp4" else "image/gif"
return send_file(
buf,
mimetype=mime,
as_attachment=True,
download_name=f"speed-read.{fmt}"
)
# ── Helper ────────────────────────────────────────────────────────────────────
def _run(cmd: list[str]):
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise subprocess.CalledProcessError(
result.returncode, cmd, output=result.stdout, stderr=result.stderr
)
return result
# ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(os.environ.get("PORT", 7860))
app.run(host="0.0.0.0", port=port)