[diffusion] misc: add realtime-webui (#26959)
This commit is contained in:
@@ -177,7 +177,8 @@ killall_sglang = "sglang.cli.killall:main"
|
|||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
"sglang" = [
|
"sglang" = [
|
||||||
"srt/**/*",
|
"srt/**/*",
|
||||||
"jit_kernel/**/*"
|
"jit_kernel/**/*",
|
||||||
|
"multimodal_gen/apps/realtime_webui/**/*"
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# SGLang Diffusion Realtime WebUI
|
||||||
|
|
||||||
|
Standalone browser demo for `/v1/realtime_video/generate`.
|
||||||
|
|
||||||
|
Open `index.html` directly in a browser, point it at an SGLang Diffusion server,
|
||||||
|
and generate. The app sends msgpack init / event messages and renders lossless
|
||||||
|
raw RGB frame batches on a canvas.
|
||||||
|
|
||||||
|
The first version is intentionally static: no npm install, no build step, and no
|
||||||
|
server-side dependencies. Presets are UI-side templates for prompt, LingBot
|
||||||
|
example images, album artwork references, and session parameters. The default
|
||||||
|
preset preloads a reference image so the demo can be tested without a file
|
||||||
|
upload.
|
||||||
|
|
||||||
|
By default, `Continuous session` is enabled for long-running camera control.
|
||||||
|
Keyboard and pointer controls send state transitions instead of scripted preset
|
||||||
|
actions. The telemetry `Chunk wait` measures request-to-chunk arrival time, not
|
||||||
|
client-side RGB decode time. Continuous playback adapts to the measured chunk
|
||||||
|
production rate so the canvas does not play a chunk at target FPS and then sit
|
||||||
|
on the last frame while waiting for the next chunk.
|
||||||
|
|
||||||
|
The interface shape follows camera-control-first video playgrounds such as
|
||||||
|
Reactor LingBot: reference image, scene prompt, enhancement, clip controls,
|
||||||
|
move/look camera controls, recordings history, and model telemetry.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,162 @@
|
|||||||
|
const RAW_RGB_CONTENT_TYPE = "application/x-raw-rgb";
|
||||||
|
const RAW_RGB_DELTA_GZIP_CONTENT_TYPE = "application/x-raw-rgb-delta-gzip";
|
||||||
|
const RAW_RGBA_DELTA_GZIP_CONTENT_TYPE = "application/x-raw-rgba-delta-gzip";
|
||||||
|
const WEBP_FRAME_CONTENT_TYPE = "image/webp";
|
||||||
|
const JPEG_FRAME_CONTENT_TYPE = "image/jpeg";
|
||||||
|
|
||||||
|
let lastFrame = null;
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
lastFrame = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gunzipBytes(payload) {
|
||||||
|
if (typeof DecompressionStream === "undefined") {
|
||||||
|
throw new Error("This browser does not support gzip stream decoding");
|
||||||
|
}
|
||||||
|
const stream = new Blob([payload]).stream().pipeThrough(new DecompressionStream("gzip"));
|
||||||
|
return new Uint8Array(await new Response(stream).arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreDeltaGzipFrames(header, payload) {
|
||||||
|
const frameBytes = Number(header.bytes_per_frame);
|
||||||
|
const count = Number(header.num_frames);
|
||||||
|
const expectedSize = frameBytes * count;
|
||||||
|
const restored = await gunzipBytes(payload);
|
||||||
|
if (restored.length !== expectedSize) {
|
||||||
|
throw new Error(`delta payload size mismatch: expected ${expectedSize}, got ${restored.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let previous = header.delta_reference === "previous-frame" ? lastFrame : null;
|
||||||
|
if (header.delta_reference === "previous-frame") {
|
||||||
|
if (!previous) throw new Error("Missing previous frame for delta payload");
|
||||||
|
if (previous.byteLength !== frameBytes) {
|
||||||
|
throw new Error("Previous frame size does not match current delta payload");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let f = 0; f < count; f++) {
|
||||||
|
const offset = f * frameBytes;
|
||||||
|
if (previous) {
|
||||||
|
for (let i = 0; i < frameBytes; i++) restored[offset + i] ^= previous[i];
|
||||||
|
}
|
||||||
|
previous = restored.slice(offset, offset + frameBytes);
|
||||||
|
}
|
||||||
|
lastFrame = previous;
|
||||||
|
return restored;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawFramesToRgbaBuffers(header, payload) {
|
||||||
|
const width = Number(header.width);
|
||||||
|
const height = Number(header.height);
|
||||||
|
const channels = Number(header.channels);
|
||||||
|
const count = Number(header.num_frames);
|
||||||
|
const frameBytes = Number(header.bytes_per_frame);
|
||||||
|
const pixels = width * height;
|
||||||
|
const buffers = [];
|
||||||
|
|
||||||
|
for (let f = 0; f < count; f++) {
|
||||||
|
const offset = f * frameBytes;
|
||||||
|
if (channels === 4) {
|
||||||
|
buffers.push(payload.buffer.slice(
|
||||||
|
payload.byteOffset + offset,
|
||||||
|
payload.byteOffset + offset + frameBytes,
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rgba = new Uint8ClampedArray(pixels * 4);
|
||||||
|
let src = offset;
|
||||||
|
let dst = 0;
|
||||||
|
for (let p = 0; p < pixels; p++) {
|
||||||
|
rgba[dst++] = payload[src++];
|
||||||
|
rgba[dst++] = payload[src++];
|
||||||
|
rgba[dst++] = payload[src++];
|
||||||
|
src += channels - 3;
|
||||||
|
rgba[dst++] = 255;
|
||||||
|
}
|
||||||
|
buffers.push(rgba.buffer);
|
||||||
|
}
|
||||||
|
return buffers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encodedFrameToRgbaBuffers(header, payload) {
|
||||||
|
if (typeof createImageBitmap === "undefined" || typeof OffscreenCanvas === "undefined") {
|
||||||
|
throw new Error("This browser does not support worker image decoding");
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([payload], { type: header.content_type });
|
||||||
|
const bitmap = await createImageBitmap(blob);
|
||||||
|
const width = bitmap.width;
|
||||||
|
const height = bitmap.height;
|
||||||
|
const canvas = new OffscreenCanvas(width, height);
|
||||||
|
const ctx = canvas.getContext("2d", { alpha: false });
|
||||||
|
ctx.drawImage(bitmap, 0, 0);
|
||||||
|
const image = ctx.getImageData(0, 0, width, height);
|
||||||
|
bitmap.close?.();
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
frames: [image.data.buffer],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decode(header, payload) {
|
||||||
|
let rawPayload;
|
||||||
|
if (
|
||||||
|
header.content_type === WEBP_FRAME_CONTENT_TYPE ||
|
||||||
|
header.content_type === JPEG_FRAME_CONTENT_TYPE
|
||||||
|
) {
|
||||||
|
const decoded = await encodedFrameToRgbaBuffers(header, payload);
|
||||||
|
return {
|
||||||
|
id: header.__decode_id,
|
||||||
|
width: decoded.width,
|
||||||
|
height: decoded.height,
|
||||||
|
chunk: Number(header.chunk_index),
|
||||||
|
frames: decoded.frames,
|
||||||
|
};
|
||||||
|
} else if (header.content_type === RAW_RGB_CONTENT_TYPE) {
|
||||||
|
rawPayload = new Uint8Array(payload);
|
||||||
|
const frameBytes = Number(header.bytes_per_frame);
|
||||||
|
const count = Number(header.num_frames);
|
||||||
|
lastFrame = count > 0
|
||||||
|
? rawPayload.slice((count - 1) * frameBytes, count * frameBytes)
|
||||||
|
: null;
|
||||||
|
} else if (
|
||||||
|
header.content_type === RAW_RGB_DELTA_GZIP_CONTENT_TYPE ||
|
||||||
|
header.content_type === RAW_RGBA_DELTA_GZIP_CONTENT_TYPE
|
||||||
|
) {
|
||||||
|
rawPayload = await restoreDeltaGzipFrames(header, payload);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported content type ${header.content_type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: header.__decode_id,
|
||||||
|
width: Number(header.width),
|
||||||
|
height: Number(header.height),
|
||||||
|
chunk: Number(header.chunk_index),
|
||||||
|
frames: rawFramesToRgbaBuffers(header, rawPayload),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = async (event) => {
|
||||||
|
const message = event.data;
|
||||||
|
try {
|
||||||
|
if (message.type === "reset") {
|
||||||
|
reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await decode(message.header, message.payload);
|
||||||
|
self.postMessage(
|
||||||
|
{ type: "decoded", ...result },
|
||||||
|
result.frames,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
self.postMessage({
|
||||||
|
type: "error",
|
||||||
|
id: message.header?.__decode_id,
|
||||||
|
message: error.message || "decode failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>sglang-diffusion Realtime Studio</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css?v=realtime-fixes-v25" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<section class="panel controls" aria-label="Session controls">
|
||||||
|
<div class="brand">
|
||||||
|
<span>sglang-diffusion</span>
|
||||||
|
<strong>Realtime Studio</strong>
|
||||||
|
</div>
|
||||||
|
<label>Server<input id="serverUrl" value="ws://127.0.0.1:30000/v1/realtime_video/generate" /></label>
|
||||||
|
<label>Model<input id="model" value="" placeholder="auto from /v1/models" /></label>
|
||||||
|
<div class="section-title">Reference</div>
|
||||||
|
<label class="reference-upload">
|
||||||
|
<input id="firstFrame" type="file" accept="image/*" />
|
||||||
|
<canvas id="referencePreview" width="320" height="180"></canvas>
|
||||||
|
<span id="referenceName">Preset reference</span>
|
||||||
|
</label>
|
||||||
|
<div class="section-title">Generate the scene</div>
|
||||||
|
<label>Prompt<textarea id="prompt" rows="4">A cinematic handheld shot of a quiet city street at dusk, soft reflections, natural motion.</textarea></label>
|
||||||
|
<button id="enhanceBtn" class="wide">Enhance</button>
|
||||||
|
<div class="split">
|
||||||
|
<label>Size<input id="size" value="832x480" /></label>
|
||||||
|
<label>FPS<input id="fps" type="number" value="25" min="1" max="60" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="split">
|
||||||
|
<label>Frames<input id="numFrames" type="number" value="9" min="5" step="4" /></label>
|
||||||
|
<label>Seed<input id="seed" type="number" value="42" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="split">
|
||||||
|
<label>Steps<input id="steps" type="number" value="4" min="1" /></label>
|
||||||
|
<label>Guidance<input id="guidance" type="number" value="1" step="0.1" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="split">
|
||||||
|
<label>Sink<input id="sinkSize" type="number" value="9" min="0" /></label>
|
||||||
|
<label>Window<input id="windowFrames" type="number" value="18" min="1" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="split">
|
||||||
|
<label>Transport
|
||||||
|
<select id="transportFormat">
|
||||||
|
<option value="webp" selected>WebP preview</option>
|
||||||
|
<option value="jpeg">JPEG preview</option>
|
||||||
|
<option value="">Lossless delta</option>
|
||||||
|
<option value="raw">Raw RGB</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Quality<input id="transportQuality" type="number" value="95" min="1" max="100" /></label>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-row"><input id="frameInterpolation" type="checkbox" />Smooth 2x frames</label>
|
||||||
|
<label class="toggle-row"><input id="continuous" type="checkbox" checked />Continuous session</label>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="connectBtn" class="primary">Generate</button>
|
||||||
|
<button id="stopBtn">Close session</button>
|
||||||
|
</div>
|
||||||
|
<button id="sendPromptBtn" class="wide">Send prompt update</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stage" aria-label="Realtime preview">
|
||||||
|
<div class="topbar">
|
||||||
|
<span id="statusDot" class="dot"></span>
|
||||||
|
<span id="statusText">Idle</span>
|
||||||
|
<span id="chunkText">chunk -</span>
|
||||||
|
<span class="topbar-spacer"></span>
|
||||||
|
<span class="stage-stat">render <b id="renderFps">0</b> fps</span>
|
||||||
|
<span class="stage-stat">theoretical <b id="theoreticalFpsText">-</b></span>
|
||||||
|
<span class="stage-stat">wait <b id="stageLatencyText">-</b></span>
|
||||||
|
</div>
|
||||||
|
<canvas id="viewport" width="1280" height="720"></canvas>
|
||||||
|
<div class="stage-controls" aria-label="Camera controls">
|
||||||
|
<div class="control-cluster" aria-label="Move camera">
|
||||||
|
<span class="control-title">Move</span>
|
||||||
|
<div class="camera-pad move-pad">
|
||||||
|
<span></span>
|
||||||
|
<button data-action="w" data-key="W">Forward</button>
|
||||||
|
<span></span>
|
||||||
|
<button data-action="a" data-key="A">Left</button>
|
||||||
|
<button data-action="s" data-key="S">Back</button>
|
||||||
|
<button data-action="d" data-key="D">Right</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="control-cluster" aria-label="Look around">
|
||||||
|
<span class="control-title">Look</span>
|
||||||
|
<div class="camera-pad look-pad">
|
||||||
|
<span></span>
|
||||||
|
<button data-action="i" data-key="↑">Pitch +</button>
|
||||||
|
<span></span>
|
||||||
|
<button data-action="j" data-key="←">Yaw -</button>
|
||||||
|
<button data-action="k" data-key="↓">Pitch -</button>
|
||||||
|
<button data-action="l" data-key="→">Yaw +</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline">
|
||||||
|
<span id="queueText">queue 0</span>
|
||||||
|
<span id="frameText">frames 0</span>
|
||||||
|
<span id="byteText">0 MB</span>
|
||||||
|
</div>
|
||||||
|
<div class="telemetry stage-telemetry">
|
||||||
|
<span>Payload<b id="payloadMode">webp</b></span>
|
||||||
|
<span>Server send<b id="serverSendText">-</b></span>
|
||||||
|
<span>Chunk bytes<b id="chunkPayloadText">-</b></span>
|
||||||
|
<span>Chunk wait<b id="latencyText">-</b></span>
|
||||||
|
<span>Decode<b id="decodeText">-</b></span>
|
||||||
|
<span>Display lag<b id="displayLagText">-</b></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel presets" aria-label="Presets and camera">
|
||||||
|
<div class="section-title">LingBot</div>
|
||||||
|
<div class="spec-grid">
|
||||||
|
<span><b>25 fps</b> target</span>
|
||||||
|
<span><b>chunked</b> stream</span>
|
||||||
|
<span><b>480p/720p</b></span>
|
||||||
|
<span><b>Cam + Act</b></span>
|
||||||
|
</div>
|
||||||
|
<div class="section-title">Presets</div>
|
||||||
|
<div id="presetList" class="preset-list"></div>
|
||||||
|
<div class="section-title">History</div>
|
||||||
|
<div id="historyList" class="history-list"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="./app.js?v=realtime-model-info-v31"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
:root {
|
||||||
|
--paper: #eef1ec;
|
||||||
|
--panel: #fbfaf5;
|
||||||
|
--ink: #171a16;
|
||||||
|
--muted: #687164;
|
||||||
|
--line: #cbd2c4;
|
||||||
|
--accent: #b9543c;
|
||||||
|
--green: #4d765f;
|
||||||
|
--blue: #3f607c;
|
||||||
|
--pressed: #8c9288;
|
||||||
|
--pressed-border: #aeb4aa;
|
||||||
|
--pressed-ring: rgba(238, 241, 236, 0.2);
|
||||||
|
--shadow: 0 18px 60px rgba(23, 26, 22, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(23, 26, 22, 0.035) 1px, transparent 1px),
|
||||||
|
linear-gradient(180deg, rgba(23, 26, 22, 0.035) 1px, transparent 1px),
|
||||||
|
var(--paper);
|
||||||
|
background-size: 28px 28px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: ui-sans-serif, "Avenir Next", "Helvetica Neue", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input, textarea, select { font: inherit; }
|
||||||
|
button:disabled { cursor: wait; opacity: 0.64; transform: none; }
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 320px) minmax(420px, 1fr) minmax(260px, 320px);
|
||||||
|
gap: 18px;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: color-mix(in oklch, var(--panel), white 20%);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand span {
|
||||||
|
color: var(--panel);
|
||||||
|
background: var(--ink);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand strong { font-size: 18px; font-weight: 650; }
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
margin: 12px 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-tooltip {
|
||||||
|
position: relative;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--muted);
|
||||||
|
background: #fffdf7;
|
||||||
|
cursor: help;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-tooltip::after {
|
||||||
|
content: attr(aria-label);
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
z-index: 20;
|
||||||
|
width: 280px;
|
||||||
|
max-width: min(280px, calc(100vw - 48px));
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--ink);
|
||||||
|
box-shadow: 0 12px 36px rgba(23, 26, 22, 0.24);
|
||||||
|
color: var(--panel);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.4;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(4px);
|
||||||
|
transition: opacity 120ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-tooltip:hover::after,
|
||||||
|
.help-tooltip:focus-visible::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea, select {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fffdf7;
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 10px 11px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea { resize: vertical; line-height: 1.45; }
|
||||||
|
input:focus, textarea:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(185, 84, 60, 0.12); }
|
||||||
|
|
||||||
|
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
|
.actions { display: grid; grid-template-columns: 1fr 0.7fr; gap: 10px; margin-top: 16px; }
|
||||||
|
.toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.toggle-row input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: #fffdf7;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 120ms ease,
|
||||||
|
border-color 120ms ease,
|
||||||
|
box-shadow 120ms ease,
|
||||||
|
color 120ms ease,
|
||||||
|
transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
border-color: var(--ink);
|
||||||
|
background: color-mix(in oklch, #fffdf7, var(--green) 10%);
|
||||||
|
box-shadow: 0 8px 18px rgba(23, 26, 22, 0.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
button:active:not(:disabled),
|
||||||
|
button.is-pressed:not(:disabled) {
|
||||||
|
border-color: var(--pressed-border);
|
||||||
|
background: var(--pressed);
|
||||||
|
color: #fffdf7;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(255, 253, 247, 0.18),
|
||||||
|
inset 0 2px 7px rgba(23, 26, 22, 0.16);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
button.is-key-active:not(:disabled) {
|
||||||
|
border-color: var(--pressed-border);
|
||||||
|
background: var(--pressed);
|
||||||
|
color: #fffdf7;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(255, 253, 247, 0.22),
|
||||||
|
0 0 0 3px var(--pressed-ring),
|
||||||
|
0 10px 22px rgba(23, 26, 22, 0.18);
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
button:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(185, 84, 60, 0.18);
|
||||||
|
}
|
||||||
|
.primary { background: var(--ink); color: var(--panel); border-color: var(--ink); }
|
||||||
|
.primary:hover:not(:disabled) {
|
||||||
|
background: color-mix(in oklch, var(--ink), var(--green) 18%);
|
||||||
|
color: var(--panel);
|
||||||
|
}
|
||||||
|
.primary:active:not(:disabled),
|
||||||
|
.primary.is-pressed:not(:disabled) {
|
||||||
|
background: var(--pressed);
|
||||||
|
border-color: var(--pressed-border);
|
||||||
|
color: var(--panel);
|
||||||
|
}
|
||||||
|
.wide { width: 100%; margin-top: 10px; }
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto auto auto auto;
|
||||||
|
align-self: start;
|
||||||
|
justify-self: center;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1040px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #11140f;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #11140f;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar, .timeline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 14px;
|
||||||
|
color: #e8eadf;
|
||||||
|
background: rgba(17, 20, 15, 0.88);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-spacer { flex: 1; }
|
||||||
|
#statusText {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 92px;
|
||||||
|
}
|
||||||
|
#chunkText {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 70px;
|
||||||
|
}
|
||||||
|
.stage-stat {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: max-content;
|
||||||
|
color: rgba(232, 234, 223, 0.72);
|
||||||
|
}
|
||||||
|
.stage-stat b {
|
||||||
|
color: #fffdf7;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline { justify-content: flex-end; border-top: 1px solid rgba(232, 234, 223, 0.12); }
|
||||||
|
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
|
||||||
|
.dot.live { background: #8ecf9d; box-shadow: 0 0 0 4px rgba(142, 207, 157, 0.14); }
|
||||||
|
.dot.error { background: var(--accent); }
|
||||||
|
|
||||||
|
#viewport {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: min(56vh, 560px);
|
||||||
|
min-height: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
image-rendering: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px 13px;
|
||||||
|
border-top: 1px solid rgba(232, 234, 223, 0.12);
|
||||||
|
background: #151912;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-cluster {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 46px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-title {
|
||||||
|
color: rgba(232, 234, 223, 0.62);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-controls .camera-pad {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-controls .camera-pad button {
|
||||||
|
position: relative;
|
||||||
|
border-color: rgba(232, 234, 223, 0.18);
|
||||||
|
background: #eef1ec;
|
||||||
|
color: #11140f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-controls .camera-pad button:active:not(:disabled),
|
||||||
|
.stage-controls .camera-pad button.is-pressed:not(:disabled),
|
||||||
|
.stage-controls .camera-pad button.is-key-active:not(:disabled) {
|
||||||
|
border-color: var(--pressed-border);
|
||||||
|
background: var(--pressed);
|
||||||
|
color: #fffdf7;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(255, 253, 247, 0.22),
|
||||||
|
0 0 0 3px var(--pressed-ring),
|
||||||
|
0 10px 22px rgba(23, 26, 22, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-controls .camera-pad button::after {
|
||||||
|
content: attr(data-key);
|
||||||
|
position: absolute;
|
||||||
|
right: 7px;
|
||||||
|
top: 5px;
|
||||||
|
color: color-mix(in oklch, var(--muted), var(--ink) 18%);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-controls .camera-pad button:active::after,
|
||||||
|
.stage-controls .camera-pad button.is-pressed::after,
|
||||||
|
.stage-controls .camera-pad button.is-key-active::after {
|
||||||
|
color: rgba(255, 253, 247, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 16px 0 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reference-upload {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reference-upload input {
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
#referencePreview {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e5e7df;
|
||||||
|
}
|
||||||
|
|
||||||
|
#referenceName {
|
||||||
|
min-height: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-grid span {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-height: 46px;
|
||||||
|
align-content: center;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fffdf7;
|
||||||
|
padding: 9px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-grid b {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presets {
|
||||||
|
position: sticky;
|
||||||
|
top: 18px;
|
||||||
|
max-height: calc(100vh - 36px);
|
||||||
|
overflow: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
min-height: 320px;
|
||||||
|
max-height: min(54vh, 560px);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 3px;
|
||||||
|
}
|
||||||
|
.preset {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px minmax(0, 1fr);
|
||||||
|
gap: 4px 9px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fffdf7;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.preset-thumb {
|
||||||
|
display: block;
|
||||||
|
grid-row: span 2;
|
||||||
|
width: 72px;
|
||||||
|
height: 46px;
|
||||||
|
min-height: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid color-mix(in oklch, var(--line), var(--ink) 8%);
|
||||||
|
}
|
||||||
|
.preset b { min-width: 0; font-size: 13px; }
|
||||||
|
.preset span { min-width: 0; color: var(--muted); font-size: 11px; line-height: 1.25; }
|
||||||
|
.preset[data-tone="green"] { border-left: 4px solid var(--green); }
|
||||||
|
.preset[data-tone="blue"] { border-left: 4px solid var(--blue); }
|
||||||
|
.preset[data-tone="accent"] { border-left: 4px solid var(--accent); }
|
||||||
|
.preset:hover:not(:disabled) {
|
||||||
|
background: color-mix(in oklch, #fffdf7, var(--blue) 9%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-pad {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-pad span { min-height: 36px; }
|
||||||
|
.camera-pad button { min-height: 36px; font-size: 12px; }
|
||||||
|
.telemetry { display: grid; gap: 7px; margin-top: 10px; }
|
||||||
|
.telemetry span {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.telemetry b { color: var(--ink); font-weight: 650; }
|
||||||
|
|
||||||
|
.stage-telemetry {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
border-top: 1px solid rgba(232, 234, 223, 0.12);
|
||||||
|
background: #11140f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-telemetry span {
|
||||||
|
min-height: 36px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-right: 1px solid rgba(232, 234, 223, 0.1);
|
||||||
|
border-bottom: 1px solid rgba(232, 234, 223, 0.1);
|
||||||
|
padding: 0 14px;
|
||||||
|
color: rgba(232, 234, 223, 0.62);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-telemetry b {
|
||||||
|
color: #fffdf7;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
max-height: 76px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list span {
|
||||||
|
display: block;
|
||||||
|
border-left: 3px solid var(--blue);
|
||||||
|
background: #fffdf7;
|
||||||
|
padding: 8px 9px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.shell { grid-template-columns: 1fr; }
|
||||||
|
.presets { position: static; max-height: none; overflow: visible; }
|
||||||
|
.preset-list { min-height: 260px; max-height: 420px; }
|
||||||
|
#viewport { max-height: 420px; }
|
||||||
|
.stage-controls { grid-template-columns: 1fr; }
|
||||||
|
.stage-telemetry { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.topbar { flex-wrap: wrap; height: auto; min-height: 44px; padding: 10px 14px; }
|
||||||
|
.topbar-spacer { display: none; }
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
|
|||||||
import httpx
|
import httpx
|
||||||
import torch
|
import torch
|
||||||
from fastapi import APIRouter, FastAPI, Request
|
from fastapi import APIRouter, FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
|
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
|
||||||
from sglang.multimodal_gen.runtime.entrypoints.openai import (
|
from sglang.multimodal_gen.runtime.entrypoints.openai import (
|
||||||
@@ -387,6 +388,13 @@ def create_app(server_args: ServerArgs):
|
|||||||
Create and configure the FastAPI application instance.
|
Create and configure the FastAPI application instance.
|
||||||
"""
|
"""
|
||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def wait_for_server_warmup(request: Request, call_next):
|
async def wait_for_server_warmup(request: Request, call_next):
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def test_realtime_webui_presets_do_not_emit_camera_scripts():
|
||||||
|
repo_root = Path(__file__).resolve().parents[6]
|
||||||
|
app_js = (
|
||||||
|
repo_root / "python/sglang/multimodal_gen/apps/realtime_webui/app.js"
|
||||||
|
).read_text()
|
||||||
|
index_html = (
|
||||||
|
repo_root / "python/sglang/multimodal_gen/apps/realtime_webui/index.html"
|
||||||
|
).read_text()
|
||||||
|
styles_css = (
|
||||||
|
repo_root / "python/sglang/multimodal_gen/apps/realtime_webui/styles.css"
|
||||||
|
).read_text()
|
||||||
|
|
||||||
|
assert "preset.actions" not in app_js
|
||||||
|
assert "repeatActions" not in app_js
|
||||||
|
assert 'id="eventFrames"' not in index_html
|
||||||
|
assert "ControlStateController" in app_js
|
||||||
|
assert 'const DEFAULT_PREVIEW_OUTPUT_FORMAT = "webp";' in app_js
|
||||||
|
assert 'id="transportFormat"' in index_html
|
||||||
|
assert 'id="fps" type="number" value="25"' in index_html
|
||||||
|
assert 'id="frameInterpolation" type="checkbox" />' in index_html
|
||||||
|
assert (
|
||||||
|
'id="serverUrl" value="ws://127.0.0.1:30000/v1/realtime_video/generate"'
|
||||||
|
in index_html
|
||||||
|
)
|
||||||
|
assert '<option value="webp" selected>WebP preview</option>' in index_html
|
||||||
|
assert 'id="serverSendText"' in index_html
|
||||||
|
assert 'id="theoreticalFpsText"' in index_html
|
||||||
|
assert 'id="renderFps"' in index_html
|
||||||
|
assert 'id="stageRenderFps"' not in index_html
|
||||||
|
assert "sglang-diffusion Realtime Studio" in index_html
|
||||||
|
assert "SGLD" not in index_html
|
||||||
|
assert 'class="tabs"' not in index_html
|
||||||
|
assert "Recordings" not in index_html
|
||||||
|
assert "API" not in index_html
|
||||||
|
assert "Info" not in index_html
|
||||||
|
assert 'id="steps" type="number" value="4"' in index_html
|
||||||
|
assert 'id="guidance" type="number" value="1"' in index_html
|
||||||
|
assert "styles.css?v=realtime-fixes-v25" in index_html
|
||||||
|
assert "app.js?v=realtime-fixes-v30" in index_html
|
||||||
|
assert 'const DECODER_WORKER_URL = "./decoder_worker.js?v=rgb-worker-v6";' in app_js
|
||||||
|
assert "const DEFAULT_TARGET_FPS = 25;" in app_js
|
||||||
|
assert "const DEFAULT_FRAME_INTERPOLATION_EXP = 1;" in app_js
|
||||||
|
assert "const DEFAULT_FRAME_INTERPOLATION_SCALE = 1.0;" in app_js
|
||||||
|
assert "enable_frame_interpolation: true" in app_js
|
||||||
|
assert "frame_interpolation_exp: DEFAULT_FRAME_INTERPOLATION_EXP" in app_js
|
||||||
|
assert "frame_interpolation_scale: DEFAULT_FRAME_INTERPOLATION_SCALE" in app_js
|
||||||
|
assert "elapsedMs % targetMs" in app_js
|
||||||
|
assert "liveQueueFrameFloor(header, chunkFrameCount)" in app_js
|
||||||
|
assert (
|
||||||
|
'const REACTOR_PRESET_BASE_URL = "https://www.reactor.inc/lingbot-world-fast-v1";'
|
||||||
|
in app_js
|
||||||
|
)
|
||||||
|
assert "Dragon Dolly" in app_js
|
||||||
|
assert "no creature morphing" in app_js
|
||||||
|
assert "A static locked-off view of the back side of Plastic Beach" in app_js
|
||||||
|
assert "clouds slowly drifting behind the island" in app_js
|
||||||
|
assert "occasional shooting star" in app_js
|
||||||
|
assert "tiny distant pigeons" in app_js
|
||||||
|
assert "Ziggy Stardust" in app_js
|
||||||
|
assert "blue K. West sign" in app_js
|
||||||
|
assert "wet pavement reflecting a yellow streetlamp" in app_js
|
||||||
|
assert "ZiggyStardust.jpg" in app_js
|
||||||
|
assert "A slow aerial orbit around a pastel floating island hotel" not in app_js
|
||||||
|
assert app_js.index("Dragon Ride") < app_js.index("Dragon Dolly")
|
||||||
|
assert app_js.index("Ziggy Stardust") < app_js.index("Plastic Beach")
|
||||||
|
assert app_js.index("Dragon Dolly") < app_js.index("Kid A")
|
||||||
|
assert "dragon-ride.jpg" in app_js
|
||||||
|
assert "stageRenderFps" not in app_js
|
||||||
|
assert 'setStatus("Receiving"' not in app_js
|
||||||
|
assert "decodeChain = decodeChain" in app_js
|
||||||
|
assert "receiveChain" not in app_js
|
||||||
|
assert 'message.type === "chunk_stats"' in app_js
|
||||||
|
assert "chunkTotal > 0 ? numFrames / chunkTotal" in app_js
|
||||||
|
assert ".stage-stat" in styles_css
|
||||||
Reference in New Issue
Block a user