trydrama-neuralgarage commited on
Commit
6388b62
·
verified ·
1 Parent(s): 5cf6571

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +234 -0
app.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import glob
2
+ import json
3
+ import os
4
+ import subprocess
5
+ import tempfile
6
+ import threading
7
+ import time
8
+ from collections import defaultdict
9
+ from datetime import date
10
+
11
+ import gradio as gr
12
+ import requests
13
+
14
+ API_KEY = os.environ.get("DRAMA_API_KEY", "") # Space secret
15
+ BASE = os.environ.get(
16
+ "DRAMA_API_BASE",
17
+ "https://studio.syncmonster.ai/neuralgarage/saas/api/v1/clients",
18
+ )
19
+ HEADERS = {"x-api-key": API_KEY}
20
+ MAX_SECONDS = float(os.environ.get("MAX_SECONDS", 10))
21
+ PER_USER_DAILY = int(os.environ.get("PER_USER_DAILY", 3))
22
+ GLOBAL_DAILY = int(os.environ.get("GLOBAL_DAILY", 200))
23
+ POLL_EVERY = 5 # seconds between status checks
24
+ JOB_TIMEOUT = 900 # give up after 15 minutes
25
+ FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
26
+
27
+ FRIENDLY_ERRORS = {
28
+ "ERR_004": "No face found in this video. Try a clip with one clear, front-facing face.",
29
+ "ERR_006": "The demo is out of credits for now. Please try again later.",
30
+ "ERR_007": "That file looks corrupted. Try exporting it again.",
31
+ "ERR_008": "Unsupported file type. Use .mp4 or .mov video and .mp3 or .wav audio.",
32
+ "ERR_009": "This video is too long.",
33
+ }
34
+
35
+ _usage = defaultdict(int)
36
+ _lock = threading.Lock()
37
+
38
+
39
+ # ---------- helpers ----------
40
+
41
+ def video_seconds(path):
42
+ out = subprocess.run(
43
+ ["ffprobe", "-v", "error", "-show_entries", "format=duration",
44
+ "-of", "csv=p=0", path],
45
+ capture_output=True, text=True, check=True,
46
+ )
47
+ return float(out.stdout.strip())
48
+
49
+
50
+ def extract_audio(video, workdir):
51
+ wav = os.path.join(workdir, "audio.wav")
52
+ result = subprocess.run(
53
+ ["ffmpeg", "-y", "-i", video, "-vn", "-acodec", "pcm_s16le", wav],
54
+ capture_output=True,
55
+ )
56
+ if result.returncode != 0 or not os.path.exists(wav):
57
+ raise gr.Error("This video has no audio track. Upload an audio file as well.")
58
+ return wav
59
+
60
+
61
+ def take_quota(user):
62
+ today = date.today().isoformat()
63
+ with _lock:
64
+ if _usage[("__all__", today)] >= GLOBAL_DAILY:
65
+ raise gr.Error("The demo has hit today's limit. Try again tomorrow, "
66
+ "or get API access at trydrama.io.")
67
+ if _usage[(user, today)] >= PER_USER_DAILY:
68
+ raise gr.Error(f"You've used your {PER_USER_DAILY} free runs for today. "
69
+ "Get API access at trydrama.io for more.")
70
+ _usage[("__all__", today)] += 1
71
+ _usage[(user, today)] += 1
72
+
73
+
74
+ def api_error(body):
75
+ data = body.get("data") or {}
76
+ code = data.get("error_code") if isinstance(data, dict) else None
77
+ return FRIENDLY_ERRORS.get(code, body.get("message") or "Something went wrong.")
78
+
79
+
80
+ def build_drama(mood, excited, angry):
81
+ drama = {}
82
+ if mood > 0:
83
+ drama["happy"] = round(mood, 2)
84
+ elif mood < 0:
85
+ drama["sad"] = round(-mood, 2)
86
+ if excited > 0:
87
+ drama["excited"] = round(excited, 2)
88
+ if angry > 0:
89
+ drama["angry"] = round(angry, 2)
90
+ return drama
91
+
92
+
93
+ # ---------- DRAMA API calls ----------
94
+
95
+ def submit_job(video, audio, drama):
96
+ with open(video, "rb") as v, open(audio, "rb") as a:
97
+ r = requests.post(
98
+ f"{BASE}/generate",
99
+ headers=HEADERS,
100
+ data={
101
+ "skip_find_face": "1",
102
+ "is_drama": "true",
103
+ "drama_params": json.dumps(drama),
104
+ },
105
+ files={
106
+ "video": (os.path.basename(video), v),
107
+ "audio": (os.path.basename(audio), a),
108
+ },
109
+ timeout=180,
110
+ )
111
+ try:
112
+ body = r.json()
113
+ except ValueError:
114
+ raise gr.Error(f"DRAMA API error (HTTP {r.status_code}).")
115
+ if body.get("status") != "success":
116
+ raise gr.Error(api_error(body))
117
+ return body["data"]["request_id"]
118
+
119
+
120
+ def job_status(request_id):
121
+ r = requests.get(f"{BASE}/get-status", headers=HEADERS,
122
+ params={"request_id": request_id}, timeout=30)
123
+ body = r.json()
124
+ if body.get("status") != "success":
125
+ raise gr.Error(api_error(body))
126
+ return str(body["data"].get("status", "")).lower()
127
+
128
+
129
+ def download(request_id, workdir):
130
+ r = requests.get(f"{BASE}/get-file", headers=HEADERS,
131
+ params={"request_id": request_id}, timeout=300, stream=True)
132
+ if "video" not in r.headers.get("Content-Type", ""):
133
+ raise gr.Error("The result isn't ready to download yet. Please try again.")
134
+ path = os.path.join(workdir, "drama_raw.mp4")
135
+ with open(path, "wb") as f:
136
+ for chunk in r.iter_content(chunk_size=1 << 20):
137
+ f.write(chunk)
138
+ return path
139
+
140
+
141
+ def watermark(src, workdir):
142
+ out = os.path.join(workdir, "drama.mp4")
143
+ vf = (f"drawtext=fontfile={FONT}:text='trydrama.io':"
144
+ "x=w-tw-20:y=h-th-20:fontsize=h/24:fontcolor=white@0.85:"
145
+ "box=1:boxcolor=black@0.35:boxborderw=8")
146
+ result = subprocess.run(
147
+ ["ffmpeg", "-y", "-i", src, "-vf", vf, "-c:a", "copy", out],
148
+ capture_output=True,
149
+ )
150
+ return out if result.returncode == 0 else src
151
+
152
+
153
+ # ---------- main handler ----------
154
+
155
+ def run(video, audio, mood, excited, angry, profile: gr.OAuthProfile | None):
156
+ if not API_KEY:
157
+ raise gr.Error("The demo isn't switched on yet. Please check back soon.")
158
+ if profile is None:
159
+ raise gr.Error("Sign in with Hugging Face (button at the top) to run the demo.")
160
+ if not video:
161
+ raise gr.Error("Upload a video first.")
162
+ if video_seconds(video) > MAX_SECONDS + 0.5:
163
+ raise gr.Error(f"Please use a clip of {int(MAX_SECONDS)} seconds or less.")
164
+ drama = build_drama(mood, excited, angry)
165
+ if not drama:
166
+ raise gr.Error("Move at least one emotion slider away from 0.")
167
+
168
+ take_quota(profile.username)
169
+ workdir = tempfile.mkdtemp()
170
+ if not audio:
171
+ audio = extract_audio(video, workdir)
172
+
173
+ yield None, "Uploading your clip…"
174
+ request_id = submit_job(video, audio, drama)
175
+
176
+ started = time.time()
177
+ while True:
178
+ status = job_status(request_id)
179
+ if status == "success":
180
+ break
181
+ if status in ("failed", "invalid_request", "internal_error"):
182
+ raise gr.Error("Processing failed. Try a different clip.")
183
+ if time.time() - started > JOB_TIMEOUT:
184
+ raise gr.Error("This is taking too long. Please try again later.")
185
+ waited = int(time.time() - started)
186
+ yield None, f"Processing… {waited}s (usually takes a minute or two)"
187
+ time.sleep(POLL_EVERY)
188
+
189
+ yield None, "Downloading the result…"
190
+ result = watermark(download(request_id, workdir), workdir)
191
+ yield result, f"Done. Settings used: `{json.dumps(drama)}`"
192
+
193
+
194
+ # ---------- UI ----------
195
+
196
+ with gr.Blocks(title="DRAMA: facial expression editing") as demo:
197
+ gr.Markdown(
198
+ "# DRAMA\n"
199
+ "Change the emotion on a face in any video. Upload a short clip with one "
200
+ f"clear face (max {int(MAX_SECONDS)} s), set the emotions, and generate.\n\n"
201
+ "Only upload footage you have the right to use. "
202
+ "[Model card](https://huggingface.co/trydrama/DRAMA) · "
203
+ "[API docs](https://docs.drama.syncmonster.ai/) · "
204
+ "[trydrama.io](https://trydrama.io/?utm_source=huggingface&utm_medium=space)"
205
+ )
206
+ gr.LoginButton()
207
+ with gr.Row():
208
+ with gr.Column():
209
+ video_in = gr.Video(label="Video (.mp4 or .mov)", sources=["upload"])
210
+ audio_in = gr.Audio(
211
+ label="Audio (optional, defaults to the video's own sound)",
212
+ type="filepath", sources=["upload"],
213
+ )
214
+ mood = gr.Slider(-1, 1, value=0.7, step=0.05,
215
+ label="Mood: sad (−1) to happy (+1)")
216
+ excited = gr.Slider(0, 1, value=0, step=0.05, label="Excited")
217
+ angry = gr.Slider(0, 1, value=0, step=0.05, label="Angry")
218
+ go = gr.Button("Generate", variant="primary")
219
+ with gr.Column():
220
+ video_out = gr.Video(label="DRAMA result")
221
+ status_out = gr.Markdown()
222
+
223
+ samples = [[p] for p in sorted(glob.glob("examples/*.mp4"))]
224
+ if samples:
225
+ gr.Examples(samples, inputs=[video_in], label="Try a sample clip")
226
+
227
+ go.click(
228
+ run,
229
+ inputs=[video_in, audio_in, mood, excited, angry],
230
+ outputs=[video_out, status_out],
231
+ concurrency_limit=2,
232
+ )
233
+
234
+ demo.queue(max_size=20).launch()