clipbot-ffmpeg / app.py
imspsycho's picture
Update app.py
51d360b verified
#!/usr/bin/env python3
"""
HuggingFace FFmpeg Space v2
65% main / 35% gameplay, zero gap using overlay on black canvas
"""
from flask import Flask, request, jsonify, send_file
import subprocess, os, uuid, threading
import urllib.request as ur
app = Flask(__name__)
JOBS = {}
OUTPUT_DIR = "/tmp/outputs"
os.makedirs(OUTPUT_DIR, exist_ok=True)
def run_ffmpeg(job_id, video_id, main_url, gameplay_url, duration=58):
try:
work_dir = f"/tmp/job_{job_id}"
os.makedirs(work_dir, exist_ok=True)
main_path = f"{work_dir}/main.mp4"
gameplay_path = f"{work_dir}/gameplay.mp4"
output_path = f"{OUTPUT_DIR}/{video_id}_final.mp4"
# Download main video
print(f"[HF] Downloading main: {main_url[:80]}")
req = ur.Request(main_url, headers={"User-Agent": "Mozilla/5.0"})
with ur.urlopen(req, timeout=120) as r:
with open(main_path, "wb") as f: f.write(r.read())
# Download gameplay
print(f"[HF] Downloading gameplay: {gameplay_url[:80]}")
req2 = ur.Request(gameplay_url, headers={"User-Agent": "Mozilla/5.0"})
with ur.urlopen(req2, timeout=120) as r:
with open(gameplay_path, "wb") as f: f.write(r.read())
# Canvas: 1080x1920 (9:16)
# Main: top 65% = 1248px (y=0)
# Gameplay: bottom 35% = 672px (y=1248)
# Total: 1248+672 = 1920 exactly — NO gap
canvas_w = 1080
main_h = 1248 # 65% of 1920
game_h = 672 # 35% of 1920
game_y = 1248 # starts exactly where main ends
filter_complex = (
# Black canvas 1080x1920
f"color=black:{canvas_w}x1920:rate=30[canvas];"
# Scale main to 1080x1248 (fill + crop, no black bars)
f"[0:v]trim=0:{duration},setpts=PTS-STARTPTS,"
f"scale={canvas_w}:{main_h}:force_original_aspect_ratio=increase,"
f"crop={canvas_w}:{main_h},setsar=1[main];"
# Scale gameplay to 1080x672 (fill + crop, no black bars)
f"[1:v]trim=0:{duration},setpts=PTS-STARTPTS,"
f"scale={canvas_w}:{game_h}:force_original_aspect_ratio=increase,"
f"crop={canvas_w}:{game_h},setsar=1[game];"
# Overlay main at y=0, then gameplay at y=1248
f"[canvas][main]overlay=0:0[tmp];"
f"[tmp][game]overlay=0:{game_y}[outv];"
# Audio from main only
f"[0:a]atrim=0:{duration},asetpts=PTS-STARTPTS,aresample=44100[outa]"
)
cmd = [
"ffmpeg", "-y",
"-f", "lavfi", "-i", f"color=black:{canvas_w}x1920:rate=30:duration={duration}",
"-i", main_path,
"-stream_loop", "-1", "-i", gameplay_path,
"-filter_complex",
(
# Scale main to 1080x1248
f"[1:v]trim=0:{duration},setpts=PTS-STARTPTS,"
f"scale={canvas_w}:{main_h}:force_original_aspect_ratio=increase,"
f"crop={canvas_w}:{main_h},setsar=1[main];"
# Scale gameplay to 1080x672
f"[2:v]trim=0:{duration},setpts=PTS-STARTPTS,"
f"scale={canvas_w}:{game_h}:force_original_aspect_ratio=increase,"
f"crop={canvas_w}:{game_h},setsar=1[game];"
# Overlay on black canvas: main at top, game at bottom
f"[0:v][main]overlay=0:0[tmp];"
f"[tmp][game]overlay=0:{game_y}[outv];"
# Audio from main only
f"[1:a]atrim=0:{duration},asetpts=PTS-STARTPTS,aresample=44100[outa]"
),
"-map", "[outv]",
"-map", "[outa]",
"-c:v", "libx264",
"-preset", "ultrafast",
"-crf", "26",
"-c:a", "aac",
"-b:a", "128k",
"-pix_fmt", "yuv420p",
"-t", str(duration),
"-avoid_negative_ts", "make_zero",
"-async", "1",
"-movflags", "+faststart",
output_path
]
print(f"[HF] Running FFmpeg...")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
if result.returncode != 0:
print(f"[HF] FFmpeg error: {result.stderr[-800:]}")
JOBS[job_id] = {"status": "error", "error": result.stderr[-500:]}
return
import shutil
shutil.rmtree(work_dir, ignore_errors=True)
size = os.path.getsize(output_path)
print(f"[HF] Done! {size/1024/1024:.1f}MB")
JOBS[job_id] = {
"status": "done",
"videoId": video_id,
"downloadUrl": f"/download/{video_id}_final.mp4",
"size": size
}
except Exception as e:
import traceback
JOBS[job_id] = {"status": "error", "error": traceback.format_exc()}
print(f"[HF] Exception: {traceback.format_exc()}")
@app.route("/clip", methods=["POST"])
def clip():
data = request.json
video_id = data.get("videoId")
main_url = data.get("mainVideoUrl")
gameplay_url = data.get("gameplayUrl")
duration = int(data.get("duration", 58))
job_id = str(uuid.uuid4())
JOBS[job_id] = {"status": "running", "videoId": video_id}
t = threading.Thread(
target=run_ffmpeg,
args=(job_id, video_id, main_url, gameplay_url, duration),
daemon=True
)
t.start()
return jsonify({"job_id": job_id, "status": "started", "videoId": video_id})
@app.route("/job/<job_id>", methods=["GET"])
def job_status(job_id):
return jsonify(JOBS.get(job_id, {"status": "not_found"}))
@app.route("/download/<filename>", methods=["GET"])
def download(filename):
path = f"{OUTPUT_DIR}/{filename}"
if not os.path.exists(path):
return jsonify({"error": "Not found"}), 404
return send_file(path, mimetype="video/mp4", as_attachment=False)
@app.route("/health", methods=["GET"])
def health():
return jsonify({"status": "ok", "jobs": len(JOBS)})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=7860)