Commit 4971821a by Iteravse

Initial commit

parents
# 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.
const $ = (id) => document.getElementById(id);
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";
const DECODER_WORKER_URL = "./decoder_worker.js?v=rgb-worker-v10";
const DEFAULT_PREVIEW_OUTPUT_FORMAT = "webp";
const DEFAULT_PREVIEW_OUTPUT_QUALITY = 80;
const MAX_WEBP_PREVIEW_OUTPUT_QUALITY = 80;
const SMOOTH_PREVIEW_OUTPUT_QUALITY = 70;
const SR_PREVIEW_OUTPUT_QUALITY = 70;
const HEAVY_PREVIEW_OUTPUT_QUALITY = 60;
const DEFAULT_TARGET_FPS = 25;
const DEFAULT_FRAME_INTERPOLATION_EXP = 1;
const DEFAULT_FRAME_INTERPOLATION_SCALE = 1.0;
const DEFAULT_UPSCALING_SCALE = 2;
const DEFAULT_UPSCALING_MODEL =
"https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-x4v3.pth";
const DEFAULT_PREVIEW_SCALE = 120;
const RECONNECT_CLOSE_TIMEOUT_MS = 15000;
const DECODE_QUEUE_SECONDS = 2.0;
const STARTUP_DECODE_QUEUE_SECONDS = 2.5;
const RECENT_DROP_DISPLAY_MS = 1800;
const CONTROL_BUFFERED_AMOUNT_LIMIT = 1 << 20;
const CONTROL_TRANSITION_FLUSH_DELAY_MS = 140;
const CONTROL_KEY_ACTIONS = new Map([
["w", "w"],
["a", "a"],
["s", "s"],
["d", "d"],
["arrowup", "i"],
["arrowleft", "j"],
["arrowdown", "k"],
["arrowright", "l"],
]);
const CONTROL_ACTION_META = {
w: {
label: "Forward",
type: "translation",
axis: "+forward",
amount: "0.05/frame",
},
a: { label: "Left", type: "translation", axis: "-right", amount: "0.05/frame" },
s: {
label: "Back",
type: "translation",
axis: "-forward",
amount: "0.05/frame",
},
d: { label: "Right", type: "translation", axis: "+right", amount: "0.05/frame" },
i: { label: "Pitch +", type: "rotation", axis: "+pitch", amount: "4deg/frame" },
j: { label: "Yaw -", type: "rotation", axis: "-yaw", amount: "6deg/frame" },
k: { label: "Pitch -", type: "rotation", axis: "-pitch", amount: "4deg/frame" },
l: { label: "Yaw +", type: "rotation", axis: "+yaw", amount: "6deg/frame" },
};
const REACTOR_PRESET_BASE_URL = "https://www.reactor.inc/lingbot-world-fast-v1";
const reactorPresets = [
{
name: "佩儿骑龙战魔族",
tone: "green",
size: "832x480",
fps: 25,
prompt: "A centered third-person anime view behind a young girl who remains firmly mounted on her dragon at all times, her tattered top fluttering in the smoky wind, gripping a glowing magic wand, overlooking a vast battlefield where elven and demon soldiers are everywhere — covering the plains, hills, and trenches — locked in endless, relentless mutual slaughter, magic missiles hurtling through the air, exploding in midair, tearing flesh and sending blood and debris flying in all directions, while fresh waves of troops from both sides continuously pour into the carnage, clashing without pause, with smoke and dust choking the sky, burning wreckage scattered across the ground, dark towering mountains in the distance, and a heavy epic fantasy war atmosphere rendered in hand-painted anime art.",
referenceUrl: "./demo/V9.png",
source: "jueqingExample01"
},
{
name: "天空之城的旅途",
tone: "blue",
size: "832x480",
fps: 25,
prompt: "A centered elevated third-person game camera behind a lone figure in a wooden boat crossing a calm deep blue alpine lake, scattered ice blocks, mirror reflections, huge snow‑covered mountain ranges, vivid sky, tiny translucent‑winged fairy spirits drifting around, and a distant floating sky city, with crisp cold wilderness scale.",
referenceUrl: `./demo/Boat.png`,
source: "jueqingExample02",
},
{
name: "迦南大陆",
tone: "green",
size: "832x480",
fps: 25,
prompt: "A centered third-person anime view behind a young girl on a flower-covered coastal hillside, surrounded by goblins with swords and spears constantly attacking her from front, overlooking a sparkling blue bay, rolling green hills, sailboats, dramatic cliffs, a small lighthouse, huge fluffy clouds, and warm hand-painted adventure atmosphere.",
referenceUrl: `./demo/Fight.png`,
source: "Reactor LingBot preset",
mime: "image/png",
},
{
name: "Dragon Ride",
tone: "green",
size: "832x480",
fps: 25,
prompt: "A locked first-person dragon-rider view matching the reference image: both tan forearms in brown leather gloves stay visible at the bottom, gripping leather reins around the green-brown scaled dragon neck; the dragon head, horns, and both wide wings frame the jungle valley, waterfalls, mist, and tall castle on the right. Smooth forward flight only, keep the same rider hands, dragon body, wing silhouette, castle placement, and humid daylight colors in every frame.",
referenceUrl: `${REACTOR_PRESET_BASE_URL}/dragon-ride.jpg`,
source: "Reactor LingBot preset",
},
{
name: "Misted Kingdom",
tone: "green",
size: "832x480",
fps: 25,
prompt: "A third-person over-the-shoulder fantasy view following a sword-slung rider on a brown horse through curling valley mist, wildflower meadows, ruined stone arches, cottages, and a many-spired castle under a ringed gas giant and crescent moon.",
referenceUrl: `${REACTOR_PRESET_BASE_URL}/misted-kingdom.jpg`,
source: "Reactor LingBot preset",
},
{
name: "Storm Crossing",
tone: "blue",
size: "832x480",
fps: 25,
prompt: "A third-person stern view of a battered grey aluminum work boat pushing through slate-black storm swells, wet wooden deck, warm cabin lamp, orange life rings, salt mist, churning wake, and a pale silver break in the dark horizon.",
referenceUrl: `${REACTOR_PRESET_BASE_URL}/storm-crossing.jpg`,
source: "Reactor LingBot preset",
},
{
name: "Citadel Approach",
tone: "accent",
size: "832x480",
fps: 25,
prompt: "A third-person rear view of a mud-streaked vintage Defender 4x4 driving along a cobblestone-and-sand track through a coral-lit desert canyon toward a cliff-built sandstone citadel, with cacti, red poppies, ochre dunes, and peach sunset haze.",
referenceUrl: `${REACTOR_PRESET_BASE_URL}/citadel-approach.jpg`,
source: "Reactor LingBot preset",
},
{
name: "Spring Valley",
tone: "green",
size: "832x480",
fps: 25,
prompt: "A third-person over-the-shoulder view following a golden retriever through a sunlit meadow with a patterned floral rug, stone bench, open book, potted seedling, cherry blossoms, rounded green oaks, soft hills, and a tender watercolor storybook atmosphere.",
referenceUrl: `${REACTOR_PRESET_BASE_URL}/spring-valley.jpg`,
source: "Reactor LingBot preset",
},
{
name: "Reef Patrol",
tone: "blue",
size: "832x480",
fps: 25,
prompt: "A third-person follow view trailing a large grey reef shark through clear tropical water above a sunlit coral reef, with drifting sediment, shifting sun-ray lattices, clouds of reef fish, a sardine bait ball, and deep blue open-water haze.",
referenceUrl: `${REACTOR_PRESET_BASE_URL}/reef-patrol.jpg`,
source: "Reactor LingBot preset",
},
{
name: "Alpine Run",
tone: "blue",
size: "832x480",
fps: 25,
prompt: "A third-person rear view of a yellow four-person whitewater raft plunging through churning rapids in an alpine canyon, red lifejackets, yellow helmets, wet paddles, dark boulders, conifer slopes, and a snow-capped mountain at the vanishing point.",
referenceUrl: `${REACTOR_PRESET_BASE_URL}/alpine-run.jpg`,
source: "Reactor LingBot preset",
},
{
name: "Ice Kayak",
tone: "blue",
size: "832x480",
fps: 25,
prompt: "A centered elevated third-person game camera behind a lone kayaker in a bright red kayak crossing a calm deep blue alpine lake, scattered ice blocks, mirror reflections, huge snow-covered mountain ranges, vivid sky, and crisp cold wilderness scale.",
referenceUrl: `${REACTOR_PRESET_BASE_URL}/ice-kayak.jpg`,
source: "Reactor LingBot preset",
},
{
name: "Penguin Colony",
tone: "green",
size: "832x480",
fps: 25,
prompt: "A third-person follow view of a single black-and-white penguin waddling across a windswept Antarctic ice shelf toward a distant colony, crystalline snow, small flippers, scattered dark boulders, rocky shoreline, and pale polar sky.",
referenceUrl: `${REACTOR_PRESET_BASE_URL}/penguin.jpg`,
source: "Reactor LingBot preset",
},
{
name: "Mars Mountain",
tone: "accent",
size: "832x480",
fps: 25,
prompt: "A centered third-person rear view of a six-wheeled Martian rover marked XR-7A P-3317 crossing cracked basalt toward a vast volcanic mountain, dusty rose twilight, ochre wheel plumes, weathered grey panels, and a cold alien horizon.",
referenceUrl: `${REACTOR_PRESET_BASE_URL}/mars-rover.jpg`,
source: "Reactor LingBot preset",
},
{
name: "Seaside Adventurer",
tone: "green",
size: "832x480",
fps: 25,
prompt: "A centered third-person anime view behind a young girl on a flower-covered coastal hillside overlooking a sparkling blue bay, rolling green hills, sailboats, dramatic cliffs, a small lighthouse, huge fluffy clouds, and warm hand-painted adventure atmosphere.",
referenceUrl: `${REACTOR_PRESET_BASE_URL}/anime3.png`,
source: "Reactor LingBot preset",
mime: "image/png",
},
{
name: "Roman Chariot",
tone: "accent",
size: "832x480",
fps: 25,
prompt: "A centered elevated third-person game camera behind a Roman warrior riding an ancient chariot pulled by two white horses across an open grassy field, worn stone path, Roman ruins, broken columns, bright midday sky, and epic historical scale.",
referenceUrl: `${REACTOR_PRESET_BASE_URL}/chariot.png`,
source: "Reactor LingBot preset",
mime: "image/png",
},
{
name: "Asylum Corridor",
tone: "accent",
size: "832x480",
fps: 25,
prompt: "A third-person over-the-shoulder traversal behind a man in a wet leather jacket holding a flashlight down a derelict asylum corridor, standing water, torn vinyl strips, rusted ceiling debris, bloodstains, a toppled wheelchair, and a distant cyan-grey doorway glow.",
referenceUrl: `${REACTOR_PRESET_BASE_URL}/horror.jpg`,
source: "Reactor LingBot preset",
},
];
const examplePresets = [
{ name: "佩儿骑龙战魔族", tone: "green", size: "832x480", fps: 25, prompt: "A centered third-person anime view behind a young girl who remains firmly mounted on her dragon at all times, her tattered top fluttering in the smoky wind, gripping a glowing magic wand, overlooking a vast battlefield where elven and demon soldiers are everywhere — covering the plains, hills, and trenches — locked in endless, relentless mutual slaughter, magic missiles hurtling through the air, exploding in midair, tearing flesh and sending blood and debris flying in all directions, while fresh waves of troops from both sides continuously pour into the carnage, clashing without pause, with smoke and dust choking the sky, burning wreckage scattered across the ground, dark towering mountains in the distance, and a heavy epic fantasy war atmosphere rendered in hand-painted anime art.", referenceUrl: "./demo/V9.png", source: "jueqingExample01" },
{
name: "天空之城的旅途",
tone: "blue",
size: "832x480",
fps: 25,
prompt: "A centered elevated third-person game camera behind a lone figure in a wooden boat crossing a calm deep blue alpine lake, scattered ice blocks, mirror reflections, huge snow‑covered mountain ranges, vivid sky, tiny translucent‑winged fairy spirits drifting around, and a distant floating sky city, with crisp cold wilderness scale.",
referenceUrl: `./demo/Boat.png`,
source: "jueqingExample02",
},
{
name: "迦南大陆",
tone: "green",
size: "832x480",
fps: 25,
prompt: "A centered third-person anime view behind a young girl on a flower-covered coastal hillside, surrounded by goblins with swords and spears constantly attacking her from all sides, overlooking a sparkling blue bay, rolling green hills, sailboats, dramatic cliffs, a small lighthouse, huge fluffy clouds, and warm hand-painted adventure atmosphere.",
referenceUrl: `./demo/Fight.png`,
source: "Reactor LingBot preset",
mime: "image/png",
},
{ name: "Dragon Dolly", tone: "green", size: "832x480", fps: 25, prompt: "A stable first-person dolly from the same dragon-rider viewpoint, keeping the black dragon head, horns, wings, jungle canopy, and distant castle consistent; slow forward camera motion, natural parallax, no creature morphing, no scene replacement.", referenceUrl: "https://raw.githubusercontent.com/robbyant/lingbot-world/main/examples/00/image.jpg", source: "LingBot example 00" },
{ name: "Stone Orbit", tone: "blue", size: "832x480", fps: 25, prompt: "A controlled look-around of the stone monument, overcast daylight, consistent geometry, subtle camera arc.", referenceUrl: "https://raw.githubusercontent.com/robbyant/lingbot-world/main/examples/01/image.jpg", source: "LingBot example 01" },
{ name: "Urban Tilt", tone: "accent", size: "832x480", fps: 25, prompt: "A cinematic urban wall shot with a slow tilt and slight forward movement, warm backlight, stable architecture.", referenceUrl: "https://raw.githubusercontent.com/robbyant/lingbot-world/main/examples/02/image.jpg", source: "LingBot example 02" },
{ name: "Lake Scout", tone: "green", size: "832x480", fps: 25, prompt: "A calm scouting shot across the lake, gentle camera drift, crisp mountains, stable reflections.", referenceUrl: "https://raw.githubusercontent.com/robbyant/lingbot-world/main/examples/03/image.jpg", source: "LingBot example 03" },
{ name: "Ziggy Stardust", tone: "accent", size: "832x480", fps: 25, prompt: "A static night view of a narrow London alley in soft rain, wet pavement reflecting a yellow streetlamp, the blue K. West sign glowing above a doorway, cardboard boxes near the wall, a pale parked car in the distance, and a slender glam-rock figure holding a guitar under the lamp; preserve the album-cover composition, brick storefronts, muted teal and amber colors, subtle rain shimmer only.", referenceUrl: "https://upload.wikimedia.org/wikipedia/en/0/01/ZiggyStardust.jpg", source: "David Bowie Ziggy Stardust artwork", mime: "image/jpeg" },
{ name: "Plastic Beach", tone: "blue", size: "832x480", fps: 25, prompt: "A static album-cover view matching the reference image: the Plastic Beach island stays centered above a dark midnight-blue ocean, the lighthouse remains on the left with its white reflection path, the starry navy sky stays unchanged, and the large white Plastic Beach title graphic stays in the lower foreground. Keep the original camera height, horizon, waterline, island silhouette, and deep blue color palette fixed; only tiny water shimmer, lighthouse glint, and subtle star twinkle, with no camera descent, no push-in, no orbit, and no turquoise color shift.", referenceUrl: "https://is1-ssl.mzstatic.com/image/thumb/Music/v4/b8/f9/b9/b8f9b9f8-a609-bde2-0302-349436ffc508/825646291038.jpg/600x600bb.jpg", source: "Gorillaz Plastic Beach artwork", mime: "image/jpeg" },
{ name: "Plastic Ono Band", tone: "green", size: "832x480", fps: 25, prompt: "A quiet sunlit park under a massive tree, a solitary figure resting in the grass, soft summer haze, restrained documentary camera, intimate and naturalistic.", referenceUrl: "https://upload.wikimedia.org/wikipedia/en/a/a4/JLPOBCover.jpg", source: "John Lennon/Plastic Ono Band artwork", mime: "image/jpeg" },
{ name: "Kid A", tone: "accent", size: "832x480", fps: 25, prompt: "A cold surreal mountain range with sharp icy peaks, black-red storm clouds, glacial light, slow lateral pan, abstract digital texture, uneasy atmospheric scale.", referenceUrl: "https://is1-ssl.mzstatic.com/image/thumb/Music122/v4/bd/8e/13/bd8e1358-b367-a689-cb84-cebd0b067dc4/634904078263.png/600x600bb.jpg", source: "Radiohead Kid A artwork", mime: "image/jpeg" },
];
const presets = [
...reactorPresets,
...examplePresets,
];
let ws = null;
let selectedPreset = null;
let selectedReferenceBytes = null;
let selectedReferenceUrl = "";
let selectedReferenceLabel = "";
let pendingHeader = null;
let frames = 0;
let bytes = 0;
let clearQueueOnClose = false;
let fpsSamples = [];
let decodeQueue = [];
let queuedDecodeFrames = 0;
let decodeInProgress = false;
let pendingDecodeBatches = 0;
let droppedDecodeFrames = 0;
let lastDecodeDropAt = 0;
let lastDecodeDropCount = 0;
let nextEventId = 1;
let lastRawRgbFrame = null;
let decoderWorker = null;
let decodeWorkerUnavailable = false;
let decodeRequestId = 1;
let streamEpoch = 0;
let lastDecodeMs = 0;
let lastDisplayLagMs = 0;
let encodedDecodeErrors = 0;
let socketHadError = false;
let socketCloseExpected = false;
let socketServerError = "";
let renderedPreviewFrames = 0;
let previewScaleFrame = 0;
let recordingActive = false;
let recordingSamples = [];
let recordingEncoder = null;
let recordingEncoderReady = null;
let recordingEncoderConfig = null;
let recordingFrameIndex = 0;
let recordingFps = DEFAULT_TARGET_FPS;
let recordingTimer = 0;
let recordingSaving = false;
let recordingEncodeChain = Promise.resolve();
const decodeRequests = new Map();
let controlStateController = null;
const stage = document.querySelector(".stage");
const previewFrame = document.querySelector(".preview-frame");
const canvas = $("viewport");
const ctx = canvas.getContext("2d", { alpha: false });
const scratchCanvas = document.createElement("canvas");
const scratchCtx = scratchCanvas.getContext("2d", { alpha: false });
const recordingCanvas = document.createElement("canvas");
const recordingCtx = recordingCanvas.getContext("2d", { alpha: false });
const playbackController = new RealtimePlaybackController({
targetFps: DEFAULT_TARGET_FPS,
});
function setStatus(text, kind = "") {
$("statusText").textContent = text;
$("statusDot").className = "dot" + (kind ? ` ${kind}` : "");
}
function setPreviewState(state) {
if (!stage) return;
stage.dataset.previewState = state;
canvas.setAttribute("aria-busy", state === "waiting" ? "true" : "false");
}
function addHistory(text) {
const item = document.createElement("span");
item.textContent = text;
$("historyList").prepend(item);
while ($("historyList").children.length > 8) $("historyList").lastChild.remove();
}
function drawIdle() {
const w = 1280, h = 720;
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
setPreviewState("idle");
renderedPreviewFrames = 0;
ctx.fillStyle = "#11140f";
ctx.fillRect(0, 0, w, h);
}
function resetStreamStats() {
pendingHeader = null;
clearFrameQueue();
playbackController.reset({ targetFps: previewPlaybackTargetFps() });
frames = 0;
bytes = 0;
fpsSamples = [];
clearQueueOnClose = false;
decodeQueue = [];
queuedDecodeFrames = 0;
decodeInProgress = false;
pendingDecodeBatches = 0;
droppedDecodeFrames = 0;
lastDecodeDropAt = 0;
lastDecodeDropCount = 0;
encodedDecodeErrors = 0;
renderedPreviewFrames = 0;
controlStateController?.reset({ sendRelease: false });
resetDecoderState();
updateStats();
$("renderFps").textContent = "0";
$("latencyText").textContent = "-";
$("stageLatencyText").textContent = "-";
$("decodeText").textContent = "-";
$("displayLagText").textContent = "-";
$("serverSendText").textContent = "-";
$("chunkPayloadText").textContent = "-";
$("theoreticalFpsText").textContent = "-";
$("chunkText").textContent = "chunk -";
$("payloadMode").textContent = selectedTransportLabel();
updateOutputSizeText();
}
function rejectPendingDecodes(message) {
for (const request of decodeRequests.values()) {
request.reject(new Error(message));
}
decodeRequests.clear();
}
function ensureDecoderWorker() {
if (decoderWorker || decodeWorkerUnavailable) return;
if (typeof Worker === "undefined") {
decodeWorkerUnavailable = true;
return;
}
decoderWorker = new Worker(DECODER_WORKER_URL);
decoderWorker.onmessage = (event) => {
const message = event.data;
const request = decodeRequests.get(message.id);
if (!request) return;
decodeRequests.delete(message.id);
if (message.type === "error") {
request.reject(new Error(message.message || "decode failed"));
return;
}
request.resolve(message);
};
decoderWorker.onerror = (event) => {
decodeWorkerUnavailable = true;
decoderWorker?.terminate();
decoderWorker = null;
rejectPendingDecodes(event.message || "decode worker failed");
};
}
function resetDecoderState() {
lastRawRgbFrame = null;
if (decoderWorker) decoderWorker.postMessage({ type: "reset" });
}
async function decodeFrameBatch(header, data) {
const decodeStartedAt = performance.now();
if (!isWorkerDecodableContentType(header.content_type)) {
const items = await framePayloadToImageData(header, data);
const decodedAt = performance.now();
lastDecodeMs = decodedAt - decodeStartedAt;
return items.map((item) => ({
...item,
receivedAt: header.__received_at,
decodedAt,
decodeMs: lastDecodeMs,
}));
}
ensureDecoderWorker();
if (!decoderWorker || decodeWorkerUnavailable) {
const items = await framePayloadToImageData(header, data);
const decodedAt = performance.now();
lastDecodeMs = decodedAt - decodeStartedAt;
return items.map((item) => ({
...item,
receivedAt: header.__received_at,
decodedAt,
decodeMs: lastDecodeMs,
}));
}
const payload = await payloadToArrayBuffer(data);
const id = decodeRequestId++;
const decodeHeader = { ...header, __decode_id: id };
const useTransfer =
isWorkerDecodableRawContentType(header.content_type) ||
isEncodedPreviewContentType(header.content_type);
try {
return await new Promise((resolve, reject) => {
decodeRequests.set(id, {
resolve: (message) => {
const decodedAt = performance.now();
lastDecodeMs = decodedAt - decodeStartedAt;
resolve(message.frames.map((frame) => ({
image: message.frame_type === "bitmap"
? frame
: new ImageData(new Uint8ClampedArray(frame), message.width, message.height),
chunk: message.chunk,
receivedAt: header.__received_at,
decodedAt,
decodeMs: lastDecodeMs,
})));
},
reject,
});
try {
decoderWorker.postMessage(
{ type: "decode", header: decodeHeader, payload },
useTransfer ? [payload] : [],
);
} catch (error) {
decodeRequests.delete(id);
reject(error);
}
});
} catch (error) {
if (isEncodedPreviewContentType(header.content_type) && !useTransfer) {
const items = await framePayloadToImageData(header, data);
const decodedAt = performance.now();
lastDecodeMs = decodedAt - decodeStartedAt;
return items.map((item) => ({
...item,
receivedAt: header.__received_at,
decodedAt,
decodeMs: lastDecodeMs,
}));
}
throw error;
}
}
function isWorkerDecodableContentType(contentType) {
return isWorkerDecodableRawContentType(contentType);
}
function isWorkerDecodableRawContentType(contentType) {
return (
contentType === RAW_RGB_CONTENT_TYPE ||
contentType === RAW_RGB_DELTA_GZIP_CONTENT_TYPE ||
contentType === RAW_RGBA_DELTA_GZIP_CONTENT_TYPE
);
}
function updateStats() {
const playback = playbackController.snapshot();
const queueParts = [`buffer ${formatMs(playback.bufferMs)}`];
queueParts.push(`q ${playback.queueFrames}`);
if (playback.buffering && playback.queueFrames) queueParts.push("hold");
if (pendingDecodeBatches) queueParts.push(`decode ${pendingDecodeBatches}`);
const now = performance.now();
if (playback.lastDropAt && now - playback.lastDropAt < RECENT_DROP_DISPLAY_MS) {
const reason = playback.lastDropReason ? ` ${playback.lastDropReason}` : "";
queueParts.push(`drop +${playback.lastDropCount}${reason}`);
}
if (lastDecodeDropAt && now - lastDecodeDropAt < RECENT_DROP_DISPLAY_MS) {
queueParts.push(`decode drop +${lastDecodeDropCount}`);
}
$("queueText").textContent = queueParts.join(" · ");
$("frameText").textContent = `frames ${frames}`;
$("byteText").textContent = `${(bytes / 1048576).toFixed(1)} MB`;
$("stageLatencyText").textContent =
`${formatMs(playback.bufferMs)} / ${formatMs(playback.targetLeadMs)}`;
}
function requestedInputFps() {
return Number($("fps").value || DEFAULT_TARGET_FPS);
}
function frameInterpolationMultiplier() {
return $("frameInterpolation").checked ? 2 ** DEFAULT_FRAME_INTERPOLATION_EXP : 1;
}
function previewPlaybackTargetFps() {
return requestedInputFps() * frameInterpolationMultiplier();
}
function syncPlaybackTargetFps() {
playbackController.setTargetFps(previewPlaybackTargetFps());
updateStats();
}
function clearFrameQueue() {
closeFrames(playbackController.clear());
}
function closeFrames(items) {
for (const item of items || []) item.image?.close?.();
}
function recordingFileName() {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
return `sglang-realtime-${stamp}.mp4`;
}
function updateRecordButton() {
const button = $("recordBtn");
button.classList.toggle("is-recording", recordingActive);
button.classList.toggle("is-saving", recordingSaving);
button.disabled = recordingSaving;
button.setAttribute("aria-pressed", recordingActive ? "true" : "false");
$("recordLabel").textContent = recordingSaving
? "Saving"
: recordingActive ? "Stop" : "Record";
const elapsedMs = recordingActive ? recordingFrameIndex / Math.max(1, recordingFps) * 1000 : 0;
$("recordDuration").textContent = formatRecordingDuration(elapsedMs);
}
function formatRecordingDuration(elapsedMs) {
const seconds = Math.max(0, Math.floor(elapsedMs / 1000));
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return `${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`;
}
function startRecording() {
if (recordingActive || recordingSaving) return;
if (!window.VideoEncoder || !window.VideoFrame) {
setStatus("MP4 unsupported", "error");
addHistory("MP4 recording requires WebCodecs H.264 support");
return;
}
recordingActive = true;
recordingSamples = [];
recordingEncoder = null;
recordingEncoderReady = null;
recordingEncoderConfig = null;
recordingFrameIndex = 0;
recordingFps = Math.max(1, previewPlaybackTargetFps());
recordingEncodeChain = Promise.resolve();
recordingTimer = window.setInterval(updateRecordButton, 250);
updateRecordButton();
addHistory("recording started");
}
async function stopRecording() {
if (!recordingActive || recordingSaving) return;
recordingActive = false;
if (recordingTimer) {
window.clearInterval(recordingTimer);
recordingTimer = 0;
}
recordingSaving = true;
updateRecordButton();
let fileHandle = null;
const fileName = recordingFileName();
try {
if (window.showSaveFilePicker) {
fileHandle = await window.showSaveFilePicker({
suggestedName: fileName,
types: [{
description: "MP4 video",
accept: { "video/mp4": [".mp4"] },
}],
});
}
await recordingEncodeChain;
if (!recordingEncoder || !recordingSamples.length) throw new Error("No frames were recorded");
await recordingEncoder.flush();
const mp4Blob = buildRecordingMp4();
if (fileHandle) {
const writable = await fileHandle.createWritable();
await writable.write(mp4Blob);
await writable.close();
} else {
downloadBlob(mp4Blob, fileName);
}
addHistory(`saved ${recordingSamples.length} frames as mp4`);
} catch (error) {
if (error?.name === "AbortError") {
addHistory("recording save canceled");
} else {
addHistory(error.message || "recording save failed");
setStatus("Save failed", "error");
}
} finally {
recordingEncoder?.close?.();
recordingEncoder = null;
recordingEncoderReady = null;
recordingSaving = false;
recordingSamples = [];
updateRecordButton();
}
}
function recordDecodedFrameBatch(decodedFrames) {
if (!recordingActive || recordingSaving) return;
for (const item of decodedFrames) {
if (!recordingActive) break;
recordDecodedFrame(item.image);
}
updateRecordButton();
}
function recordDecodedFrame(image) {
if (!recordingActive || recordingSaving) return;
const frameIndex = recordingFrameIndex;
const duration = Math.round(1_000_000 / Math.max(1, recordingFps));
const timestamp = frameIndex * duration;
let frame;
try {
frame = createRecordingFrame(image, timestamp, duration);
} catch (error) {
recordingActive = false;
addHistory(error.message || "recording frame capture failed");
updateRecordButton();
return;
}
recordingFrameIndex += 1;
recordingEncodeChain = recordingEncodeChain
.then(async () => {
await ensureRecordingEncoder(frame.displayWidth, frame.displayHeight);
recordingEncoder.encode(frame, { keyFrame: frameIndex === 0 || frameIndex % 120 === 0 });
frame.close();
})
.catch((error) => {
frame.close();
recordingActive = false;
addHistory(error.message || "recording encode failed");
updateRecordButton();
});
}
function createRecordingFrame(image, timestamp, duration) {
if (image instanceof ImageData) {
if (recordingCanvas.width !== image.width || recordingCanvas.height !== image.height) {
recordingCanvas.width = image.width;
recordingCanvas.height = image.height;
}
recordingCtx.putImageData(image, 0, 0);
return new VideoFrame(recordingCanvas, { timestamp, duration });
}
return new VideoFrame(image, { timestamp, duration });
}
async function ensureRecordingEncoder(width, height) {
if (recordingEncoderReady) return recordingEncoderReady;
recordingEncoderReady = createRecordingEncoder(width, height);
return recordingEncoderReady;
}
async function createRecordingEncoder(width, height) {
const fps = Math.max(1, recordingFps);
const bitrate = Math.round(Math.min(
180_000_000,
Math.max(24_000_000, width * height * fps * 0.8),
));
const configs = [
{ codec: "avc1.640028", width, height, bitrate, framerate: fps },
{ codec: "avc1.4d4028", width, height, bitrate, framerate: fps },
{ codec: "avc1.42e028", width, height, bitrate, framerate: fps },
];
let supported = null;
for (const config of configs) {
const candidate = {
...config,
avc: { format: "avc" },
bitrateMode: "variable",
hardwareAcceleration: "prefer-hardware",
latencyMode: "realtime",
};
const result = await VideoEncoder.isConfigSupported(candidate);
if (result.supported) {
supported = result.config;
break;
}
}
if (!supported) throw new Error("This browser cannot encode H.264 MP4");
recordingEncoderConfig = supported;
recordingEncoder = new VideoEncoder({
output: (chunk, metadata) => recordEncodedChunk(chunk, metadata),
error: (error) => {
recordingActive = false;
addHistory(error.message || "recording encoder failed");
updateRecordButton();
},
});
recordingEncoder.configure(supported);
}
function recordEncodedChunk(chunk, metadata) {
if (metadata?.decoderConfig?.description) {
recordingEncoderConfig.description = metadata.decoderConfig.description;
}
const data = new Uint8Array(chunk.byteLength);
chunk.copyTo(data);
recordingSamples.push({
data,
timestamp: chunk.timestamp,
duration: chunk.duration || 0,
key: chunk.type === "key",
});
}
function downloadBlob(blob, fileName) {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function buildRecordingMp4() {
if (!recordingEncoderConfig.description) {
throw new Error("H.264 encoder did not return MP4 decoder config");
}
const width = recordingEncoderConfig.width;
const height = recordingEncoderConfig.height;
const samples = normalizeRecordingSamples(recordingSamples);
const mdatPayload = concatBytes(samples.map((sample) => sample.data));
const ftyp = mp4Box("ftyp", ascii("isom"), u32(0x200), ascii("isom"), ascii("iso2"), ascii("avc1"), ascii("mp41"));
const mdat = mp4Box("mdat", mdatPayload);
const firstSampleOffset = ftyp.byteLength + 8;
const moov = buildMoovBox({
width,
height,
samples,
firstSampleOffset,
avcConfig: new Uint8Array(recordingEncoderConfig.description),
});
return new Blob([ftyp, mdat, moov], { type: "video/mp4" });
}
function normalizeRecordingSamples(samples) {
const ordered = [...samples].sort((left, right) => left.timestamp - right.timestamp);
const timescale = 90_000;
const fallbackDuration = Math.round(timescale / Math.max(1, recordingFps));
const normalized = ordered.map((sample) => ({
...sample,
time: Math.round(sample.timestamp * timescale / 1_000_000),
}));
for (let i = 0; i < normalized.length; i++) {
const next = normalized[i + 1];
normalized[i].duration = next
? Math.max(1, next.time - normalized[i].time)
: Math.max(1, Math.round((ordered[i].duration || 0) * timescale / 1_000_000) || fallbackDuration);
}
return normalized;
}
function buildMoovBox({ width, height, samples, firstSampleOffset, avcConfig }) {
const timescale = 90_000;
const duration = samples.reduce((sum, sample) => sum + sample.duration, 0);
const movieTimescale = 1000;
const movieDuration = Math.ceil(duration * movieTimescale / timescale);
return mp4Box(
"moov",
buildMvhdBox(movieTimescale, movieDuration),
mp4Box(
"trak",
buildTkhdBox(width, height, movieDuration),
mp4Box(
"mdia",
buildMdhdBox(timescale, duration),
buildHdlrBox(),
mp4Box(
"minf",
buildVmhdBox(),
buildDinfBox(),
buildStblBox({ width, height, samples, firstSampleOffset, avcConfig }),
),
),
),
);
}
function buildMvhdBox(timescale, duration) {
return mp4Box(
"mvhd",
u32(0),
u32(0),
u32(0),
u32(timescale),
u32(duration),
u32(0x00010000),
u16(0x0100),
u16(0),
zeros(8),
u32(0x00010000), u32(0), u32(0),
u32(0), u32(0x00010000), u32(0),
u32(0), u32(0), u32(0x40000000),
zeros(24),
u32(2),
);
}
function buildTkhdBox(width, height, duration) {
return mp4Box(
"tkhd",
u32(0x00000007),
u32(0),
u32(0),
u32(1),
u32(0),
u32(duration),
zeros(8),
u16(0),
u16(0),
u16(0),
u16(0),
u32(0x00010000), u32(0), u32(0),
u32(0), u32(0x00010000), u32(0),
u32(0), u32(0), u32(0x40000000),
u32(width << 16),
u32(height << 16),
);
}
function buildMdhdBox(timescale, duration) {
return mp4Box(
"mdhd",
u32(0),
u32(0),
u32(0),
u32(timescale),
u32(duration),
u16(0x55c4),
u16(0),
);
}
function buildHdlrBox() {
return mp4Box("hdlr", u32(0), u32(0), ascii("vide"), zeros(12), ascii("VideoHandler\0"));
}
function buildVmhdBox() {
return mp4Box("vmhd", u32(0x00000001), u16(0), u16(0), u16(0), u16(0));
}
function buildDinfBox() {
return mp4Box(
"dinf",
mp4Box(
"dref",
u32(0),
u32(1),
mp4Box("url ", u32(0x00000001)),
),
);
}
function buildStblBox({ width, height, samples, firstSampleOffset, avcConfig }) {
return mp4Box(
"stbl",
buildStsdBox(width, height, avcConfig),
buildSttsBox(samples),
buildStssBox(samples),
buildStscBox(samples.length),
buildStszBox(samples),
buildStcoBox(firstSampleOffset),
);
}
function buildStsdBox(width, height, avcConfig) {
const compressor = new Uint8Array(32);
return mp4Box(
"stsd",
u32(0),
u32(1),
mp4Box(
"avc1",
zeros(6),
u16(1),
zeros(16),
u16(width),
u16(height),
u32(0x00480000),
u32(0x00480000),
u32(0),
u16(1),
compressor,
u16(24),
u16(0xffff),
mp4Box("avcC", avcConfig),
),
);
}
function buildSttsBox(samples) {
const entries = [];
for (const sample of samples) {
const last = entries[entries.length - 1];
if (last && last.duration === sample.duration) {
last.count += 1;
} else {
entries.push({ count: 1, duration: sample.duration });
}
}
return mp4Box("stts", u32(0), u32(entries.length), ...entries.flatMap((entry) => [u32(entry.count), u32(entry.duration)]));
}
function buildStssBox(samples) {
const keySamples = samples
.map((sample, index) => sample.key ? index + 1 : 0)
.filter(Boolean);
if (!keySamples.length && samples.length) keySamples.push(1);
return mp4Box("stss", u32(0), u32(keySamples.length), ...keySamples.map(u32));
}
function buildStscBox(sampleCount) {
return mp4Box("stsc", u32(0), u32(1), u32(1), u32(sampleCount), u32(1));
}
function buildStszBox(samples) {
return mp4Box("stsz", u32(0), u32(0), u32(samples.length), ...samples.map((sample) => u32(sample.data.byteLength)));
}
function buildStcoBox(firstSampleOffset) {
return mp4Box("stco", u32(0), u32(1), u32(firstSampleOffset));
}
function mp4Box(type, ...payloads) {
const size = 8 + payloads.reduce((sum, payload) => sum + payload.byteLength, 0);
const output = new Uint8Array(size);
const view = new DataView(output.buffer);
view.setUint32(0, size, false);
output.set(ascii(type), 4);
let offset = 8;
for (const payload of payloads) {
output.set(payload, offset);
offset += payload.byteLength;
}
return output;
}
function concatBytes(parts) {
const output = new Uint8Array(parts.reduce((sum, part) => sum + part.byteLength, 0));
let offset = 0;
for (const part of parts) {
output.set(part, offset);
offset += part.byteLength;
}
return output;
}
function ascii(text) {
const output = new Uint8Array(text.length);
for (let i = 0; i < text.length; i++) output[i] = text.charCodeAt(i);
return output;
}
function zeros(length) {
return new Uint8Array(length);
}
function u16(value) {
const output = new Uint8Array(2);
new DataView(output.buffer).setUint16(0, value, false);
return output;
}
function u32(value) {
const output = new Uint8Array(4);
new DataView(output.buffer).setUint32(0, value >>> 0, false);
return output;
}
function hasPendingPlaybackInput() {
return (
pendingDecodeBatches > 0 ||
decodeInProgress ||
decodeQueue.length > 0 ||
Boolean(ws && ws.readyState === WebSocket.OPEN)
);
}
function enqueueDecodeBatch(header, data, epoch) {
const frameCount = Number(header.num_frames || 1);
decodeQueue.push({ header, data, epoch, frameCount });
queuedDecodeFrames += frameCount;
pendingDecodeBatches += 1;
trimDecodeQueue();
pumpDecodeQueue();
updateStats();
}
function trimDecodeQueue() {
if (recordingActive) return;
if (!decodeQueue.length) return;
const playback = playbackController.snapshot();
const decodeWindowSeconds = renderedPreviewFrames
? Math.max(DECODE_QUEUE_SECONDS, (playback.maxLeadMs || 0) / 1000)
: STARTUP_DECODE_QUEUE_SECONDS;
const maxQueuedFrames = Math.max(
2,
Math.round(previewPlaybackTargetFps() * decodeWindowSeconds),
);
while (queuedDecodeFrames > maxQueuedFrames && decodeQueue.length > 1) {
const item = decodeQueue[0];
if (!isEncodedPreviewContentType(item.header.content_type)) break;
decodeQueue.shift();
queuedDecodeFrames = Math.max(0, queuedDecodeFrames - item.frameCount);
pendingDecodeBatches = Math.max(0, pendingDecodeBatches - 1);
droppedDecodeFrames += item.frameCount;
lastDecodeDropAt = performance.now();
lastDecodeDropCount = item.frameCount;
}
}
async function pumpDecodeQueue() {
if (decodeInProgress) return;
const item = decodeQueue.shift();
if (!item) return;
queuedDecodeFrames = Math.max(0, queuedDecodeFrames - item.frameCount);
decodeInProgress = true;
try {
await decodeAndEnqueueFrameBatch(item.header, item.data, item.epoch);
} catch (error) {
handleReceiveError(error, item.epoch);
} finally {
pendingDecodeBatches = Math.max(0, pendingDecodeBatches - 1);
decodeInProgress = false;
updateStats();
if (decodeQueue.length) pumpDecodeQueue();
}
}
function rgbToImageData(header, payload) {
const width = Number(header.width), height = Number(header.height);
const channels = Number(header.channels), count = Number(header.num_frames);
const frameBytes = Number(header.bytes_per_frame);
const src = payload instanceof Uint8Array ? payload : new Uint8Array(payload);
const items = [];
for (let f = 0; f < count; f++) {
const img = ctx.createImageData(width, height);
let s = f * frameBytes, d = 0;
for (let p = 0; p < width * height; p++) {
img.data[d++] = src[s++];
img.data[d++] = src[s++];
img.data[d++] = src[s++];
if (channels > 3) s += channels - 3;
img.data[d++] = 255;
}
items.push({ image: img, chunk: header.chunk_index });
}
return items;
}
function rgbaToImageData(header, payload) {
const width = Number(header.width), height = Number(header.height);
const count = Number(header.num_frames);
const frameBytes = Number(header.bytes_per_frame);
const src = payload instanceof Uint8Array ? payload : new Uint8Array(payload);
const items = [];
for (let f = 0; f < count; f++) {
const offset = f * frameBytes;
const imageBytes = new Uint8ClampedArray(
src.buffer,
src.byteOffset + offset,
frameBytes,
);
items.push({ image: new ImageData(imageBytes, width, height), chunk: header.chunk_index });
}
return items;
}
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 restoreDeltaGzipRawRgb(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" ? lastRawRgbFrame : null;
if (header.delta_reference === "previous-frame" && !previous) {
throw new Error("Missing previous frame for delta payload");
}
for (let f = 0; f < count; f++) {
const current = f * frameBytes;
if (previous) {
for (let i = 0; i < frameBytes; i++) {
restored[current + i] ^= previous[i];
}
}
previous = restored.slice(current, current + frameBytes);
}
return restored;
}
async function framePayloadToImageData(header, payload) {
let rawPayload;
const isRgba = header.content_type === RAW_RGBA_DELTA_GZIP_CONTENT_TYPE;
if (
header.content_type === WEBP_FRAME_CONTENT_TYPE ||
header.content_type === JPEG_FRAME_CONTENT_TYPE
) {
return encodedImageToImageData(header, payload);
} else if (header.content_type === RAW_RGB_CONTENT_TYPE) {
rawPayload = payload instanceof Uint8Array ? payload : new Uint8Array(payload);
} else if (header.content_type === RAW_RGB_DELTA_GZIP_CONTENT_TYPE) {
rawPayload = await restoreDeltaGzipRawRgb(header, payload);
} else if (isRgba) {
rawPayload = await restoreDeltaGzipRawRgb(header, payload);
} else {
throw new Error(`Unsupported content type ${header.content_type}`);
}
const frameBytes = Number(header.bytes_per_frame);
const frameCount = Number(header.num_frames);
if (frameCount > 0) {
const offset = (frameCount - 1) * frameBytes;
lastRawRgbFrame = rawPayload.slice(offset, offset + frameBytes);
}
if (isRgba) {
return rgbaToImageData(header, rawPayload);
}
return rgbToImageData(header, rawPayload);
}
function isEncodedPreviewContentType(contentType) {
return (
contentType === WEBP_FRAME_CONTENT_TYPE ||
contentType === JPEG_FRAME_CONTENT_TYPE
);
}
async function encodedImageToImageData(header, payload) {
const framePayloads = splitEncodedPayload(header, payload);
if (typeof createImageBitmap === "function") {
try {
return await Promise.all(framePayloads.map(async (framePayload) => ({
image: await createImageBitmap(new Blob([framePayload], { type: header.content_type })),
chunk: header.chunk_index,
})));
} catch (error) {
return Promise.all(framePayloads.map((framePayload) => (
encodedImageElementFallback(
new Blob([framePayload], { type: header.content_type }),
header,
error,
)
)));
}
}
return Promise.all(framePayloads.map((framePayload) => (
encodedImageElementFallback(
new Blob([framePayload], { type: header.content_type }),
header,
new Error("createImageBitmap unavailable"),
)
)));
}
function splitEncodedPayload(header, payload) {
const bytes = payload instanceof Uint8Array ? payload : new Uint8Array(payload);
const lengths = Array.isArray(header.payload_lengths) && header.payload_lengths.length
? header.payload_lengths.map(Number)
: [bytes.byteLength];
const payloads = [];
let offset = 0;
for (const length of lengths) {
payloads.push(bytes.buffer.slice(
bytes.byteOffset + offset,
bytes.byteOffset + offset + length,
));
offset += length;
}
return payloads;
}
async function encodedImageElementFallback(blob, header, createBitmapError) {
const url = URL.createObjectURL(blob);
try {
const image = await loadImageElement(url, createBitmapError);
if (
scratchCanvas.width !== image.naturalWidth ||
scratchCanvas.height !== image.naturalHeight
) {
scratchCanvas.width = image.naturalWidth;
scratchCanvas.height = image.naturalHeight;
}
scratchCtx.drawImage(image, 0, 0);
return {
image: scratchCtx.getImageData(0, 0, image.naturalWidth, image.naturalHeight),
chunk: header.chunk_index,
};
} finally {
URL.revokeObjectURL(url);
}
}
function loadImageElement(url, createBitmapError) {
return new Promise((resolve, reject) => {
const image = new Image();
image.decoding = "async";
image.onload = () => resolve(image);
image.onerror = () => reject(createBitmapError);
image.src = url;
});
}
function handleEncodedPreviewDecodeError(error, header, data, payloadBytes) {
encodedDecodeErrors += 1;
const signature = payloadSignature(data);
const mode = shortPayloadMode(header.content_type);
const message = error?.message || "encoded preview decode failed";
$("decodeText").textContent = `drop ${encodedDecodeErrors}`;
setStatus("Decode dropped", "error");
addHistory(
`decode drop c${header.chunk_index} ${mode} ${formatBytes(payloadBytes)} ${signature} · ${message}`,
);
}
function payloadSignature(data) {
let bytes;
if (data instanceof Uint8Array) {
bytes = data.subarray(0, Math.min(12, data.byteLength));
} else if (data instanceof ArrayBuffer) {
bytes = new Uint8Array(data, 0, Math.min(12, data.byteLength));
} else {
return "";
}
return Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
async function payloadToArrayBuffer(data) {
if (data instanceof ArrayBuffer) return data;
if (data instanceof Uint8Array) {
return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
}
return data.arrayBuffer();
}
function drawFrame(image, { close = true, markRendered = true } = {}) {
const sourceWidth = image.width;
const sourceHeight = image.height;
let drawSource = image;
if (image instanceof ImageData) {
if (scratchCanvas.width !== sourceWidth || scratchCanvas.height !== sourceHeight) {
scratchCanvas.width = sourceWidth;
scratchCanvas.height = sourceHeight;
}
scratchCtx.putImageData(image, 0, 0);
drawSource = scratchCanvas;
}
if (canvas.width !== sourceWidth || canvas.height !== sourceHeight) {
canvas.width = sourceWidth;
canvas.height = sourceHeight;
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(drawSource, 0, 0, sourceWidth, sourceHeight);
if (markRendered) renderedPreviewFrames += 1;
setPreviewState("live");
if (close && !(image instanceof ImageData)) image.close?.();
}
function renderLoop(now) {
const decision = playbackController.render(now, {
hasPendingInput: hasPendingPlaybackInput(),
});
closeFrames(decision.droppedFrames);
if (decision.action === "draw") {
const item = decision.frame;
drawFrame(item.image);
fpsSamples.push(now);
fpsSamples = fpsSamples.filter((t) => now - t < 1000);
const renderedFps = String(fpsSamples.length);
$("renderFps").textContent = renderedFps;
$("chunkText").textContent = `chunk ${item.chunk}`;
lastDisplayLagMs = now - (item.receivedAt || now);
$("decodeText").textContent = `${Math.round(item.decodeMs || lastDecodeMs)} ms`;
$("displayLagText").textContent = `${(lastDisplayLagMs / 1000).toFixed(1)} s`;
updateStats();
} else if (decision.action === "hold") {
updateStats();
}
requestAnimationFrame(renderLoop);
}
async function readFirstFrame() {
const file = $("firstFrame").files[0];
if (file) return new Uint8Array(await file.arrayBuffer());
if (selectedReferenceBytes) return selectedReferenceBytes;
if (selectedReferenceUrl) {
// 相对路径 / 同源 URL:浏览器取回字节再发,后端无需解析 URL
try {
const resp = await fetch(selectedReferenceUrl);
if (resp.ok) return new Uint8Array(await resp.arrayBuffer());
} catch (_) {}
return selectedReferenceUrl; // 跨域远端图 fetch 失败时回退原行为
}
return undefined;
}
function drawReferencePreviewFromImageSource(src, label) {
const preview = $("referencePreview");
const previewCtx = preview.getContext("2d", { alpha: false });
previewCtx.fillStyle = "#e5e7df";
previewCtx.fillRect(0, 0, preview.width, preview.height);
$("referenceName").textContent = label;
const img = new Image();
img.onload = () => {
const scale = Math.min(preview.width / img.width, preview.height / img.height);
const w = img.width * scale, h = img.height * scale;
previewCtx.fillRect(0, 0, preview.width, preview.height);
previewCtx.drawImage(img, (preview.width - w) / 2, (preview.height - h) / 2, w, h);
if (src.startsWith("blob:")) URL.revokeObjectURL(src);
};
img.onerror = () => {
if (src.startsWith("blob:")) URL.revokeObjectURL(src);
};
img.src = src;
}
function drawReferencePreview(file) {
selectedReferenceBytes = null;
selectedReferenceUrl = "";
selectedReferenceLabel = file ? file.name : "";
if (!file) return;
drawReferencePreviewFromImageSource(URL.createObjectURL(file), file.name);
}
async function setPresetReference(preset) {
selectedReferenceBytes = null;
selectedReferenceUrl = preset.referenceUrl;
selectedReferenceLabel = preset.source;
$("firstFrame").value = "";
drawReferencePreviewFromImageSource(preset.referenceUrl, selectedReferenceLabel);
}
function showError(error) {
setStatus("Reference load failed", "error");
if (!renderedPreviewFrames) setPreviewState("idle");
addHistory(error.message || "reference load failed");
}
function abortCurrentSession(reason = "session closed by client", {
clearFrames = true,
expectedClose = true,
keepConnectDisabled = false,
} = {}) {
const socket = ws;
ws = null;
streamEpoch++;
clearQueueOnClose = clearFrames;
socketCloseExpected = expectedClose;
controlStateController?.reset({ sendRelease: false });
pendingHeader = null;
rejectPendingDecodes("session aborted");
resetDecoderState();
if (clearFrames) {
clearFrameQueue();
updateStats();
}
if (!socket) {
clearQueueOnClose = false;
if (!keepConnectDisabled) $("connectBtn").disabled = false;
setStatus("Closed");
if (!renderedPreviewFrames) setPreviewState("idle");
return null;
}
if (!keepConnectDisabled) $("connectBtn").disabled = false;
setStatus(expectedClose ? "Closing" : "Aborting");
if (!renderedPreviewFrames) setPreviewState("idle");
addHistory(reason);
socket.close(expectedClose ? 1000 : 1011, reason.slice(0, 120));
return socket;
}
function closeSession(reason = "session closed by client", clearFrames = true) {
abortCurrentSession(reason, { clearFrames, expectedClose: true });
}
function waitForSocketClose(socket, timeoutMs = RECONNECT_CLOSE_TIMEOUT_MS) {
return new Promise((resolve) => {
if (!socket || socket.readyState === WebSocket.CLOSED) {
resolve();
return;
}
const finish = () => {
socket.removeEventListener("close", finish);
window.clearTimeout(timer);
resolve();
};
const timer = window.setTimeout(finish, timeoutMs);
socket.addEventListener("close", finish, { once: true });
socket.close(1000, "replace session");
});
}
async function connect() {
$("connectBtn").disabled = true;
setStatus("Preparing");
setPreviewState("waiting");
addHistory("preparing session");
try {
if (ws && ws.readyState !== WebSocket.CLOSED) {
setStatus("Replacing");
const oldSocket = abortCurrentSession("closing previous socket before reconnect", {
keepConnectDisabled: true,
});
await waitForSocketClose(oldSocket);
}
resetStreamStats();
const epoch = ++streamEpoch;
if (!$("firstFrame").files[0] && !selectedReferenceBytes && !selectedReferenceUrl) {
await setPresetReference(presets[0]);
}
const firstFrame = await readFirstFrame();
if (!firstFrame) {
setStatus("Pick a reference", "error");
setPreviewState("idle");
addHistory("reference image required");
$("connectBtn").disabled = false;
return;
}
const previewTransportParams = readPreviewTransportParams();
const frameInterpolationParams = readFrameInterpolationParams();
const superResolutionParams = readSuperResolutionParams();
const init = compact({
type: "init",
model: $("model").value,
prompt: $("prompt").value,
size: $("size").value,
fps: Number($("fps").value || DEFAULT_TARGET_FPS),
num_frames: Number($("numFrames").value),
seed: Number($("seed").value),
num_inference_steps: Number($("steps").value),
guidance_scale: Number($("guidance").value),
realtime_causal_sink_size: readOptionalInteger("sinkSize"),
realtime_causal_kv_cache_num_frames: readOptionalInteger("windowFrames"),
max_chunks: $("continuous").checked ? undefined : 1,
first_frame: firstFrame,
...previewTransportParams,
...frameInterpolationParams,
...superResolutionParams,
});
document.activeElement?.blur?.();
canvas.tabIndex = 0;
canvas.focus();
const socket = new WebSocket($("serverUrl").value);
ws = socket;
socket.binaryType = "arraybuffer";
socketHadError = false;
socketCloseExpected = false;
socketServerError = "";
socket.onopen = () => {
if (epoch !== streamEpoch) return;
socket.send(pack(init));
setStatus("Starting", "live");
addHistory(
`session started with ${selectedReferenceLabel || "uploaded reference"}`
);
};
socket.onclose = (event) => {
if (epoch !== streamEpoch) return;
if (ws === socket) ws = null;
$("connectBtn").disabled = false;
if (clearQueueOnClose) {
clearFrameQueue();
updateStats();
}
clearQueueOnClose = false;
const reason = event.reason ? ` · ${event.reason}` : "";
const closeText = `socket closed code=${event.code}${reason}`;
const normalClose = event.code === 1000 || event.code === 1001;
if (socketServerError) {
setStatus("Server closed", "error");
addHistory(`${closeText} · ${socketServerError}`);
} else if (socketHadError && !socketCloseExpected && !normalClose) {
setStatus("Socket closed", "error");
addHistory(`${closeText} · transport error`);
} else {
setStatus("Closed");
addHistory(closeText);
}
if (!renderedPreviewFrames) setPreviewState("idle");
socketCloseExpected = false;
};
socket.onerror = () => {
if (epoch !== streamEpoch) return;
if (!socketCloseExpected) {
socketHadError = true;
$("connectBtn").disabled = false;
}
};
socket.onmessage = (event) => {
if (epoch !== streamEpoch) return;
try {
receive(event.data, epoch);
} catch (error) {
handleReceiveError(error, epoch);
}
};
} catch (error) {
$("connectBtn").disabled = false;
setStatus("Init failed", "error");
if (!renderedPreviewFrames) setPreviewState("idle");
addHistory(error.message || "init failed");
}
}
function handleReceiveError(error, epoch) {
if (epoch !== streamEpoch) return;
setStatus("Receive failed", "error");
addHistory(error.message || "receive failed");
abortCurrentSession(error.message || "receive failed", {
clearFrames: false,
expectedClose: false,
});
}
function receive(data, epoch) {
if (!pendingHeader) {
const message = unpack(new Uint8Array(data));
message.__received_at = performance.now();
if (message.type === "error") {
socketServerError = message.content || "unknown";
setStatus(socketServerError, "error");
addHistory(`server error: ${socketServerError}`);
return;
}
if (message.type === "chunk_stats") {
updateServerChunkStats(message);
return;
}
if (message.type === "frame_batch") {
const payload = message.payload;
delete message.payload;
enqueueDecodeBatch(message, payload, epoch);
if (!renderedPreviewFrames) setStatus("Receiving", "live");
return;
}
pendingHeader = message;
if (pendingHeader && !renderedPreviewFrames) setStatus("Receiving", "live");
return;
}
const header = pendingHeader;
pendingHeader = null;
enqueueDecodeBatch(header, data, epoch);
}
async function decodeAndEnqueueFrameBatch(header, data, epoch) {
const chunkFrameCount = Number(header.num_frames || 0);
const payloadBytes = data.byteLength || data.size || 0;
let decodedFrames;
try {
decodedFrames = await decodeFrameBatch(header, data);
if (isEncodedPreviewContentType(header.content_type)) encodedDecodeErrors = 0;
} catch (error) {
if (!isEncodedPreviewContentType(header.content_type)) throw error;
handleEncodedPreviewDecodeError(error, header, data, payloadBytes);
return;
}
if (epoch !== streamEpoch) {
for (const item of decodedFrames) item.image?.close?.();
return;
}
const now = performance.now();
if (!renderedPreviewFrames && decodedFrames.length) {
drawFrame(decodedFrames[0].image, { close: false, markRendered: false });
}
// record source frames before preview playback can hold or drop for latency
recordDecodedFrameBatch(decodedFrames);
const enqueueResult = playbackController.enqueueDecodedFrames(header, decodedFrames, now);
closeFrames(enqueueResult.droppedFrames);
if (enqueueResult.cutover?.latencyMs) {
const eventLatency = enqueueResult.cutover.latencyMs / 1000;
$("latencyText").textContent = `${eventLatency.toFixed(1)}s · event`;
}
frames += chunkFrameCount;
bytes += payloadBytes;
$("payloadMode").textContent = header.encoding || "raw RGB";
updateOutputSizeFromHeader(header);
setStatus("Live", "live");
updateStats();
}
function updateServerChunkStats(stats) {
const rawWrite = Number(stats.raw_write_ms || 0) / 1000;
const wsWrite = Number(stats.ws_write_ms || 0) / 1000;
const chunkTotal = Number(stats.chunk_total_ms || 0) / 1000;
const numFrames = Number(stats.num_frames || 0);
const chunkIndex = Number(stats.chunk_index || 0);
const targetFps = previewPlaybackTargetFps();
const theoreticalFps = chunkTotal > 0 ? numFrames / chunkTotal : 0;
const playback = playbackController.observeServerStats(stats, performance.now());
const realtimeRatio = targetFps > 0 ? theoreticalFps / targetFps : 0;
const isWarmupChunk =
chunkIndex === 0 && theoreticalFps > 0 && theoreticalFps < targetFps * 0.8;
$("serverSendText").textContent = `raw ${rawWrite.toFixed(2)}s · ws ${wsWrite.toFixed(2)}s`;
$("chunkPayloadText").textContent = `${formatBytes(stats.ws_payload_bytes || 0)} · ${numFrames}f`;
$("theoreticalFpsText").textContent = isWarmupChunk
? `warmup · ${chunkTotal.toFixed(2)}s`
: theoreticalFps > 0
? `${playback.sourceFps.toFixed(1)} fps · ${realtimeRatio.toFixed(2)}x`
: "-";
if (chunkTotal > 0) {
$("latencyText").textContent = `${chunkTotal.toFixed(2)}s · ${playback.sourceFps.toFixed(1)}fps`;
}
if (stats.content_type) $("payloadMode").textContent = shortPayloadMode(stats.content_type);
}
function sendEvent(kind, payload, historyText = null) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
addHistory(`${historyText || `${kind} event`} · socket not open`);
return null;
}
const eventId = nextEventId++;
ws.send(pack({ type: "event", kind, payload, event_id: eventId }));
if (kind === "camera_actions" || kind === "prompt") {
playbackController.noteInputEvent(eventId, performance.now(), {
cutoverMode: cameraActionHasActiveMotion(payload) || kind === "prompt" ? "motion" : "settle",
});
updateStats();
setStatus("Updating", "live");
}
addHistory(`${historyText || `${kind} event sent`} · event#${eventId}`);
return eventId;
}
function cameraActionHasActiveMotion(payload) {
const transitions = payload?.transitions || [];
const finalTransition = transitions[transitions.length - 1];
return Array.isArray(finalTransition?.actions) && finalTransition.actions.length > 0;
}
function sendCameraControlTransitions(transitions) {
if (!transitions.length) return null;
const payload = {
mode: "state",
transitions: transitions.map((transition) => ({
actions: transition.actions,
client_ts_ms: transition.clientTsMs,
})),
};
return sendEvent(
"camera_actions",
payload,
describeCameraStateEvent(transitions),
);
}
async function applyPreset(preset, options = {}) {
const sendRuntimeEvents = options.sendRuntimeEvents
?? Boolean(ws && ws.readyState === WebSocket.OPEN);
selectedPreset = preset;
$("prompt").value = preset.prompt;
$("size").value = preset.size;
$("fps").value = preset.fps;
updateOutputSizeText();
syncPlaybackTargetFps();
await setPresetReference(preset);
if (sendRuntimeEvents) {
sendEvent("prompt", preset.prompt, `prompt update · ${preset.name}`);
}
addHistory(`preset ${preset.name}`);
}
function describeCameraStateEvent(transitions) {
const parts = transitions
.map((transition) => describeControlActions(transition.actions))
.join(" -> ");
return `camera state · ${parts} · transitions=${transitions.length}`;
}
function describeControlActions(actions) {
return actions.map((action) => describeControlAction(action)).join(" + ") || "No-op";
}
function describeControlAction(action, samples = 1) {
const meta = CONTROL_ACTION_META[action];
if (!meta) return `${action} (custom)`;
const distance = describeControlDistance(meta.amount, samples);
return `${meta.label} [${meta.type}, ${meta.axis}, ${distance}]`;
}
function describeControlDistance(amount, samples) {
const match = /^([0-9.]+)(deg)?\/frame$/.exec(amount);
if (!match) return amount;
const perFrame = Number(match[1]);
const unit = match[2] || "";
const total = perFrame * Math.max(1, Number(samples || 1));
return `${amount} x ${samples} frames = ${formatControlDistance(total, unit)}`;
}
function formatControlDistance(value, unit) {
if (unit === "deg") return `${value.toFixed(0)}deg`;
return value.toFixed(2);
}
function modelsUrlFromServerUrl(serverUrl) {
const url = new URL(serverUrl, window.location.href);
if (url.protocol === "ws:") url.protocol = "http:";
if (url.protocol === "wss:") url.protocol = "https:";
url.pathname = "/v1/models";
url.search = "";
url.hash = "";
return url.toString();
}
function firstServedModelInfo(payload) {
if (Array.isArray(payload?.data) && payload.data.length > 0) return payload.data[0];
if (payload && typeof payload === "object") return payload;
return null;
}
function servedModelId(info) {
return String(info?.id || info?.model || info?.root || "");
}
function presetForModelInfo(info) {
const id = servedModelId(info).toLowerCase();
if (!id) return null;
return presets.find((preset) => (
preset.model && id.includes(preset.model.toLowerCase())
)) || null;
}
async function queryServerModelInfo(options = {}) {
const applyPresetForModel = options.applyPresetForModel ?? true;
let info;
try {
const response = await fetch(modelsUrlFromServerUrl($("serverUrl").value), {
cache: "no-store",
});
if (!response.ok) throw new Error(`/v1/models ${response.status}`);
info = firstServedModelInfo(await response.json());
} catch (error) {
addHistory(`model query failed · ${error.message || "unknown"}`);
return null;
}
if (!info) return null;
const modelId = servedModelId(info);
const preset = presetForModelInfo(info);
if (preset && applyPresetForModel && preset !== selectedPreset) {
await applyPreset(preset, { sendRuntimeEvents: false });
}
if (modelId) $("model").value = modelId;
addHistory(
preset
? `server model · ${preset.name}`
: `server model · ${modelId || "unknown"}`,
);
return info;
}
function enhancePrompt() {
const suffix = " high-fidelity temporal consistency, stable camera geometry, natural motion, clean lighting.";
if (!$("prompt").value.includes("temporal consistency")) {
$("prompt").value = `${$("prompt").value.trim()},${suffix}`;
}
}
function compact(obj) {
return Object.fromEntries(
Object.entries(obj).filter(([, v]) => v !== undefined && v !== "" && v !== null)
);
}
function readOptionalInteger(id) {
const value = $(id).value;
if (value === "") return undefined;
return Number(value);
}
function readPreviewTransportParams() {
const outputFormat = $("transportFormat").value;
const outputQuality = Number($("transportQuality").value || DEFAULT_PREVIEW_OUTPUT_QUALITY);
if (!outputFormat) return {};
const params = {
realtime_output_format: outputFormat,
realtime_output_pacing: true,
};
if (outputFormat === "webp" || outputFormat === "jpeg") {
params.output_compression = outputQuality;
if ($("superResolution").checked && $("frameInterpolation").checked) {
const baseSize = parseSizeValue($("size").value);
if (baseSize?.width) params.realtime_preview_max_width = baseSize.width;
}
}
return params;
}
function tunePreviewQualityForPostprocess() {
if ($("transportFormat").value !== "webp") return;
const currentQuality = Number($("transportQuality").value || DEFAULT_PREVIEW_OUTPUT_QUALITY);
let qualityCap = MAX_WEBP_PREVIEW_OUTPUT_QUALITY;
if ($("frameInterpolation").checked && $("superResolution").checked) {
qualityCap = HEAVY_PREVIEW_OUTPUT_QUALITY;
} else if ($("frameInterpolation").checked) {
qualityCap = SMOOTH_PREVIEW_OUTPUT_QUALITY;
} else if ($("superResolution").checked) {
qualityCap = SR_PREVIEW_OUTPUT_QUALITY;
}
if (currentQuality > qualityCap) $("transportQuality").value = String(qualityCap);
}
function readFrameInterpolationParams() {
if (!$("frameInterpolation").checked) return {};
return {
enable_frame_interpolation: true,
frame_interpolation_exp: DEFAULT_FRAME_INTERPOLATION_EXP,
frame_interpolation_scale: DEFAULT_FRAME_INTERPOLATION_SCALE,
};
}
function readUpscalingScale() {
return Number($("upscalingScale").value || DEFAULT_UPSCALING_SCALE);
}
function readSuperResolutionParams() {
if (!$("superResolution").checked) return {};
const params = {
enable_upscaling: true,
upscaling_scale: readUpscalingScale(),
};
const modelPath = $("upscalingModel").value;
if (modelPath) params.upscaling_model_path = modelPath;
return params;
}
function parseSizeValue(sizeText) {
const match = /^(\d+)\s*x\s*(\d+)$/i.exec(String(sizeText || "").trim());
if (!match) return null;
return {
width: Number(match[1]),
height: Number(match[2]),
};
}
function updateOutputSizeText(width = null, height = null) {
let outputWidth = Number(width || 0);
let outputHeight = Number(height || 0);
const srEnabled = $("superResolution").checked;
const scale = srEnabled ? readUpscalingScale() : 1;
if (!outputWidth || !outputHeight) {
const base = parseSizeValue($("size").value);
if (base) {
outputWidth = base.width * scale;
outputHeight = base.height * scale;
}
}
$("outputSizeText").textContent = outputWidth && outputHeight
? `${outputWidth}x${outputHeight}${srEnabled ? ` · SR ${scale}x` : ""}`
: "-";
}
function updateOutputSizeFromHeader(header) {
const width = Number(header.source_width || header.width || 0);
const height = Number(header.source_height || header.height || 0);
if (!width || !height) return;
updateOutputSizeText(width, height);
if (header.preview_width && header.preview_height) {
$("outputSizeText").textContent += ` · preview ${header.preview_width}x${header.preview_height}`;
}
}
function updateSuperResolutionControls() {
const disabled = !$("superResolution").checked;
$("upscalingScale").disabled = disabled;
$("upscalingModel").disabled = disabled;
updateOutputSizeText();
}
function setPreviewScale(value) {
if (!previewFrame) return;
const scale = Math.max(80, Math.min(170, Number(value || DEFAULT_PREVIEW_SCALE)));
$("previewScale").value = String(scale);
$("previewScaleText").textContent = `${scale}%`;
if (previewScaleFrame) cancelAnimationFrame(previewScaleFrame);
previewScaleFrame = requestAnimationFrame(() => {
previewScaleFrame = 0;
previewFrame.style.setProperty("--preview-scale", String(scale / 100));
});
}
function selectedTransportLabel() {
const select = $("transportFormat");
return select.options[select.selectedIndex]?.textContent || "raw RGB";
}
function shortPayloadMode(contentType) {
if (contentType === WEBP_FRAME_CONTENT_TYPE) return "webp";
if (contentType === JPEG_FRAME_CONTENT_TYPE) return "jpeg";
if (contentType === RAW_RGB_DELTA_GZIP_CONTENT_TYPE) return "delta-gzip";
if (contentType === RAW_RGB_CONTENT_TYPE) return "raw RGB";
return contentType;
}
function formatBytes(value) {
return `${(Number(value || 0) / 1048576).toFixed(1)} MB`;
}
function formatMs(value) {
const ms = Number(value || 0);
if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.round(ms)}ms`;
}
function renderPresets() {
$("presetList").innerHTML = "";
presets.forEach((preset) => {
const btn = document.createElement("button");
btn.className = "preset";
btn.dataset.tone = preset.tone;
btn.innerHTML = `<img class="preset-thumb" src="${preset.referenceUrl}" alt="" loading="lazy" /><b>${preset.name}</b><span>${preset.source} · ${preset.size} · ${preset.fps}fps</span>`;
btn.onclick = () => applyPreset(preset).catch(showError);
$("presetList").appendChild(btn);
});
}
async function applyQueryParams() {
const params = new URLSearchParams(window.location.search);
const server = params.get("server");
if (server) $("serverUrl").value = server;
const model = params.get("model");
if (model) $("model").value = model;
$("transportFormat").value = params.get("transport") || DEFAULT_PREVIEW_OUTPUT_FORMAT;
$("transportQuality").value = params.get("quality") || String(DEFAULT_PREVIEW_OUTPUT_QUALITY);
const srParam = params.get("sr");
$("superResolution").checked = srParam === "1" || srParam === "true";
const smoothParam = params.get("smooth");
$("frameInterpolation").checked = smoothParam === "1" || smoothParam === "true";
$("upscalingScale").value = params.get("sr_scale") || String(DEFAULT_UPSCALING_SCALE);
$("upscalingModel").value = params.get("sr_model") || DEFAULT_UPSCALING_MODEL;
tunePreviewQualityForPostprocess();
setPreviewScale(params.get("preview_scale") || params.get("zoom"));
updateSuperResolutionControls();
syncPlaybackTargetFps();
const presetKey = params.get("preset");
let appliedPreset = false;
if (presetKey) {
const normalized = presetKey.toLowerCase();
const preset = presets.find((item) => (
item.name.toLowerCase() === normalized
|| item.name.toLowerCase().replaceAll(" ", "-") === normalized
));
if (preset && preset !== selectedPreset) {
await applyPreset(preset, { sendRuntimeEvents: false });
appliedPreset = true;
}
}
return {
model: Boolean(model),
preset: Boolean(presetKey && appliedPreset),
};
}
function pack(value) {
const out = [];
const bytes = (arr) => {
for (const item of arr) out.push(item);
};
const str = (s) => new TextEncoder().encode(s);
const u16 = (n) => [(n >> 8) & 255, n & 255];
const u32 = (n) => [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255];
const write = (v) => {
if (v === null) return out.push(0xc0);
if (typeof v === "boolean") return out.push(v ? 0xc3 : 0xc2);
if (typeof v === "number") {
if (Number.isInteger(v) && v >= 0 && v < 128) return out.push(v);
if (Number.isInteger(v) && v < 0 && v >= -32) return out.push(0xe0 | (v + 32));
if (Number.isInteger(v) && v >= 0 && v < 256) return bytes([0xcc, v]);
if (Number.isInteger(v) && v >= 0 && v < 65536) return bytes([0xcd, ...u16(v)]);
const b = new ArrayBuffer(9), view = new DataView(b);
view.setUint8(0, 0xcb); view.setFloat64(1, v);
return bytes(new Uint8Array(b));
}
if (typeof v === "string") {
const b = str(v), n = b.length;
if (n < 32) bytes([0xa0 | n]); else if (n < 256) bytes([0xd9, n]); else bytes([0xda, ...u16(n)]);
return bytes(b);
}
if (v instanceof Uint8Array) {
if (v.length < 256) bytes([0xc4, v.length]); else if (v.length < 65536) bytes([0xc5, ...u16(v.length)]); else bytes([0xc6, ...u32(v.length)]);
return bytes(v);
}
if (Array.isArray(v)) {
v.length < 16 ? bytes([0x90 | v.length]) : bytes([0xdc, ...u16(v.length)]);
return v.forEach(write);
}
const entries = Object.entries(v);
entries.length < 16 ? bytes([0x80 | entries.length]) : bytes([0xde, ...u16(entries.length)]);
entries.forEach(([k, val]) => { write(k); write(val); });
};
write(value);
return new Uint8Array(out);
}
function unpack(buf) {
let i = 0;
const text = new TextDecoder();
const read = () => {
const b = buf[i++];
if (b <= 0x7f) return b;
if ((b & 0xe0) === 0xa0) return readStr(b & 0x1f);
if ((b & 0xf0) === 0x80) return readMap(b & 0x0f);
if ((b & 0xf0) === 0x90) return Array.from({ length: b & 0x0f }, read);
if (b === 0xc0) return null;
if (b === 0xc2 || b === 0xc3) return b === 0xc3;
if (b === 0xcc) return buf[i++];
if (b === 0xcd) return (buf[i++] << 8) | buf[i++];
if (b === 0xce) return (buf[i++] * 16777216) + (buf[i++] << 16) + (buf[i++] << 8) + buf[i++];
if (b === 0xca) {
const value = new DataView(buf.buffer, buf.byteOffset + i, 4).getFloat32(0);
i += 4;
return value;
}
if (b === 0xcb) {
const value = new DataView(buf.buffer, buf.byteOffset + i, 8).getFloat64(0);
i += 8;
return value;
}
if (b === 0xc4) return readBin(buf[i++]);
if (b === 0xc5) return readBin((buf[i++] << 8) | buf[i++]);
if (b === 0xc6) {
return readBin(
(buf[i++] * 16777216) + (buf[i++] << 16) + (buf[i++] << 8) + buf[i++],
);
}
if (b === 0xdc) return Array.from({ length: (buf[i++] << 8) | buf[i++] }, read);
if (b === 0xdd) {
return Array.from({
length: (buf[i++] * 16777216) + (buf[i++] << 16) + (buf[i++] << 8) + buf[i++],
}, read);
}
if (b === 0xd9) return readStr(buf[i++]);
if (b === 0xda) return readStr((buf[i++] << 8) | buf[i++]);
if (b === 0xde) return readMap((buf[i++] << 8) | buf[i++]);
throw new Error(`Unsupported msgpack byte ${b}`);
};
const readStr = (n) => text.decode(buf.slice(i, i += n));
const readBin = (n) => buf.subarray(i, i += n);
const readMap = (n) => {
const obj = {};
for (let j = 0; j < n; j++) obj[read()] = read();
return obj;
};
return read();
}
renderPresets();
drawIdle();
setPreviewScale(DEFAULT_PREVIEW_SCALE);
updateSuperResolutionControls();
applyQueryParams()
.then(async (query) => {
if (!query.preset) await applyPreset(presets[0], { sendRuntimeEvents: false });
return query;
})
.then((query) => queryServerModelInfo({
applyPresetForModel: !query.model && !query.preset,
}))
.catch(showError);
requestAnimationFrame(renderLoop);
updateRecordButton();
$("connectBtn").onclick = connect;
$("stopBtn").onclick = () => closeSession();
$("sendPromptBtn").onclick = () => sendEvent("prompt", $("prompt").value);
$("enhanceBtn").onclick = enhancePrompt;
$("recordBtn").onclick = () => {
if (recordingActive) {
stopRecording();
} else {
startRecording();
}
};
$("firstFrame").onchange = () => drawReferencePreview($("firstFrame").files[0]);
$("size").addEventListener("input", () => updateOutputSizeText());
$("fps").addEventListener("input", syncPlaybackTargetFps);
$("superResolution").addEventListener("change", updateSuperResolutionControls);
$("upscalingScale").addEventListener("change", () => updateOutputSizeText());
$("frameInterpolation").addEventListener("change", () => {
tunePreviewQualityForPostprocess();
syncPlaybackTargetFps();
});
$("superResolution").addEventListener("change", tunePreviewQualityForPostprocess);
$("previewScale").addEventListener("input", () => setPreviewScale($("previewScale").value));
$("serverUrl").addEventListener("change", () => {
queryServerModelInfo({ applyPresetForModel: true }).catch(showError);
});
document.querySelectorAll("button").forEach((btn) => {
btn.addEventListener("pointerdown", () => btn.classList.add("is-pressed"));
["pointerup", "pointercancel", "pointerleave", "blur"].forEach((eventName) => {
btn.addEventListener(eventName, () => btn.classList.remove("is-pressed"));
});
});
document.querySelectorAll("[data-action]").forEach((btn) => {
const action = btn.dataset.action;
btn.addEventListener("pointerdown", (event) => {
event.preventDefault();
controlStateController.setAction(action, true);
});
["pointerup", "pointercancel", "pointerleave", "blur"].forEach((eventName) => {
btn.addEventListener(eventName, (event) => {
event.preventDefault();
controlStateController.setAction(action, false);
});
});
});
function isTypingTarget(target) {
return target && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName);
}
function keyboardAction(event) {
return CONTROL_KEY_ACTIONS.get(event.key.toLowerCase()) || null;
}
function setControlButtonActive(action, active) {
document.querySelectorAll(`[data-action="${action}"]`).forEach((btn) => {
btn.classList.toggle("is-key-active", active);
btn.setAttribute("aria-pressed", active ? "true" : "false");
});
}
class ControlStateController {
constructor() {
this.activeActions = new Set();
this.pendingTransitions = [];
this.flushTimer = 0;
}
reset({ sendRelease = false } = {}) {
const hadActions = this.activeActions.size > 0;
this.activeActions.clear();
this.pendingTransitions = [];
this.clearFlushTimer();
this.updateButtons();
if (sendRelease && hadActions) {
this.enqueueTransition();
}
}
setAction(action, active) {
const hadAction = this.activeActions.has(action);
if (active === hadAction) return;
if (active) {
this.activeActions.add(action);
} else {
this.activeActions.delete(action);
}
this.updateButtons();
this.enqueueTransition();
}
releaseAll() {
this.reset({ sendRelease: true });
}
enqueueTransition() {
const actions = Array.from(this.activeActions).sort();
const last = this.pendingTransitions[this.pendingTransitions.length - 1];
if (last && this.sameActions(last.actions, actions)) return;
this.pendingTransitions.push({
actions,
clientTsMs: Math.round(performance.now()),
});
this.compactPendingIfNeeded();
this.scheduleFlush();
}
scheduleFlush() {
if (this.flushTimer) return;
this.flushTimer = window.setTimeout(() => {
this.flushTimer = 0;
this.flush();
}, CONTROL_TRANSITION_FLUSH_DELAY_MS);
}
flush() {
this.clearFlushTimer();
if (!this.pendingTransitions.length) return;
if (ws && ws.bufferedAmount > CONTROL_BUFFERED_AMOUNT_LIMIT) {
this.compactPendingToLatestPulse();
}
const transitions = this.pendingTransitions;
this.pendingTransitions = [];
sendCameraControlTransitions(transitions);
}
compactPendingIfNeeded() {
if (this.pendingTransitions.length <= 8) return;
this.compactPendingToLatestPulse();
}
compactPendingToLatestPulse() {
const final = this.pendingTransitions[this.pendingTransitions.length - 1];
const latestPulse = [...this.pendingTransitions]
.reverse()
.find((transition) => transition.actions.length > 0);
if (latestPulse && !this.sameActions(latestPulse.actions, final.actions)) {
this.pendingTransitions = [latestPulse, final];
} else {
this.pendingTransitions = [final];
}
}
updateButtons() {
CONTROL_ACTION_META_KEYS.forEach((action) => {
setControlButtonActive(action, this.activeActions.has(action));
});
}
sameActions(left, right) {
return left.length === right.length && left.every((item, idx) => item === right[idx]);
}
clearFlushTimer() {
if (!this.flushTimer) return;
window.clearTimeout(this.flushTimer);
this.flushTimer = 0;
}
}
const CONTROL_ACTION_META_KEYS = Object.keys(CONTROL_ACTION_META);
controlStateController = new ControlStateController();
document.addEventListener("keydown", (event) => {
if (isTypingTarget(event.target)) return;
const action = keyboardAction(event);
if (!action) return;
event.preventDefault();
if (event.repeat) return;
controlStateController.setAction(action, true);
});
document.addEventListener("keyup", (event) => {
if (isTypingTarget(event.target)) return;
const action = keyboardAction(event);
if (!action) return;
event.preventDefault();
controlStateController.setAction(action, false);
});
window.addEventListener("blur", () => {
controlStateController.releaseAll();
});
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
controlStateController.releaseAll();
}
});
/* ============================================================================
* 觉梦·三千世界渲染引擎测试台 — 自定义控制层
* 1) 攻击按钮:prompt 事件触发攻击动画,1.5s 后自动恢复场景 prompt
* 2) 视角摇杆:四方向(i/j/k/l),与键盘/按钮状态自动同步
* 3) 画面拖拽:在 viewport 上拖拽移动视角,右下摇杆实时跟随
* ==========================================================================*/
(function initJueMengControls() {
"use strict";
/* ---------- 1. 技能系统(可增删技能 + 自定义快捷键 + 本地持久化) ---------- */
const SKILL_LS_KEY = "juemeng_skills_v1";
const DEFAULT_SKILLS = [
{
id: "skill_atk",
name: "攻击",
key: "KeyG",
prompt:
"the protagonist suddenly performs a fast weapon slash attack, " +
"weapon swinging forward with strong dynamic motion, explosive action",
},
{
id: "skill_jump",
name: "跳跃",
key: "Space",
prompt:
"the protagonist leaps high into the air, the camera rising with the jump, " +
"ground dropping away, strong dynamic upward motion",
},
];
const skillBar = $("skillBar");
const skillList = $("skillList");
const addSkillBtn = $("addSkillBtn");
let skills = loadSkills();
let skillCooldown = false;
let capturingSkillId = null;
function loadSkills() {
try {
const saved = JSON.parse(localStorage.getItem(SKILL_LS_KEY));
if (Array.isArray(saved) && saved.length) return saved;
} catch (err) { /* ignore */ }
return JSON.parse(JSON.stringify(DEFAULT_SKILLS));
}
function saveSkills() {
localStorage.setItem(SKILL_LS_KEY, JSON.stringify(skills));
}
function friendlyKey(code) {
return (code || "?")
.replace(/^Key/, "")
.replace(/^Digit/, "")
.replace(/^Arrow/, "方向");
}
function triggerSkill(skill) {
if (skillCooldown) return;
skillCooldown = true;
const btn = skillBar && skillBar.querySelector(`[data-skill-id="${skill.id}"]`);
if (btn) btn.classList.add("attacking");
const base = $("prompt").value;
sendEvent("prompt", `${base}, ${skill.prompt}`, `${skill.name}`);
setTimeout(() => {
sendEvent("prompt", base, "恢复场景");
if (btn) btn.classList.remove("attacking");
skillCooldown = false;
}, 1500);
}
/* --- 技能按钮栏(舞台中央) --- */
function renderSkillBar() {
if (!skillBar) return;
skillBar.innerHTML = "";
skills.forEach((skill) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "skill-btn";
btn.dataset.skillId = skill.id;
btn.innerHTML =
`<span class="skill-name">${skill.name}</span>` +
`<span class="key-badge">${friendlyKey(skill.key)}</span>`;
btn.addEventListener("click", () => triggerSkill(skill));
skillBar.appendChild(btn);
});
}
/* --- 技能编辑区(左侧面板) --- */
function renderSkillEditor() {
if (!skillList) return;
skillList.innerHTML = "";
skills.forEach((skill) => {
const row = document.createElement("div");
row.className = "skill-row";
const head = document.createElement("div");
head.className = "skill-row-head";
const nameInput = document.createElement("input");
nameInput.className = "skill-name-input";
nameInput.value = skill.name;
nameInput.placeholder = "技能名";
nameInput.addEventListener("input", () => {
skill.name = nameInput.value.trim() || "技能";
saveSkills();
renderSkillBar();
});
const keyBtn = document.createElement("button");
keyBtn.type = "button";
keyBtn.className = "skill-key-btn";
keyBtn.textContent = capturingSkillId === skill.id ? "按键…" : friendlyKey(skill.key);
keyBtn.title = "点击后按任意键设定快捷键";
keyBtn.addEventListener("click", () => {
capturingSkillId = capturingSkillId === skill.id ? null : skill.id;
renderSkillEditor();
});
const delBtn = document.createElement("button");
delBtn.type = "button";
delBtn.className = "skill-del-btn";
delBtn.textContent = "×";
delBtn.title = "删除技能";
delBtn.addEventListener("click", () => {
skills = skills.filter((s) => s.id !== skill.id);
saveSkills();
renderSkillEditor();
renderSkillBar();
});
head.append(nameInput, keyBtn, delBtn);
const promptInput = document.createElement("textarea");
promptInput.className = "skill-prompt-input";
promptInput.rows = 2;
promptInput.value = skill.prompt;
promptInput.placeholder = "动作描述(英文,附加在场景 prompt 后发送)";
promptInput.addEventListener("input", () => {
skill.prompt = promptInput.value;
saveSkills();
});
row.append(head, promptInput);
skillList.appendChild(row);
});
}
if (addSkillBtn) {
addSkillBtn.addEventListener("click", () => {
skills.push({
id: "skill_" + Date.now().toString(36),
name: "新技能",
key: "",
prompt: "",
});
saveSkills();
renderSkillEditor();
renderSkillBar();
});
}
/* --- 快捷键捕获(编辑区"按键…"状态时下一次按键即绑定) --- */
window.addEventListener("keydown", (event) => {
if (capturingSkillId) {
event.preventDefault();
event.stopPropagation();
if (event.code !== "Escape") {
const skill = skills.find((s) => s.id === capturingSkillId);
if (skill) {
skill.key = event.code;
saveSkills();
renderSkillBar();
}
}
capturingSkillId = null;
renderSkillEditor();
return;
}
/* --- 技能快捷键触发(输入框聚焦时不触发) --- */
if (event.target.closest("input, textarea, select")) return;
const skill = skills.find((s) => s.key && s.key === event.code);
if (skill) {
event.preventDefault();
triggerSkill(skill);
}
}, true);
renderSkillEditor();
renderSkillBar();
/* ---------- 2/3. 视角摇杆 + 画面拖拽(共用向量驱动) ---------- */
const stick = $("lookStick");
const knob = $("lookKnob");
const viewport = $("viewport");
const JOY_TRAVEL = 34; // 摇杆头最大行程(px)
const JOY_DEAD = 0.22; // 死区(0~1)
const DRAG_SENSITIVITY = 1 / 90; // 拖拽灵敏度:90px ≈ 满行程
// 统一入口:dx/dy ∈ [-1,1],驱动相机动作 + 摇杆头位置
function applyLookVector(dx, dy) {
controlStateController.setAction("j", dx < -JOY_DEAD); // 左转
controlStateController.setAction("l", dx > JOY_DEAD); // 右转
controlStateController.setAction("i", dy < -JOY_DEAD); // 上抬
controlStateController.setAction("k", dy > JOY_DEAD); // 下压
if (knob) {
knob.style.transform =
`translate(calc(-50% + ${(dx * JOY_TRAVEL).toFixed(1)}px), ` +
`calc(-50% + ${(dy * JOY_TRAVEL).toFixed(1)}px))`;
}
}
/* --- 摇杆本体:按住/拨动 --- */
if (stick) {
let stickPointerId = null;
function vectorFromStickEvent(event) {
const rect = stick.getBoundingClientRect();
let dx = (event.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
let dy = (event.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
const mag = Math.hypot(dx, dy);
if (mag > 1) { dx /= mag; dy /= mag; }
return [dx, dy];
}
stick.addEventListener("pointerdown", (event) => {
event.preventDefault();
stickPointerId = event.pointerId;
stick.setPointerCapture(stickPointerId);
applyLookVector(...vectorFromStickEvent(event));
});
stick.addEventListener("pointermove", (event) => {
if (event.pointerId !== stickPointerId) return;
event.preventDefault();
applyLookVector(...vectorFromStickEvent(event));
});
const releaseStick = (event) => {
if (event.pointerId !== stickPointerId) return;
stickPointerId = null;
applyLookVector(0, 0);
};
["pointerup", "pointercancel"].forEach((name) =>
stick.addEventListener(name, releaseStick)
);
}
/* --- 画面拖拽:拖动 viewport 转视角,摇杆同步跟随 --- */
if (viewport) {
let dragPointerId = null;
let vecX = 0;
let vecY = 0;
let lastX = 0;
let lastY = 0;
viewport.addEventListener("pointerdown", (event) => {
if (dragPointerId !== null) return;
dragPointerId = event.pointerId;
vecX = 0;
vecY = 0;
lastX = event.clientX;
lastY = event.clientY;
viewport.setPointerCapture(dragPointerId);
viewport.classList.add("is-dragging");
});
viewport.addEventListener("pointermove", (event) => {
if (event.pointerId !== dragPointerId) return;
vecX += (event.clientX - lastX) * DRAG_SENSITIVITY;
vecY += (event.clientY - lastY) * DRAG_SENSITIVITY;
lastX = event.clientX;
lastY = event.clientY;
applyLookVector(
Math.max(-1, Math.min(1, vecX)),
Math.max(-1, Math.min(1, vecY))
);
});
const releaseDrag = (event) => {
if (event.pointerId !== dragPointerId) return;
dragPointerId = null;
viewport.classList.remove("is-dragging");
applyLookVector(0, 0);
};
["pointerup", "pointercancel"].forEach((name) =>
viewport.addEventListener(name, releaseDrag)
);
}
})();
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;
}
function splitEncodedPayload(header, payload) {
const bytes = payload instanceof Uint8Array ? payload : new Uint8Array(payload);
const lengths = Array.isArray(header.payload_lengths) && header.payload_lengths.length
? header.payload_lengths.map(Number)
: [bytes.byteLength];
const payloads = [];
let offset = 0;
for (const length of lengths) {
payloads.push(bytes.buffer.slice(
bytes.byteOffset + offset,
bytes.byteOffset + offset + length,
));
offset += length;
}
return payloads;
}
async function encodedFramesToImageBitmaps(header, payload) {
if (typeof createImageBitmap === "undefined") {
throw new Error("This browser does not support worker image decoding");
}
const frames = await Promise.all(splitEncodedPayload(header, payload).map((framePayload) => (
createImageBitmap(new Blob([framePayload], { type: header.content_type }))
)));
return {
width: frames[0]?.width || 0,
height: frames[0]?.height || 0,
frame_type: "bitmap",
frames,
};
}
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 encodedFramesToImageBitmaps(header, payload);
return {
id: header.__decode_id,
width: decoded.width,
height: decoded.height,
chunk: Number(header.chunk_index),
frame_type: decoded.frame_type,
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",
});
}
};
demo/V9.png

3.45 MB

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>觉梦·三千世界 实时推演</title>
<link rel="stylesheet" href="./styles.css?v=juemeng-v4" />
</head>
<body>
<header class="app-header" aria-label="品牌">
<img src="./logo.png" class="app-logo" alt="觉梦 LOGO" />
<h1 class="app-title">觉梦·三千世界</h1>
<span class="app-subtitle">实时推演Reaction</span>
</header>
<main class="shell">
<section class="panel controls" aria-label="Session controls">
<label>服务器<input id="serverUrl" value="ws://114.94.190.4:13669/v1/realtime_video/generate" /></label>
<label>模型<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</div>
<label>世界引导词素<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>尺寸<input id="size" value="832x480" /></label>
<label>帧率<input id="fps" type="number" value="25" min="1" max="60" /></label>
</div>
<div class="split">
<label>帧数<input id="numFrames" type="number" value="9" min="5" step="4" /></label>
<label>种子<input id="seed" type="number" value="42" /></label>
</div>
<div class="split">
<label>步数<input id="steps" type="number" value="4" min="1" /></label>
<label>引导<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>窗口<input id="windowFrames" type="number" value="18" min="1" /></label>
</div>
<div class="split">
<label>传输
<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>质量<input id="transportQuality" type="number" value="95" min="1" max="100" /></label>
</div>
<div class="split output-options">
<label class="toggle-row"><input id="superResolution" type="checkbox" />超分 SR</label>
<label>倍率
<select id="upscalingScale">
<option value="2" selected>2x</option>
<option value="4">4x</option>
</select>
</label>
</div>
<label>SR 模型
<select id="upscalingModel">
<option value="">Quality x2</option>
<option
value="https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-x4v3.pth"
selected
>
Fast general
</option>
<option value="/scratch/realesr-animevideov3.pth">Fast anime</option>
</select>
</label>
<label class="toggle-row"><input id="frameInterpolation" type="checkbox" />插帧平滑 2x</label>
<label class="toggle-row"><input id="continuous" type="checkbox" checked />持续会话 Continuous</label>
<div class="actions">
<button id="connectBtn" class="primary">开始推演</button>
<button id="stopBtn">关闭会话</button>
</div>
<button id="sendPromptBtn" class="wide">发送提示词更新</button>
<div class="section-title">技能编辑 Skills</div>
<div id="skillList" class="skill-list"></div>
<button id="addSkillBtn" class="wide">+ 添加技能</button>
</section>
<section class="workspace" aria-label="Realtime workspace">
<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>
<button id="recordBtn" class="record-button" type="button" aria-pressed="false" title="世界快照">
<span class="record-button-icon" aria-hidden="true"></span>
<span id="recordLabel">世界快照</span>
<span id="recordDuration" class="record-button-duration">00:00</span>
</button>
<span class="topbar-spacer"></span>
<label class="preview-scale-control">世界时钟
<input id="previewScale" type="range" min="80" max="170" value="120" />
<b id="previewScaleText">120%</b>
</label>
<span class="stage-stat">相界倍率 <b id="outputSizeText"></b>3960x6480</b></span>
<span class="stage-stat">成相体征 <b id="renderFps">0</b> 生命体</span>
<span class="stage-stat">源质 <b id="theoreticalFpsText">-</b></span>
<span class="stage-stat">世界缓存</span> <b id="stageLatencyText">-</b></span>
</div>
<div class="preview-frame">
<canvas id="viewport" width="1280" height="720"></canvas>
<div id="previewOverlay" class="preview-overlay" aria-hidden="true">
<span class="preview-loader"></span>
</div>
</div>
<div class="stage-controls" aria-label="Camera controls">
<div class="control-cluster move-cluster" aria-label="移动">
<span class="control-title">移动 MOVE</span>
<div class="wasd-pad">
<button data-action="w" data-key="W" class="wasd wasd-w" aria-label="前进">W</button>
<button data-action="a" data-key="A" class="wasd wasd-a" aria-label="左移">A</button>
<button data-action="s" data-key="S" class="wasd wasd-s" aria-label="后退">S</button>
<button data-action="d" data-key="D" class="wasd wasd-d" aria-label="右移">D</button>
</div>
</div>
<div class="control-cluster attack-cluster" aria-label="技能">
<span class="control-title">技能 SKILLS</span>
<div id="skillBar" class="skill-bar"></div>
<span class="attack-hint">左侧编辑区可自定义技能与快捷键</span>
</div>
<div class="control-cluster look-cluster" aria-label="视角">
<span class="control-title">视角 LOOK</span>
<div id="lookStick" class="joystick" aria-label="视角摇杆" role="application">
<button data-action="i" data-key="↑" class="joy-zone zone-up" tabindex="-1" aria-hidden="true"></button>
<button data-action="j" data-key="←" class="joy-zone zone-left" tabindex="-1" aria-hidden="true"></button>
<button data-action="k" data-key="↓" class="joy-zone zone-down" tabindex="-1" aria-hidden="true"></button>
<button data-action="l" data-key="→" class="joy-zone zone-right" tabindex="-1" aria-hidden="true"></button>
<div class="joystick-ring" aria-hidden="true"></div>
<div id="lookKnob" class="joystick-knob" aria-hidden="true"></div>
</div>
<span class="joy-hint">拖拽画面 · 或拨动摇杆</span>
</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">引擎规格 Engine</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>
</section>
</main>
<script src="./playback_controller.js?v=realtime-playback-v13"></script>
<script src="./app.js?v=juemeng-v4"></script>
</body>
</html>
logo.png

267 KB

(function attachRealtimePlaybackController(global) {
const DEFAULT_CONFIG = {
targetFps: 25,
minSourceFps: 1,
serverFpsAlphaUp: 0.28,
serverFpsAlphaDown: 0.2,
deliveryFpsAlphaUp: 0.08,
deliveryFpsAlphaDown: 0.55,
targetLeadChunkRatio: 1.5,
minTargetLeadMs: 1500,
maxTargetLeadMs: 2600,
maxLeadExtraChunkRatio: 8.0,
startLeadChunkRatio: 1.85,
minStartLeadMs: 1700,
resumeLeadChunkRatio: 2.5,
minResumeLeadMs: 1000,
maxResumeLeadMs: 1800,
rebufferLeadBoostMs: 250,
rebufferLeadBoostDecayMsPerSecond: 120,
deliveryLeadBoostDecayMsPerSecond: 80,
maxDeliveryLeadBoostMs: 2000,
deliveryStallExpectedMultiplier: 1.25,
receiveStallPlaybackRateMin: 0.65,
receiveStallPlaybackRateSlewPerSecond: 0.5,
lowWaterRatio: 0.4,
playbackRateGain: 0.14,
playbackRateMin: 0.92,
playbackRateMax: 1.08,
emergencyPlaybackRateMin: 0.9,
emergencyPlaybackRateMax: 1.12,
playbackRateSlewPerSecond: 0.08,
eventCutoverMaxMs: 420,
eventCutoverMaxFrames: 10,
settleEventCutoverMaxMs: 720,
settleEventCutoverMaxFrames: 18,
startupWarmupMinMs: 1500,
startupWarmupExpectedMultiplier: 3,
};
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function finitePositive(value) {
return Number.isFinite(value) && value > 0;
}
class RealtimePlaybackController {
constructor(config = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
this.reset({ targetFps: this.config.targetFps });
}
reset({ targetFps } = {}) {
this.targetFps = Math.max(1, Number(targetFps || this.config.targetFps));
this.sourceFps = this.targetFps;
this.serverFps = this.targetFps;
this.deliveryFps = this.targetFps;
this.hasServerSample = false;
this.hasDeliverySample = false;
this.latestChunkDurationMs = 1000 / this.targetFps;
this.latestChunkFrames = 1;
this.playbackRate = 1;
this.renderFps = this.targetFps;
this.queue = [];
this.lastDrawAt = 0;
this.lastRateUpdateAt = 0;
this.renderedFrames = 0;
this.droppedFrames = 0;
this.buffering = true;
this.pendingEventId = 0;
this.pendingEventSentAt = 0;
this.pendingEventCutoverMode = "motion";
this.lastDropReason = "";
this.lastDropAt = 0;
this.lastDropCount = 0;
this.rebufferLeadBoostMs = 0;
this.deliveryLeadBoostMs = 0;
this.chunkReceives = new Map();
this.serverStatChunks = new Set();
this.lastFinalReceiveAt = 0;
this.receiveStalled = false;
}
setTargetFps(targetFps) {
const nextTargetFps = Math.max(1, Number(targetFps || this.config.targetFps));
this.targetFps = nextTargetFps;
if (!this.hasServerSample && !this.hasDeliverySample) {
this.serverFps = nextTargetFps;
this.deliveryFps = nextTargetFps;
this.sourceFps = nextTargetFps;
this.renderFps = nextTargetFps;
} else {
this.serverFps = clamp(this.serverFps, this.config.minSourceFps, nextTargetFps);
this.deliveryFps = clamp(this.deliveryFps, this.config.minSourceFps, nextTargetFps);
this.sourceFps = clamp(this.sourceFps, this.config.minSourceFps, nextTargetFps);
this.renderFps = this.sourceFps * this.playbackRate;
}
this.latestChunkDurationMs = Math.max(this.latestChunkDurationMs, 1000 / this.targetFps);
}
clear() {
const frames = this.queue.splice(0);
this.lastDrawAt = 0;
this.buffering = true;
return frames;
}
noteInputEvent(eventId, now, { cutoverMode = "motion" } = {}) {
this.pendingEventId = Number(eventId || 0);
this.pendingEventSentAt = Number(now || 0);
this.pendingEventCutoverMode = cutoverMode;
}
observeServerStats(stats, now) {
const chunkIndex = Number(stats.chunk_index || 0);
const numFrames = Number(stats.num_frames || 0);
const chunkTotalMs = Number(stats.chunk_total_ms || 0);
if (numFrames > 0 && chunkTotalMs > 0) {
this.serverStatChunks.add(chunkIndex);
if (this.serverStatChunks.size > 128) {
this.serverStatChunks.delete(this.serverStatChunks.values().next().value);
}
const expectedMs = numFrames / Math.max(1, this.targetFps) * 1000;
const isStartupWarmup =
chunkIndex === 0 &&
chunkTotalMs > Math.max(
this.config.startupWarmupMinMs,
expectedMs * this.config.startupWarmupExpectedMultiplier,
);
if (isStartupWarmup) return this.snapshot();
this.#observeFpsSample("server", {
fps: numFrames / (chunkTotalMs / 1000),
frameCount: numFrames,
durationMs: chunkTotalMs,
now,
});
}
return this.snapshot();
}
enqueueDecodedFrames(header, frames, now) {
const chunkIndex = Number(header.chunk_index || 0);
const eventId = Number(header.event_id || 0);
const receivedAt = Number(header.__received_at || now);
const preparedFrames = frames.map((frame) => ({
...frame,
chunk: Number(frame.chunk ?? chunkIndex),
chunkIndex,
eventId,
}));
const droppedFrames = [];
let cutover = null;
if (this.pendingEventId && eventId >= this.pendingEventId) {
const oldEventFrameCount = this.#oldEventFrameCount(eventId);
const graceFrames = this.#eventGraceFrames();
const dropCount = Math.max(0, oldEventFrameCount - graceFrames);
if (dropCount > 0) {
droppedFrames.push(...this.queue.splice(graceFrames, dropCount));
this.#recordDrop(dropCount, "event cutover", now);
}
cutover = {
eventId,
latencyMs: this.pendingEventSentAt ? now - this.pendingEventSentAt : 0,
};
this.pendingEventId = 0;
this.pendingEventSentAt = 0;
this.pendingEventCutoverMode = "motion";
}
this.queue.push(...preparedFrames);
this.#observeChunkArrival(header, preparedFrames.length, receivedAt, now);
droppedFrames.push(...this.#trimBacklog(now));
return { droppedFrames, cutover, snapshot: this.snapshot() };
}
render(now, { hasPendingInput = true } = {}) {
this.#decayRebufferBoost(now);
this.#updateReceiveStallGuard(now);
const droppedFrames = this.#trimBacklog(now);
if (!this.queue.length) {
if (this.renderedFrames && hasPendingInput && !this.buffering) {
this.buffering = true;
this.rebufferLeadBoostMs = Math.max(
this.rebufferLeadBoostMs,
this.config.rebufferLeadBoostMs,
);
}
return { action: "hold", droppedFrames, snapshot: this.snapshot() };
}
const bufferMs = this.bufferDurationMs;
if (
hasPendingInput &&
this.receiveStalled &&
this.renderedFrames &&
bufferMs < this.targetLeadMs
) {
this.buffering = true;
this.lastDrawAt = 0;
return { action: "hold", droppedFrames, snapshot: this.snapshot() };
}
if (
hasPendingInput &&
this.buffering &&
bufferMs < (this.renderedFrames ? this.#resumeLeadMs() : this.#startLeadMs())
) {
this.buffering = true;
this.lastDrawAt = 0;
return { action: "hold", droppedFrames, snapshot: this.snapshot() };
}
if (this.buffering) {
this.buffering = false;
this.lastDrawAt = 0;
}
this.#updatePlaybackRate(now);
const targetMs = 1000 / Math.max(1, this.renderFps);
const elapsedMs = this.lastDrawAt ? now - this.lastDrawAt : targetMs;
if (elapsedMs < targetMs) {
return { action: "wait", droppedFrames, snapshot: this.snapshot() };
}
const frame = this.queue.shift();
this.renderedFrames += 1;
this.lastDrawAt = !this.lastDrawAt || elapsedMs > targetMs * 4
? now
: now - (elapsedMs % targetMs);
return { action: "draw", frame, droppedFrames, snapshot: this.snapshot() };
}
get queuedFrames() {
return this.queue.length;
}
get bufferDurationMs() {
return this.queue.length / Math.max(1, this.sourceFps) * 1000;
}
get targetLeadMs() {
const base = clamp(
this.latestChunkDurationMs * this.config.targetLeadChunkRatio,
this.config.minTargetLeadMs,
this.config.maxTargetLeadMs,
);
return clamp(
base + this.rebufferLeadBoostMs + this.deliveryLeadBoostMs,
this.config.minTargetLeadMs,
this.config.maxTargetLeadMs +
this.config.rebufferLeadBoostMs +
this.config.maxDeliveryLeadBoostMs,
);
}
get maxLeadMs() {
return this.targetLeadMs + this.latestChunkDurationMs * this.config.maxLeadExtraChunkRatio;
}
snapshot() {
return {
queueFrames: this.queue.length,
bufferMs: this.bufferDurationMs,
targetLeadMs: this.targetLeadMs,
maxLeadMs: this.maxLeadMs,
sourceFps: this.sourceFps,
serverFps: this.serverFps,
deliveryFps: this.deliveryFps,
targetFps: this.targetFps,
renderFps: this.renderFps,
playbackRate: this.playbackRate,
droppedFrames: this.droppedFrames,
lastDropAt: this.lastDropAt,
lastDropCount: this.lastDropCount,
buffering: this.buffering,
lastDropReason: this.lastDropReason,
};
}
#observeFpsSample(kind, { fps, frameCount, durationMs, now }) {
if (!finitePositive(fps)) return;
const cappedFps = clamp(fps, this.config.minSourceFps, this.targetFps);
const isDelivery = kind === "delivery";
const currentFps = isDelivery ? this.deliveryFps : this.serverFps;
const hasSample = isDelivery ? this.hasDeliverySample : this.hasServerSample;
let nextFps;
if (!hasSample) {
nextFps = cappedFps;
} else {
const alpha = cappedFps > currentFps
? (isDelivery ? this.config.deliveryFpsAlphaUp : this.config.serverFpsAlphaUp)
: (isDelivery ? this.config.deliveryFpsAlphaDown : this.config.serverFpsAlphaDown);
nextFps = currentFps * (1 - alpha) + cappedFps * alpha;
}
if (isDelivery) {
this.deliveryFps = nextFps;
this.hasDeliverySample = true;
this.#observeDeliveryJitter(frameCount, durationMs);
} else {
this.serverFps = nextFps;
this.hasServerSample = true;
}
const effectiveFps = this.hasServerSample
? this.serverFps
: (this.hasDeliverySample ? this.deliveryFps : this.targetFps);
this.sourceFps = clamp(effectiveFps, this.config.minSourceFps, this.targetFps);
if (!isDelivery || !this.hasServerSample) {
this.latestChunkFrames = Math.max(1, Number(frameCount || this.latestChunkFrames));
this.latestChunkDurationMs = clamp(
Number(durationMs || (this.latestChunkFrames / Math.max(1, this.sourceFps) * 1000)),
1000 / Math.max(1, this.targetFps),
2500,
);
}
this.#updatePlaybackRate(now);
}
#observeDeliveryJitter(frameCount, durationMs) {
if (!this.hasServerSample || !finitePositive(durationMs)) return;
const expectedMs = Number(frameCount || 0) / Math.max(1, this.serverFps) * 1000;
if (expectedMs <= 0) return;
if (durationMs <= expectedMs * this.config.deliveryStallExpectedMultiplier) return;
const boostMs = clamp(
durationMs - expectedMs,
0,
this.config.maxDeliveryLeadBoostMs,
);
this.deliveryLeadBoostMs = Math.max(this.deliveryLeadBoostMs, boostMs);
}
#updateReceiveStallGuard(now) {
this.receiveStalled = false;
if (!this.lastFinalReceiveAt || !this.hasServerSample) return;
const elapsedMs = now - this.lastFinalReceiveAt;
const expectedMs = Math.max(
this.latestChunkDurationMs,
this.latestChunkFrames / Math.max(1, this.serverFps) * 1000,
);
if (elapsedMs <= expectedMs * this.config.deliveryStallExpectedMultiplier) return;
this.receiveStalled = true;
this.deliveryLeadBoostMs = Math.max(
this.deliveryLeadBoostMs,
clamp(elapsedMs - expectedMs, 0, this.config.maxDeliveryLeadBoostMs),
);
}
#observeChunkArrival(header, frameCount, receivedAt, now) {
const chunkIndex = Number(header.chunk_index || 0);
const state = this.chunkReceives.get(chunkIndex) || {
firstAt: receivedAt,
frames: 0,
};
state.frames += Number(frameCount || 0);
state.lastAt = receivedAt;
this.chunkReceives.set(chunkIndex, state);
const frameBatchIndex = Number(header.frame_batch_index || 0);
const numFrameBatches = Number(header.num_frame_batches || 1);
const isFinalFrameBatch =
Boolean(header.is_final_frame_batch) ||
frameBatchIndex + 1 >= numFrameBatches;
if (!isFinalFrameBatch) return;
const durationMs = this.lastFinalReceiveAt
? receivedAt - this.lastFinalReceiveAt
: 0;
this.lastFinalReceiveAt = receivedAt;
if (state.frames > 0 && durationMs > 0) {
this.#observeFpsSample("delivery", {
fps: state.frames / (durationMs / 1000),
frameCount: state.frames,
durationMs,
now,
});
}
this.chunkReceives.delete(chunkIndex);
}
#updatePlaybackRate(now) {
const bufferMs = this.bufferDurationMs;
const targetLeadMs = Math.max(1, this.targetLeadMs);
const error = (bufferMs - targetLeadMs) / targetLeadMs;
const emergency =
bufferMs > this.maxLeadMs ||
bufferMs < targetLeadMs * this.config.lowWaterRatio ||
(this.receiveStalled && bufferMs < targetLeadMs);
const minRate = emergency
? (
this.receiveStalled
? this.config.receiveStallPlaybackRateMin
: this.config.emergencyPlaybackRateMin
)
: this.config.playbackRateMin;
const maxRate = this.receiveStalled && bufferMs < targetLeadMs
? 1
: emergency
? this.config.emergencyPlaybackRateMax
: this.config.playbackRateMax;
const desiredRate = clamp(
1 + error * this.config.playbackRateGain,
minRate,
maxRate,
);
if (!this.lastRateUpdateAt) {
this.playbackRate = desiredRate;
} else {
const dtSeconds = Math.max(0.001, (now - this.lastRateUpdateAt) / 1000);
const slewPerSecond = this.receiveStalled
? this.config.receiveStallPlaybackRateSlewPerSecond
: this.config.playbackRateSlewPerSecond;
const maxDelta = slewPerSecond * dtSeconds;
this.playbackRate = clamp(
desiredRate,
this.playbackRate - maxDelta,
this.playbackRate + maxDelta,
);
}
this.lastRateUpdateAt = now;
this.renderFps = clamp(
this.sourceFps * this.playbackRate,
this.config.minSourceFps,
this.targetFps * this.config.emergencyPlaybackRateMax,
);
}
#trimBacklog(now) {
const droppedFrames = [];
while (this.queue.length && this.bufferDurationMs > this.maxLeadMs) {
const firstChunk = this.queue[0].chunkIndex;
let dropCount = 0;
while (
dropCount < this.queue.length &&
this.queue[dropCount].chunkIndex === firstChunk
) {
dropCount += 1;
}
if (!dropCount || dropCount >= this.queue.length) break;
droppedFrames.push(...this.queue.splice(0, dropCount));
this.#recordDrop(dropCount, "backlog", now);
}
return droppedFrames;
}
#oldEventFrameCount(nextEventId) {
let count = 0;
while (count < this.queue.length && this.queue[count].eventId < nextEventId) {
count += 1;
}
return count;
}
#eventGraceFrames() {
const byTime = Math.max(
1,
Math.round(this.sourceFps * this.#eventCutoverMaxMs() / 1000),
);
const byChunkRatio = this.pendingEventCutoverMode === "settle" ? 1.5 : 0.85;
const byChunk = Math.max(1, Math.round(this.latestChunkFrames * byChunkRatio));
return Math.min(this.#eventCutoverMaxFrames(), byTime, byChunk);
}
#eventCutoverMaxMs() {
return this.pendingEventCutoverMode === "settle"
? this.config.settleEventCutoverMaxMs
: this.config.eventCutoverMaxMs;
}
#eventCutoverMaxFrames() {
return this.pendingEventCutoverMode === "settle"
? this.config.settleEventCutoverMaxFrames
: this.config.eventCutoverMaxFrames;
}
#startLeadMs() {
return Math.max(
this.config.minStartLeadMs,
this.latestChunkDurationMs * this.config.startLeadChunkRatio,
this.targetLeadMs,
);
}
#resumeLeadMs() {
return clamp(
this.latestChunkDurationMs * this.config.resumeLeadChunkRatio,
this.config.minResumeLeadMs,
this.config.maxResumeLeadMs,
);
}
#decayRebufferBoost(now) {
if ((!this.rebufferLeadBoostMs && !this.deliveryLeadBoostMs) || !this.lastRateUpdateAt) return;
const dtSeconds = Math.max(0, (now - this.lastRateUpdateAt) / 1000);
this.rebufferLeadBoostMs = Math.max(
0,
this.rebufferLeadBoostMs - dtSeconds * this.config.rebufferLeadBoostDecayMsPerSecond,
);
this.deliveryLeadBoostMs = Math.max(
0,
this.deliveryLeadBoostMs - dtSeconds * this.config.deliveryLeadBoostDecayMsPerSecond,
);
}
#recordDrop(count, reason, now) {
this.droppedFrames += count;
this.lastDropAt = Number(now || 0);
this.lastDropCount = count;
this.lastDropReason = reason;
}
}
global.RealtimePlaybackController = RealtimePlaybackController;
if (typeof module !== "undefined" && module.exports) {
module.exports = { RealtimePlaybackController };
}
})(typeof globalThis !== "undefined" ? globalThis : window);
const assert = require("node:assert/strict");
const { RealtimePlaybackController } = require("./playback_controller.js");
function frames(count, chunk) {
return Array.from({ length: count }, (_, index) => ({
image: { close() {} },
chunk,
index,
}));
}
function enqueueChunk(controller, {
chunk,
eventId = 0,
frameCount = 12,
durationMs = 480,
now,
}) {
controller.observeServerStats({
chunk_index: chunk,
num_frames: frameCount,
chunk_total_ms: durationMs,
}, now);
return controller.enqueueDecodedFrames({
chunk_index: chunk,
event_id: eventId,
num_frames: frameCount,
__received_at: now,
is_final_frame_batch: true,
}, frames(frameCount, chunk), now);
}
function renderFor(controller, startMs, durationMs) {
let rendered = 0;
for (let now = startMs; now <= startMs + durationMs; now += 16.67) {
const decision = controller.render(now, { hasPendingInput: true });
if (decision.action === "draw") rendered += 1;
}
return rendered;
}
function stableSourceDoesNotDrop() {
const controller = new RealtimePlaybackController({ targetFps: 25 });
let now = 0;
for (let chunk = 0; chunk < 8; chunk += 1) {
now += 480;
enqueueChunk(controller, { chunk, now });
renderFor(controller, now, 480);
}
const snapshot = controller.snapshot();
assert.equal(snapshot.droppedFrames, 0);
assert.ok(snapshot.sourceFps > 24 && snapshot.sourceFps <= 25);
}
function slowServerCapsRenderFps() {
const controller = new RealtimePlaybackController({ targetFps: 25 });
let now = 0;
for (let chunk = 0; chunk < 8; chunk += 1) {
now += 1360;
enqueueChunk(controller, { chunk, durationMs: 1360, now });
renderFor(controller, now, 1360);
}
const snapshot = controller.snapshot();
assert.ok(snapshot.sourceFps > 8 && snapshot.sourceFps < 10);
assert.ok(snapshot.renderFps <= 10);
}
function backlogDropsContiguousOldFrames() {
const controller = new RealtimePlaybackController({ targetFps: 25 });
let now = 100;
for (let chunk = 0; chunk < 13; chunk += 1) {
enqueueChunk(controller, { chunk, now, durationMs: 480 });
now += 20;
}
const snapshot = controller.snapshot();
assert.ok(snapshot.droppedFrames > 0);
assert.equal(snapshot.lastDropReason, "backlog");
}
function eventCutoverKeepsShortGrace() {
const controller = new RealtimePlaybackController({ targetFps: 25 });
enqueueChunk(controller, { chunk: 1, frameCount: 24, durationMs: 960, now: 1000 });
controller.noteInputEvent(5, 1050);
const result = enqueueChunk(controller, {
chunk: 2,
eventId: 5,
frameCount: 12,
durationMs: 480,
now: 1150,
});
assert.ok(result.cutover);
assert.ok(result.droppedFrames.length >= 14);
assert.equal(controller.queue[0].chunk, 1);
assert.equal(controller.queue[0].index, 0);
}
function settleEventCutoverKeepsWiderGrace() {
const controller = new RealtimePlaybackController({ targetFps: 25 });
enqueueChunk(controller, { chunk: 1, frameCount: 24, durationMs: 960, now: 1000 });
controller.noteInputEvent(5, 1050, { cutoverMode: "settle" });
const result = enqueueChunk(controller, {
chunk: 2,
eventId: 5,
frameCount: 12,
durationMs: 480,
now: 1150,
});
assert.ok(result.cutover);
assert.ok(result.droppedFrames.length <= 12);
}
stableSourceDoesNotDrop();
slowServerCapsRenderFps();
backlogDropsContiguousOldFrames();
eventCutoverKeepsShortGrace();
settleEventCutoverKeepsWiderGrace();
: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;
overflow-x: hidden;
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(0, 1fr);
gap: 18px;
width: 100%;
max-width: 100vw;
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; }
.output-options {
align-items: end;
}
.output-options .toggle-row {
min-height: 40px;
margin: 12px 0;
}
.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; }
.workspace {
display: grid;
gap: 18px;
min-width: 0;
max-width: 100%;
}
.stage {
position: relative;
display: grid;
grid-template-rows: auto auto auto auto auto;
align-self: start;
justify-self: center;
min-width: 0;
width: 100%;
max-width: min(1500px, 100%);
overflow: hidden;
border: 1px solid #11140f;
border-radius: 8px;
background: #11140f;
box-shadow: var(--shadow);
}
.preview-frame {
position: relative;
display: grid;
place-items: center;
justify-self: center;
width: min(calc(1040px * var(--preview-scale, 1.2)), 100%);
overflow: hidden;
background: #11140f;
contain: paint;
isolation: isolate;
}
.preview-frame::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
background: linear-gradient(
180deg,
rgba(238, 241, 236, 0.045),
transparent 34%,
rgba(0, 0, 0, 0.18)
);
}
.preview-frame::after {
content: none;
}
.stage[data-preview-state="waiting"] .preview-frame::after {
animation: none;
}
.topbar, .timeline {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
height: 44px;
padding: 0 14px;
color: #e8eadf;
background: rgba(17, 20, 15, 0.88);
font-size: 12px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.topbar > * {
flex: 0 0 auto;
align-self: center;
}
.topbar-spacer { flex: 1; }
.record-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
flex: 0 0 118px;
width: 118px;
min-height: 28px;
height: 28px;
padding: 0 9px;
border-color: rgba(232, 234, 223, 0.22);
background: rgba(238, 241, 236, 0.08);
color: #e8eadf;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.record-button:hover:not(:disabled) {
border-color: rgba(232, 234, 223, 0.44);
background: rgba(238, 241, 236, 0.14);
box-shadow: none;
transform: none;
}
.record-button:active:not(:disabled),
.record-button.is-pressed:not(:disabled),
.record-button:focus-visible {
transform: none;
}
.record-button.is-recording {
border-color: color-mix(in oklch, var(--accent), white 18%);
background: var(--accent);
color: #fffdf7;
}
.record-button.is-saving {
cursor: wait;
opacity: 0.76;
}
#recordLabel {
flex: 0 0 36px;
text-align: left;
}
.record-button-icon {
flex: 0 0 9px;
width: 9px;
height: 9px;
min-width: 9px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 3px rgba(185, 84, 60, 0.16);
}
.record-button.is-recording .record-button-icon {
border-radius: 2px;
background: #fffdf7;
box-shadow: none;
}
.record-button-duration {
display: inline-block;
flex: 0 0 34px;
min-width: 34px;
text-align: right;
color: rgba(232, 234, 223, 0.7);
}
.record-button.is-recording .record-button-duration {
color: rgba(255, 253, 247, 0.86);
}
.preview-scale-control {
display: inline-flex;
align-items: center;
gap: 8px;
flex: 0 0 170px;
min-width: 170px;
margin: 0;
color: rgba(232, 234, 223, 0.72);
font-size: 11px;
line-height: 1;
}
.preview-scale-control input {
width: 92px;
min-width: 72px;
padding: 0;
border: 0;
background: transparent;
accent-color: #eef1ec;
}
.preview-scale-control b {
min-width: 36px;
color: #fffdf7;
font-weight: 650;
}
#statusText {
display: inline-block;
min-width: 92px;
line-height: 1;
}
#chunkText {
display: inline-block;
min-width: 70px;
line-height: 1;
}
.stage-stat {
display: inline-flex;
align-items: center;
gap: 5px;
flex: 0 1 auto;
min-width: 0;
color: rgba(232, 234, 223, 0.72);
line-height: 1;
}
.stage-stat b {
display: inline-block;
color: #fffdf7;
font-weight: 650;
font-variant-numeric: tabular-nums;
}
#outputSizeText { min-width: 206px; }
#renderFps { min-width: 2ch; text-align: right; }
#theoreticalFpsText { min-width: 116px; }
#stageLatencyText { min-width: 120px; }
@media (max-width: 1180px) {
.topbar {
flex-wrap: wrap;
height: auto;
min-height: 44px;
padding: 8px 14px;
row-gap: 7px;
}
.topbar-spacer { display: none; }
.preview-scale-control { flex-basis: 170px; min-width: 170px; }
#outputSizeText { min-width: 156px; }
#theoreticalFpsText { min-width: 100px; }
#stageLatencyText { min-width: 108px; }
}
.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 {
position: relative;
z-index: 1;
display: block;
width: 100%;
height: auto;
max-height: min(calc(56vh * var(--preview-scale, 1.2)), 82vh);
min-height: 0;
object-fit: contain;
image-rendering: auto;
transform: translateZ(0);
}
.preview-overlay {
position: absolute;
inset: 0;
z-index: 3;
display: none;
place-items: center;
pointer-events: none;
background: transparent;
}
.stage[data-preview-state="waiting"] .preview-overlay {
display: grid;
}
.preview-loader {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid rgba(238, 241, 236, 0.22);
border-top-color: rgba(238, 241, 236, 0.82);
animation: previewProgressSpin 0.8s linear infinite;
}
.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(4, minmax(120px, 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: static;
max-height: none;
overflow: visible;
scrollbar-gutter: stable;
}
.preset-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 7px;
min-height: 0;
max-height: 230px;
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: 92px;
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; }
.spec-grid { grid-template-columns: repeat(2, 1fr); }
.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; }
.preview-scale-control { min-width: 160px; }
}
@keyframes previewProgressSpin {
to { transform: rotate(360deg); }
}
/* ============================================================================
* 觉梦·三千世界渲染引擎测试台 — 主题升级
* 深墨青配色 · WASD 简化移动 · 攻击按钮 · 视角摇杆 · 画面拖拽
* 本块规则置于原样式之后,同优先级下以后者为准
* ==========================================================================*/
:root {
--paper: #0d1417;
--panel: #162126;
--ink: #e8f0f1;
--muted: #8fa3ab;
--line: #2b3d44;
--accent: #3e8e8a;
--teal-hi: #7fc4c1;
--gold: #c9a86a;
--danger: #b9554a;
--pressed: #2e5654;
--pressed-border: #4fa3a0;
--pressed-ring: rgba(127, 196, 193, 0.25);
--shadow: 0 18px 60px rgba(0, 0, 0, 0.45);
}
body {
background:
radial-gradient(1100px 500px at 70% -10%, rgba(62, 142, 138, 0.14), transparent 60%),
linear-gradient(90deg, rgba(127, 196, 193, 0.04) 1px, transparent 1px),
linear-gradient(180deg, rgba(127, 196, 193, 0.04) 1px, transparent 1px),
var(--paper);
background-size: auto, 28px 28px, 28px 28px, auto;
}
/* ---------- 品牌区 ---------- */
.brand {
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.brand span {
background: rgba(62, 142, 138, 0.14);
border: 1px solid rgba(127, 196, 193, 0.25);
border-radius: 4px;
padding: 3px 8px;
font-size: 10px;
letter-spacing: 0.28em;
color: var(--teal-hi);
}
.brand strong {
font-size: 19px;
font-weight: 700;
letter-spacing: 0.12em;
background: linear-gradient(100deg, #e8f0f1 30%, var(--teal-hi) 70%, var(--gold));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* ---------- 面板与输入 ---------- */
.panel {
background: linear-gradient(180deg, rgba(22, 33, 38, 0.96), rgba(15, 24, 28, 0.96));
border: 1px solid rgba(127, 196, 193, 0.14);
}
input, textarea, select {
background: #0f181c;
border-color: var(--line);
color: var(--ink);
}
input:focus, textarea:focus, select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(62, 142, 138, 0.22);
}
input::placeholder, textarea::placeholder { color: #5b6f76; }
/* ---------- 通用按钮 ---------- */
button {
background: #1c2b31;
border-color: rgba(127, 196, 193, 0.22);
color: var(--ink);
}
button:hover:not(:disabled) {
border-color: var(--teal-hi);
box-shadow: 0 0 0 3px rgba(127, 196, 193, 0.12), 0 8px 20px rgba(0, 0, 0, 0.35);
}
button.primary {
background: linear-gradient(150deg, #2e5654, #1f3a40);
border-color: rgba(127, 196, 193, 0.45);
}
/* ---------- 舞台控制区:三栏(移动 | 战斗 | 视角) ---------- */
.stage-controls {
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 18px;
background: linear-gradient(180deg, #101a1e, #0c1417);
border-top: 1px solid rgba(127, 196, 193, 0.14);
}
.control-cluster {
grid-template-columns: 1fr;
justify-items: center;
gap: 8px;
}
.control-title {
color: rgba(127, 196, 193, 0.6);
letter-spacing: 0.22em;
font-weight: 600;
}
/* ---------- WASD 简化移动键 ---------- */
.wasd-pad {
display: grid;
grid-template-columns: repeat(3, 46px);
grid-template-rows: repeat(2, 46px);
gap: 6px;
}
.wasd {
position: relative;
border-radius: 10px;
font-weight: 700;
font-size: 15px;
letter-spacing: 0.05em;
background: linear-gradient(180deg, #1d2e34, #142126);
border: 1px solid rgba(127, 196, 193, 0.25);
color: var(--teal-hi);
}
.wasd-w { grid-column: 2; grid-row: 1; }
.wasd-a { grid-column: 1; grid-row: 2; }
.wasd-s { grid-column: 2; grid-row: 2; }
.wasd-d { grid-column: 3; grid-row: 2; }
.wasd:active:not(:disabled),
.wasd.is-pressed:not(:disabled),
.wasd.is-key-active:not(:disabled) {
background: linear-gradient(180deg, #2e5654, #22484a);
border-color: var(--teal-hi);
color: #eafffe;
box-shadow: 0 0 14px rgba(127, 196, 193, 0.45), inset 0 0 8px rgba(127, 196, 193, 0.25);
}
.wasd::after { content: none; } /* 简化表达:去掉角标 */
/* ---------- 攻击按钮 ---------- */
.attack-cluster { align-self: stretch; justify-content: center; }
.attack-btn {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 20px 44px;
border-radius: 14px;
font-size: 19px;
font-weight: 800;
letter-spacing: 0.35em;
text-indent: 0.35em;
color: #ffe9d6;
background: linear-gradient(160deg, #a03d2e 0%, #6e2418 100%);
border: 1px solid rgba(255, 180, 120, 0.45);
box-shadow:
0 0 26px rgba(200, 90, 50, 0.35),
inset 0 1px 0 rgba(255, 220, 180, 0.25);
transition: transform 0.08s ease, box-shadow 0.15s ease, filter 0.15s ease;
}
.attack-btn:hover:not(:disabled) {
filter: brightness(1.15);
box-shadow:
0 0 36px rgba(220, 100, 55, 0.55),
inset 0 1px 0 rgba(255, 220, 180, 0.3);
transform: translateY(-1px);
}
.attack-btn:active:not(:disabled),
.attack-btn.attacking {
transform: translateY(1px) scale(0.97);
filter: brightness(1.3);
box-shadow: 0 0 44px rgba(255, 120, 60, 0.7);
}
.attack-glyph { font-size: 21px; }
/* ---------- 视角摇杆 ---------- */
.look-cluster { justify-items: center; }
.joystick {
position: relative;
width: 138px;
height: 138px;
border-radius: 50%;
background:
radial-gradient(circle at 50% 42%, rgba(62, 142, 138, 0.22), rgba(13, 22, 26, 0.9) 68%),
#101a1e;
border: 1px solid rgba(127, 196, 193, 0.28);
box-shadow:
inset 0 0 22px rgba(0, 0, 0, 0.55),
0 6px 18px rgba(0, 0, 0, 0.4);
touch-action: none;
user-select: none;
cursor: pointer;
}
.joystick-ring {
position: absolute;
inset: 24px;
border-radius: 50%;
border: 1px dashed rgba(127, 196, 193, 0.25);
pointer-events: none;
}
.joystick-knob {
position: absolute;
left: 50%;
top: 50%;
width: 54px;
height: 54px;
border-radius: 50%;
transform: translate(-50%, -50%);
background:
radial-gradient(circle at 38% 32%, #8fd3d0, #3e8e8a 62%, #265a58);
border: 1px solid rgba(200, 240, 238, 0.5);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.55),
0 0 18px rgba(127, 196, 193, 0.35);
pointer-events: none;
}
/* 四方向指示:仅视觉,交互由摇杆本体统一处理;键盘/按钮激活态自动同步 */
.joy-zone {
position: absolute;
width: 22px;
height: 22px;
padding: 0;
border: none;
background: transparent;
color: rgba(127, 196, 193, 0.55);
font-size: 12px;
line-height: 22px;
text-align: center;
pointer-events: none;
transition: color 0.12s ease, text-shadow 0.12s ease;
}
.joy-zone::after { content: none; }
.zone-up { left: 50%; top: 4px; transform: translateX(-50%); }
.zone-down { left: 50%; bottom: 4px; transform: translateX(-50%); }
.zone-left { left: 6px; top: 50%; transform: translateY(-50%); }
.zone-right { right: 6px; top: 50%; transform: translateY(-50%); }
.joy-zone.is-key-active,
.joy-zone.is-pressed {
color: #eafffe;
text-shadow: 0 0 10px rgba(127, 196, 193, 0.9);
}
.joy-hint {
color: rgba(143, 163, 171, 0.55);
font-size: 11px;
letter-spacing: 0.08em;
}
/* ---------- 画面拖拽 ---------- */
#viewport { cursor: grab; touch-action: none; }
#viewport.is-dragging { cursor: grabbing; }
/* ---------- 顶部栏与遥测 ---------- */
.topbar, .timeline {
background: #0e171b;
border-bottom: 1px solid rgba(127, 196, 193, 0.12);
}
.timeline { border-top: 1px solid rgba(127, 196, 193, 0.12); border-bottom: none; }
.stage-stat, .telemetry span { color: var(--muted); }
.stage-stat b, .telemetry b { color: var(--teal-hi); }
/* ---------- 小屏适配 ---------- */
@media (max-width: 900px) {
.stage-controls { grid-template-columns: 1fr; justify-items: center; }
}
/* ---------- 攻击快捷键提示(juemeng-v2) ---------- */
.attack-hint {
color: rgba(143, 163, 171, 0.55);
font-size: 11px;
letter-spacing: 0.08em;
}
/* ============================================================================
* 觉梦·三千世界渲染引擎测试台 v3 — 淡紫 × 淡青 渐变主题 + 技能系统
* ==========================================================================*/
:root {
--purple: #a78bfa;
--purple-hi: #d4c8f5;
--cyan-hi: #8fdeda;
--grad-main: linear-gradient(120deg, #d4c8f5 0%, #8fdeda 100%);
}
/* ---------- 背景:紫青双色晕染 ---------- */
body {
background:
radial-gradient(900px 520px at 12% -8%, rgba(167, 139, 250, 0.16), transparent 60%),
radial-gradient(1000px 560px at 88% 108%, rgba(127, 196, 193, 0.14), transparent 60%),
linear-gradient(90deg, rgba(167, 139, 250, 0.045) 1px, transparent 1px),
linear-gradient(180deg, rgba(143, 222, 218, 0.045) 1px, transparent 1px),
#0d1417;
background-size: auto, auto, 28px 28px, 28px 28px, auto;
}
/* ---------- 品牌:紫→青渐变字 ---------- */
.brand span {
background: rgba(167, 139, 250, 0.12);
border-color: rgba(212, 200, 245, 0.3);
color: var(--purple-hi);
}
.brand strong {
background: linear-gradient(100deg, #e8f0f1 15%, var(--purple-hi) 55%, var(--cyan-hi) 90%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* ---------- 面板与输入:紫调描边 ---------- */
.panel { border-color: rgba(167, 139, 250, 0.16); }
input:focus, textarea:focus, select:focus {
border-color: var(--purple);
box-shadow: 0 0 0 3px rgba(167, 139, 250, 0.18);
}
/* ---------- 主按钮:紫青渐变 ---------- */
button.primary {
background: linear-gradient(150deg, #6d5fc0 0%, #3e8e8a 100%);
border-color: rgba(212, 200, 245, 0.5);
box-shadow: 0 0 22px rgba(167, 139, 250, 0.3);
}
button.primary:hover:not(:disabled) {
box-shadow: 0 0 32px rgba(167, 139, 250, 0.5), 0 8px 22px rgba(0, 0, 0, 0.4);
}
/* ---------- 技能按钮栏(舞台中央) ---------- */
.skill-bar {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 14px;
}
.skill-btn {
position: relative;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 18px 30px;
border-radius: 14px;
font-size: 17px;
font-weight: 800;
letter-spacing: 0.25em;
text-indent: 0.1em;
color: #f5f1fc;
background:
linear-gradient(160deg, rgba(167, 139, 250, 0.32), rgba(62, 142, 138, 0.3)),
#141d24;
border: 1px solid rgba(212, 200, 245, 0.4);
box-shadow:
0 0 22px rgba(167, 139, 250, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.12);
transition: transform 0.08s ease, box-shadow 0.15s ease, filter 0.15s ease;
}
.skill-btn:hover:not(:disabled) {
filter: brightness(1.18);
transform: translateY(-1px);
box-shadow:
0 0 34px rgba(167, 139, 250, 0.5),
0 0 18px rgba(143, 222, 218, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.16);
}
.skill-btn:active:not(:disabled),
.skill-btn.attacking {
transform: translateY(1px) scale(0.96);
filter: brightness(1.35);
box-shadow:
0 0 44px rgba(167, 139, 250, 0.65),
0 0 26px rgba(143, 222, 218, 0.5);
}
.skill-btn .key-badge {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-indent: 0;
padding: 3px 8px;
border-radius: 6px;
background: rgba(212, 200, 245, 0.16);
border: 1px solid rgba(212, 200, 245, 0.35);
color: var(--purple-hi);
}
/* ---------- 技能编辑区(左侧面板) ---------- */
.skill-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
}
.skill-row {
padding: 10px;
border-radius: 10px;
background: rgba(167, 139, 250, 0.06);
border: 1px solid rgba(167, 139, 250, 0.18);
}
.skill-row-head {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 8px;
margin-bottom: 8px;
}
.skill-name-input {
font-weight: 700;
}
.skill-key-btn {
min-width: 62px;
padding: 0 12px;
border-radius: 8px;
font-weight: 700;
color: var(--purple-hi);
background: rgba(167, 139, 250, 0.12);
border: 1px dashed rgba(212, 200, 245, 0.45);
}
.skill-key-btn:hover:not(:disabled) {
background: rgba(167, 139, 250, 0.24);
box-shadow: 0 0 12px rgba(167, 139, 250, 0.35);
}
.skill-del-btn {
width: 34px;
border-radius: 8px;
font-size: 16px;
color: #d98c84;
background: rgba(185, 85, 74, 0.1);
border-color: rgba(217, 140, 132, 0.3);
}
.skill-del-btn:hover:not(:disabled) {
background: rgba(185, 85, 74, 0.28);
border-color: #d98c84;
box-shadow: none;
}
.skill-prompt-input {
width: 100%;
font-size: 12px;
line-height: 1.5;
}
/* ---------- 摇杆头:紫青渐变 ---------- */
.joystick-knob {
background: radial-gradient(circle at 38% 32%, var(--purple-hi), var(--purple) 48%, #3e8e8a 88%);
border-color: rgba(230, 220, 250, 0.55);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.55),
0 0 20px rgba(167, 139, 250, 0.45);
}
.joystick { border-color: rgba(167, 139, 250, 0.3); }
.joystick-ring { border-color: rgba(212, 200, 245, 0.28); }
.joy-zone { color: rgba(212, 200, 245, 0.55); }
.joy-zone.is-key-active,
.joy-zone.is-pressed {
color: #f5f1fc;
text-shadow: 0 0 12px rgba(167, 139, 250, 0.95);
}
/* ---------- WASD 激活态:青色保留 ---------- */
.wasd:active:not(:disabled),
.wasd.is-pressed:not(:disabled),
.wasd.is-key-active:not(:disabled) {
box-shadow: 0 0 14px rgba(143, 222, 218, 0.5), inset 0 0 8px rgba(143, 222, 218, 0.28);
}
/* ---------- v3 补遗:卡片类深色统一 ---------- */
.spec-grid span {
background: rgba(167, 139, 250, 0.07);
border-color: rgba(167, 139, 250, 0.2);
}
.spec-grid b { color: var(--cyan-hi); }
.preset {
background: #141d24;
border-color: rgba(167, 139, 250, 0.18);
color: var(--ink);
}
.preset:hover:not(:disabled) {
border-color: rgba(212, 200, 245, 0.5);
box-shadow: 0 0 14px rgba(167, 139, 250, 0.25);
}
.preset b { color: var(--ink); }
.history-list span {
background: #141d24;
border-left-color: var(--purple);
color: var(--muted);
}
/* ---------- v4:全局顶置居中品牌头 ---------- */
body { padding-top: 118px; }
.app-header {
position: fixed;
top: 12px;
left:56%;
transform: translateX(-50%);
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
pointer-events: none;
user-select: none;
}
.app-logo {
width: 52px;
height: 52px;
filter: drop-shadow(0 0 14px rgba(167, 139, 250, 0.55));
}
.app-title {
margin: 0;
font-size: 22px;
font-weight: 800;
letter-spacing: 0.22em;
text-indent: 0.22em;
background: linear-gradient(100deg, #f5f1fc 10%, var(--purple-hi) 50%, var(--cyan-hi) 90%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 0 0 24px rgba(167, 139, 250, 0.25);
}
.app-subtitle {
font-size: 11px;
letter-spacing: 0.34em;
text-indent: 0.34em;
color: rgba(212, 200, 245, 0.65);
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment