Download app.py from trydrama/DRAMA: direct link, hf CLI and curl.
- Browser
- Download file 111 kB
-
https://huggingface.co/spaces/trydrama/DRAMA/resolve/main/app.py
- Command line
-
hf download hf://spaces/trydrama/DRAMA/app.py
-
curl -L -o app.py https://huggingface.co/spaces/trydrama/DRAMA/resolve/main/app.py
111 kB
| """DRAMA demo Space: change the emotion on a face in a video via the DRAMA API. | |
| Every run gets a short ID (e.g. run=7f3a2c). Search the Space logs for it to see | |
| each step: input file details, every API call and reply, timings, and a final | |
| SUCCESS or FAILED summary with a copy-paste report for engineering. | |
| """ | |
| import glob | |
| import json | |
| import logging | |
| import os | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import threading | |
| import time | |
| import traceback | |
| import uuid | |
| from collections import defaultdict | |
| from datetime import date, datetime, timezone | |
| import gradio as gr | |
| import requests | |
| # ---------- sign-in loop guard ---------- | |
| # Gradio's OAuth keeps its login state in a cookie. A cookie left over from an older sign-in setup | |
| # (for example after the OAuth scopes changed) makes the login callback fail and retry forever | |
| # ("redirected you too many times"; gradio issue #13634). We (1) use our own cookie name, so stale | |
| # "session" cookies are ignored, and (2) stop after a few failed attempts: clear the cookies and show | |
| # a page with a fresh sign-in link instead of looping. Gradio adds this middleware itself when OAuth is on. | |
| import urllib.parse as _urlparse | |
| import starlette.middleware.sessions as _starlette_sessions | |
| from starlette.responses import HTMLResponse as _HTMLResponse | |
| _SESSION_COOKIE = "drama_session" | |
| _LOGIN_RETRY_PAGE = """<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"> | |
| <title>Sign in to DRAMA</title><body style="background:#0b0b0e;color:#ededf0;font:16px/1.6 system-ui,sans-serif; | |
| display:grid;place-items:center;min-height:100vh;margin:0"><div style="max-width:440px;padding:24px;text-align:center"> | |
| <h1 style="font-size:22px;margin:0 0 8px">Sign-in didn't finish</h1> | |
| <p style="color:#9a9aa6;margin:0 0 20px">We cleared this site's sign-in cookies. Please try once more. | |
| If it keeps happening, open the demo in a private window.</p> | |
| <a href="/login/huggingface?_target_url=/" style="display:inline-block;background:#e3141c;color:#fff; | |
| text-decoration:none;font-weight:600;padding:10px 18px;border-radius:8px">Sign in with Hugging Face</a> | |
| <p><a href="/" style="color:#9a9aa6">Back to the demo</a></p></div></body>""" | |
| class _DramaSessionMiddleware(_starlette_sessions.SessionMiddleware): | |
| def __init__(self, app, *args, **kwargs): | |
| kwargs["session_cookie"] = _SESSION_COOKIE | |
| super().__init__(app, *args, **kwargs) | |
| async def __call__(self, scope, receive, send): | |
| if scope.get("type") == "http" and scope.get("path") == "/login/huggingface": | |
| query = _urlparse.parse_qs((scope.get("query_string") or b"").decode()) | |
| try: | |
| attempts = int((query.get("_nb_redirects") or ["0"])[0]) | |
| except ValueError: | |
| attempts = 0 | |
| if attempts >= 2: | |
| print(f"[signin] login loop stopped after {attempts} attempts; cookies cleared", flush=True) | |
| response = _HTMLResponse(_LOGIN_RETRY_PAGE) | |
| for name in (_SESSION_COOKIE, "session"): | |
| response.delete_cookie(name, secure=True, httponly=True, samesite="none") | |
| await response(scope, receive, send) | |
| return | |
| await super().__call__(scope, receive, send) | |
| _starlette_sessions.SessionMiddleware = _DramaSessionMiddleware | |
| # ---------- configuration (Space settings) ---------- | |
| API_KEY = os.environ.get("DRAMA_API_KEY", "").strip() # must be a Space *secret* | |
| BASE = os.environ.get( | |
| "DRAMA_API_BASE", | |
| "https://studio.syncmonster.ai/neuralgarage/saas/api/v1/clients", | |
| ).rstrip("/") | |
| HEADERS = {"x-api-key": API_KEY} | |
| MAX_SECONDS = float(os.environ.get("MAX_SECONDS", 15)) | |
| PER_USER_DAILY = int(os.environ.get("PER_USER_DAILY", 5)) | |
| MIN_SECONDS = float(os.environ.get("MIN_SECONDS", 0.5)) # shorter clips fail on the DRAMA server | |
| LENGTH_GRACE = float(os.environ.get("LENGTH_GRACE", 1.0)) # we say 15 s, but accept up to 16 s | |
| ACCEPT_SECONDS = MAX_SECONDS + LENGTH_GRACE | |
| MAX_UPLOAD_SECONDS = float(os.environ.get("MAX_UPLOAD_SECONDS", 120)) # longest upload people can trim from | |
| LEADS_DATASET = os.environ.get("LEADS_DATASET", "").strip() # e.g. trydrama/demo-users (private dataset) | |
| HF_TOKEN = os.environ.get("HF_TOKEN", "").strip() # Space *secret* with write access | |
| GLOBAL_DAILY = int(os.environ.get("GLOBAL_DAILY", 200)) | |
| ADMIN_USERS = {u.strip().lower() for u in os.environ.get("ADMIN_USERS", "").split(",") if u.strip()} | |
| FLOW = os.environ.get("FLOW", "oneshot").strip().lower() # "oneshot" (default) or "twostep" | |
| SKIP_FIND_FACE = "1" # one-step mode: DRAMA detects and picks the face itself | |
| POLL_EVERY = 5 # seconds between status checks | |
| JOB_TIMEOUT = 900 # give up after 15 minutes | |
| FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" | |
| # ---------- logging ---------- | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s %(levelname)-5s %(message)s", | |
| datefmt="%H:%M:%S", | |
| stream=sys.stdout, | |
| force=True, | |
| ) | |
| log = logging.getLogger("drama") | |
| logging.getLogger().setLevel(logging.WARNING) # keep library chatter (httpx etc.) out of the logs | |
| log.setLevel(logging.INFO) | |
| def short(value, limit=600): | |
| """Compact one-line text for logs.""" | |
| text = value if isinstance(value, str) else json.dumps(value, default=str) | |
| text = " ".join(text.split()) | |
| return text if len(text) <= limit else text[:limit] + f"… (+{len(text) - limit} chars)" | |
| class Run: | |
| """Per-click context: ID, user, timings and facts for the final report.""" | |
| def __init__(self, user): | |
| self.id = uuid.uuid4().hex[:6] | |
| self.user = user | |
| self.started = time.time() | |
| self.stage = "start" | |
| self.facts = {} | |
| def log(self, msg, level=logging.INFO): | |
| log.log(level, f"[run={self.id}] [{self.stage}] {msg}") | |
| def elapsed(self): | |
| return f"{time.time() - self.started:.1f}s" | |
| # ---------- errors ---------- | |
| class DemoError(Exception): | |
| """An expected failure: category for the logs, message for the user.""" | |
| def __init__(self, category, user_message, detail=""): | |
| super().__init__(user_message) | |
| self.category = category | |
| self.user_message = user_message | |
| self.detail = detail | |
| API_ERROR_CODES = { | |
| "ERR_003": ("API_INTERNAL", "DRAMA had an internal error. Please try again."), | |
| "ERR_004": ("NO_FACE", "No face found in this video. Try a clip with one clear, front-facing face."), | |
| "ERR_006": ("NO_CREDITS", "The demo has reached its usage limit for now. Please try again later."), | |
| "ERR_007": ("BAD_FILE", "That file looks corrupted. Try exporting it again."), | |
| "ERR_008": ("BAD_FORMAT", "Unsupported file type. Upload an MP4 video."), | |
| "ERR_009": ("TOO_LONG", "This video is too long."), | |
| } | |
| DONE = ("success", "completed", "complete", "done") | |
| IN_PROGRESS = ("in_progress", "pending", "created", "processing", "queued", "running", "started") | |
| FAILED = ("failed", "failure", "error", "invalid_request", "internal_error", "cancelled", "canceled") | |
| def norm_status(value): | |
| return str(value or "").strip().lower().replace(" ", "_").replace("-", "_") | |
| def extract_api_error(body): | |
| """Pull (code, message) out of any DRAMA reply shape we've seen.""" | |
| if not isinstance(body, dict): | |
| return None, None | |
| data = body.get("data") if isinstance(body.get("data"), dict) else {} | |
| err = data.get("error") or body.get("error") | |
| code = data.get("error_code") or body.get("error_code") | |
| msg = data.get("error_message") or body.get("error_message") | |
| if isinstance(err, dict): | |
| code = code or err.get("code") | |
| msg = msg or err.get("message") | |
| elif isinstance(err, str): | |
| msg = msg or err | |
| msg = msg or data.get("message") or body.get("message") or body.get("detail") | |
| if isinstance(msg, (list, dict)): | |
| msg = short(msg, 300) | |
| return code, msg | |
| def error_from_api(code, message, http_code=None): | |
| if code in API_ERROR_CODES: | |
| category, user_msg = API_ERROR_CODES[code] | |
| return DemoError(category, user_msg, f"{code}: {message}") | |
| if http_code in (401, 403): | |
| return DemoError("AUTH", "The demo's API key was rejected.", | |
| f"HTTP {http_code}: {message} (check the DRAMA_API_KEY secret)") | |
| if http_code == 404: | |
| return DemoError("NOT_FOUND", "DRAMA API endpoint or job not found.", | |
| f"HTTP 404: {message}") | |
| if http_code == 422: | |
| return DemoError("BAD_REQUEST", "DRAMA rejected the request.", | |
| f"HTTP 422: {message}") | |
| if http_code and http_code >= 500: | |
| return DemoError("API_DOWN", "DRAMA is having trouble right now. Please try again shortly.", | |
| f"HTTP {http_code}: {message}") | |
| return DemoError("API_ERROR", f"DRAMA error: {message or 'unknown'}", | |
| f"HTTP {http_code}: code={code} message={message}") | |
| # ---------- API calls (all logged) ---------- | |
| def api(run, method, name, params=None, data=None, files=None, timeout=60, stream=False): | |
| """Call a DRAMA endpoint, log request and reply, raise DemoError on failure. | |
| Returns the parsed JSON body (or the raw response when stream=True).""" | |
| path = PATHS.get(name, f"/{name}") | |
| file_info = {k: f"{os.path.basename(p)} ({os.path.getsize(p) // 1024}KB)" | |
| for k, p in (files or {}).items()} | |
| run.log(f"→ {method} {path} params={params or {}} form={data or {}} files={file_info}") | |
| opened = {} | |
| t0 = time.time() | |
| try: | |
| opened = {k: (os.path.basename(p), open(p, "rb")) for k, p in (files or {}).items()} | |
| r = requests.request(method, f"{BASE}{path}", headers=HEADERS, params=params, | |
| data=data, files=opened or None, timeout=timeout, stream=stream) | |
| except requests.Timeout as e: | |
| run.log(f"✗ {method} {path} timed out after {time.time() - t0:.1f}s: {e}", logging.WARNING) | |
| raise DemoError("NETWORK", "DRAMA took too long to respond. Please try again.", f"timeout on {path}") | |
| except requests.RequestException as e: | |
| run.log(f"✗ {method} {path} network error: {type(e).__name__}: {e}", logging.WARNING) | |
| raise DemoError("NETWORK", "Couldn't reach the DRAMA service. Please try again in a minute.", | |
| f"{type(e).__name__} on {path}") | |
| finally: | |
| for _, fh in opened.values(): | |
| fh.close() | |
| ms = int((time.time() - t0) * 1000) | |
| ctype = r.headers.get("Content-Type", "") | |
| if stream and r.ok and "json" not in ctype: | |
| run.log(f"← {r.status_code} {path} {ms}ms content-type={ctype} " | |
| f"length={r.headers.get('Content-Length', '?')}") | |
| return r | |
| try: | |
| body = r.json() | |
| except ValueError: | |
| run.log(f"← {r.status_code} {path} {ms}ms NON-JSON content-type={ctype}: {short(r.text, 400)}", | |
| logging.WARNING) | |
| raise error_from_api(None, f"non-JSON reply ({ctype})", r.status_code) | |
| run.log(f"← {r.status_code} {path} {ms}ms {short(body)}") | |
| top = norm_status(body.get("status")) if isinstance(body, dict) else "" | |
| if not r.ok or top not in ("success", "created"): | |
| code, msg = extract_api_error(body) | |
| raise error_from_api(code, msg, r.status_code) | |
| return body | |
| PATHS = { | |
| "generate": "/generate", "get-status": "/get-status", "get-file": "/get-file", | |
| "get-request": "/get-request", "find-face": "/find-face", | |
| "find-face/status": "/find-face/status", "get-face": "/get-face", | |
| } | |
| def route_exists(path): | |
| """GET a route with no parameters. Only a router-level 404 means it's missing. | |
| A GET never creates a job or spends credits.""" | |
| try: | |
| r = requests.get(f"{BASE}{path}", headers=HEADERS, timeout=15) | |
| except requests.RequestException as e: | |
| log.warning(f"[startup] probe {path} failed: {e}") | |
| return False | |
| missing = False | |
| if r.status_code == 404: | |
| try: | |
| missing = r.json().get("detail") == "Not Found" | |
| except ValueError: | |
| missing = True | |
| return not missing | |
| def startup_check(): | |
| """Log config, API health, credits and the real path of each endpoint.""" | |
| log.info(f"[startup] base={BASE} flow={FLOW} skip_find_face={SKIP_FIND_FACE} " | |
| f"max_seconds={MAX_SECONDS} per_user_daily={PER_USER_DAILY} " | |
| f"global_daily={GLOBAL_DAILY} admins={sorted(ADMIN_USERS) or 'none'}") | |
| if not API_KEY: | |
| log.warning("[startup] DRAMA_API_KEY secret is not set; the demo is switched off.") | |
| return | |
| for path, headers in (("/health", {}), ("/get-credits", HEADERS)): | |
| try: | |
| r = requests.get(f"{BASE}{path}", headers=headers, timeout=15) | |
| level = logging.INFO if r.ok else logging.WARNING | |
| log.log(level, f"[startup] GET {path} -> {r.status_code}: {short(r.text, 300)}") | |
| if path == "/get-credits" and r.status_code in (401, 403): | |
| log.error("[startup] API key rejected. Check the DRAMA_API_KEY secret.") | |
| except requests.RequestException as e: | |
| log.error(f"[startup] GET {path} failed: {type(e).__name__}: {e}") | |
| for name in PATHS: | |
| for candidate in (f"/{name}", f"/v2/{name}"): | |
| if route_exists(candidate): | |
| PATHS[name] = candidate | |
| break | |
| else: | |
| log.warning(f"[startup] no route found for {name}; keeping {PATHS[name]}") | |
| log.info(f"[startup] endpoint paths: {PATHS}") | |
| # ---------- media helpers ---------- | |
| def probe(path): | |
| """Codec, size, fps, duration and audio details, for the logs.""" | |
| try: | |
| out = subprocess.run( | |
| ["ffprobe", "-v", "error", "-show_entries", | |
| "format=duration,size,format_name:stream=codec_type,codec_name,width,height," | |
| "r_frame_rate,pix_fmt,sample_rate,channels:stream_side_data=rotation", | |
| "-of", "json", path], | |
| capture_output=True, text=True, timeout=30, | |
| ) | |
| info = json.loads(out.stdout or "{}") | |
| except Exception as e: | |
| return {"error": f"ffprobe failed: {e}"} | |
| fmt = info.get("format", {}) | |
| summary = { | |
| "file": os.path.basename(path), | |
| "container": fmt.get("format_name"), | |
| "duration_s": round(float(fmt.get("duration", 0) or 0), 2), | |
| "size_kb": int(int(fmt.get("size", 0) or 0) / 1024), | |
| } | |
| for s in info.get("streams", []): | |
| if s.get("codec_type") == "video" and "video" not in summary: | |
| rot = next((d.get("rotation") for d in s.get("side_data_list", []) if "rotation" in d), None) | |
| summary["video"] = (f"{s.get('codec_name')} {s.get('width')}x{s.get('height')} " | |
| f"{s.get('r_frame_rate')}fps {s.get('pix_fmt')}" | |
| + (f" rotation={rot}" if rot else "")) | |
| elif s.get("codec_type") == "audio" and "audio" not in summary: | |
| summary["audio"] = f"{s.get('codec_name')} {s.get('sample_rate')}Hz {s.get('channels')}ch" | |
| summary.setdefault("audio", "none") | |
| return summary | |
| def video_seconds(path): | |
| out = subprocess.run( | |
| ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path], | |
| capture_output=True, text=True, | |
| ) | |
| try: | |
| return float(out.stdout.strip()) | |
| except ValueError: | |
| raise DemoError("BAD_FILE", "Couldn't read this video. Try exporting it as a standard .mp4.", | |
| f"ffprobe: {short(out.stderr, 300)}") | |
| def normalize_video(src, workdir, start=0.0, length=None, for_trimming=False): | |
| """Re-encode any upload (HEVC/ProRes .mov etc.) to H.264 + AAC MP4, | |
| which browsers can preview and the API can read. Keeps the audio track. | |
| With start/length, cuts that part of the clip at the same time. | |
| for_trimming: long clips that the person will trim in their browser are | |
| capped at 1920px and compressed a little more, so the in-browser trim copes.""" | |
| out = os.path.join(workdir, "input_h264.mp4") | |
| cut = (["-ss", f"{start:.2f}"] if start > 0 else []) | |
| limit = (["-t", f"{length:.2f}"] if length else []) | |
| vf = "scale=trunc(iw/2)*2:trunc(ih/2)*2" | |
| if for_trimming: | |
| vf = ("scale='if(gt(iw,ih),min(1920,iw),-2)':'if(gt(iw,ih),-2,min(1920,ih))'," | |
| "scale=trunc(iw/2)*2:trunc(ih/2)*2") | |
| crf = "20" if for_trimming else "18" | |
| result = subprocess.run( | |
| ["ffmpeg", "-y", *cut, "-i", src, *limit, | |
| "-map", "0:v:0", "-map", "0:a:0?", | |
| "-vf", vf, | |
| "-c:v", "libx264", "-preset", "veryfast", "-crf", crf, | |
| "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", | |
| "-movflags", "+faststart", out], | |
| capture_output=True, text=True, | |
| ) | |
| if result.returncode != 0 or not os.path.exists(out): | |
| log.warning(f"[normalize] ffmpeg failed for {os.path.basename(src)}: {short(result.stderr[-800:])}") | |
| raise DemoError("BAD_FILE", "Couldn't read this video. Try exporting it as a standard .mp4.", | |
| "ffmpeg conversion failed") | |
| return out | |
| def extract_audio(video, workdir): | |
| """The API expects an audio file with each job. Send the video's own sound, | |
| or matching silence when the clip has none. Returns (path, source).""" | |
| wav = os.path.join(workdir, "audio.wav") | |
| result = subprocess.run( | |
| ["ffmpeg", "-y", "-i", video, "-vn", "-acodec", "pcm_s16le", wav], | |
| capture_output=True, text=True, | |
| ) | |
| if result.returncode == 0 and os.path.exists(wav) and os.path.getsize(wav) >= 1024: | |
| return wav, "extracted from video" | |
| seconds = video_seconds(video) | |
| result = subprocess.run( | |
| ["ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono", | |
| "-t", f"{seconds:.3f}", "-acodec", "pcm_s16le", wav], | |
| capture_output=True, text=True, | |
| ) | |
| if result.returncode != 0 or not os.path.exists(wav): | |
| raise DemoError("NO_AUDIO", "Couldn't prepare this clip's audio. Try another file.", | |
| short(result.stderr[-300:])) | |
| return wav, "silence (clip has no sound)" | |
| _WATERMARK_PNG = None | |
| def watermark_png(): | |
| """trydrama.io wordmark on a soft dark rounded backing, built once from brand_assets. | |
| The backing keeps the white "try" readable on bright footage.""" | |
| global _WATERMARK_PNG | |
| if _WATERMARK_PNG and os.path.exists(_WATERMARK_PNG): | |
| return _WATERMARK_PNG | |
| try: | |
| import base64 | |
| import io | |
| from PIL import Image, ImageDraw | |
| from brand_assets import LOGO_WORDMARK_WEBP | |
| mark = Image.open(io.BytesIO(base64.b64decode(LOGO_WORDMARK_WEBP))).convert("RGBA") | |
| pad_x, pad_y = int(mark.height * 0.45), int(mark.height * 0.3) | |
| w, h = mark.width + 2 * pad_x, mark.height + 2 * pad_y | |
| tile = Image.new("RGBA", (w, h), (0, 0, 0, 0)) | |
| ImageDraw.Draw(tile).rounded_rectangle([0, 0, w - 1, h - 1], radius=h // 2, fill=(0, 0, 0, 140)) | |
| tile.alpha_composite(mark, (pad_x, pad_y)) | |
| alpha = tile.getchannel("A").point(lambda a: int(a * 0.92)) # very slightly see-through | |
| tile.putalpha(alpha) | |
| path = os.path.join(tempfile.gettempdir(), "drama_watermark.png") | |
| tile.save(path) | |
| _WATERMARK_PNG = path | |
| return path | |
| except Exception as e: | |
| log.warning(f"[watermark] couldn't build the logo watermark, using text: {type(e).__name__}: {e}") | |
| return None | |
| def watermark(src, workdir, run): | |
| """Stamps the trydrama.io wordmark bottom-right, sized to the video | |
| (about 5.5% of its shorter side). Re-encodes at high quality so the result stays sharp.""" | |
| out = os.path.join(workdir, "drama.mp4") | |
| encode = ["-c:v", "libx264", "-preset", "veryfast", "-crf", "18", "-pix_fmt", "yuv420p", | |
| "-c:a", "copy", "-movflags", "+faststart", out] | |
| logo = watermark_png() | |
| if logo: | |
| info = probe(src) | |
| try: | |
| side = min(int(n) for n in str(info.get("video", "")).split()[1].split("x")) | |
| except (IndexError, ValueError): | |
| side = 1080 | |
| mark_h = max(28, round(side * 0.055)) // 2 * 2 # sized from the shorter side, so vertical | |
| margin = max(12, round(side * 0.025)) # videos get the same size mark | |
| cmds = [["ffmpeg", "-y", "-i", src, "-i", logo, "-filter_complex", | |
| f"[1:v]scale=-2:{mark_h}[wm];[0:v][wm]overlay=W-w-{margin}:H-h-{margin}[v]", | |
| "-map", "[v]", "-map", "0:a?", *encode]] | |
| else: | |
| cmds = [] | |
| vf = (f"drawtext=fontfile={FONT}:text='trydrama.io':" | |
| "x=w-tw-20:y=h-th-20:fontsize=h/24:fontcolor=white@0.85:" | |
| "box=1:boxcolor=black@0.35:boxborderw=8") | |
| cmds.append(["ffmpeg", "-y", "-i", src, "-vf", vf, *encode]) # text mark as the fallback | |
| for cmd in cmds: | |
| result = subprocess.run(cmd, capture_output=True, text=True) | |
| if result.returncode == 0 and os.path.exists(out): | |
| return out | |
| run.log(f"watermark attempt failed: {short(result.stderr[-400:])}", logging.WARNING) | |
| # Demo outputs are promised to be watermarked, so never return the clean file. | |
| raise DemoError("WATERMARK", "Couldn't prepare your download. Please try again.", | |
| "both logo and text watermark failed") | |
| def clock(seconds): | |
| seconds = int(round(seconds)) | |
| return f"{seconds // 60}:{seconds % 60:02d}" | |
| def length_note(seconds, after_trim=False): | |
| """Message under the upload box. Empty when the clip length is fine.""" | |
| if seconds is None: | |
| return gr.update(value="", visible=False) | |
| if seconds < MIN_SECONDS: | |
| return gr.update(visible=True, value=( | |
| f'<div class="dr-trim-note"><b>This clip is too short.</b> Use at least {MIN_SECONDS:g} ' | |
| f'seconds of video.</div>')) | |
| if seconds <= ACCEPT_SECONDS: | |
| return gr.update(value="", visible=False) | |
| if after_trim: | |
| return gr.update(visible=True, value=( | |
| f'<div class="dr-trim-note"><b>The clip is still {clock(seconds)} long.</b> Either the ' | |
| f'selection was over {int(MAX_SECONDS)} seconds or the trim didn\'t work in this browser. ' | |
| f'Try again with a shorter selection, use Chrome on a computer, or cut the clip to ' | |
| f'{int(MAX_SECONDS)} seconds before uploading.</div>')) | |
| return gr.update(visible=True, value=( | |
| f'<div class="dr-trim-note"><b>Your video is {clock(seconds)} long.</b> The demo takes up to ' | |
| f'{int(MAX_SECONDS)} seconds. Open the trim tool (scissors icon, top right of the video): the first ' | |
| f'{int(MAX_SECONDS)} seconds are selected for you. Drag the selection or its edges to choose a ' | |
| f'different segment, then select <b>Trim</b>.</div>')) | |
| def prepare_upload(video): | |
| """Runs right after upload so the preview plays and the API gets a clean file. | |
| Long clips are kept whole so the person can trim them with the video player's | |
| scissors; check_length (on change) shows how long the clip is.""" | |
| if not video: | |
| return None | |
| try: | |
| before = probe(video) | |
| seconds = video_seconds(video) | |
| log.info(f"[upload] original: {before}") | |
| if seconds > MAX_UPLOAD_SECONDS: | |
| raise DemoError("TOO_LONG", f"This video is {clock(seconds)} long. Upload a clip under " | |
| f"{clock(MAX_UPLOAD_SECONDS)}, then trim it to " | |
| f"{int(MAX_SECONDS)} seconds.") | |
| long_clip = seconds > ACCEPT_SECONDS | |
| out = normalize_video(video, tempfile.mkdtemp(), for_trimming=long_clip) | |
| log.info(f"[upload] converted{' (long, needs trimming)' if long_clip else ''}: {probe(out)}") | |
| return out | |
| except DemoError as e: | |
| raise gr.Error(e.user_message, print_exception=False) | |
| except Exception: | |
| log.error(f"[upload] unexpected error:\n{traceback.format_exc()}") | |
| raise gr.Error("Couldn't process this upload. Try another file.", print_exception=False) | |
| def check_length(video): | |
| """Runs whenever the clip changes, including after the player's Trim button, | |
| so the message always reflects the clip that will actually be sent.""" | |
| if not video: | |
| return length_note(None) | |
| try: | |
| seconds = video_seconds(video) | |
| except Exception: | |
| return length_note(None) | |
| trimmed = os.path.basename(video) == "video.mp4" # produced by the player's Trim button | |
| if trimmed: | |
| log.info(f"[trim] browser trim returned a {seconds:.1f}s clip") | |
| return length_note(seconds, after_trim=trimmed) | |
| # ---------- user log (who tried the demo) ---------- | |
| # One JSON line per event, pushed every 10 minutes to a private dataset repo. | |
| # Off unless LEADS_DATASET and HF_TOKEN are set. Never blocks or breaks the demo. | |
| _leads = None | |
| _leads_file = None | |
| _seen_today = set() | |
| if LEADS_DATASET and HF_TOKEN: | |
| try: | |
| from huggingface_hub import CommitScheduler, create_repo | |
| create_repo(LEADS_DATASET, repo_type="dataset", private=True, exist_ok=True, token=HF_TOKEN) | |
| _leads_dir = os.path.join(tempfile.gettempdir(), "drama_users") | |
| os.makedirs(_leads_dir, exist_ok=True) | |
| _leads_file = os.path.join(_leads_dir, f"events-{uuid.uuid4().hex[:8]}.jsonl") | |
| _leads = CommitScheduler(repo_id=LEADS_DATASET, repo_type="dataset", folder_path=_leads_dir, | |
| path_in_repo="data", every=10, private=True, token=HF_TOKEN) | |
| log.info(f"[users] logging sign-ins and runs to dataset {LEADS_DATASET}") | |
| except Exception as e: | |
| _leads = None | |
| log.warning(f"[users] user log is off: {type(e).__name__}: {e}") | |
| TRACK_COLUMNS = ["outcome", "error", "error_message", "preset", "happy", "sad", "excited", "angry", | |
| "clip_seconds", "processing_seconds", "marketing_opt_in", "run_id", "job_id"] | |
| def track(event, profile, **extra): | |
| if _leads is None or profile is None: | |
| return | |
| try: | |
| orgs = profile.get("orgs") or [] | |
| row = { | |
| "time": datetime.now(timezone.utc).isoformat(timespec="seconds"), | |
| "event": event, | |
| "username": profile.username, | |
| "name": profile.name, | |
| "email": profile.get("email"), | |
| "email_verified": profile.get("email_verified"), | |
| "profile_url": profile.profile, | |
| "avatar": profile.picture, | |
| "orgs": [o.get("preferred_username") or o.get("name") for o in orgs if isinstance(o, dict)], | |
| "admin": is_admin(profile.username), | |
| } | |
| drama = extra.pop("drama", None) or {} | |
| row.update({k: None for k in TRACK_COLUMNS}) # same columns on every row, for the viewer/SQL | |
| row.update({k: float(drama.get(k, 0)) for k in ("happy", "sad", "excited", "angry")} if drama else {}) | |
| row.update(extra) | |
| with _leads.lock: | |
| with open(_leads_file, "a") as f: | |
| f.write(json.dumps(row, default=str) + "\n") | |
| except Exception as e: | |
| log.warning(f"[users] couldn't record {event}: {type(e).__name__}: {e}") | |
| def on_visit(profile: gr.OAuthProfile | None): | |
| """Records signed-in visitors once a day, including ones who never press the button.""" | |
| if profile is None: | |
| return | |
| key = (profile.username, utc_today()) | |
| if key not in _seen_today: | |
| _seen_today.add(key) | |
| track("visit", profile) | |
| # ---------- usage limits ---------- | |
| _usage = defaultdict(int) | |
| def utc_today(): | |
| """Day key for the daily allowance; resets at 00:00 UTC.""" | |
| return datetime.now(timezone.utc).date().isoformat() | |
| _lock = threading.Lock() | |
| def is_admin(user): | |
| return user.lower() in ADMIN_USERS | |
| def take_quota(user): | |
| if is_admin(user): | |
| return # team testing doesn't count against limits | |
| today = utc_today() | |
| with _lock: | |
| if _usage[("__all__", today)] >= GLOBAL_DAILY: | |
| raise DemoError("LIMIT", "The demo has reached today's overall limit. Please try again after " | |
| "00:00 UTC, or see the API docs at docs.drama.syncmonster.ai.") | |
| if _usage[(user, today)] >= PER_USER_DAILY: | |
| raise DemoError("LIMIT", f"You've used your {PER_USER_DAILY} free generations for today. " | |
| "Your allowance resets at 00:00 UTC.") | |
| _usage[("__all__", today)] += 1 | |
| _usage[(user, today)] += 1 | |
| def refund_quota(user): | |
| """Failed generations don't use up the person's daily allowance. The global count is kept, | |
| because a failed job may still have used credits.""" | |
| if is_admin(user): | |
| return | |
| today = utc_today() | |
| with _lock: | |
| if _usage[(user, today)] > 0: | |
| _usage[(user, today)] -= 1 | |
| # ---------- DRAMA job steps ---------- | |
| def build_drama(mood, excited, angry): | |
| drama = {} | |
| if mood > 0: | |
| drama["happy"] = round(mood, 2) | |
| elif mood < 0: | |
| drama["sad"] = round(-mood, 2) | |
| if excited > 0: | |
| drama["excited"] = round(excited, 2) | |
| if angry > 0: | |
| drama["angry"] = round(angry, 2) | |
| return drama | |
| def submit_job(run, video, audio, drama, find_face_id=None, face_id=None): | |
| run.stage = "generate" | |
| form = {"is_drama": "true", "drama_params": json.dumps(drama)} | |
| files = {"audio": audio} | |
| if find_face_id: | |
| form.update({"find_face_id": find_face_id, "global_face_id": str(face_id)}) | |
| else: | |
| form["skip_find_face"] = SKIP_FIND_FACE | |
| files["video"] = video | |
| run.facts["sent"] = form | |
| body = api(run, "POST", "generate", data=form, files=files, timeout=180) | |
| request_id = (body.get("data") or {}).get("request_id") | |
| if not request_id: | |
| raise DemoError("API_ERROR", "DRAMA didn't return a job ID. Please try again.", | |
| f"no request_id in {short(body, 300)}") | |
| run.facts["job"] = request_id | |
| run.log(f"job created: {request_id}") | |
| return request_id | |
| def poll(run, name, id_param, ident, label): | |
| """Poll a status endpoint until done. Yields progress text.""" | |
| run.stage = f"{name}" | |
| started = time.time() | |
| last_status = None | |
| while True: | |
| body = api(run, "GET", name, params={id_param: ident}, timeout=30) | |
| data = body.get("data") or {} | |
| status = norm_status(data.get("status")) | |
| if status != last_status: | |
| run.log(f"status: {last_status or '-'} -> {status} after {time.time() - started:.1f}s") | |
| last_status = status | |
| if status in DONE: | |
| return data | |
| if status in FAILED: | |
| code, msg = extract_api_error(body) | |
| if not code and name == "get-status": | |
| code, msg = fetch_job_error(run, ident) or (code, msg) | |
| run.facts["api_error"] = f"{code}: {msg}" | |
| run.facts["time_to_fail"] = f"{time.time() - started:.1f}s" | |
| raise error_from_api(code, msg) | |
| if status not in IN_PROGRESS: | |
| run.log(f"unknown status '{status}', still waiting: {short(body, 300)}", logging.WARNING) | |
| if time.time() - started > JOB_TIMEOUT: | |
| raise DemoError("TIMEOUT", "This is taking too long. Please try again later.", | |
| f"still '{status}' after {JOB_TIMEOUT}s") | |
| yield f"{label}… {int(time.time() - started)}s" | |
| time.sleep(POLL_EVERY) | |
| def fetch_job_error(run, request_id): | |
| """Ask /get-request for the job record when the status reply has no error code.""" | |
| try: | |
| body = api(run, "GET", "get-request", params={"request_id": request_id}, timeout=30) | |
| code, msg = extract_api_error(body) | |
| return (code, msg) if (code or msg) else None | |
| except DemoError as e: | |
| run.log(f"get-request didn't help: {e.detail}", logging.WARNING) | |
| return None | |
| def run_find_face(run, video): | |
| run.stage = "find-face" | |
| body = api(run, "POST", "find-face", files={"video": video}, timeout=180) | |
| ffid = (body.get("data") or {}).get("find_face_id") | |
| if not ffid: | |
| raise DemoError("API_ERROR", "DRAMA didn't return a face-detection ID.", | |
| f"no find_face_id in {short(body, 300)}") | |
| run.facts["find_face_id"] = ffid | |
| return ffid | |
| def pick_face(run, ffid): | |
| run.stage = "get-face" | |
| body = api(run, "GET", "get-face", params={"find_face_id": ffid}, timeout=60) | |
| faces = (body.get("data") or {}).get("faces") or [] | |
| ids = [f.get("global_face_id") for f in faces] | |
| run.facts["faces_found"] = len(faces) | |
| run.log(f"{len(faces)} face(s) found, ids={ids}") | |
| if not faces: | |
| raise DemoError("NO_FACE", API_ERROR_CODES["ERR_004"][1], "find-face returned 0 faces") | |
| return ids[0] | |
| def download(run, request_id, workdir): | |
| run.stage = "download" | |
| r = api(run, "GET", "get-file", params={"request_id": request_id}, timeout=300, stream=True) | |
| if not hasattr(r, "iter_content"): # got JSON back instead of a video | |
| raise DemoError("NOT_READY", "The result isn't ready to download yet. Please try again.", | |
| f"get-file returned JSON: {short(r, 300)}") | |
| path = os.path.join(workdir, "drama_raw.mp4") | |
| with open(path, "wb") as f: | |
| for chunk in r.iter_content(chunk_size=1 << 20): | |
| f.write(chunk) | |
| if os.path.getsize(path) < 1024: | |
| raise DemoError("BAD_RESULT", "DRAMA returned an empty video. Please try again.", | |
| f"downloaded {os.path.getsize(path)} bytes") | |
| run.log(f"downloaded result: {probe(path)}") | |
| return path | |
| def report(run, outcome, category="", detail=""): | |
| """One summary line per run, ready to copy to engineering.""" | |
| f = run.facts | |
| line = (f"[run={run.id}] {outcome}" | |
| + (f" category={category}" if category else "") | |
| + f" user={run.user} stage={run.stage} total={run.elapsed()} flow={FLOW}" | |
| + f" job={f.get('job', '-')}" | |
| + (f" find_face_id={f['find_face_id']}" if "find_face_id" in f else "") | |
| + (f" faces_found={f['faces_found']}" if "faces_found" in f else "") | |
| + f" sent={short(f.get('sent', {}), 200)}" | |
| + f" video=[{f.get('video', '-')}] audio=[{f.get('audio', '-')}]" | |
| + (f" api_error=[{f['api_error']}]" if "api_error" in f else "") | |
| + (f" time_to_fail={f['time_to_fail']}" if "time_to_fail" in f else "") | |
| + (f" detail=[{detail}]" if detail else "")) | |
| (log.info if outcome == "SUCCESS" else log.error)(line) | |
| # ---------- main handler ---------- | |
| # ---------- status panel ---------- | |
| STEPS = ["Uploading", "Detecting face", "Editing expression", "Downloading"] | |
| def status_html(state, title, step=0, note=""): | |
| """Progress card under the result: idle, working (with a stepper), done or error.""" | |
| dots = "".join( | |
| f'<div class="dr-step {"is-done" if i < step else "is-now" if i == step and state == "working" else ""}">' | |
| f'<span class="dr-dot"></span><span>{name}</span></div>' | |
| for i, name in enumerate(STEPS, start=1) | |
| ) | |
| stepper = f'<div class="dr-stepper">{dots}</div>' if state in ("working", "done") else "" | |
| icon = {"idle": "", "working": '<span class="dr-spin"></span>', "done": "✓", "error": "!"}[state] | |
| note_html = f'<div class="dr-status-note">{note}</div>' if note else "" | |
| return (f'<div class="dr-status dr-status-{state}">' | |
| f'<div class="dr-status-head"><span class="dr-status-icon">{icon}</span>' | |
| f'<span class="dr-status-title">{title}</span></div>{stepper}{note_html}</div>') | |
| IDLE = status_html("idle", "Your edited MP4 will appear here", | |
| note="Upload a video, choose an expression, then select Generate edited video. " | |
| "Generation time varies with the clip and service load.") | |
| CONTROL_NAMES = {"happy": "Smile", "sad": "Sadness", "excited": "Excitement", "angry": "Anger"} | |
| def settings_chips(drama): | |
| """Setting summary for the finished run, e.g. "Smile 0.9 · Excitement 0.3".""" | |
| return " ".join(f'<span class="dr-chip">{CONTROL_NAMES.get(k, k)} {v:.1f}</span>' for k, v in drama.items()) | |
| # ---------- main handler ---------- | |
| def run(video, mood, excited, angry, updates, preset_name, profile: gr.OAuthProfile | None): | |
| def stop(message): | |
| raise gr.Error(message, print_exception=False) | |
| if not API_KEY: | |
| stop("The demo isn't switched on yet. Please check back soon.") | |
| if profile is None: | |
| stop("Sign in with Hugging Face (top right) to generate an edited video.") | |
| if not video: | |
| stop("Upload a video first.") | |
| drama = build_drama(mood, excited, angry) | |
| if not drama: | |
| stop("Choose an expression, or move at least one control away from 0.") | |
| ctx = Run(profile.username) | |
| ctx.log(f"new run: user={ctx.user} admin={is_admin(ctx.user)} drama={drama} flow={FLOW}") | |
| counted = False | |
| clip = {"preset": preset_name or CUSTOM, "drama": drama, "marketing_opt_in": bool(updates)} | |
| try: | |
| take_quota(ctx.user) | |
| counted = True | |
| ctx.stage = "prepare" | |
| yield None, status_html("working", "Uploading your video…", 1) | |
| clip_len = video_seconds(video) | |
| if clip_len < MIN_SECONDS: | |
| raise DemoError("TOO_SHORT", f"This clip is too short. Use at least {MIN_SECONDS:g} seconds of video.") | |
| if clip_len > ACCEPT_SECONDS: | |
| raise DemoError("TOO_LONG", f"This video is longer than {int(MAX_SECONDS)} seconds. Use the trim " | |
| "tool (scissors icon, top right of the video) to select a segment first.") | |
| workdir = tempfile.mkdtemp() | |
| if not os.path.basename(video).startswith("input_h264"): | |
| video = normalize_video(video, workdir) # sample clips skip the upload step | |
| audio, audio_source = extract_audio(video, workdir) | |
| v, a = probe(video), probe(audio) | |
| ctx.facts["video"] = f"{v.get('video')} {v.get('duration_s')}s {v.get('size_kb')}KB" | |
| clip["clip_seconds"] = v.get("duration_s") | |
| ctx.facts["audio"] = f"{a.get('audio')} {a.get('duration_s')}s ({audio_source})" | |
| ctx.log(f"video: {v}") | |
| ctx.log(f"audio: {a} ({audio_source})") | |
| if abs((v.get("duration_s") or 0) - (a.get("duration_s") or 0)) > 0.5: | |
| ctx.log("video and audio lengths differ by more than 0.5s", logging.WARNING) | |
| if FLOW == "twostep": | |
| yield None, status_html("working", "Detecting face…", 2) | |
| ffid = run_find_face(ctx, video) | |
| for msg in poll(ctx, "find-face/status", "find_face_id", ffid, "Detecting face"): | |
| yield None, status_html("working", msg, 2) | |
| face_id = pick_face(ctx, ffid) | |
| yield None, status_html("working", "Editing expression…", 3) | |
| request_id = submit_job(ctx, video, audio, drama, find_face_id=ffid, face_id=face_id) | |
| else: | |
| yield None, status_html("working", "Uploading your video…", 1) | |
| request_id = submit_job(ctx, video, audio, drama) | |
| for msg in poll(ctx, "get-status", "request_id", request_id, "Editing expression"): | |
| yield None, status_html("working", msg, 3, "Keep this tab open. Time varies with the clip and service load.") | |
| yield None, status_html("working", "Downloading…", 4) | |
| raw = download(ctx, request_id, workdir) | |
| ctx.stage = "watermark" | |
| result = watermark(raw, workdir, ctx) | |
| report(ctx, "SUCCESS") | |
| track("run", profile, outcome="success", run_id=ctx.id, job_id=ctx.facts.get("job"), | |
| processing_seconds=round(time.time() - ctx.started), **clip) | |
| yield result, status_html("done", f"Completed in {time.time() - ctx.started:.1f} seconds", 5, | |
| settings_chips(drama)) | |
| except DemoError as e: | |
| if counted and e.category != "LIMIT": | |
| refund_quota(ctx.user) | |
| if e.category == "LIMIT": | |
| ctx.log(f"blocked by usage limit: {e.user_message}") | |
| track("limit_hit", profile, run_id=ctx.id, **clip) | |
| else: | |
| report(ctx, "FAILED", e.category, e.detail) | |
| track("run", profile, outcome="failed", error=e.category, error_message=e.user_message, | |
| run_id=ctx.id, job_id=ctx.facts.get("job"), **clip) | |
| yield None, status_html("error", e.user_message, note=f"Reference: {ctx.id}") | |
| stop(f"{e.user_message} (ref: {ctx.id})") | |
| except gr.Error: | |
| raise | |
| except Exception as e: | |
| if counted: | |
| refund_quota(ctx.user) | |
| ctx.log(f"UNEXPECTED {type(e).__name__}: {e}\n{traceback.format_exc()}", logging.ERROR) | |
| report(ctx, "FAILED", "UNEXPECTED", f"{type(e).__name__}: {e}") | |
| track("run", profile, outcome="failed", error="UNEXPECTED", run_id=ctx.id, | |
| job_id=ctx.facts.get("job"), **clip) | |
| yield None, status_html("error", "Something unexpected went wrong.", note=f"Reference: {ctx.id}") | |
| stop(f"Something unexpected went wrong. (ref: {ctx.id})") | |
| # ---------- UI ---------- | |
| try: # trydrama logos (brand_assets.py, uploaded next to this file) | |
| from brand_assets import FAVICON_PNG, LOGO_MARK_PNG, LOGO_WORDMARK_WEBP | |
| except ImportError: | |
| FAVICON_PNG = LOGO_MARK_PNG = LOGO_WORDMARK_WEBP = "" | |
| log.warning("[startup] brand_assets.py not found; using a text logo") | |
| CUSTOM = "Custom controls" | |
| PRESETS = { # name: (sadness −1 … +1 smile, excitement, anger) | |
| "Subtle smile": (0.35, 0.0, 0.0), | |
| "Big smile": (0.9, 0.3, 0.0), | |
| "Sadness": (-0.8, 0.0, 0.0), | |
| "Excitement": (0.4, 0.9, 0.0), | |
| "Anger": (0.0, 0.3, 0.9), | |
| CUSTOM: None, | |
| } | |
| DEFAULT_PRESET = "Big smile" | |
| def apply_preset(name): | |
| """Sets the sliders for a preset. "Custom controls" opens the intensity controls instead. | |
| Outputs: happy, sad, excitement, anger, accordion.""" | |
| values = PRESETS.get(name) | |
| if values is None: | |
| return gr.update(), gr.update(), gr.update(), gr.update(), gr.update(open=True) | |
| mood, excited, angry = values | |
| return (gr.update(value=mood), gr.update(value=-mood), | |
| gr.update(value=excited), gr.update(value=angry), gr.update()) | |
| def mirror(value): | |
| """Happy and Sad are one axis seen from both ends: the other slider is always the negative.""" | |
| return gr.update(value=round(-(value or 0), 2)) | |
| UTM = "utm_source=huggingface&utm_medium=space&utm_campaign=drama_demo" | |
| SITE = f"https://trydrama.io/?{UTM}" | |
| STUDIO = f"https://studio.syncmonster.ai/login?{UTM}" | |
| PRICING = f"https://trydrama.io/?{UTM}#pricing" | |
| DOCS = "https://docs.drama.syncmonster.ai/" | |
| # Where "Use the API" points. Set API_QUICKSTART_URL to the model card's quickstart once that page is public. | |
| API_QUICKSTART = os.environ.get("API_QUICKSTART_URL", DOCS) | |
| # Public model documentation. This must open for signed-out visitors (make the model repo public). | |
| CARD = os.environ.get("MODEL_DOCS_URL", "https://huggingface.co/trydrama-neuralgarage/DRAMA") | |
| PRIVACY = "https://trydrama.io/privacypolicy/" | |
| TERMS = "https://trydrama.io/termsofuses/" | |
| SUPPORT = "https://trydrama.io/gethelp/" | |
| PAGE_TITLE = "DRAMA | AI Expression Editor for Video" | |
| META_DESC = (f"Edit facial expressions in video with DRAMA. Add smiles or change the mood. Free demo and API: " | |
| f"up to {int(MAX_SECONDS)} seconds per video, {PER_USER_DAILY} generations per day.") | |
| SOCIAL_TITLE = "DRAMA AI expression editor for video" | |
| SOCIAL_DESC = (f"Change facial expressions in existing videos. Try the free demo and API with clips up to " | |
| f"{int(MAX_SECONDS)} seconds and {PER_USER_DAILY} generations per day.") | |
| KEYWORDS = ("AI expression editor, facial expression editing, expression editor, AI mood changer, " | |
| "add a smile to a video, facial expression editing API") | |
| HEAD = f""" | |
| <meta name="description" content="{META_DESC}"> | |
| <meta name="keywords" content="{KEYWORDS}"> | |
| <meta property="og:type" content="website"> | |
| <meta property="og:title" content="{SOCIAL_TITLE}"> | |
| <meta property="og:description" content="{SOCIAL_DESC}"> | |
| <meta name="twitter:card" content="summary_large_image"> | |
| <meta name="twitter:title" content="{SOCIAL_TITLE}"> | |
| <meta name="twitter:description" content="{SOCIAL_DESC}"> | |
| <script type="application/ld+json">{json.dumps({ | |
| "@context": "https://schema.org", "@type": "SoftwareApplication", | |
| "name": "DRAMA AI Expression Editor", "applicationCategory": "MultimediaApplication", | |
| "operatingSystem": "Web", "description": META_DESC, "url": "https://trydrama.io/", | |
| "brand": {"@type": "Brand", "name": "SyncMonster"}, | |
| "publisher": {"@type": "Organization", "name": "NeuralGarage Pvt. Ltd."}, | |
| "offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"}, | |
| })}</script> | |
| """ | |
| def _a(url, text): | |
| return f'<a href="{url}" target="_blank">{text}</a>' | |
| FAQ = [ # order follows the review: what it is, free access, how to, API, then details | |
| ("What is DRAMA?", | |
| "DRAMA is an AI expression editor for videos and images. It changes facial expressions in any " | |
| "footage, with presets and intensity controls for smiles, sadness, excitement, anger and more."), | |
| ("Can I change facial expressions online for free?", | |
| f"Yes. Try the DRAMA demo or API for free with videos up to {int(MAX_SECONDS)} seconds and " | |
| f"{PER_USER_DAILY} generations per day. Demo outputs are watermarked. In this demo the allowance is per " | |
| "signed-in Hugging Face account, resets at 00:00 UTC, and failed generations don't count; the demo also " | |
| "has an overall daily capacity. See the " | |
| f"{_a(DOCS, 'API docs')} for authentication and output details."), | |
| ("How do I change a facial expression in a video?", | |
| f"Upload an MP4 video up to {int(MAX_SECONDS)} seconds with one clearly visible, front-facing person. " | |
| "Choose an expression preset, adjust the intensity if needed, and select <b>Generate edited video</b>. " | |
| "Preview the result and download the edited MP4."), | |
| ("How do I add a smile to a video with AI?", | |
| "Upload your clip and choose <b>Subtle smile</b> or <b>Big smile</b>. Use the Happy control to " | |
| "adjust the strength, then generate a preview and compare it with the original."), | |
| ("Is there an API?", | |
| f"Yes. The DRAMA API docs are at {_a(DOCS, 'docs.drama.syncmonster.ai')}. Open the API docs for a " | |
| "request example, supported controls, authentication and the output format. The model weights " | |
| "remain private."), | |
| ("Which expressions can I edit?", | |
| "The demo includes smile, sadness, excitement and anger controls. Start with a preset or open " | |
| "<b>Custom controls</b> to adjust the available settings. Results depend on the input footage and " | |
| "selected intensity."), | |
| ("Can DRAMA work as an AI mood changer?", | |
| "DRAMA can change the apparent mood of a performance by editing visible facial expressions, such as " | |
| "a smile or a sadder look. It does not measure a person's actual feelings."), | |
| ("Does DRAMA change anything else in the video?", | |
| "DRAMA aims to edit the expression while preserving the person and scene, so compare the result " | |
| f"with the original. Demo outputs include a watermark; see the {_a(DOCS, 'API docs')} for output " | |
| "specifications."), | |
| ("Can I edit one person in a group video?", | |
| "This demo is designed for clips with one clearly visible person. For selecting a face in group " | |
| f"footage, explore {_a(SITE, 'DRAMA Studio')} and its current feature availability."), | |
| ("Does DRAMA work on photos?", | |
| f"This demo focuses on videos. For photo expression editing, explore {_a(SITE, 'DRAMA Studio')} " | |
| "and its current access options."), | |
| ("Can I download the model weights?", | |
| "DRAMA is a private model. You can use its hosted demo and API within the published limits; the " | |
| "model weights are not publicly available for download."), | |
| ("What footage should I use?", | |
| "Use footage you have the right to edit, with permission from the people shown. For this demo, " | |
| "start with one clearly visible person. Review each result before publishing and avoid presenting " | |
| "an edited expression as an unaltered recording."), | |
| ("What happens to my uploaded video?", | |
| "Your video is sent to the DRAMA service to generate the edited clip. When you sign in, the demo " | |
| "also records your Hugging Face profile (username, name, email, profile link and picture), your " | |
| "visits, and the settings and outcome of each generation. How uploads and this data are handled and " | |
| "kept is set out in our " | |
| f"{_a(PRIVACY, 'Privacy Policy')}."), | |
| ("How long does a generation take?", | |
| "Generation time varies with the clip and service load. The demo shows processing status and the " | |
| "time taken for your completed request."), | |
| ] | |
| # ---------- example grid ("Compare facial expression edits") ---------- | |
| # Clips live in the model repo under drama-examples/<n>/ (drama_examples/ also works): | |
| # <n>_original.<ext> the untouched clip | |
| # <n>_<expression>[_<intensity>].<ext> a DRAMA output, e.g. 2_happy_0.6.mp4 or 6_sad.mp4 | |
| # They are copied into the Space at startup (needs HF_TOKEN with read access while the repo is private), | |
| # remuxed to MP4 with the index at the front (no re-encoding, so quality is unchanged), and given a | |
| # still frame, so the page only downloads a clip when someone presses play. | |
| EXAMPLES_REPO = os.environ.get("EXAMPLES_REPO", "trydrama-neuralgarage/DRAMA") | |
| EXAMPLES_FOLDERS = ["drama-examples", "drama_examples"] # either spelling works | |
| EXAMPLES_VISIBLE = 3 # rows shown before "Show more examples" | |
| EXAMPLE_TITLES = { # folder number -> what the example shows (folders without a title fall back to "Example NN") | |
| "1": "High precision", | |
| "2": "Studio shot, lip movement preserved", | |
| "3": "Side angle", | |
| "4": "Lighting variation", | |
| "5": "Multiple faces", | |
| "6": "Webcam footage", | |
| "7": "Moving camera", | |
| } | |
| EX_DOWNLOAD = os.path.abspath("example_cache") # copy of the repo folder | |
| EX_WEB = os.path.abspath("example_web") # browser-ready MP4s and still frames | |
| def _parse_example(path): | |
| """'2_happy_0.6.mp4' -> ('happy', '0.6'); '6_original.mp4' -> ('original', None).""" | |
| parts = os.path.splitext(os.path.basename(path))[0].split("_")[1:] | |
| if not parts: | |
| return None | |
| intensity = None | |
| if len(parts) > 1: | |
| try: | |
| float(parts[-1]) | |
| intensity = parts.pop() | |
| except ValueError: | |
| pass | |
| return " ".join(parts).lower(), intensity | |
| def _web_ready(src, n): | |
| """MP4 copy with the index first (fast start) plus a still frame. Falls back to the source file.""" | |
| stem = os.path.splitext(os.path.basename(src))[0] | |
| out_dir = os.path.join(EX_WEB, str(n)) | |
| os.makedirs(out_dir, exist_ok=True) | |
| mp4, jpg = os.path.join(out_dir, stem + ".mp4"), os.path.join(out_dir, stem + ".jpg") | |
| if not os.path.exists(mp4): | |
| r = subprocess.run(["ffmpeg", "-y", "-i", src, "-map", "0:v:0", "-map", "0:a:0?", "-c", "copy", | |
| "-movflags", "+faststart", mp4], capture_output=True, text=True) | |
| if r.returncode != 0: | |
| log.warning(f"[examples] couldn't remux {os.path.basename(src)}, using it as is") | |
| mp4 = src | |
| if not os.path.exists(jpg): | |
| subprocess.run(["ffmpeg", "-y", "-ss", "1", "-i", src, "-frames:v", "1", "-vf", "scale=960:-2", | |
| "-q:v", "4", jpg], capture_output=True, text=True) | |
| return mp4, (jpg if os.path.exists(jpg) else None) | |
| def load_examples(): | |
| """Returns [(number, [(label, intensity, video_path, poster_path), ...]), ...].""" | |
| # a copy uploaded into the Space itself wins; otherwise copy it from the model repo | |
| base = next((os.path.abspath(f) for f in EXAMPLES_FOLDERS if os.path.isdir(f)), None) | |
| if base is None: | |
| try: | |
| from huggingface_hub import snapshot_download | |
| snapshot_download(EXAMPLES_REPO, repo_type="model", token=HF_TOKEN or None, local_dir=EX_DOWNLOAD, | |
| allow_patterns=[f"{f}/*/*" for f in EXAMPLES_FOLDERS]) | |
| base = next((os.path.join(EX_DOWNLOAD, f) for f in EXAMPLES_FOLDERS | |
| if os.path.isdir(os.path.join(EX_DOWNLOAD, f))), "") | |
| if not base: | |
| log.warning(f"[examples] no drama-examples/ folder found in {EXAMPLES_REPO}") | |
| return [] | |
| except Exception as e: | |
| log.warning(f"[examples] couldn't copy drama-examples/ from {EXAMPLES_REPO} " | |
| f"({type(e).__name__}: {short(str(e), 200)}). HF_TOKEN needs read access to that repo.") | |
| return [] | |
| rows = [] | |
| folders = [d for d in os.listdir(base) if os.path.isdir(os.path.join(base, d))] if os.path.isdir(base) else [] | |
| for folder in sorted(folders, key=lambda d: (not d.isdigit(), int(d) if d.isdigit() else 0, d)): | |
| clips = [] | |
| for f in sorted(os.listdir(os.path.join(base, folder))): | |
| if f.startswith(".") or os.path.splitext(f)[1].lower() not in (".mp4", ".mov", ".webm", ".m4v"): | |
| continue | |
| parsed = _parse_example(f) | |
| if parsed: | |
| clips.append((parsed, os.path.join(base, folder, f))) | |
| if not clips: | |
| continue | |
| clips.sort(key=lambda c: (c[0][0] != "original", c[0][0], float(c[0][1] or 0))) | |
| rows.append((folder, [(name, inten, *_web_ready(path, folder)) for (name, inten), path in clips])) | |
| log.info(f"[examples] {len(rows)} examples, {sum(len(c) for _, c in rows)} clips ready") | |
| return rows | |
| # ---------- sample input clips ("Try a sample clip") ---------- | |
| # Before-clips from the model repo's examples/ folder, offered under the upload box for people without a | |
| # clip of their own. All are under 15 seconds, so they never need trimming. | |
| SAMPLE_INPUTS = [ # file number in examples/, caption | |
| (1, "Outdoor interview"), | |
| (2, "Talking head"), | |
| (5, "Street portrait"), | |
| (7, "Webinar host"), | |
| (8, "Explainer to camera"), | |
| ] | |
| SAMPLE_WEB = os.path.abspath("sample_inputs") | |
| def load_sample_inputs(): | |
| """Returns [(video_path, poster_path, caption), ...] for the sample strip.""" | |
| wanted = [n for n, _ in SAMPLE_INPUTS] | |
| local = lambda n: sorted(glob.glob(os.path.join("examples", f"{n}_before.*")) + | |
| glob.glob(os.path.join(EX_DOWNLOAD, "examples", f"{n}_before.*"))) | |
| if not all(local(n) for n in wanted): | |
| try: | |
| from huggingface_hub import snapshot_download | |
| snapshot_download(EXAMPLES_REPO, repo_type="model", token=HF_TOKEN or None, local_dir=EX_DOWNLOAD, | |
| allow_patterns=[f"examples/{n}_before.*" for n in wanted]) | |
| except Exception as e: | |
| log.warning(f"[samples] couldn't copy sample clips from {EXAMPLES_REPO}/examples " | |
| f"({type(e).__name__}: {short(str(e), 200)})") | |
| os.makedirs(SAMPLE_WEB, exist_ok=True) | |
| out = [] | |
| for n, caption in SAMPLE_INPUTS: | |
| found = local(n) | |
| if not found: | |
| continue | |
| src = found[0] | |
| if video_seconds(src) > ACCEPT_SECONDS: | |
| log.warning(f"[samples] {os.path.basename(src)} is longer than {ACCEPT_SECONDS:g}s, skipped") | |
| continue | |
| poster = os.path.join(SAMPLE_WEB, f"{n}_before.jpg") | |
| if not os.path.exists(poster): | |
| subprocess.run(["ffmpeg", "-y", "-ss", "1", "-i", src, "-frames:v", "1", "-vf", "scale=480:-2", | |
| "-q:v", "4", poster], capture_output=True, text=True) | |
| out.append((src, poster if os.path.exists(poster) else None, caption)) | |
| log.info(f"[samples] {len(out)} sample clips ready") | |
| return out | |
| def _file_url(path): | |
| return f"/gradio_api/file={path}" | |
| def examples_html(rows): | |
| if not rows: | |
| return "" | |
| def figure(name, intensity, video, poster): | |
| if name == "original": | |
| cap = '<b>Original</b>' | |
| else: | |
| cap = f'<b>{name.title()}</b>' + (f'<span>Intensity {intensity}</span>' if intensity else "") | |
| poster_attr = f' poster="{_file_url(poster)}"' if poster else "" | |
| return (f'<figure class="{"is-original" if name == "original" else "is-edit"}">' | |
| f'<video src="{_file_url(video)}"{poster_attr} controls muted playsinline preload="none"></video>' | |
| f'<figcaption>{cap}</figcaption></figure>') | |
| def row(folder, clips): | |
| num = f"{int(folder):02d}" if folder.isdigit() else folder | |
| title = EXAMPLE_TITLES.get(folder.lstrip("0") or folder, f"Example {num}") | |
| return (f'<div class="dr-ex-row dr-sync"><div class="dr-ex-head"><span class="dr-ex-num">{num}</span>' | |
| f'<h3>{title}</h3></div>' | |
| f'<div class="dr-ex-grid">{"".join(figure(*c) for c in clips)}</div></div>') | |
| shown = "".join(row(*r) for r in rows[:EXAMPLES_VISIBLE]) | |
| more = rows[EXAMPLES_VISIBLE:] | |
| more_html = (f'<details class="dr-ex-more"><summary>Show {len(more)} more example{"s" if len(more) != 1 else ""}' | |
| f'</summary>{"".join(row(*r) for r in more)}</details>') if more else "" | |
| return f""" | |
| <section class="dr-section" id="examples"> | |
| <div class="dr-center-head"><div class="dr-kicker">Before and after</div> | |
| <h2>Compare facial expression edits</h2> | |
| <div class="dr-section-sub">Each row shows an original clip next to DRAMA edits with different expressions. | |
| Press play on any video and the whole row plays in sync.</div></div> | |
| <div class="dr-ex">{shown}{more_html}</div> | |
| </section>""" | |
| PROMO_VIDEOS = [ # YouTube video ID, descriptive title, caption | |
| ("WYi2y1NOhhc", "DRAMA demo: edit facial expressions in any video", | |
| "A walkthrough of choosing an expression and generating an edited clip."), | |
| ("NngMqkyotx8", "Before and after: change expressions with DRAMA", | |
| "Original takes next to the same takes with an edited expression."), | |
| ] | |
| FEATURES = [ | |
| ("Add a smile with AI", | |
| "Use this AI expression editor to add a subtle or bigger smile to an existing video. Compare the " | |
| "output with the original to choose the result that fits your scene."), | |
| ("Change the mood of a performance", | |
| "Use DRAMA as an AI mood changer for visible facial expressions. Create a sadder, angrier or more " | |
| "excited expression to explore a different feel for the take."), | |
| ("Adjust expression intensity", | |
| "Fine tune happiness, sadness, excitement and anger with the available controls. Start with a " | |
| "preset, then adjust the settings for your clip."), | |
| ("Select a face in DRAMA Studio", | |
| "For group footage, explore face selection in DRAMA Studio. This demo is designed for clips with " | |
| "one clearly visible person."), | |
| ("Keep the focus on expression", | |
| "DRAMA aims to preserve the person and scene while editing the facial expression. Results vary by " | |
| "clip, so review the output before use."), | |
| ("Edit photo expressions in DRAMA Studio", | |
| "Looking for an expression editor for photos? Explore DRAMA Studio for photo editing and its " | |
| "current access options."), | |
| ] | |
| def logo_html(size="lg"): | |
| if LOGO_MARK_PNG and LOGO_WORDMARK_WEBP: | |
| return (f'<a class="dr-brand dr-brand-{size}" href="{SITE}" target="_blank" ' | |
| f'aria-label="trydrama.io">' | |
| f'<img class="dr-mark" src="data:image/png;base64,{LOGO_MARK_PNG}" alt="DRAMA logo">' | |
| f'<img class="dr-word" src="data:image/webp;base64,{LOGO_WORDMARK_WEBP}" ' | |
| f'alt="trydrama.io"></a>') | |
| return f'<a class="dr-brand dr-brand-{size}" href="{SITE}" target="_blank">DRAMA</a>' | |
| THEME = gr.themes.Base( | |
| primary_hue=gr.themes.Color( | |
| c50="#fff0f0", c100="#ffd6d6", c200="#ffadad", c300="#ff7f7f", c400="#ff4d4d", | |
| c500="#f01e1e", c600="#d41414", c700="#ad0f0f", c800="#850c0c", c900="#5e0909", | |
| c950="#330404"), | |
| neutral_hue="zinc", | |
| radius_size="lg", | |
| font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"], | |
| ).set( | |
| body_background_fill="#0a0a0d", body_background_fill_dark="#0a0a0d", | |
| body_text_color="#ededf0", body_text_color_dark="#ededf0", | |
| body_text_color_subdued="#9a9aa6", body_text_color_subdued_dark="#9a9aa6", | |
| background_fill_primary="#141418", background_fill_primary_dark="#141418", | |
| background_fill_secondary="#1b1b21", background_fill_secondary_dark="#1b1b21", | |
| block_background_fill="#141418", block_background_fill_dark="#141418", | |
| block_border_color="#25252d", block_border_color_dark="#25252d", | |
| block_label_background_fill="#1b1b21", block_label_background_fill_dark="#1b1b21", | |
| block_label_text_color="#b4b4be", block_label_text_color_dark="#b4b4be", | |
| block_title_text_color="#ededf0", block_title_text_color_dark="#ededf0", | |
| border_color_primary="#25252d", border_color_primary_dark="#25252d", | |
| input_background_fill="#1b1b21", input_background_fill_dark="#1b1b21", | |
| slider_color="#f01e1e", slider_color_dark="#f01e1e", | |
| button_primary_background_fill="#e3141c", | |
| button_primary_background_fill_dark="#e3141c", | |
| button_primary_background_fill_hover="#f02a31", | |
| button_primary_background_fill_hover_dark="#f02a31", | |
| button_primary_text_color="#ffffff", button_primary_text_color_dark="#ffffff", | |
| button_secondary_background_fill="#1b1b21", button_secondary_background_fill_dark="#1b1b21", | |
| button_secondary_text_color="#ededf0", button_secondary_text_color_dark="#ededf0", | |
| button_secondary_border_color="#2c2c35", button_secondary_border_color_dark="#2c2c35", | |
| color_accent_soft="#2a1010", color_accent_soft_dark="#2a1010", | |
| ) | |
| # Every text rule is scoped under .gradio-container with !important where Gradio's own | |
| # "prose" styles would otherwise win (they load after this sheet when SSR is on). | |
| CSS = """ | |
| :root { --dr-red: #e3141c; --dr-red-soft: rgba(227,20,28,.12); --dr-line: #2a2a32; --dr-card: #121216; | |
| --dr-text: #ededf0; --dr-muted: #9a9aa6; --dr-faint: #6b6b76; } | |
| .gradio-container { max-width: 1120px !important; margin: 0 auto !important; padding: 0 24px 48px !important; } | |
| /* let the example grid extend wider than the page column; the page itself clips any sideways overflow */ | |
| .gradio-container { overflow: visible !important; } | |
| html, body { overflow-x: clip !important; } | |
| .gradio-container .main.app { padding: 8px 0 !important; } | |
| .gradio-container .html-container { padding: 0 !important; } | |
| .gradio-container .prose { max-width: none !important; } | |
| .gradio-container .dr-glow { position: fixed; inset: -30vh 0 auto; height: 70vh; pointer-events: none; z-index: 0; | |
| background: radial-gradient(45% 50% at 50% 0%, rgba(227,20,28,.10), transparent 70%); } | |
| /* header */ | |
| #dr-top { align-items: center !important; padding: 20px 0 0; position: relative; z-index: 1; | |
| flex-wrap: nowrap !important; gap: 12px; } | |
| #dr-top > * { min-width: 0 !important; } | |
| .gradio-container a.dr-brand { display: inline-flex !important; align-items: center; gap: 10px; | |
| text-decoration: none !important; } | |
| .gradio-container .dr-mark { height: 32px !important; width: auto !important; margin: 0 !important; } | |
| .gradio-container .dr-word { height: 20px !important; width: auto !important; margin: 0 !important; } | |
| #dr-login { max-width: 280px; margin-left: auto; flex: none !important; width: auto !important; } | |
| .gradio-container #dr-login, .gradio-container #dr-login button { border-radius: 10px !important; } | |
| .gradio-container #dr-login button, .gradio-container button#dr-login { | |
| background: #fff !important; color: #111 !important; border: 1px solid #fff !important; | |
| font-size: 14px !important; font-weight: 600 !important; padding: 9px 16px !important; | |
| box-shadow: 0 0 0 4px rgba(255,255,255,.08) !important; } | |
| .gradio-container #dr-login button:hover, .gradio-container button#dr-login:hover { background: #e9e9ee !important; } | |
| /* once signed in it becomes a quiet account button */ | |
| .gradio-container .dr-signed-in#dr-login, .gradio-container .dr-signed-in#dr-login button, | |
| .gradio-container #dr-login.dr-signed-in button { | |
| background: transparent !important; color: var(--dr-muted) !important; border: 1px solid var(--dr-line) !important; | |
| box-shadow: none !important; font-weight: 500 !important; font-size: 13px !important; } | |
| /* in-form sign-in, shown in place of Generate while signed out */ | |
| .gradio-container #dr-login-cta, .gradio-container #dr-login-cta button { | |
| width: 100% !important; background: #fff !important; color: #111 !important; border: 1px solid #fff !important; | |
| border-radius: 10px !important; font-size: 16px !important; font-weight: 700 !important; min-height: 50px; } | |
| .gradio-container #dr-login-cta:hover, .gradio-container #dr-login-cta button:hover { background: #e9e9ee !important; } | |
| .gradio-container #dr-login-cta img, .gradio-container #dr-login img { filter: none !important; } | |
| /* hero */ | |
| .gradio-container .dr-hero { text-align: center !important; padding: 64px 0 44px; position: relative; z-index: 1; } | |
| .gradio-container .dr-kicker { font-size: 12px !important; font-weight: 600 !important; letter-spacing: .12em !important; | |
| text-transform: uppercase; color: #ff6b6b !important; margin: 0 0 14px !important; } | |
| .gradio-container .dr-hero h1 { font-size: clamp(36px, 5.6vw, 64px) !important; line-height: 1.02 !important; | |
| font-weight: 750 !important; margin: 0 auto 18px !important; letter-spacing: -0.04em !important; | |
| color: #ffffff !important; text-align: center !important; max-width: 900px; } | |
| .gradio-container .dr-hero h1 .dr-accent { color: var(--dr-red) !important; } | |
| .gradio-container .dr-hero .dr-sub { font-size: 18px !important; color: #a8a8b3 !important; max-width: 600px; | |
| margin: 0 auto !important; line-height: 1.55 !important; text-align: center !important; } | |
| .gradio-container .dr-meta { display: flex; justify-content: center; flex-wrap: wrap; margin-top: 22px; } | |
| .gradio-container .dr-meta span { font-size: 13px !important; color: var(--dr-faint) !important; } | |
| .gradio-container .dr-meta span + span::before { content: "·"; margin: 0 10px; color: #3a3a44; } | |
| /* cards */ | |
| #dr-main { gap: 16px !important; position: relative; z-index: 1; align-items: stretch !important; } | |
| .gradio-container .dr-card { background: var(--dr-card) !important; border: 1px solid var(--dr-line) !important; | |
| border-radius: 16px !important; padding: 20px !important; gap: 12px !important; | |
| justify-content: flex-start !important; } | |
| .gradio-container .dr-card > * { flex-grow: 0 !important; } | |
| .gradio-container .dr-label { display: flex !important; align-items: baseline; gap: 10px; font-weight: 600 !important; | |
| font-size: 15px !important; color: var(--dr-text) !important; margin: 0 !important; } | |
| .gradio-container .dr-label b { font: 600 12px/1 ui-monospace, "SF Mono", Menlo, monospace !important; | |
| color: var(--dr-faint) !important; } | |
| .gradio-container .dr-hint { font-size: 13px !important; color: var(--dr-faint) !important; margin: 4px 0 0 !important; } | |
| .gradio-container .dr-media video, .gradio-container .dr-media .wrap { border-radius: 10px !important; } | |
| /* Let the upload box grow while Gradio's trim tool (filmstrip + Trim/Cancel) is open */ | |
| .gradio-container #dr-in { height: auto !important; min-height: 360px !important; overflow: visible !important; } | |
| .gradio-container #dr-in .video-container { height: auto !important; min-height: 358px !important; } | |
| /* same visible size as the result player: fit the clip inside the same 360px box */ | |
| .gradio-container #dr-in video, .gradio-container .dr-out video { height: 358px !important; max-height: 358px !important; | |
| width: 100% !important; object-fit: contain !important; background: #000; } | |
| .gradio-container #dr-in .video-container:has(.edit-buttons) { min-height: 0 !important; } | |
| .gradio-container #dr-in .video-container:has(.edit-buttons) video { height: 300px !important; max-height: 300px !important; } | |
| /* card headers take the same height, so both boxes start on the same line */ | |
| .gradio-container .dr-head { min-height: 64px; } | |
| .gradio-container #dr-in .controls { height: auto !important; overflow: visible !important; padding: 8px 0 4px !important; } | |
| .gradio-container #dr-in .opaque-layer { cursor: grab !important; } | |
| body.dr-sliding, body.dr-sliding * { cursor: grabbing !important; user-select: none !important; } | |
| .gradio-container #dr-in .handle::before { content: ""; position: absolute; top: 0; bottom: 0; left: -7px; right: -7px; } | |
| .gradio-container #dr-in .edit-buttons { height: auto !important; align-items: center !important; gap: 8px !important; } | |
| .gradio-container #dr-in .edit-buttons button { height: auto !important; line-height: 1.2 !important; padding: 7px 16px !important; margin: 0 !important; border: 1px solid var(--dr-line) !important; | |
| border-radius: 8px !important; font-size: 13px !important; color: var(--dr-text) !important; } | |
| .gradio-container #dr-in .edit-buttons button:first-child { background: var(--dr-red) !important; border-color: var(--dr-red) !important; color: #fff !important; } | |
| /* presets */ | |
| #dr-presets { background: transparent !important; border: 0 !important; padding: 0 !important; } | |
| #dr-presets .wrap { gap: 6px !important; } | |
| #dr-presets label { border-radius: 8px !important; border: 1px solid #2a2a33 !important; background: transparent !important; | |
| padding: 7px 13px !important; transition: border-color .15s ease, color .15s ease; cursor: pointer; margin: 0 !important; | |
| color: #b4b4be !important; font-size: 14px !important; } | |
| #dr-presets label:hover { border-color: #4a4a55 !important; color: #fff !important; } | |
| #dr-presets label.selected { background: var(--dr-red-soft) !important; border-color: var(--dr-red) !important; | |
| color: #fff !important; } | |
| #dr-presets input[type=radio] { display: none !important; } | |
| /* generate */ | |
| #dr-go { height: 50px; font-size: 16px !important; font-weight: 600 !important; border-radius: 10px !important; | |
| box-shadow: none !important; transition: background .15s ease; } | |
| .gradio-container .dr-fine { font-size: 10px !important; line-height: 1.5 !important; color: var(--dr-faint) !important; text-align: center !important; margin: 0 !important; } | |
| .gradio-container .dr-fine a { color: var(--dr-faint) !important; text-decoration: underline !important; padding: 0 !important; margin: 0 !important; display: inline !important; } | |
| .gradio-container .dr-trim { border: 0 !important; background: transparent !important; padding: 0 !important; box-shadow: none !important; } | |
| .gradio-container .dr-trim-note { font-size: 13px !important; color: var(--dr-muted) !important; line-height: 1.5 !important; | |
| border: 1px solid rgba(227,20,28,.45) !important; background: var(--dr-red-soft) !important; border-radius: 10px !important; padding: 12px 14px !important; } | |
| .gradio-container .dr-trim-note b { color: var(--dr-text) !important; font-weight: 600 !important; } | |
| .gradio-container #dr-optin { margin: 2px 0 !important; } | |
| .gradio-container #dr-optin label span { font-size: 13px !important; color: var(--dr-muted) !important; } | |
| /* status */ | |
| .gradio-container .dr-status { border-radius: 10px; padding: 14px 16px; border: 1px solid var(--dr-line); background: #0e0e11; } | |
| .gradio-container .dr-status-head { display: flex; align-items: center; gap: 10px; font-weight: 600 !important; | |
| font-size: 14px !important; color: var(--dr-text) !important; } | |
| .gradio-container .dr-status-icon { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; font-size: 12px; flex: none; } | |
| .gradio-container .dr-status-idle .dr-status-icon { width: 8px; height: 8px; background: #3a3a44; } | |
| .gradio-container .dr-status-note { color: var(--dr-muted) !important; font-size: 13px !important; margin-top: 8px; line-height: 1.6 !important; } | |
| .gradio-container .dr-status-done .dr-status-icon { background: #22c55e; color: #04210f; font-weight: 700; } | |
| .gradio-container .dr-status-error { border-color: rgba(239,68,68,.35); } | |
| .gradio-container .dr-status-error .dr-status-icon { background: #ef4444; color: #fff; font-weight: 700; } | |
| .gradio-container .dr-spin { width: 14px; height: 14px; border-radius: 50%; border: 2px solid #2c2c35; | |
| border-top-color: var(--dr-red); animation: dr-spin .8s linear infinite; display: inline-block; } | |
| @keyframes dr-spin { to { transform: rotate(360deg); } } | |
| .gradio-container .dr-stepper { display: flex; gap: 6px; margin-top: 12px; } | |
| .gradio-container .dr-step { flex: 1; font-size: 12px !important; color: var(--dr-faint) !important; display: flex; | |
| flex-direction: column; gap: 6px; } | |
| .gradio-container .dr-dot { height: 3px; border-radius: 3px; background: #26262e; display: block; } | |
| .gradio-container .dr-step.is-done { color: #b4b4be !important; } .dr-step.is-done .dr-dot { background: var(--dr-red); } | |
| .gradio-container .dr-step.is-now { color: #fff !important; } | |
| .gradio-container .dr-step.is-now .dr-dot { background: linear-gradient(90deg, #26262e 0%, var(--dr-red) 50%, #26262e 100%); | |
| background-size: 200% 100%; animation: dr-load 1.4s linear infinite; } | |
| @keyframes dr-load { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } } | |
| .gradio-container .dr-chip { display: inline-block; font: 500 12px/1.4 ui-monospace, "SF Mono", Menlo, monospace !important; | |
| padding: 3px 8px; border-radius: 6px; margin: 2px 4px 2px 0; background: #1b1b21; | |
| border: 1px solid #2a2a33; color: #c9c9d1 !important; } | |
| /* CTA */ | |
| .gradio-container .dr-cta { padding: 16px 18px; border-radius: 10px; border: 1px solid var(--dr-line); display: flex; gap: 14px; | |
| align-items: center; justify-content: space-between; flex-wrap: wrap; } | |
| .gradio-container .dr-cta-text { font-size: 13px !important; color: var(--dr-muted) !important; line-height: 1.55 !important; flex: 1 1 240px; } | |
| .gradio-container .dr-cta-text b { color: #fff !important; display: block; margin-bottom: 2px; font-size: 14px; } | |
| .gradio-container .dr-cta-btns { display: flex; gap: 8px; flex-wrap: wrap; } | |
| .gradio-container a.dr-btn { font-size: 13px !important; font-weight: 600 !important; color: #fff !important; | |
| text-decoration: none !important; padding: 8px 14px; border-radius: 8px; border: 1px solid #2c2c35; white-space: nowrap; } | |
| .gradio-container a.dr-btn:hover { border-color: #4a4a55; } | |
| .gradio-container a.dr-btn-primary { background: var(--dr-red); border-color: var(--dr-red); } | |
| .gradio-container a.dr-btn-primary:hover { background: #f02a31; border-color: #f02a31; } | |
| /* editorial sections */ | |
| .gradio-container .dr-section { margin: 0 !important; padding-top: 96px !important; position: relative; z-index: 1; } | |
| .gradio-container .dr-split { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: 56px; align-items: start; } | |
| .gradio-container .dr-split-head { position: sticky; top: 24px; } | |
| .gradio-container .dr-section h2 { font-size: clamp(26px, 3vw, 34px) !important; font-weight: 700 !important; | |
| letter-spacing: -0.025em !important; line-height: 1.15 !important; color: #fff !important; margin: 0 0 12px !important; | |
| text-align: left !important; } | |
| .gradio-container .dr-section .dr-section-sub { color: var(--dr-muted) !important; font-size: 15px !important; | |
| line-height: 1.6 !important; margin: 0 !important; text-align: left !important; } | |
| .gradio-container .dr-section-sub a { color: #fff !important; text-decoration: underline !important; | |
| padding: 0 !important; margin: 0 !important; background: none !important; | |
| text-underline-offset: 3px; text-decoration-color: #4a4a55 !important; } | |
| .gradio-container .dr-list { display: grid; grid-template-columns: 1fr 1fr; column-gap: 32px; } | |
| .gradio-container .dr-item { border-top: 1px solid var(--dr-line); padding: 18px 0 24px; } | |
| .gradio-container .dr-item h3 { font-size: 15px !important; font-weight: 600 !important; color: #fff !important; | |
| margin: 0 0 6px !important; } | |
| .gradio-container .dr-item span { font-size: 14px !important; color: var(--dr-muted) !important; line-height: 1.55 !important; } | |
| /* promo videos */ | |
| .gradio-container .dr-center-head { text-align: center !important; max-width: 640px; margin: 0 auto 28px !important; } | |
| .gradio-container .dr-center-head h2, .gradio-container .dr-center-head .dr-section-sub { text-align: center !important; } | |
| .gradio-container .dr-videos { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } | |
| .gradio-container .dr-video { position: relative; aspect-ratio: 16 / 9; border-radius: 14px; overflow: hidden; | |
| border: 1px solid var(--dr-line); background: #000; } | |
| .gradio-container .dr-video iframe { position: absolute; inset: 0; width: 100% !important; height: 100% !important; | |
| border: 0 !important; } | |
| /* hero disclosure + actions (review S1) */ | |
| .gradio-container .dr-disclose { font-size: 13px !important; color: var(--dr-muted) !important; margin-top: 8px !important; text-align: center !important; } | |
| .gradio-container .dr-hero-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-top: 24px; } | |
| .gradio-container .dr-hero-actions a.dr-btn { font-size: 14px !important; padding: 10px 18px; } | |
| /* allowance line next to Generate (review S2) */ | |
| .gradio-container .dr-quota { font-size: 13px !important; color: var(--dr-muted) !important; line-height: 1.5 !important; | |
| border-top: 1px solid var(--dr-line); padding-top: 12px; margin-top: 4px; } | |
| .gradio-container .dr-quota-row { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 2px; } | |
| .gradio-container .dr-quota-row b { color: var(--dr-text) !important; font-weight: 600 !important; } | |
| .gradio-container .dr-quota a { color: #fff !important; text-decoration: underline !important; text-underline-offset: 3px; | |
| text-decoration-color: #4a4a55 !important; padding: 0 !important; margin: 0 !important; background: none !important; } | |
| /* free API box in the result card */ | |
| .gradio-container .dr-api { padding: 14px 18px; border-radius: 10px; border: 1px solid var(--dr-line); display: flex; gap: 14px; | |
| align-items: center; justify-content: space-between; flex-wrap: wrap; } | |
| .gradio-container .dr-api-text { font-size: 13px !important; color: var(--dr-muted) !important; line-height: 1.55 !important; flex: 1 1 240px; } | |
| .gradio-container .dr-api-text b { color: #fff !important; display: block; margin-bottom: 2px; font-size: 14px; } | |
| /* download button under the result */ | |
| .gradio-container #dr-dl { width: 100%; } | |
| /* captions under the showcase videos and examples */ | |
| .gradio-container .dr-video-fig { margin: 0 !important; } | |
| .gradio-container .dr-video-fig figcaption { margin-top: 10px; font-size: 13px !important; color: var(--dr-muted) !important; | |
| line-height: 1.5 !important; text-align: left !important; } | |
| .gradio-container .dr-video-fig figcaption b { display: block; color: #fff !important; font-weight: 600 !important; font-size: 14px !important; } | |
| /* sample clip strip under the upload box */ | |
| .gradio-container .dr-samples-label { font-size: 13px !important; color: var(--dr-muted) !important; margin: 4px 0 8px !important; } | |
| .gradio-container .dr-samples-label b { color: var(--dr-text) !important; font-weight: 600 !important; } | |
| .gradio-container .dr-samples { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; margin-bottom: 10px; } | |
| .gradio-container .dr-sample { display: block; padding: 0 !important; margin: 0 !important; cursor: pointer; text-align: left; | |
| background: transparent !important; border: 0 !important; min-width: 0; } | |
| .gradio-container .dr-sample img, .gradio-container .dr-sample .dr-sample-ph { display: block; width: 100%; | |
| aspect-ratio: 16 / 10; object-fit: cover; border-radius: 8px; border: 1px solid var(--dr-line); background: #000; | |
| transition: border-color .15s; } | |
| .gradio-container .dr-sample span:not(.dr-sample-ph) { display: block; margin-top: 5px; font-size: 11px !important; | |
| line-height: 1.3 !important; color: var(--dr-muted) !important; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| .gradio-container .dr-sample:hover img { border-color: #6a6a75; } | |
| .gradio-container .dr-sample.is-on img { border-color: var(--dr-red); box-shadow: 0 0 0 1px var(--dr-red); } | |
| .gradio-container .dr-sample.is-on span:not(.dr-sample-ph) { color: #fff !important; } | |
| /* example grid */ | |
| .gradio-container .dr-ex { width: min(1440px, calc(100vw - 64px)); position: relative; left: 50%; | |
| transform: translateX(-50%); min-width: 0; } | |
| .gradio-container .dr-ex-row { min-width: 0; margin: 0 0 56px !important; } | |
| .gradio-container .dr-ex-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px !important; } | |
| .gradio-container .dr-ex-num { font: 600 12px/1 ui-monospace, "SF Mono", Menlo, monospace !important; | |
| color: var(--dr-red) !important; } | |
| .gradio-container .dr-ex-head h3 { font-size: 18px !important; font-weight: 600 !important; color: #fff !important; | |
| margin: 0 !important; letter-spacing: -0.01em; } | |
| .gradio-container .dr-ex-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } | |
| .gradio-container .dr-ex-grid figure { margin: 0 !important; min-width: 0; } | |
| .gradio-container .dr-ex-grid video { display: block; width: 100% !important; aspect-ratio: 16 / 9; object-fit: contain; | |
| background: #000; border-radius: 10px; border: 1px solid var(--dr-line); } | |
| .gradio-container .dr-ex-grid .is-edit video { border-color: rgba(227,20,28,.45); } | |
| .gradio-container .dr-ex-grid figcaption { display: flex; gap: 8px; align-items: baseline; margin-top: 8px; | |
| font-size: 13px !important; color: var(--dr-muted) !important; } | |
| .gradio-container .dr-ex-grid figcaption b { color: #fff !important; font-weight: 600 !important; } | |
| .gradio-container .dr-ex-grid figcaption span::before { content: "·"; margin-right: 8px; color: var(--dr-faint); } | |
| .gradio-container .dr-ex-more { display: block; } | |
| .gradio-container .dr-ex-more > summary { list-style: none; cursor: pointer; width: max-content; margin: 0 auto !important; font-size: 14px !important; | |
| font-weight: 600 !important; color: #fff !important; padding: 9px 18px !important; border-radius: 999px; | |
| border: 1px solid var(--dr-line); } | |
| .gradio-container .dr-ex-more > summary::-webkit-details-marker { display: none; } | |
| .gradio-container .dr-ex-more > summary:hover { border-color: #4a4a55; } | |
| .gradio-container .dr-ex-more[open] > summary { display: none; } | |
| .gradio-container .dr-ex-more[open] { display: block; } | |
| /* FAQ */ | |
| .gradio-container .dr-faq { display: grid; border-top: 1px solid var(--dr-line); } | |
| .gradio-container .dr-faq details { border-bottom: 1px solid var(--dr-line); padding: 0 !important; margin: 0 !important; | |
| background: transparent !important; } | |
| .gradio-container .dr-faq summary { cursor: pointer; list-style: none; padding: 18px 40px 18px 0 !important; | |
| font-size: 15px !important; font-weight: 500 !important; color: var(--dr-text) !important; position: relative; } | |
| .gradio-container .dr-faq summary::-webkit-details-marker { display: none; } | |
| .gradio-container .dr-faq summary::after { content: ""; position: absolute; right: 6px; top: 50%; width: 8px; height: 8px; | |
| border-right: 1.5px solid #8b8b97; border-bottom: 1.5px solid #8b8b97; transform: translateY(-75%) rotate(45deg); | |
| transition: transform .15s; } | |
| .gradio-container .dr-faq details[open] summary::after { transform: translateY(-25%) rotate(-135deg); } | |
| .gradio-container .dr-faq summary:hover { color: #fff !important; } | |
| .gradio-container .dr-faq .dr-ans { padding: 0 40px 20px 0 !important; color: var(--dr-muted) !important; | |
| font-size: 14px !important; line-height: 1.65 !important; margin: 0 !important; } | |
| .gradio-container .dr-faq a { color: #fff !important; text-decoration: underline !important; | |
| padding: 0 !important; margin: 0 !important; background: none !important; | |
| text-underline-offset: 3px; text-decoration-color: #4a4a55 !important; } | |
| .gradio-container .dr-faq b { color: #d4d4da !important; font-weight: 600 !important; } | |
| /* footer */ | |
| .gradio-container .dr-foot { color: var(--dr-faint) !important; font-size: 13px !important; margin-top: 96px; | |
| padding: 28px 0 0; border-top: 1px solid var(--dr-line); line-height: 1.8 !important; position: relative; z-index: 1; | |
| display: grid; grid-template-columns: auto 1fr; gap: 6px 32px; align-items: center; } | |
| .gradio-container .dr-foot .dr-mark { height: 24px !important; } .dr-foot .dr-word { height: 15px !important; } | |
| .gradio-container .dr-foot nav { justify-self: end; } | |
| .gradio-container .dr-foot nav a { color: var(--dr-muted) !important; margin-left: 20px; text-decoration: none !important; } | |
| .gradio-container .dr-foot nav a:hover { color: #fff !important; } | |
| .gradio-container .dr-foot .dr-foot-copy { grid-column: 1 / -1; } | |
| @media (max-width: 900px) { | |
| .gradio-container .dr-split { grid-template-columns: 1fr; gap: 24px; } | |
| .gradio-container .dr-split-head { position: static; } | |
| } | |
| @media (max-width: 640px) { | |
| .gradio-container { padding: 0 16px 32px !important; } | |
| .gradio-container .dr-list { grid-template-columns: 1fr; } | |
| .gradio-container .dr-videos { grid-template-columns: 1fr; } | |
| .gradio-container .dr-ex-grid { grid-template-columns: none; grid-auto-flow: column; grid-auto-columns: 82%; | |
| overflow-x: auto; scroll-snap-type: x mandatory; padding-bottom: 6px; } | |
| .gradio-container .dr-ex-grid figure { scroll-snap-align: start; } | |
| .gradio-container .dr-ex { width: calc(100vw - 48px); } | |
| .gradio-container .dr-ex-row { margin-bottom: 40px !important; } | |
| .gradio-container .dr-ex-head h3 { font-size: 16px !important; } | |
| .gradio-container .dr-hero { padding: 40px 0 28px; } | |
| .gradio-container .dr-hero .dr-sub { font-size: 16px !important; } | |
| .gradio-container .dr-meta { gap: 4px 14px; } | |
| .gradio-container .dr-meta span + span::before { content: none; } | |
| .gradio-container .dr-hero-actions a.dr-btn { flex: 1 1 100%; text-align: center; } | |
| .gradio-container .dr-head { min-height: 0; } | |
| .gradio-container .dr-samples { grid-template-columns: repeat(3, minmax(0, 1fr)); } | |
| .gradio-container #dr-login-cta, .gradio-container #dr-login-cta button { font-size: 15px !important; } | |
| .gradio-container .dr-card { padding: 16px !important; } | |
| .gradio-container .dr-mark { height: 28px !important; } .dr-word { height: 17px !important; } | |
| .gradio-container #dr-login button, .gradio-container button#dr-login { font-size: 12px !important; padding: 7px 10px !important; } | |
| .gradio-container .dr-cta-btns { width: 100%; } .gradio-container .dr-cta-btns a.dr-btn { flex: 1 1 100%; text-align: center; } | |
| .gradio-container .dr-section { padding-top: 64px !important; } | |
| .gradio-container .dr-foot { grid-template-columns: 1fr; margin-top: 64px; } | |
| .gradio-container .dr-foot nav { justify-self: start; display: flex; flex-wrap: wrap; gap: 4px 18px; } | |
| .gradio-container .dr-foot nav a { margin: 0 !important; padding: 0 !important; white-space: nowrap; } | |
| } | |
| """ | |
| def _fix_media_rules(css): | |
| """Gradio rewrites selectors inside @media blocks to | |
| ".gradio-container.gradio-container-X .contain <selector>", so a selector that itself | |
| starts with ".gradio-container" never matches. Inside @media, drop that leading part | |
| so the rewritten selector lands on our elements.""" | |
| import re | |
| out, i = [], 0 | |
| for m in re.finditer(r"@media[^{]+\{", css): | |
| start = m.end() | |
| depth, j = 1, start | |
| while depth: | |
| depth += {"{": 1, "}": -1}.get(css[j], 0) | |
| j += 1 | |
| body = re.sub(r"(^|[,{}])(\s*)\.gradio-container\s+(?=[.#a-zA-Z])", r"\1\2", css[start:j - 1], | |
| flags=re.M) | |
| out.append(css[i:start] + body + "}") | |
| i = j | |
| out.append(css[i:]) | |
| return "".join(out) | |
| CSS = _fix_media_rules(CSS) | |
| FORCE_DARK = """ | |
| () => { | |
| document.documentElement.classList.add('dark'); document.body.classList.add('dark'); | |
| if (window.__drBA) return; window.__drBA = true; | |
| // Sign-in inside the huggingface.co frame: when the browser blocks cookies in frames | |
| // (Safari by default, some Chrome profiles), Gradio's in-frame login loops ("redirected too many | |
| // times"). In that case open sign-in in a full tab on the app's own domain, where it works. | |
| window.__drFramedNoCookies = false; | |
| try { | |
| if (window.top !== window.self && document.hasStorageAccess) { | |
| document.hasStorageAccess().then((ok) => { window.__drFramedNoCookies = !ok; }).catch(() => {}); | |
| } | |
| } catch (e) {} | |
| document.addEventListener('click', (e) => { | |
| const btn = e.target.closest && e.target.closest('#dr-login, #dr-login-cta'); | |
| if (!btn || !window.__drFramedNoCookies) return; | |
| if (!/sign in/i.test(btn.textContent || '')) return; // leave "Logout" alone | |
| e.preventDefault(); e.stopImmediatePropagation(); | |
| window.open(window.location.origin + '/login/huggingface?_target_url=/', '_blank', 'noopener'); | |
| }, true); | |
| const markSignedIn = () => { | |
| const b = document.getElementById('dr-login'); if (!b) return; | |
| b.classList.toggle('dr-signed-in', /^\\s*sign out/i.test(b.textContent || '')); | |
| }; | |
| new MutationObserver(markSignedIn).observe(document.body, { childList: true, subtree: true, characterData: true }); | |
| // Sample clips: tell the app which one was picked | |
| document.addEventListener('click', (e) => { | |
| const card = e.target.closest && e.target.closest('.dr-sample'); if (!card) return; | |
| document.querySelectorAll('.dr-sample').forEach((c) => c.classList.toggle('is-on', c === card)); | |
| const btn = document.getElementById('dr-sample-' + card.dataset.i); | |
| if (btn) (btn.tagName === 'BUTTON' ? btn : btn.querySelector('button') || btn).click(); | |
| }); | |
| // Trim timeline: preselect the first MAX seconds so the person only has to press Trim. | |
| // Gradio has no option for this, so we move the end handle the way a mouse drag would. | |
| const MAXS = __MAXSEC__, MINS = __MINSEC__, ACCEPT = __ACCEPT__; | |
| const autoTrim = () => { | |
| const root = document.getElementById('dr-in'); if (!root) return; | |
| const right = root.querySelector('.handle.right'), tl = root.querySelector('#timeline'), v = root.querySelector('video'); | |
| if (!right || !tl || !v || right.dataset.drAuto) return; | |
| const d = v.duration, r = tl.getBoundingClientRect(); | |
| if (!(d > 0) || !r.width) return; | |
| right.dataset.drAuto = '1'; | |
| if (d <= ACCEPT) return; | |
| const y = r.top + r.height / 2; | |
| right.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: r.right, clientY: y })); | |
| window.dispatchEvent(new MouseEvent('mousemove', { clientX: Math.floor(r.left + r.width * (MAXS / d)), clientY: y })); | |
| window.dispatchEvent(new MouseEvent('mouseup', {})); | |
| v.currentTime = 0; | |
| }; | |
| new MutationObserver(autoTrim).observe(document.body, { childList: true, subtree: true }); | |
| // Trim timeline: lock the selection to between MINS and MAXS seconds while dragging. | |
| // Gradio moves a handle on window mousemove; we clamp the pointer position before it sees it. | |
| let dragSide = null; | |
| const ours = new WeakSet(); | |
| const tlInfo = () => { | |
| const root = document.getElementById('dr-in'); if (!root) return null; | |
| const tl = root.querySelector('#timeline'), v = root.querySelector('video'); | |
| const L = root.querySelector('.handle.left'), R = root.querySelector('.handle.right'); | |
| if (!tl || !v || !L || !R || !(v.duration > 0)) return null; | |
| return { rect: tl.getBoundingClientRect(), d: v.duration, | |
| l: parseFloat(L.style.left) || 0, r: R.style.left ? parseFloat(R.style.left) : 100 }; | |
| }; | |
| const range = (side, t) => { | |
| const pct = 100 / t.d, maxW = Math.min(MAXS, t.d) * pct, minW = Math.min(MINS, t.d) * pct; | |
| return side === 'right' ? [Math.max(0, t.l + minW), Math.min(100, t.l + maxW)] | |
| : [Math.max(0, t.r - maxW), Math.min(100, t.r - minW)]; | |
| }; | |
| document.addEventListener('mousedown', (e) => { | |
| const h = e.target.closest && e.target.closest('#dr-in .handle'); | |
| dragSide = h ? (h.classList.contains('left') ? 'left' : 'right') : null; | |
| // Gradio bug: when you grab the other handle, the first handle's blur cancels the new drag. | |
| // Moving focus now, before Gradio handles the mousedown, lets the drag start normally. | |
| if (h && document.activeElement !== h) h.focus({ preventScroll: true }); | |
| }, true); | |
| window.addEventListener('mouseup', () => { dragSide = null; }, true); | |
| window.addEventListener('mousemove', (e) => { | |
| if (!dragSide || ours.has(e)) return; | |
| const t = tlInfo(); if (!t || !t.rect.width) return; | |
| const want = (e.clientX - t.rect.left) / t.rect.width * 100, [lo, hi] = range(dragSide, t); | |
| const got = Math.min(Math.max(want, lo), hi); | |
| if (Math.abs(got - want) < 1e-6) return; | |
| e.stopImmediatePropagation(); | |
| const px = t.rect.left + t.rect.width * got / 100; // round toward the inside of the allowed range | |
| const ev = new MouseEvent('mousemove', { clientX: got === hi ? Math.floor(px) : Math.ceil(px), clientY: e.clientY }); | |
| ours.add(ev); window.dispatchEvent(ev); | |
| }, true); | |
| // Trim timeline: drag the selection box itself to slide the whole window along the clip. | |
| // Done by moving Gradio's two handles in turn (front edge first so they never cross). | |
| const nudge = (handle, x, y) => { | |
| handle.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: x, clientY: y })); | |
| const mv = new MouseEvent('mousemove', { clientX: x, clientY: y }); ours.add(mv); window.dispatchEvent(mv); | |
| const up = new MouseEvent('mouseup', {}); ours.add(up); window.dispatchEvent(up); | |
| }; | |
| let slide = null; | |
| document.addEventListener('mousedown', (e) => { | |
| const box = e.target.closest && e.target.closest('#dr-in .opaque-layer'); if (!box) return; | |
| const t = tlInfo(); if (!t || !t.rect.width) return; | |
| e.preventDefault(); e.stopPropagation(); | |
| const widthPx = Math.floor(t.rect.width * (t.r - t.l) / 100); | |
| slide = { x0: e.clientX, l0px: t.rect.left + t.rect.width * t.l / 100, widthPx, lastL: null }; | |
| document.body.classList.add('dr-sliding'); | |
| }, true); | |
| window.addEventListener('mousemove', (e) => { | |
| if (!slide || ours.has(e)) return; | |
| e.stopImmediatePropagation(); | |
| const t = tlInfo(); if (!t) return; | |
| const root = document.getElementById('dr-in'); | |
| const L = root.querySelector('.handle.left'), R = root.querySelector('.handle.right'); | |
| const minL = Math.ceil(t.rect.left), maxL = Math.floor(t.rect.right) - slide.widthPx; | |
| const newL = Math.min(Math.max(Math.round(slide.l0px + e.clientX - slide.x0), minL), maxL); | |
| if (newL === slide.lastL) return; | |
| const goingRight = slide.lastL === null ? newL > slide.l0px : newL > slide.lastL; | |
| slide.lastL = newL; | |
| const y = t.rect.top + t.rect.height / 2; | |
| if (goingRight) { nudge(R, newL + slide.widthPx, y); nudge(L, newL, y); } | |
| else { nudge(L, newL, y); nudge(R, newL + slide.widthPx, y); } | |
| const v = root.querySelector('video'); if (v) v.currentTime = (newL - t.rect.left) / t.rect.width * t.d; | |
| }, true); | |
| window.addEventListener('mouseup', (e) => { | |
| if (slide && !ours.has(e)) { slide = null; document.body.classList.remove('dr-sliding'); } | |
| }, true); | |
| window.addEventListener('keydown', (e) => { // arrow keys on a focused handle | |
| if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; | |
| const h = document.activeElement && document.activeElement.closest && document.activeElement.closest('#dr-in .handle'); | |
| if (!h) return; const t = tlInfo(); if (!t) return; | |
| const side = h.classList.contains('left') ? 'left' : 'right', [lo, hi] = range(side, t); | |
| const next = (side === 'left' ? t.l : t.r) + (e.key === 'ArrowLeft' ? -1 : 1) * 100 / t.d; | |
| if (next < lo - 1e-6 || next > hi + 1e-6) { e.preventDefault(); e.stopImmediatePropagation(); } | |
| }, true); | |
| // Example grid: every video in a row plays, pauses and seeks together | |
| const others = (v) => { const r = v.closest && v.closest('.dr-sync'); if (!r) return []; | |
| return [...r.querySelectorAll('video')].filter((o) => o !== v); }; | |
| const quiet = (o) => { o.__drSyncAt = Date.now(); }; | |
| const fromSync = (v) => v.__drSyncAt && Date.now() - v.__drSyncAt < 400; | |
| document.addEventListener('play', (e) => { const v = e.target; if (fromSync(v)) return; | |
| others(v).forEach((o) => { quiet(o); if (Math.abs(o.currentTime - v.currentTime) > 0.15) o.currentTime = v.currentTime; | |
| if (o.paused) o.play().catch(() => {}); }); }, true); | |
| document.addEventListener('pause', (e) => { const v = e.target; if (fromSync(v) || v.ended) return; | |
| others(v).forEach((o) => { if (!o.paused) { quiet(o); o.pause(); } }); }, true); | |
| document.addEventListener('seeked', (e) => { const v = e.target; if (fromSync(v)) return; | |
| others(v).forEach((o) => { if (Math.abs(o.currentTime - v.currentTime) > 0.15) { quiet(o); o.currentTime = v.currentTime; } }); }, true); | |
| } | |
| """.replace("__MAXSEC__", str(MAX_SECONDS)).replace("__MINSEC__", str(MIN_SECONDS)).replace("__ACCEPT__", str(ACCEPT_SECONDS)) | |
| EXAMPLE_ROWS = load_examples() | |
| SAMPLE_CLIPS = load_sample_inputs() | |
| def remaining_html(profile: gr.OAuthProfile | None = None): | |
| """Free allowance line next to the Generate button, with the live balance for signed-in users.""" | |
| if profile is None: | |
| left = "Free with a Hugging Face account." | |
| elif is_admin(profile.username): | |
| left = "Team account: no daily limit." | |
| else: | |
| used = _usage[(profile.username, utc_today())] | |
| n = max(0, PER_USER_DAILY - used) | |
| left = f"{n} generation{'s' if n != 1 else ''} remaining today." | |
| return (f'<div class="dr-quota"><div>Free allowance: {PER_USER_DAILY} generations per day. ' | |
| f'Maximum video length: {int(MAX_SECONDS)} seconds.</div>' | |
| f'<div class="dr-quota-row"><b>{left}</b></div></div>') | |
| HIDE_DL = gr.update(visible=False, value=None) | |
| def samples_strip_html(): | |
| items = "".join( | |
| f'<button type="button" class="dr-sample" data-i="{i}" title="Use the {caption} sample">' | |
| + (f'<img src="{_file_url(poster)}" alt="" loading="lazy">' if poster else '<span class="dr-sample-ph"></span>') | |
| + f'<span>{caption}</span></button>' | |
| for i, (_, poster, caption) in enumerate(SAMPLE_CLIPS)) | |
| return (f'<div class="dr-samples-label">No clip handy? <b>Try a sample clip</b></div>' | |
| f'<div class="dr-samples">{items}</div>') | |
| def pick_sample(index): | |
| """Loads the chosen sample into the upload box (converted like an upload) and clears the old result.""" | |
| video, _, caption = SAMPLE_CLIPS[index] | |
| log.info(f"[samples] picked {caption}") | |
| return prepare_upload(video), None, IDLE, HIDE_DL | |
| def signin_state(profile: gr.OAuthProfile | None = None): | |
| """Signed out: the big sign-in button sits where Generate goes. Signed in: show Generate.""" | |
| signed_in = profile is not None | |
| return gr.update(visible=not signed_in), gr.update(visible=signed_in) | |
| def generate(video, happy, sad, excited, angry, updates, preset_name, profile: gr.OAuthProfile | None): | |
| """UI wrapper around run(): adds the download button and the remaining-generations line.""" | |
| last = (None, IDLE) | |
| try: | |
| mood = happy or 0 # one axis: positive = happy, negative = sad (Sad slider is always -Happy) | |
| for result, status in run(video, mood, excited, angry, updates, preset_name, profile): | |
| last = (result, status) | |
| done = result is not None | |
| yield (result, status, gr.update(visible=True, value=result) if done else HIDE_DL, | |
| remaining_html(profile) if done else gr.update()) | |
| except gr.Error: | |
| yield (*last, HIDE_DL, remaining_html(profile)) | |
| raise | |
| PLAYER_H = 360 # both players use the same box so original and result are the same size | |
| with gr.Blocks(title=PAGE_TITLE) as demo: | |
| gr.HTML('<div class="dr-glow"></div>') | |
| with gr.Row(elem_id="dr-top"): | |
| gr.HTML(logo_html()) | |
| gr.LoginButton("Sign in with Hugging Face", logout_value="Sign out ({})", elem_id="dr-login", size="sm") | |
| gr.HTML(f""" | |
| <header class="dr-hero"> | |
| <div class="dr-kicker">Free demo and API</div> | |
| <h1>DRAMA AI expression editor<br><span class="dr-accent">for videos</span></h1> | |
| <div class="dr-sub">Change facial expressions in any video. Add a smile, create a sadder or angrier | |
| expression, or adjust excitement using precise intensity controls.</div> | |
| <div class="dr-meta"><span>Free demo and API</span><span>Up to {int(MAX_SECONDS)} seconds per video</span> | |
| <span>{PER_USER_DAILY} generations per day</span></div> | |
| <div class="dr-disclose">Demo outputs are watermarked. Private model weights</div> | |
| <div class="dr-hero-actions"> | |
| <a class="dr-btn dr-btn-primary" href="#dr-main">Try the demo</a> | |
| <a class="dr-btn" href="{API_QUICKSTART}" target="_blank">Use the API</a> | |
| <a class="dr-btn" href="{CARD}" target="_blank">Read model documentation</a> | |
| </div> | |
| </header>""") | |
| with gr.Row(elem_id="dr-main", equal_height=True): | |
| with gr.Column(scale=1, elem_classes="dr-card"): | |
| gr.HTML('<div class="dr-head"><div class="dr-label"><b>01</b>Upload a video</div>' | |
| f'<div class="dr-hint">Upload an MP4 video up to {int(MAX_SECONDS)} seconds. Use a clip with ' | |
| 'one clearly visible, front-facing person. For a longer video, select a segment with the ' | |
| 'trim tool.</div></div>') | |
| video_in = gr.Video(show_label=False, sources=["upload"], height=PLAYER_H, elem_classes="dr-media", | |
| elem_id="dr-in") | |
| trim_info = gr.HTML(visible=False, elem_classes="dr-trim") | |
| if SAMPLE_CLIPS: | |
| gr.HTML(samples_strip_html()) | |
| # one hidden button per sample; the thumbnails click these (see the page script) | |
| sample_btns = [gr.Button(f"Sample {i + 1}", visible="hidden", elem_id=f"dr-sample-{i}") | |
| for i in range(len(SAMPLE_CLIPS))] | |
| gr.HTML('<div class="dr-label"><b>02</b>Choose an expression</div>') | |
| preset = gr.Radio(list(PRESETS), value=DEFAULT_PRESET, show_label=False, | |
| container=False, elem_id="dr-presets") | |
| d_mood, d_exc, d_ang = PRESETS[DEFAULT_PRESET] | |
| with gr.Accordion("Adjust expression intensity", open=False, elem_id="dr-adjust") as adjust: | |
| gr.HTML('<div class="dr-hint">Use the presets as a starting point, then adjust the controls. ' | |
| 'Happy and Sad move in opposite directions: Sad 0.5 is the same as Happy −0.5.</div>') | |
| happy = gr.Slider(-1, 1, value=d_mood, step=0.05, label="Happy", | |
| info="+1 = biggest smile · 0 = neutral · −1 = sad") | |
| sad = gr.Slider(-1, 1, value=-d_mood, step=0.05, label="Sad", | |
| info="+1 = strongest sadness · 0 = neutral · −1 = happy") | |
| excited = gr.Slider(0, 1, value=d_exc, step=0.05, label="Excitement", | |
| info="0 = no added excitement · 1 = strongest excitement") | |
| angry = gr.Slider(0, 1, value=d_ang, step=0.05, label="Anger", | |
| info="0 = no added anger · 1 = strongest anger") | |
| quota = gr.HTML(remaining_html()) | |
| login_cta = gr.LoginButton("Sign in with Hugging Face to generate", logout_value="Sign out ({})", | |
| elem_id="dr-login-cta", size="lg") | |
| go = gr.Button("Generate edited video", variant="primary", elem_id="dr-go", visible=False) | |
| updates = gr.Checkbox(label="Email me DRAMA product updates and offers.", value=False, | |
| container=False, elem_id="dr-optin") | |
| gr.HTML('<div class="dr-fine">Only upload footage you have the right to edit, with permission from ' | |
| 'the people shown. Demo outputs are watermarked. When you sign in, the demo records your Hugging ' | |
| 'Face profile (username, name, email, profile link and picture), your visits, and the ' | |
| 'settings and outcome of each generation. Read our ' | |
| f'<a href="{PRIVACY}" target="_blank">Privacy Policy</a> for upload handling and account ' | |
| 'data.</div>') | |
| with gr.Column(scale=1, elem_classes="dr-card"): | |
| gr.HTML('<div class="dr-head"><div class="dr-label"><b>03</b>Edited video</div>' | |
| '<div class="dr-hint">The person\'s identity and facial details stay the same as in your ' | |
| 'original. Only the expression changes.</div></div>') | |
| video_out = gr.Video(show_label=False, interactive=False, height=PLAYER_H, elem_classes=["dr-media", "dr-out"]) | |
| dl_btn = gr.DownloadButton("Download edited MP4", visible=False, variant="primary", | |
| elem_id="dr-dl") | |
| status_out = gr.HTML(IDLE) | |
| gr.HTML(f""" | |
| <div class="dr-api"><div class="dr-api-text"><b>Build it into your product</b> | |
| Add facial expression editing to your own app with the free API: videos up to {int(MAX_SECONDS)} | |
| seconds and {PER_USER_DAILY} generations per day.</div> | |
| <a class="dr-btn" href="{API_QUICKSTART}" target="_blank">Free API docs</a></div>""") | |
| gr.HTML(f""" | |
| <div class="dr-cta"><div class="dr-cta-text"><b>Explore DRAMA Studio</b> | |
| Need longer videos, photo editing or face selection in group shots? Explore DRAMA Studio | |
| and its current plans.</div> | |
| <div class="dr-cta-btns"><a class="dr-btn" href="{PRICING}" target="_blank">View Studio features and plans</a> | |
| <a class="dr-btn dr-btn-primary" href="{SITE}" target="_blank">Open DRAMA Studio</a></div></div>""") | |
| gr.HTML(examples_html(EXAMPLE_ROWS)) | |
| videos = "".join( | |
| f'<figure class="dr-video-fig"><div class="dr-video"><iframe ' | |
| f'src="https://www.youtube-nocookie.com/embed/{vid}?rel=0&modestbranding=1" ' | |
| f'title="{title}" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" ' | |
| f'allow="accelerometer; encrypted-media; gyroscope; picture-in-picture; fullscreen" allowfullscreen>' | |
| f'</iframe></div><figcaption><b>{title}</b><span>{caption}</span></figcaption></figure>' | |
| for vid, title, caption in PROMO_VIDEOS) | |
| gr.HTML(f""" | |
| <section class="dr-section"> | |
| <div class="dr-center-head"><div class="dr-kicker">In action</div> | |
| <h2>See facial expression editing in action</h2> | |
| <div class="dr-section-sub">Watch DRAMA edit visible facial expressions in existing footage, then | |
| compare the results with the original clips.</div></div> | |
| <div class="dr-videos">{videos}</div> | |
| </section>""") | |
| features = "".join(f'<div class="dr-item"><h3>{title}</h3><span>{text}</span></div>' | |
| for title, text in FEATURES) | |
| gr.HTML(f""" | |
| <section class="dr-section dr-split"> | |
| <div class="dr-split-head"><div class="dr-kicker">What it does</div> | |
| <h2>Edit facial expressions in real footage</h2> | |
| <div class="dr-section-sub">Use DRAMA to edit emotions as they appear on a face, from a subtle smile | |
| to a stronger expression. Choose a preset or adjust the intensity controls.</div></div> | |
| <div class="dr-list">{features}</div> | |
| </section>""") | |
| faq = "".join(f'<details><summary>{q}</summary><div class="dr-ans">{a}</div></details>' for q, a in FAQ) | |
| gr.HTML(f""" | |
| <section class="dr-section dr-split"> | |
| <div class="dr-split-head"><div class="dr-kicker">FAQ</div> | |
| <h2>Questions about DRAMA</h2> | |
| <div class="dr-section-sub">Learn how the AI expression editor works, what you can try for free, and | |
| how to integrate the API. Need help? <a href="{SUPPORT}" target="_blank">Contact the DRAMA team</a>.</div></div> | |
| <div class="dr-faq">{faq}</div> | |
| </section>""") | |
| gr.HTML(f""" | |
| <footer class="dr-foot">{logo_html("sm")} | |
| <nav><a href="{CARD}" target="_blank">Model documentation</a><a href="{API_QUICKSTART}" target="_blank">Free API docs</a><a href="{SITE}" target="_blank">DRAMA Studio</a><a href="{PRIVACY}" target="_blank">Privacy</a><a href="{TERMS}" target="_blank">Terms</a><a href="{SUPPORT}" target="_blank">Support</a></nav> | |
| <div class="dr-foot-copy">DRAMA by SyncMonster is an AI facial expression editor for videos and images. Try the demo or | |
| use the API to change visible expressions in existing footage.<br>© 2026 NeuralGarage Pvt. Ltd., Bengaluru. | |
| SyncMonster and DRAMA are products of NeuralGarage.</div> | |
| </footer>""") | |
| preset.change(apply_preset, inputs=[preset], outputs=[happy, sad, excited, angry, adjust], | |
| api_visibility="private", show_progress="hidden") | |
| happy.input(mirror, inputs=[happy], outputs=[sad], api_visibility="private", | |
| show_progress="hidden", trigger_mode="always_last") | |
| sad.input(mirror, inputs=[sad], outputs=[happy], api_visibility="private", | |
| show_progress="hidden", trigger_mode="always_last") | |
| for slider in (happy, sad, excited, angry): | |
| slider.release(lambda: CUSTOM, outputs=[preset], | |
| api_visibility="private", show_progress="hidden") | |
| video_in.upload(prepare_upload, inputs=[video_in], outputs=[video_in], api_visibility="private") | |
| if SAMPLE_CLIPS: | |
| for i, btn in enumerate(sample_btns): | |
| btn.click(lambda i=i: pick_sample(i), inputs=None, outputs=[video_in, video_out, status_out, dl_btn], | |
| api_visibility="private", show_progress="minimal") | |
| video_in.change(check_length, inputs=[video_in], outputs=[trim_info], api_visibility="private", | |
| show_progress="hidden", trigger_mode="always_last") | |
| video_in.clear(lambda: (None, IDLE, HIDE_DL), outputs=[video_out, status_out, dl_btn], | |
| api_visibility="private") | |
| demo.load(on_visit, inputs=None, outputs=None, api_visibility="private", show_progress="hidden") | |
| demo.load(remaining_html, inputs=None, outputs=[quota], api_visibility="private", show_progress="hidden") | |
| demo.load(signin_state, inputs=None, outputs=[login_cta, go], api_visibility="private", show_progress="hidden") | |
| go.click( | |
| generate, | |
| inputs=[video_in, happy, sad, excited, angry, updates, preset], | |
| outputs=[video_out, status_out, dl_btn, quota], | |
| concurrency_limit=2, | |
| api_visibility="private", | |
| show_progress="hidden", | |
| ) | |
| def favicon_file(): | |
| if not FAVICON_PNG: | |
| return None | |
| import base64 | |
| path = os.path.join(tempfile.gettempdir(), "drama_favicon.png") | |
| with open(path, "wb") as f: | |
| f.write(base64.b64decode(FAVICON_PNG)) | |
| return path | |
| startup_check() | |
| demo.queue(max_size=20).launch(theme=THEME, css=CSS, js=FORCE_DARK, head=HEAD, | |
| allowed_paths=[EX_WEB, EX_DOWNLOAD, SAMPLE_WEB, os.path.abspath("examples"), *(os.path.abspath(f) for f in EXAMPLES_FOLDERS)], | |
| favicon_path=favicon_file(), footer_links=[]) |