Spaces:
Running on Zero
Running on Zero
Upload folder using huggingface_hub
Browse files- README.md +28 -6
- __pycache__/app.cpython-312.pyc +0 -0
- app.py +197 -0
- index.html +203 -0
- requirements.txt +1 -0
README.md
CHANGED
|
@@ -1,13 +1,35 @@
|
|
| 1 |
---
|
| 2 |
title: StreamDiffusionV2 Realtime
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
|
|
|
|
|
|
|
|
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: StreamDiffusionV2 Realtime
|
| 3 |
+
emoji: 🌀
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: pink
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 6.10.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
+
python_version: "3.10"
|
| 10 |
+
short_description: Realtime webcam video diffusion (StreamDiffusionV2 / Wan2.1)
|
| 11 |
+
startup_duration_timeout: 1h
|
| 12 |
pinned: false
|
| 13 |
---
|
| 14 |
|
| 15 |
+
# StreamDiffusionV2 · Realtime Webcam Diffusion
|
| 16 |
+
|
| 17 |
+
Live ZeroGPU demo of [**StreamDiffusionV2**](https://streamdiffusionv2.github.io/)
|
| 18 |
+
(MLSys 2026 Best Paper) on **Wan2.1-T2V-1.3B**, with a custom `gradio.Server`
|
| 19 |
+
frontend.
|
| 20 |
+
|
| 21 |
+
Unlike a fixed-length generator, StreamDiffusionV2 is **designed for continuous
|
| 22 |
+
streaming**: a causal Diffusion-Transformer with a **sink-token-guided rolling KV
|
| 23 |
+
cache**, a motion-aware noise controller, and StreamVAE. Your webcam is streamed
|
| 24 |
+
through it prompt-by-prompt and the stylized result flows back live, without the
|
| 25 |
+
window-shift burst that fixed-horizon models show.
|
| 26 |
+
|
| 27 |
+
The browser captures the webcam and posts frames to a lightweight FastAPI route;
|
| 28 |
+
a held `@spaces.GPU` session runs StreamDiffusionV2's single-GPU streaming loop
|
| 29 |
+
(`start_stream_session` → `run_stream_batch`) and streams frames back over the
|
| 30 |
+
Gradio JS client, paced by a client jitter buffer.
|
| 31 |
+
|
| 32 |
+
One of three rolling/streaming demos:
|
| 33 |
+
- StreamDiffusionV2 (this) — video-to-video webcam.
|
| 34 |
+
- LongLive — interactive long text-to-video.
|
| 35 |
+
- Rolling Forcing — real-time multi-minute text-to-video.
|
__pycache__/app.cpython-312.pyc
ADDED
|
Binary file (9.24 kB). View file
|
|
|
app.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
import base64
|
| 4 |
+
import tempfile
|
| 5 |
+
import queue as pyqueue
|
| 6 |
+
import multiprocessing as mp
|
| 7 |
+
from io import BytesIO
|
| 8 |
+
|
| 9 |
+
import spaces # before torch / CUDA imports
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
import numpy as np
|
| 13 |
+
from PIL import Image
|
| 14 |
+
from huggingface_hub import snapshot_download, hf_hub_download
|
| 15 |
+
|
| 16 |
+
from streamdiffusionv2 import StreamDiffusionV2Pipeline
|
| 17 |
+
|
| 18 |
+
# ----------------------------------------------------------------------------
|
| 19 |
+
# Config
|
| 20 |
+
# ----------------------------------------------------------------------------
|
| 21 |
+
WAN_REPO = "Wan-AI/Wan2.1-T2V-1.3B"
|
| 22 |
+
WAN_DIR = "wan_models/Wan2.1-T2V-1.3B"
|
| 23 |
+
SDV2_REPO = "jerryfeng/StreamDiffusionV2"
|
| 24 |
+
CKPT_DIR = "ckpts"
|
| 25 |
+
CKPT_FOLDER = os.path.join(CKPT_DIR, "wan_causal_dmd_v2v") # 1.3B v2v checkpoint
|
| 26 |
+
|
| 27 |
+
HEIGHT, WIDTH = 480, 832
|
| 28 |
+
SESSION_DURATION = 58
|
| 29 |
+
POLL_INTERVAL = 0.005
|
| 30 |
+
DEFAULT_PROMPT = "a psychedelic neon dream, vivid saturated colors, glowing"
|
| 31 |
+
NOISE_SCALE = 0.8
|
| 32 |
+
|
| 33 |
+
SESSION_DIR = tempfile.gettempdir()
|
| 34 |
+
INSTRUCTION_FILE = os.path.join(SESSION_DIR, "sdv2_prompt.txt")
|
| 35 |
+
READY_SENTINEL = "__READY__"
|
| 36 |
+
|
| 37 |
+
# Fork-safe live frame queue (created before any ZeroGPU fork).
|
| 38 |
+
FRAME_Q = mp.get_context("fork").Queue(maxsize=512)
|
| 39 |
+
|
| 40 |
+
# ----------------------------------------------------------------------------
|
| 41 |
+
# Weights + pipeline at module scope (ZeroGPU snapshot preload)
|
| 42 |
+
# ----------------------------------------------------------------------------
|
| 43 |
+
snapshot_download(
|
| 44 |
+
repo_id=WAN_REPO, local_dir=WAN_DIR,
|
| 45 |
+
allow_patterns=[
|
| 46 |
+
"config.json", "diffusion_pytorch_model.safetensors", "Wan2.1_VAE.pth",
|
| 47 |
+
"models_t5_umt5-xxl-enc-bf16.pth", "google/umt5-xxl/*",
|
| 48 |
+
],
|
| 49 |
+
)
|
| 50 |
+
snapshot_download(repo_id=SDV2_REPO, local_dir=CKPT_DIR,
|
| 51 |
+
allow_patterns=["wan_causal_dmd_v2v/*"])
|
| 52 |
+
|
| 53 |
+
device = torch.device("cuda")
|
| 54 |
+
|
| 55 |
+
# StreamDiffusionV2 single-GPU streaming pipeline (rolling KV + sink tokens are
|
| 56 |
+
# built into the model -> continuous streaming without the window-shift burst).
|
| 57 |
+
stream = StreamDiffusionV2Pipeline(
|
| 58 |
+
checkpoint_folder=CKPT_FOLDER,
|
| 59 |
+
mode="single",
|
| 60 |
+
device=device,
|
| 61 |
+
height=HEIGHT,
|
| 62 |
+
width=WIDTH,
|
| 63 |
+
step=2,
|
| 64 |
+
noise_scale=NOISE_SCALE,
|
| 65 |
+
model_type="T2V-1.3B",
|
| 66 |
+
use_taehv=False,
|
| 67 |
+
)
|
| 68 |
+
PM = stream.pipeline_manager
|
| 69 |
+
CHUNK = PM.base_chunk_size * PM.pipeline.num_frame_per_block # 4 px frames / chunk
|
| 70 |
+
FIRST_BATCH = 1 + CHUNK # 5 px frames first
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _read_prompt():
|
| 74 |
+
try:
|
| 75 |
+
with open(INSTRUCTION_FILE, encoding="utf-8") as f:
|
| 76 |
+
return f.read().strip()
|
| 77 |
+
except FileNotFoundError:
|
| 78 |
+
return ""
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _decode_jpeg_to_tensor(jpeg_bytes):
|
| 82 |
+
"""JPEG bytes -> [C, H, W] in [-1, 1]."""
|
| 83 |
+
im = Image.open(BytesIO(jpeg_bytes)).convert("RGB").resize((WIDTH, HEIGHT), Image.BICUBIC)
|
| 84 |
+
arr = torch.from_numpy(np.asarray(im)).float().permute(2, 0, 1) / 255.0
|
| 85 |
+
return arr * 2.0 - 1.0
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _frames_to_video_tensor(frame_list):
|
| 89 |
+
"""list of [C,H,W] -> [B, C, T, H, W] bf16 on device."""
|
| 90 |
+
vid = torch.stack(frame_list, dim=1).unsqueeze(0) # [1, C, T, H, W]
|
| 91 |
+
return vid.to(device=device, dtype=torch.bfloat16)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _to_data_uri(frame01):
|
| 95 |
+
im = Image.fromarray((np.clip(frame01, 0, 1) * 255.0).astype(np.uint8))
|
| 96 |
+
buf = BytesIO()
|
| 97 |
+
im.save(buf, format="JPEG", quality=80)
|
| 98 |
+
return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ----------------------------------------------------------------------------
|
| 102 |
+
# Gradio Server
|
| 103 |
+
# ----------------------------------------------------------------------------
|
| 104 |
+
from gradio import Server
|
| 105 |
+
from fastapi import Request
|
| 106 |
+
from fastapi.responses import HTMLResponse
|
| 107 |
+
|
| 108 |
+
app = Server()
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@app.api(name="run_session")
|
| 112 |
+
@spaces.GPU(duration=60, size="xlarge")
|
| 113 |
+
@torch.inference_mode()
|
| 114 |
+
def run_session() -> str:
|
| 115 |
+
# Drain stale frames, then signal the client to start streaming.
|
| 116 |
+
try:
|
| 117 |
+
while True:
|
| 118 |
+
FRAME_Q.get_nowait()
|
| 119 |
+
except pyqueue.Empty:
|
| 120 |
+
pass
|
| 121 |
+
yield READY_SENTINEL
|
| 122 |
+
|
| 123 |
+
prompt = _read_prompt() or DEFAULT_PROMPT
|
| 124 |
+
buffer = []
|
| 125 |
+
session = None
|
| 126 |
+
deadline = time.time() + SESSION_DURATION
|
| 127 |
+
last = None
|
| 128 |
+
|
| 129 |
+
while time.time() < deadline:
|
| 130 |
+
drained = 0
|
| 131 |
+
while drained < 256:
|
| 132 |
+
try:
|
| 133 |
+
buffer.append(FRAME_Q.get_nowait())
|
| 134 |
+
except pyqueue.Empty:
|
| 135 |
+
break
|
| 136 |
+
drained += 1
|
| 137 |
+
|
| 138 |
+
need = FIRST_BATCH if session is None else CHUNK
|
| 139 |
+
if len(buffer) < need:
|
| 140 |
+
time.sleep(POLL_INTERVAL)
|
| 141 |
+
continue
|
| 142 |
+
|
| 143 |
+
frames = [_decode_jpeg_to_tensor(b) for b in buffer[:need]]
|
| 144 |
+
buffer = buffer[need:]
|
| 145 |
+
vid = _frames_to_video_tensor(frames)
|
| 146 |
+
|
| 147 |
+
t0 = time.time()
|
| 148 |
+
if session is None:
|
| 149 |
+
session, init_video = PM.start_stream_session(prompt, vid, NOISE_SCALE)
|
| 150 |
+
outs = [init_video]
|
| 151 |
+
else:
|
| 152 |
+
outs = PM.run_stream_batch(session, vid)
|
| 153 |
+
dt = time.time() - t0
|
| 154 |
+
|
| 155 |
+
n = 0
|
| 156 |
+
for arr in outs: # each arr: [T, H, W, C] in [0,1]
|
| 157 |
+
for fr in arr:
|
| 158 |
+
last = _to_data_uri(fr)
|
| 159 |
+
yield last
|
| 160 |
+
n += 1
|
| 161 |
+
if n:
|
| 162 |
+
print(f"[sdv2] {n} frames in {dt:.2f}s ({n/max(1e-3,dt):.1f} fps)", flush=True)
|
| 163 |
+
|
| 164 |
+
if last is not None:
|
| 165 |
+
yield last
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@app.post("/frame")
|
| 169 |
+
async def post_frame(request: Request):
|
| 170 |
+
body = await request.body()
|
| 171 |
+
if body:
|
| 172 |
+
try:
|
| 173 |
+
FRAME_Q.put_nowait(body)
|
| 174 |
+
except pyqueue.Full:
|
| 175 |
+
pass
|
| 176 |
+
return {"ok": True}
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
@app.post("/instruction")
|
| 180 |
+
async def post_instruction(request: Request):
|
| 181 |
+
data = await request.json()
|
| 182 |
+
text = (data.get("instruction", "") or "").strip()
|
| 183 |
+
tmp = INSTRUCTION_FILE + ".tmp"
|
| 184 |
+
with open(tmp, "w", encoding="utf-8") as f:
|
| 185 |
+
f.write(text)
|
| 186 |
+
os.replace(tmp, INSTRUCTION_FILE)
|
| 187 |
+
return {"ok": True}
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
@app.get("/", response_class=HTMLResponse)
|
| 191 |
+
async def homepage():
|
| 192 |
+
here = os.path.dirname(os.path.abspath(__file__))
|
| 193 |
+
with open(os.path.join(here, "index.html"), encoding="utf-8") as f:
|
| 194 |
+
return f.read()
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
app.launch(show_error=True)
|
index.html
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>LiveEdit · Realtime</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root { --bg:#0e0f13; --panel:#171922; --line:#2a2e3a; --fg:#e8e8ee; --accent:#c084fc; --good:#86efac; }
|
| 9 |
+
* { box-sizing: border-box; }
|
| 10 |
+
body { margin:0; background:var(--bg); color:var(--fg); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; }
|
| 11 |
+
.wrap { max-width:1100px; margin:0 auto; padding:24px 18px 60px; }
|
| 12 |
+
h1 { font-size:1.5rem; margin:0 0 4px; }
|
| 13 |
+
.sub { color:#9aa0ad; font-size:.95rem; margin:0 0 18px; line-height:1.5; }
|
| 14 |
+
.sub a { color:var(--accent); }
|
| 15 |
+
.controls { display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-bottom:16px; }
|
| 16 |
+
input[type=text] { flex:1; min-width:260px; background:var(--panel); border:1px solid var(--line); color:var(--fg); padding:12px 14px; border-radius:10px; font-size:1rem; }
|
| 17 |
+
button { background:var(--accent); color:#1a1024; border:0; padding:12px 18px; border-radius:10px; font-size:1rem; font-weight:600; cursor:pointer; }
|
| 18 |
+
button:disabled { opacity:.5; cursor:not-allowed; }
|
| 19 |
+
.timer { font-family:ui-monospace,Menlo,monospace; color:var(--good); background:#11210f; border:1px solid #234; padding:8px 12px; border-radius:8px; display:none; }
|
| 20 |
+
.grid { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
|
| 21 |
+
.card { background:var(--panel); border:1px solid var(--line); border-radius:14px; overflow:hidden; }
|
| 22 |
+
.card h2 { font-size:.85rem; text-transform:uppercase; letter-spacing:.05em; color:#9aa0ad; margin:0; padding:10px 14px; border-bottom:1px solid var(--line); }
|
| 23 |
+
.media { aspect-ratio:832/480; background:#000; display:flex; align-items:center; justify-content:center; }
|
| 24 |
+
.media video, .media img { width:100%; height:100%; object-fit:cover; display:block; }
|
| 25 |
+
.placeholder { color:#5a6070; font-size:.9rem; }
|
| 26 |
+
.status { margin-top:14px; color:#9aa0ad; font-size:.9rem; min-height:1.2em; }
|
| 27 |
+
@media (max-width:780px){ .grid{ grid-template-columns:1fr; } }
|
| 28 |
+
</style>
|
| 29 |
+
</head>
|
| 30 |
+
<body>
|
| 31 |
+
<div class="wrap">
|
| 32 |
+
<h1>🌀 StreamDiffusionV2 · Realtime Webcam Diffusion</h1>
|
| 33 |
+
<p class="sub">
|
| 34 |
+
Live demo of <a href="https://streamdiffusionv2.github.io/" target="_blank">StreamDiffusionV2</a>
|
| 35 |
+
(MLSys 2026 Best Paper) on Wan2.1-T2V-1.3B. It streams your webcam through a causal video-diffusion
|
| 36 |
+
model with a <b>sink-token rolling KV cache</b> — built for <i>continuous</i> streaming, so it
|
| 37 |
+
keeps flowing without the window-shift burst. Type a style prompt, click <b>Start</b> to grab ZeroGPU
|
| 38 |
+
for ~60s. (Prompt is fixed for the session — restart to change it.)
|
| 39 |
+
</p>
|
| 40 |
+
|
| 41 |
+
<div class="controls">
|
| 42 |
+
<input id="instruction" type="text" placeholder="style prompt · e.g. psychedelic neon dream · van gogh · cyberpunk city" />
|
| 43 |
+
<button id="startBtn">▶ Start session</button>
|
| 44 |
+
<span id="timer" class="timer">⏱ <span id="count">58</span>s</span>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<div class="grid">
|
| 48 |
+
<div class="card">
|
| 49 |
+
<h2>Your webcam</h2>
|
| 50 |
+
<div class="media"><video id="cam" autoplay muted playsinline></video></div>
|
| 51 |
+
</div>
|
| 52 |
+
<div class="card">
|
| 53 |
+
<h2>Edited (live)</h2>
|
| 54 |
+
<div class="media"><img id="out" alt="" /><span id="outPh" class="placeholder">edited stream appears here</span></div>
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
<div id="status" class="status"></div>
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<canvas id="grab" width="832" height="480" style="display:none"></canvas>
|
| 62 |
+
|
| 63 |
+
<script type="module">
|
| 64 |
+
import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
|
| 65 |
+
|
| 66 |
+
const camEl = document.getElementById("cam");
|
| 67 |
+
const outEl = document.getElementById("out");
|
| 68 |
+
const outPh = document.getElementById("outPh");
|
| 69 |
+
const startBtn = document.getElementById("startBtn");
|
| 70 |
+
const instr = document.getElementById("instruction");
|
| 71 |
+
const timerEl = document.getElementById("timer");
|
| 72 |
+
const countEl = document.getElementById("count");
|
| 73 |
+
const statusEl = document.getElementById("status");
|
| 74 |
+
const grab = document.getElementById("grab");
|
| 75 |
+
const gctx = grab.getContext("2d");
|
| 76 |
+
|
| 77 |
+
let client = null;
|
| 78 |
+
let stream = null;
|
| 79 |
+
let captureTimer = null;
|
| 80 |
+
let countdownTimer = null;
|
| 81 |
+
let running = false;
|
| 82 |
+
|
| 83 |
+
const FPS = 10; // webcam frames sent per second
|
| 84 |
+
const SESSION_SECONDS = 58;
|
| 85 |
+
|
| 86 |
+
// --- jitter buffer: edited frames arrive in bursts (one chunk at a time) but
|
| 87 |
+
// we play them out at a smooth, adaptive rate so the preview doesn't stutter.
|
| 88 |
+
let playQueue = [];
|
| 89 |
+
const MAX_QUEUE = 36; // bound latency (~3 chunks)
|
| 90 |
+
let lastShown = 0;
|
| 91 |
+
function playLoop(ts){
|
| 92 |
+
if (playQueue.length > MAX_QUEUE) playQueue = playQueue.slice(-MAX_QUEUE);
|
| 93 |
+
// drain faster as the backlog grows, slower when nearly empty
|
| 94 |
+
const n = playQueue.length;
|
| 95 |
+
const fps = n > 24 ? 18 : n > 14 ? 13 : n > 6 ? 9 : 6;
|
| 96 |
+
if (n && ts - lastShown >= 1000 / fps){
|
| 97 |
+
outEl.src = playQueue.shift();
|
| 98 |
+
outPh.style.display = "none";
|
| 99 |
+
lastShown = ts;
|
| 100 |
+
}
|
| 101 |
+
requestAnimationFrame(playLoop);
|
| 102 |
+
}
|
| 103 |
+
requestAnimationFrame(playLoop);
|
| 104 |
+
|
| 105 |
+
function setStatus(t){ statusEl.textContent = t; }
|
| 106 |
+
|
| 107 |
+
async function ensureClient(){
|
| 108 |
+
if (!client) client = await Client.connect(window.location.origin);
|
| 109 |
+
return client;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
async function ensureCam(){
|
| 113 |
+
if (stream) return;
|
| 114 |
+
stream = await navigator.mediaDevices.getUserMedia({ video: { width: 832, height: 480 }, audio: false });
|
| 115 |
+
camEl.srcObject = stream;
|
| 116 |
+
await camEl.play().catch(()=>{});
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
async function sendInstruction(){
|
| 120 |
+
try {
|
| 121 |
+
await fetch("/instruction", {
|
| 122 |
+
method:"POST", headers:{ "Content-Type":"application/json" },
|
| 123 |
+
body: JSON.stringify({ instruction: instr.value || "" })
|
| 124 |
+
});
|
| 125 |
+
} catch(e){}
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
function startCapture(){
|
| 129 |
+
captureTimer = setInterval(() => {
|
| 130 |
+
if (!camEl.videoWidth) return;
|
| 131 |
+
gctx.drawImage(camEl, 0, 0, grab.width, grab.height);
|
| 132 |
+
grab.toBlob(async (blob) => {
|
| 133 |
+
if (!blob) return;
|
| 134 |
+
try { await fetch("/frame", { method:"POST", body: blob }); } catch(e){}
|
| 135 |
+
}, "image/jpeg", 0.8);
|
| 136 |
+
}, 1000 / FPS);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
function stopAll(){
|
| 140 |
+
running = false;
|
| 141 |
+
if (captureTimer) { clearInterval(captureTimer); captureTimer = null; }
|
| 142 |
+
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
|
| 143 |
+
timerEl.style.display = "none";
|
| 144 |
+
startBtn.disabled = false;
|
| 145 |
+
startBtn.textContent = "▶ Start session";
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
function startCountdown(){
|
| 149 |
+
let r = SESSION_SECONDS;
|
| 150 |
+
countEl.textContent = r;
|
| 151 |
+
timerEl.style.display = "inline-block";
|
| 152 |
+
countdownTimer = setInterval(() => {
|
| 153 |
+
r -= 1; countEl.textContent = Math.max(0, r);
|
| 154 |
+
if (r <= 0) clearInterval(countdownTimer);
|
| 155 |
+
}, 1000);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
instr.addEventListener("change", () => { if (running) sendInstruction(); });
|
| 159 |
+
instr.addEventListener("input", () => { if (running) sendInstruction(); });
|
| 160 |
+
|
| 161 |
+
startBtn.addEventListener("click", async () => {
|
| 162 |
+
if (running) return;
|
| 163 |
+
startBtn.disabled = true;
|
| 164 |
+
try {
|
| 165 |
+
setStatus("Requesting webcam…");
|
| 166 |
+
await ensureCam();
|
| 167 |
+
setStatus("Connecting…");
|
| 168 |
+
await ensureClient();
|
| 169 |
+
running = true;
|
| 170 |
+
playQueue = [];
|
| 171 |
+
startBtn.textContent = "◌ Acquiring ZeroGPU…";
|
| 172 |
+
await sendInstruction();
|
| 173 |
+
setStatus("Queued for ZeroGPU — webcam streaming starts once the GPU is acquired…");
|
| 174 |
+
|
| 175 |
+
const job = client.submit("/run_session", {});
|
| 176 |
+
let frames = 0;
|
| 177 |
+
for await (const msg of job) {
|
| 178 |
+
if (msg.type !== "data" || !msg.data || msg.data[0] == null) continue;
|
| 179 |
+
const payload = msg.data[0];
|
| 180 |
+
if (payload === "__READY__") {
|
| 181 |
+
// GPU is now allocated — only now start capturing & sending frames.
|
| 182 |
+
startBtn.textContent = "● Live";
|
| 183 |
+
await sendInstruction();
|
| 184 |
+
startCapture();
|
| 185 |
+
startCountdown();
|
| 186 |
+
setStatus("ZeroGPU acquired — streaming your webcam through LiveEdit…");
|
| 187 |
+
continue;
|
| 188 |
+
}
|
| 189 |
+
playQueue.push(payload); // jitter buffer paces actual display
|
| 190 |
+
frames += 1;
|
| 191 |
+
if (frames % 12 === 0) setStatus(`Streaming… ${frames} edited frames`);
|
| 192 |
+
}
|
| 193 |
+
setStatus(frames ? "Session ended. Click Start to run another ~60s session."
|
| 194 |
+
: "Session ended before any frames were produced — try again.");
|
| 195 |
+
} catch (e) {
|
| 196 |
+
setStatus("Error: " + (e && e.message ? e.message : e));
|
| 197 |
+
} finally {
|
| 198 |
+
stopAll();
|
| 199 |
+
}
|
| 200 |
+
});
|
| 201 |
+
</script>
|
| 202 |
+
</body>
|
| 203 |
+
</html>
|
requirements.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
streamdiffusionv2
|