imspsycho commited on
Commit
1e0585d
·
verified ·
1 Parent(s): 51bb6a0

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +116 -144
app.py CHANGED
@@ -1,181 +1,153 @@
1
  #!/usr/bin/env python3
2
  """
3
- FFmpeg Split-Screen Service - HuggingFace Space
4
- Accepts video URLs, processes split-screen, returns download link
 
5
  """
6
 
7
  from flask import Flask, request, jsonify, send_file
8
- import subprocess, os, uuid, threading, glob, shutil, requests, time
 
9
 
10
  app = Flask(__name__)
11
-
12
- WORK_DIR = "/tmp/clipbot"
13
- OUTPUT_DIR = "/tmp/clipbot/output"
14
- GAMEPLAY_DIR = "/tmp/clipbot/gameplay"
15
-
16
  os.makedirs(OUTPUT_DIR, exist_ok=True)
17
- os.makedirs(GAMEPLAY_DIR, exist_ok=True)
18
 
19
- JOBS = {}
20
 
21
- GAMEPLAY_URLS = [
22
- # Add your gameplay clip direct URLs here (e.g. from HuggingFace dataset or GitHub releases)
23
- # "https://your-hf-space.hf.space/gameplay/1.mp4",
24
- ]
25
-
26
- def download_file(url, dest):
27
- """Download a file from URL to dest path."""
28
- r = requests.get(url, stream=True, timeout=120)
29
- r.raise_for_status()
30
- with open(dest, 'wb') as f:
31
- for chunk in r.iter_content(chunk_size=8192):
32
- f.write(chunk)
33
- return dest
34
-
35
-
36
- def run_ffmpeg(main_path, gameplay_path, output_path, dur=58):
37
- """Run split-screen FFmpeg command."""
38
- filter_complex = (
39
- "[0:v]scale=1080:960:force_original_aspect_ratio=increase,"
40
- "crop=1080:960,setsar=1[top];"
41
- "[1:v]scale=1080:960:force_original_aspect_ratio=increase,"
42
- "crop=1080:960,setsar=1[bot];"
43
- "[top][bot]vstack=inputs=2[stacked];"
44
- "[stacked]drawbox=x=0:y=956:w=1080:h=8:color=white@0.9:t=fill[out]"
45
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
- cmd = [
48
- "ffmpeg", "-y",
49
- "-ss", "0", "-t", str(dur), "-i", main_path,
50
- "-stream_loop", "-1", "-t", str(dur), "-i", gameplay_path,
51
- "-filter_complex", filter_complex,
52
- "-map", "[out]", "-map", "0:a",
53
- "-t", str(dur),
54
- "-c:v", "libx264", "-preset", "ultrafast", "-crf", "26",
55
- "-c:a", "aac", "-b:a", "128k", "-ar", "44100",
56
- "-movflags", "+faststart", "-pix_fmt", "yuv420p",
57
- output_path
58
- ]
59
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
60
- return result
61
-
62
-
63
- # ── /clip ───────────────────────────────────────────────────
64
  @app.route("/clip", methods=["POST"])
65
  def clip():
66
- """
67
- Accepts:
68
- - mainVideoUrl: direct URL to main video file
69
- - gameplayUrl: direct URL to gameplay clip (optional, picks random if not provided)
70
- - videoId: unique identifier
71
- - duration: max seconds (default 58)
72
- Returns job_id immediately, poll /job/<id> for result
73
- """
74
  data = request.json
75
- video_id = data.get("videoId", str(uuid.uuid4()))
76
  main_url = data.get("mainVideoUrl")
77
  gameplay_url = data.get("gameplayUrl")
78
- dur = min(int(data.get("duration", 58)), 58)
79
-
80
- if not main_url:
81
- return jsonify({"error": "mainVideoUrl is required"}), 400
82
 
83
  job_id = str(uuid.uuid4())
84
  JOBS[job_id] = {"status": "running", "videoId": video_id}
85
 
86
- def run():
87
- work = f"/tmp/job_{job_id}"
88
- os.makedirs(work, exist_ok=True)
89
- try:
90
- # Download main video
91
- main_path = f"{work}/main.mp4"
92
- download_file(main_url, main_path)
93
-
94
- # Download or pick gameplay
95
- gameplay_path = f"{work}/gameplay.mp4"
96
- if gameplay_url:
97
- download_file(gameplay_url, gameplay_path)
98
- else:
99
- # Pick from local gameplay dir
100
- files = glob.glob(f"{GAMEPLAY_DIR}/*.mp4")
101
- if not files:
102
- JOBS[job_id] = {"status": "error", "error": "No gameplay clips available"}
103
- return
104
- import random
105
- shutil.copy(random.choice(files), gameplay_path)
106
-
107
- output_path = f"{OUTPUT_DIR}/{video_id}_final.mp4"
108
- result = run_ffmpeg(main_path, gameplay_path, output_path, dur)
109
-
110
- if result.returncode == 0 and os.path.exists(output_path):
111
- JOBS[job_id] = {
112
- "status": "done",
113
- "videoId": video_id,
114
- "downloadUrl": f"/download/{video_id}_final.mp4",
115
- "outputPath": output_path
116
- }
117
- else:
118
- JOBS[job_id] = {"status": "error", "error": result.stderr[-500:]}
119
-
120
- except Exception as e:
121
- JOBS[job_id] = {"status": "error", "error": str(e)}
122
- finally:
123
- shutil.rmtree(work, ignore_errors=True)
124
-
125
- threading.Thread(target=run, daemon=True).start()
126
  return jsonify({"job_id": job_id, "status": "started", "videoId": video_id})
127
 
128
 
129
- # ── /job/<id> ───────────────────────────────────────────────
130
  @app.route("/job/<job_id>", methods=["GET"])
131
  def job_status(job_id):
132
  return jsonify(JOBS.get(job_id, {"status": "not_found"}))
133
 
134
 
135
- # ── /download/<filename> ─────────────────────────────────────
136
  @app.route("/download/<filename>", methods=["GET"])
137
- def download_file_route(filename):
138
  path = f"{OUTPUT_DIR}/{filename}"
139
  if not os.path.exists(path):
140
- return jsonify({"error": "File not found"}), 404
141
- return send_file(path, as_attachment=True, mimetype="video/mp4")
142
 
143
 
144
- # ── /upload_gameplay ─────────────────────────────────────────
145
- @app.route("/upload_gameplay", methods=["POST"])
146
- def upload_gameplay():
147
- """Upload a gameplay clip to the space."""
148
- if 'file' not in request.files:
149
- return jsonify({"error": "No file provided"}), 400
150
- f = request.files['file']
151
- filename = f"gameplay_{uuid.uuid4().hex[:8]}.mp4"
152
- f.save(f"{GAMEPLAY_DIR}/{filename}")
153
- return jsonify({"status": "done", "filename": filename})
154
-
155
-
156
- # ── /health ────���─────────────────────────────────────────────
157
  @app.route("/health", methods=["GET"])
158
  def health():
159
- # Check ffmpeg available
160
- ffmpeg_ok = subprocess.run(["ffmpeg", "-version"], capture_output=True).returncode == 0
161
- return jsonify({
162
- "status": "ok",
163
- "ffmpeg": ffmpeg_ok,
164
- "gameplay_clips": len(glob.glob(f"{GAMEPLAY_DIR}/*.mp4")),
165
- "active_jobs": len([j for j in JOBS.values() if j.get("status") == "running"])
166
- })
167
-
168
-
169
- # ── Cleanup old files every hour ────────────────────────────
170
- def cleanup_loop():
171
- while True:
172
- time.sleep(3600)
173
- now = time.time()
174
- for f in glob.glob(f"{OUTPUT_DIR}/*.mp4"):
175
- if now - os.path.getmtime(f) > 7200: # 2 hours
176
- os.remove(f)
177
-
178
- threading.Thread(target=cleanup_loop, daemon=True).start()
179
 
180
  if __name__ == "__main__":
181
- app.run(host="0.0.0.0", port=7860, debug=True, threaded=True)
 
1
  #!/usr/bin/env python3
2
  """
3
+ HuggingFace FFmpeg Space
4
+ Clips and merges main video + gameplay into 9:16 split-screen
5
+ 65% main / 35% gameplay, no gap, audio sync fixed
6
  """
7
 
8
  from flask import Flask, request, jsonify, send_file
9
+ import subprocess, os, uuid, threading, json
10
+ import urllib.request as ur
11
 
12
  app = Flask(__name__)
13
+ JOBS = {}
14
+ OUTPUT_DIR = "/tmp/outputs"
 
 
 
15
  os.makedirs(OUTPUT_DIR, exist_ok=True)
 
16
 
 
17
 
18
+ def run_ffmpeg(job_id, video_id, main_url, gameplay_url, duration=58):
19
+ try:
20
+ work_dir = f"/tmp/job_{job_id}"
21
+ os.makedirs(work_dir, exist_ok=True)
22
+
23
+ main_path = f"{work_dir}/main.mp4"
24
+ gameplay_path = f"{work_dir}/gameplay.mp4"
25
+ output_path = f"{OUTPUT_DIR}/{video_id}_final.mp4"
26
+
27
+ # Download main video
28
+ print(f"[HF] Downloading main: {main_url[:80]}")
29
+ req = ur.Request(main_url, headers={"User-Agent": "Mozilla/5.0"})
30
+ with ur.urlopen(req, timeout=120) as r:
31
+ with open(main_path, "wb") as f: f.write(r.read())
32
+
33
+ # Download gameplay
34
+ print(f"[HF] Downloading gameplay: {gameplay_url[:80]}")
35
+ req2 = ur.Request(gameplay_url, headers={"User-Agent": "Mozilla/5.0"})
36
+ with ur.urlopen(req2, timeout=120) as r:
37
+ with open(gameplay_path, "wb") as f: f.write(r.read())
38
+
39
+ # Canvas: 1080x1920 (9:16)
40
+ # Main: 65% = 1248px height
41
+ # Gameplay: 35% = 672px height
42
+ # No gap, no divider
43
+ canvas_w = 1080
44
+ canvas_h = 1920
45
+ main_h = 1248 # 65%
46
+ game_h = 672 # 35%
47
+
48
+ cmd = [
49
+ "ffmpeg", "-y",
50
+ "-i", main_path,
51
+ "-stream_loop", "-1", "-i", gameplay_path,
52
+ "-filter_complex",
53
+ (
54
+ # Scale main to fill top 65% (crop width to 1080)
55
+ f"[0:v]trim=0:{duration},setpts=PTS-STARTPTS,"
56
+ f"scale={canvas_w}:{main_h}:force_original_aspect_ratio=increase,"
57
+ f"crop={canvas_w}:{main_h}[main];"
58
+
59
+ # Scale gameplay to fill bottom 35%
60
+ f"[1:v]trim=0:{duration},setpts=PTS-STARTPTS,"
61
+ f"scale={canvas_w}:{game_h}:force_original_aspect_ratio=increase,"
62
+ f"crop={canvas_w}:{game_h}[game];"
63
+
64
+ # Stack vertically with NO gap
65
+ f"[main][game]vstack=inputs=2[outv];"
66
+
67
+ # Audio from main only
68
+ f"[0:a]atrim=0:{duration},asetpts=PTS-STARTPTS[outa]"
69
+ ),
70
+ "-map", "[outv]",
71
+ "-map", "[outa]",
72
+ "-c:v", "libx264",
73
+ "-preset", "ultrafast",
74
+ "-crf", "26",
75
+ "-c:a", "aac",
76
+ "-b:a", "128k",
77
+ "-pix_fmt", "yuv420p",
78
+ "-t", str(duration),
79
+ # Fix audio/video sync - write headers first
80
+ "-avoid_negative_ts", "make_zero",
81
+ "-async", "1",
82
+ "-movflags", "+faststart",
83
+ output_path
84
+ ]
85
+
86
+ print(f"[HF] Running FFmpeg...")
87
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
88
+
89
+ if result.returncode != 0:
90
+ print(f"[HF] FFmpeg error: {result.stderr[-500:]}")
91
+ JOBS[job_id] = {"status": "error", "error": result.stderr[-300:]}
92
+ return
93
+
94
+ # Clean up temp files
95
+ import shutil
96
+ shutil.rmtree(work_dir, ignore_errors=True)
97
+
98
+ size = os.path.getsize(output_path)
99
+ print(f"[HF] Done! {size/1024/1024:.1f}MB")
100
+ JOBS[job_id] = {
101
+ "status": "done",
102
+ "videoId": video_id,
103
+ "downloadUrl": f"/download/{video_id}_final.mp4",
104
+ "size": size
105
+ }
106
+
107
+ except Exception as e:
108
+ import traceback
109
+ JOBS[job_id] = {"status": "error", "error": traceback.format_exc()}
110
+ print(f"[HF] Exception: {traceback.format_exc()}")
111
+
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  @app.route("/clip", methods=["POST"])
114
  def clip():
 
 
 
 
 
 
 
 
115
  data = request.json
116
+ video_id = data.get("videoId")
117
  main_url = data.get("mainVideoUrl")
118
  gameplay_url = data.get("gameplayUrl")
119
+ duration = int(data.get("duration", 58))
 
 
 
120
 
121
  job_id = str(uuid.uuid4())
122
  JOBS[job_id] = {"status": "running", "videoId": video_id}
123
 
124
+ t = threading.Thread(
125
+ target=run_ffmpeg,
126
+ args=(job_id, video_id, main_url, gameplay_url, duration),
127
+ daemon=True
128
+ )
129
+ t.start()
130
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  return jsonify({"job_id": job_id, "status": "started", "videoId": video_id})
132
 
133
 
 
134
  @app.route("/job/<job_id>", methods=["GET"])
135
  def job_status(job_id):
136
  return jsonify(JOBS.get(job_id, {"status": "not_found"}))
137
 
138
 
 
139
  @app.route("/download/<filename>", methods=["GET"])
140
+ def download(filename):
141
  path = f"{OUTPUT_DIR}/{filename}"
142
  if not os.path.exists(path):
143
+ return jsonify({"error": "Not found"}), 404
144
+ return send_file(path, mimetype="video/mp4", as_attachment=False)
145
 
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  @app.route("/health", methods=["GET"])
148
  def health():
149
+ return jsonify({"status": "ok", "jobs": len(JOBS)})
150
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
 
152
  if __name__ == "__main__":
153
+ app.run(host="0.0.0.0", port=7860)