imspsycho commited on
Commit
34119d6
Β·
verified Β·
1 Parent(s): 4145596

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +181 -0
app.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)