Video-to-Video
Diffusers
English
Chinese
video-editing
streaming
quantization
int8
text-stream-stripping
Instructions to use kylinfires/Joy-face_change_optimized with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Diffusers
How to use kylinfires/Joy-face_change_optimized with Diffusers:
pip install -U diffusers transformers accelerate
import torch from diffusers import DiffusionPipeline # switch to "mps" for apple devices pipe = DiffusionPipeline.from_pretrained("kylinfires/Joy-face_change_optimized", dtype=torch.bfloat16, device_map="cuda") prompt = "Astronaut in a jungle, cold color palette, muted colors, detailed, 8k" image = pipe(prompt).images[0] - Notebooks
- Google Colab
- Kaggle
Download deploy/static/index.html from kylinfires/Joy-face_change_optimized: direct link, hf CLI and curl.
- Browser
- Download file 118 kB
-
https://huggingface.co/kylinfires/Joy-face_change_optimized/resolve/main/deploy/static/index.html
- Command line
-
hf download hf://kylinfires/Joy-face_change_optimized/deploy/static/index.html
-
curl -L -o index.html https://huggingface.co/kylinfires/Joy-face_change_optimized/resolve/main/deploy/static/index.html
118 kB
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>实时视频编辑</title>
<style>
:root {
color-scheme: dark;
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
--bg: #0a0a0c;
--panel: #131316;
--panel-2: #1a1a1e;
--line: #2b2b31;
--text: #ededf0;
--muted: #8f8f98;
--accent: #f2643f;
--accent-2: #d7a64a;
--media-bg: #000000;
--shadow: 0 1px 2px rgba(0,0,0,.55), 0 10px 28px rgba(0,0,0,.45);
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
.topbar { flex: 0 0 auto; display: flex; align-items: center; gap: 12px; padding: 16px 28px; }
.topbar .brand-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--accent); flex: 0 0 auto; }
.topbar .brand { font-size: 18px; font-weight: 760; letter-spacing: .2px; }
.topbar .tagline { font-size: 13px; color: var(--muted); }
.lang-seg { margin-left: auto; display: inline-flex; gap: 3px; }
.lang-seg button { cursor: pointer; font-size: 12px; font-weight: 600; color: var(--muted); background: var(--panel-2); border: 1px solid var(--line); border-radius: 999px; padding: 4px 12px; }
.lang-seg button:hover { border-color: var(--accent); color: var(--accent); }
.lang-seg button.on { color: #fff; background: var(--accent); border-color: var(--accent); }
.stage { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 22px; padding: 4px 28px 10px; }
.vpanel { min-width: 0; min-height: 0; display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--line); border-radius: 16px; box-shadow: var(--shadow); overflow: hidden; }
.vpanel-head { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 16px; }
.vpanel-head .vp-title { display: flex; align-items: center; gap: 7px; font-size: 14px; font-weight: 720; }
.vpanel-head .vp-title .ico { font-size: 14px; color: var(--accent); }
.src-controls { display: inline-flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.media-box { position: relative; flex: 1 1 auto; min-height: 0; margin: 0 12px 12px; overflow: hidden; border-radius: 12px; background: var(--media-bg); }
.media-box img:not([src]) { visibility: hidden; }
video, canvas, img { width: 100%; height: 100%; display: block; object-fit: contain; background: var(--media-bg); }
.overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; text-align: center; padding: 20px; color: #cfcfca; }
.overlay .hint { font-size: 13px; color: #9a9a95; max-width: 320px; line-height: 1.5; text-wrap: balance; }
.overlay .idle-ico { font-size: 30px; color: #6f6f6a; }
.res-banner { text-align: center; font-size: 14px; font-weight: 700; color: #e0b877; background: #1b1610; border-top: 1px solid #33280f; border-bottom: 1px solid #33280f; padding: 9px 12px; line-height: 1.45; }
.res-banner .res-ico { margin-right: 6px; font-size: 15px; }
@keyframes joy-spin { to { transform: rotate(360deg); } }
.idle-ico.spin { display: inline-block; animation: joy-spin 1.1s linear infinite; }
.wait-overlay { background: var(--media-bg); z-index: 3; }
.wait-overlay .idle-ico { font-size: 40px; color: var(--accent); }
.wait-overlay .hint { font-size: 15px; color: #d9d9d4; font-weight: 600; max-width: 380px; }
.toast { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
z-index: 4; padding: 10px 18px; border-radius: 999px; max-width: 80%;
background: rgba(20,20,22,.66); color: #fff; font-size: 14px; font-weight: 600;
text-align: center; line-height: 1.4; box-shadow: 0 4px 20px rgba(0,0,0,.4);
backdrop-filter: blur(2px); pointer-events: none; opacity: 0;
transition: opacity .25s ease; }
.toast.show { opacity: 1; }
.download-bubble { position: absolute; right: 14px; bottom: 14px; z-index: 5; cursor: pointer;
padding: 9px 16px; border: none; border-radius: 999px; font: inherit; font-size: 13px; font-weight: 700;
color: #fff; background: var(--accent); box-shadow: 0 4px 18px rgba(242,100,63,.45);
opacity: 0; transform: translateY(8px); transition: opacity .3s ease, transform .3s ease;
animation: bubblePulse 2s ease-in-out infinite; }
.download-bubble.show { opacity: 1; transform: translateY(0); }
.download-bubble:hover { filter: brightness(1.06); }
@keyframes bubblePulse { 0%,100% { box-shadow: 0 4px 18px rgba(242,100,63,.45); } 50% { box-shadow: 0 4px 26px rgba(242,100,63,.75); } }
.start-btn { cursor: pointer; display: inline-flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 640; color: #fff; background: rgba(40,40,40,.92); border: 1px solid rgba(255,255,255,.14); border-radius: 12px; padding: 11px 20px; }
.start-btn:hover { background: rgba(64,64,64,.95); }
.crop-mask { position: absolute; display: none; pointer-events: none; border-radius: 8px; outline: 2px solid rgba(255,255,255,.9); outline-offset: -1px; box-shadow: 0 0 0 9999px rgba(0,0,0,.5); z-index: 1; }
.crop-mask.show { display: block; }
#camera { transform: scaleX(-1); }
#camera.no-mirror { transform: none; }
.cmdbar { position: relative; flex: 0 0 auto; margin: 0 28px 20px; background: var(--panel); border: 1px solid var(--line); border-radius: 18px; box-shadow: var(--shadow); padding: 12px 14px; }
.cmd-row { display: flex; align-items: center; gap: 12px; }
.ref-slot { flex: 0 0 auto; position: relative; }
.ref-add { display: flex; align-items: center; justify-content: center; width: 46px; height: 46px; border-radius: 12px; border: 1px dashed var(--line); color: var(--muted); font-size: 22px; line-height: 1; cursor: pointer; user-select: none; }
.ref-add:hover { border-color: var(--muted); color: var(--text); }
.ref-slot.has-thumb .ref-add { display: none; }
.ref-thumb { display: none; position: relative; width: 46px; height: 46px; border-radius: 12px; overflow: hidden; border: 1px solid var(--line); }
.ref-slot.has-thumb .ref-thumb { display: block; }
.ref-thumb img { width: 100%; height: 100%; object-fit: cover; background: var(--panel-2); cursor: zoom-in; }
.ref-thumb .ref-clear { position: absolute; top: 2px; right: 2px; z-index: 2; box-sizing: border-box; width: 18px; height: 18px; border-radius: 50%; background: rgba(0,0,0,.6); color: #fff; border: 1px solid rgba(255,255,255,.55); font-size: 12px; line-height: 15px; text-align: center; cursor: pointer; padding: 0; }
.ref-lightbox { display: none; position: fixed; inset: 0; z-index: 100; background: rgba(0,0,0,.82); align-items: center; justify-content: center; cursor: zoom-out; padding: 24px; }
.ref-lightbox.show { display: flex; }
.ref-lightbox img { max-width: 92vw; max-height: 92vh; object-fit: contain; border-radius: 10px; box-shadow: 0 8px 40px rgba(0,0,0,.5); }
.cmd-input { flex: 1 1 auto; min-width: 0; border: none; outline: none; background: transparent; resize: none; font: inherit; font-size: 15px; color: var(--text); line-height: 1.4; max-height: 150px; padding: 12px 4px; }
.cmd-input::placeholder { color: var(--muted); }
.send-fab { flex: 0 0 auto; width: 46px; height: 46px; border-radius: 50%; border: none; cursor: pointer; background: var(--accent); color: #fff; font-size: 0; display: inline-flex; align-items: center; justify-content: center; }
.send-fab::before { content: "\2191"; font-size: 20px; font-weight: 700; line-height: 1; }
.send-fab:hover { filter: brightness(1.05); }
.send-fab:disabled, .send-fab.busy { opacity: .5; cursor: not-allowed; }
.send-fab.busy::before { content: "\22EF"; }
.latency-row { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; margin: 8px 6px 0; font-size: 12px; color: var(--muted); }
.latency-row #netReadout, .latency-row #dispLatency, .latency-row #fpsReadout { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.cmd-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; margin: 8px 6px 0; font-size: 12px; color: var(--muted); }
.cmd-foot .res-readout { white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.cmd-foot .pe-toggle { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
.seg { display: inline-flex; padding: 2px; gap: 2px; background: var(--panel-2); border: 1px solid var(--line); border-radius: 999px; }
.seg button { margin: 0; padding: 3px 12px; font-size: 12px; font-weight: 600; color: var(--muted); background: transparent; border: none; border-radius: 999px; cursor: pointer; white-space: nowrap; transition: background .15s, color .15s; }
.seg button:hover { color: var(--accent); }
.seg button.on { color: #fff; background: var(--accent); }
.cmd-foot .steps-field { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.cmd-foot .steps-field input { width: 52px; margin: 0; padding: 3px 6px; font-size: 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel-2); color: var(--text); }
.cmd-foot .blend-field { display: inline-flex; align-items: center; gap: 8px; white-space: nowrap; }
.cmd-foot .blend-field input[type=range] { width: 96px; margin: 0; accent-color: var(--accent); cursor: pointer; vertical-align: middle; }
.cmd-foot .blend-field .blend-val { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; min-width: 30px; color: var(--text); }
.cmd-foot .kvreset-field { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.cmd-foot .kvreset-field input { width: 64px; margin: 0; padding: 3px 6px; font-size: 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel-2); color: var(--text); }
.qtier { display: inline-flex; gap: 3px; }
.qtier button { width: auto; margin: 0; padding: 2px 9px; font-size: 12px; font-weight: 600; color: var(--muted); background: var(--panel-2); border: 1px solid var(--line); border-radius: 999px; cursor: pointer; white-space: nowrap; }
.qtier button:hover { border-color: var(--accent); color: var(--accent); }
.qtier button.on { color: #fff; background: var(--accent); border-color: var(--accent); }
.qtier button.auto-active { border-color: var(--accent); color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.cmd-foot .pe-toggle[aria-disabled="true"] { opacity: .45; cursor: not-allowed; }
.cmd-foot .grow { flex: 1 1 auto; }
.cmd-foot .send-hint { color: var(--accent); font-weight: 600; }
.scene-row { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 12px 4px 2px; }
.scene-row .scene-label { font-size: 12px; color: var(--muted); margin-right: 2px; }
.pe-result { display: none; margin: 8px 6px 0; padding: 7px 10px; font-size: 12px; line-height: 1.5; color: var(--muted); background: rgba(120,160,200,0.08); border: 1px solid rgba(120,160,200,0.18); border-radius: 8px; white-space: pre-wrap; word-break: break-word; max-height: 6.5em; overflow-y: auto; }
.pe-result .pe-result-label { color: var(--text); font-weight: 600; margin-right: 6px; }
.chip { cursor: pointer; font-size: 13px; font-weight: 560; color: var(--text); background: var(--panel-2); border: 1px solid var(--line); border-radius: 999px; padding: 7px 15px; }
.chip:hover { border-color: var(--accent); color: var(--accent); }
.chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.chip.chip-ref { background: linear-gradient(180deg,#241c10,#2e2313); border-color: #5d4318; color: #e8bf7a; font-weight: 700; }
.chip.chip-ref::before { content: "📎 "; }
.chip.chip-ref:hover { border-color: var(--accent); color: #f2d29b; }
.chip.chip-ref.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.cards-pop { display: none; position: absolute; left: 14px; right: 14px; bottom: calc(100% + 8px); z-index: 20; padding: 12px; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow); }
.cards-pop.open { display: block; }
.cards-pop .pop-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; max-height: 46vh; overflow: auto; }
button.prompt-case { text-align: left; cursor: pointer; margin: 0; padding: 9px 11px; line-height: 1.35; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; color: var(--text); }
button.prompt-case:hover { border-color: var(--accent); }
button.prompt-case.has-ref { background: linear-gradient(180deg,#1d1710,#241c12); border-color: #4a3a1c; border-left: 3px solid var(--accent); }
button.prompt-case.has-ref:hover { border-color: var(--accent); }
.prompt-case .pc-title { display: block; font-size: 13px; font-weight: 700; }
.prompt-case .pc-desc { display: block; font-size: 11px; font-weight: 400; color: var(--muted); margin-top: 2px; }
.metric { color: var(--muted); font-size: 11px; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 6px; }
.stats-grid .stat { background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; padding: 5px 8px; }
.stats-grid .stat-label { font-size: 10px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.stats-grid .stat-value { font-size: 13px; font-weight: 700; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.stat-value.accent { color: var(--accent); }
.stat-value.warn { color: var(--accent-2); }
.stage-timings-wrap { flex: 0 0 auto; margin: 0 12px 12px; }
.stage-timings-wrap .st-title { font-size: 11px; color: var(--muted); margin: 0 2px 6px; }
#netReadout.net-warn { color: #e5343d; font-weight: 700; }
@media (max-width: 1000px) {
body { height: auto; min-height: 100vh; overflow: auto; }
.topbar { padding: 10px 12px; }
.topbar .brand { white-space: nowrap; }
.topbar .tagline { display: none; }
.res-banner { text-wrap: balance; text-wrap: pretty; }
/* keep the two panels side by side: portrait video suits two columns */
.stage { gap: 8px; padding: 4px 8px 8px; }
.vpanel-head { padding: 8px 10px; }
.media-box { aspect-ratio: 9 / 16; max-height: 52vh; margin: 0 8px 8px; }
.cmdbar { margin: 0 8px 14px; }
.cmd-foot .kvreset-field { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; width: 100%; }
.qtier { flex-wrap: wrap; }
.qtier button { padding: 7px 12px; }
/* panel titles go icon-only so 摄像头/MP4 fits the narrow head */
.vpanel-head .vp-title span[data-i18n] { display: none; }
.cmd-input { font-size: 12px; max-height: 125px; }
/* bottom sheet: the cmdbar is tall on mobile, absolute-above would cover the camera */
.cards-pop { position: fixed; left: 8px; right: 8px; bottom: 8px; }
}
@media (max-width: 1000px) and (orientation: landscape) {
.media-box { aspect-ratio: 16 / 9; max-height: 60vh; }
}
/* dark theme touch-ups */
.ref-add { background: var(--panel-2); }
.cmdbar { backdrop-filter: saturate(1.2); }
.chip:hover, .qtier button:hover, .seg button:hover { border-color: var(--muted); color: var(--text); }
.chip.on, .qtier button.on { color: #fff; background: var(--accent); border-color: var(--accent); }
.hint, .overlay .hint { color: var(--muted); }
</style>
</head>
<body>
<div class="topbar">
<span class="brand-dot"></span>
<span class="tagline" data-i18n="tagline">流式视频编辑体验</span>
<span id="langToggle" class="lang-seg" role="group" aria-label="Language">
<button type="button" data-lang="zh">中文</button>
<button type="button" data-lang="en">EN</button>
</span>
</div>
<div class="res-banner" id="resBanner" style="display:none"><span class="res-ico">⚠️</span><span id="resBannerText"></span></div>
<div class="stage">
<div class="vpanel">
<div class="vpanel-head">
<span class="vp-title"><span class="ico">📷</span> <span data-i18n="src_title">原始画面</span></span>
<span class="src-controls">
<span id="sourceSeg" class="seg" role="tablist">
<button id="segCamera" type="button" class="on" data-src="camera" data-i18n="seg_camera">摄像头</button>
<button id="segMp4" type="button" data-src="mp4">MP4</button>
</span>
<input id="videoFile" type="file" accept="video/*" style="display:none" />
</span>
</div>
<div class="media-box">
<video id="camera" autoplay muted playsinline></video>
<div id="cropMask" class="crop-mask"></div>
<div id="cameraOverlay" class="overlay">
<button id="startCameraBtn" type="button" class="start-btn" data-i18n="start_camera">📷 开启摄像头</button>
<div class="hint" data-i18n="camera_hint">仅采集画面,不支持语音;指令请使用文字输入</div>
</div>
</div>
</div>
<div class="vpanel">
<div class="vpanel-head">
<span class="vp-title"><span class="ico">✨</span> <span data-i18n="out_title">编辑结果</span></span>
</div>
<div class="media-box">
<img id="outputImg" alt="编辑输出" data-i18n-alt="output_img_alt" />
<canvas id="outputCanvas" style="display:none"></canvas>
<video id="outputResult" playsinline controls style="display:none; position:absolute; inset:0; z-index:4;"></video>
<button id="downloadBubble" type="button" class="download-bubble" style="display:none" data-i18n="download_done">✨ 编辑完成 · 下载</button>
<div id="outputIdleOverlay" class="overlay">
<div class="idle-ico">✨</div>
<div class="hint" data-i18n="wait_edit_cmd">等待编辑指令</div>
</div>
<div id="outputWaitOverlay" class="overlay wait-overlay" style="display:none">
<div id="outputWaitIco" class="idle-ico">👤</div>
<div id="outputWaitHint" class="hint" data-i18n="face_camera">请正对摄像头</div>
</div>
<div id="outputStartOverlay" class="overlay wait-overlay" style="display:none">
<div class="idle-ico spin">⏳</div>
<div id="outputStartHint" class="hint" data-i18n="busy_starting">正在启动</div>
</div>
<div id="outputToast" class="toast"></div>
</div>
<div id="stageTimingsPanel" class="stage-timings-wrap" style="display:none">
<div class="st-title" data-i18n="prof_title">性能分析(各阶段耗时 · 开启会略降性能)</div>
<div id="stageTimings" class="stats-grid"></div>
</div>
</div>
</div>
<div class="cmdbar">
<div class="cmd-row">
<div id="refSlot" class="ref-slot">
<label id="refAdd" class="ref-add" for="refImage">+</label>
<div id="refThumb" class="ref-thumb">
<img id="refThumbImg" alt="reference" />
<button id="refClear" type="button" class="ref-clear">×</button>
</div>
<input id="refImage" type="file" accept="image/*" style="display:none" />
</div>
<textarea id="prompt" class="cmd-input" rows="1" placeholder="描述你想要的编辑效果" data-i18n-ph="prompt_ph"></textarea>
<button id="send" class="send-fab"></button>
</div>
<div class="latency-row">
<span id="netReadout" class="keep-min">-</span>
<span id="dispLatency" class="keep-min">-</span>
<span id="fpsReadout" class="keep-min">-</span>
<span id="sendHint" class="send-hint keep-min"></span>
</div>
<div class="cmd-foot minimal">
<label id="peToggle" class="pe-toggle keep-min"><input id="usePe" type="checkbox" checked style="width:auto" /> <span data-i18n="pe_toggle">提示词增强</span></label>
<label id="gateToggle" class="pe-toggle keep-min"><input id="faceDetection" type="checkbox" checked style="width:auto" /> <span data-i18n="face_detection">人脸检测</span></label>
<label class="pe-toggle keep-min"><input id="fullBodyDetection" type="checkbox" style="width:auto" /> <span data-i18n="full_body_detection">全身检测</span></label>
<label id="profToggle" class="pe-toggle keep-min"><input id="profileTimings" type="checkbox" style="width:auto" /> <span data-i18n="prof_toggle">性能分析</span></label>
<span class="kvreset-field keep-min">
<span data-i18n="upload_quality">上传清晰度</span>
<span id="upqTier" class="qtier">
<button type="button" data-upq="0.2" data-i18n="q_low">低</button>
<button type="button" data-upq="0.4" data-i18n="q_mid">中</button>
<button type="button" data-upq="0.6" data-i18n="q_high">高</button>
<button type="button" data-upq="auto" class="on" data-i18n="q_auto">自动</button>
</span>
</span>
<span class="kvreset-field keep-min">
<span data-i18n="download_quality">下载清晰度</span>
<span id="qtier" class="qtier">
<button type="button" data-q="20" data-i18n="q_low">低</button>
<button type="button" data-q="40" data-i18n="q_mid">中</button>
<button type="button" data-q="60" data-i18n="q_high">高</button>
<button type="button" data-q="auto" class="on" data-i18n="q_auto">自动</button>
</span>
<input id="downQualityUi" type="hidden" />
</span>
<span class="kvreset-field keep-min">
<span data-i18n="frame_speed">送帧速度</span>
<span id="fpsTier" class="qtier">
<button type="button" data-fps="32">32</button>
<button type="button" data-fps="30">30</button>
<button type="button" data-fps="28">28</button>
<button type="button" data-fps="24" class="on">24</button>
<button type="button" data-fps="20">20</button>
<button type="button" data-fps="16">16</button>
<button type="button" data-fps="12">12</button>
<button type="button" data-fps="8">8</button>
</span>
</span>
</div>
<div id="peResult" class="pe-result" style="display:none"></div>
<div class="scene-row">
<span class="scene-label" data-i18n="scene_label">例子:</span>
<div id="sceneChips" style="display:contents"></div>
</div>
<div id="cardsPop" class="cards-pop"></div>
</div>
<canvas id="capture" style="display:none"></canvas>
<div id="refLightbox" class="ref-lightbox"><img id="refLightboxImg" alt="reference enlarged" /></div>
<script>
const SERVER_DEFAULTS = __SERVER_DEFAULTS__;
const sessionConfig = {
...SERVER_DEFAULTS,
camera_fps: 24,
uplink_quality: 0.6,
smooth_playback: true,
target_queue_delay_ms: 200,
max_queue_delay_ms: 300,
};
const I18N = {
zh: {
tagline: "流式视频编辑体验",
src_title: "原始画面",
out_title: "编辑结果",
seg_camera: "摄像头",
start_camera: "📷 开启摄像头",
camera_hint: "仅采集画面,不支持语音;指令请使用文字输入",
output_img_alt: "编辑输出",
download_done: "✨ 编辑完成 · 下载",
wait_edit_cmd: "等待编辑指令",
face_camera: "请正对摄像头",
prof_title: "性能分析(各阶段耗时 · 开启会略降性能)",
prompt_ph: "描述你想要的编辑效果",
net_label: "网络延迟",
disp_label: "显示延迟",
fps_label: "发/收/显",
pe_toggle: "提示词增强",
face_detection: "人脸检测",
full_body_detection: "全身检测",
gate_face_unavailable: "面部检测暂不可用,请关闭检测或检查服务",
gate_full_body: "请让头部和双脚完整入镜,然后站稳",
gate_full_body_unavailable: "全身检测暂不可用,请关闭该开关或检查服务",
prof_toggle: "性能分析",
upload_quality: "上传清晰度",
download_quality: "下载清晰度",
frame_speed: "送帧速度",
q_low: "低", q_mid: "中", q_high: "高", q_auto: "自动",
scene_label: "例子:",
pe_enhanced_label: "增强提示词",
busy_starting: "正在启动",
busy_running: "正在运行",
busy_camera_err: "摄像头错误",
busy_queue: "排队 #",
busy_resetting: "正在重置会话",
hint_no_face: "请正对摄像头,露出完整的脸 😊",
hint_no_person: "未检测到人,请回到画面中 😊",
gate_too_close: "请让上半身入镜(坐正 / 把脸移到画面上半部)",
gate_too_small: "离得有点远,请靠近一点让人脸更清晰",
gate_face_cut: "脸没完整入镜,请让整张脸都在画面里",
gate_off_center: "请把脸移到画面中间",
pe_running: "画面就绪,正在增强提示词",
stage_pil: "PIL→张量", stage_vae_enc: "VAE 编码", stage_dit: "DiT 去噪",
stage_kv: "KV cache", stage_vae_dec: "VAE 解码", stage_jpeg: "JPEG 解码", stage_h264: "H264 编码",
stage_q_enc: "排队·编码", stage_q_dit: "排队·DiT", stage_q_dec: "排队·解码",
stage_q_pseudo: "排队·伪编码", stage_q_post: "排队·后处理",
stage_ws: "WS 发送", stage_total: "服务端合计", stage_residence: "服务端驻留",
stage_enc_q: "编码队列", stage_ws_buf: "发送缓冲", stage_recv_q: "接收队列",
busy_released: "已释放", busy_disconnected: "已断开", busy_conn_err: "连接错误",
reset_count_changed: "检测到人数变化,重新编辑",
reset_person_returned: "重新检测到人,重新开始编辑",
reset_kv: "已连续编辑较久,正在重新校准,请保持人脸居中 😊",
gate_settling: "保持不动,正在对焦",
gate_no_face_wait: "没有检测到人脸,请正对摄像头 😊",
toast_done: "✨ 编辑完成,点右下角气泡下载",
download_retry: "重试下载",
download_failed: "下载失败:",
toast_finalizing: "✨ 编辑完成,正在生成可下载视频",
edit_processing: "✨ 正在编辑",
q_downgrade_auto: " · 已自动降到低画质", q_downgrade_hint: " · 建议降低画质",
flow_drop_tail: " · 带宽不足,正在丢帧保延迟",
up_auto_tail: " · 上行已自动降至中档",
res_notice: "本演示运行在 {res},而非模型原生的 1248×720 分辨率,画质与编辑效果会有所下降。",
},
en: {
tagline: "Streaming video editing",
src_title: "Source",
out_title: "Result",
seg_camera: "Camera",
start_camera: "📷 Start camera",
camera_hint: "Video only, no audio; type your instruction as text",
output_img_alt: "Edited output",
download_done: "✨ Done · Download",
wait_edit_cmd: "Waiting for an edit instruction",
face_camera: "Face the camera",
prof_title: "Profiling (per-stage timing · slight perf cost when on)",
prompt_ph: "Describe the edit you want",
net_label: "Network",
disp_label: "Display lag",
fps_label: "out/in/disp",
pe_toggle: "Prompt Enhance",
face_detection: "Face detection",
full_body_detection: "Full-body detection",
gate_face_unavailable: "Face detection unavailable; disable detection or check the server",
gate_full_body: "Show your head and feet, then hold still",
gate_full_body_unavailable: "Full-body detection unavailable; disable this option or check the server",
prof_toggle: "Profiling",
upload_quality: "Upload quality",
download_quality: "Download quality",
frame_speed: "Frame rate",
q_low: "Low", q_mid: "Mid", q_high: "High", q_auto: "Auto",
scene_label: "Examples:",
pe_enhanced_label: "Enhanced prompt",
busy_starting: "Starting",
busy_running: "Running",
busy_camera_err: "Camera error",
busy_queue: "Queue #",
busy_resetting: "Resetting session",
hint_no_face: "Face the camera and show your full face 😊",
hint_no_person: "No person detected, please step back into frame 😊",
gate_too_close: "Show your upper body (sit up / move your face to the upper half)",
gate_too_small: "A bit far — move closer so your face is clearer",
gate_face_cut: "Your face is cut off — fit your whole face in frame",
gate_off_center: "Move your face to the center",
pe_running: "Frame ready, enhancing the prompt",
stage_pil: "PIL→Tensor", stage_vae_enc: "VAE encode", stage_dit: "DiT denoise",
stage_kv: "KV cache", stage_vae_dec: "VAE decode", stage_jpeg: "JPEG decode", stage_h264: "H264 encode",
stage_q_enc: "Queue·encode", stage_q_dit: "Queue·DiT", stage_q_dec: "Queue·decode",
stage_q_pseudo: "Queue·pseudo", stage_q_post: "Queue·post",
stage_ws: "WS send", stage_total: "Server total", stage_residence: "Server residence",
stage_enc_q: "Encode queue", stage_ws_buf: "Send buffer", stage_recv_q: "Recv queue",
busy_released: "Released", busy_disconnected: "Disconnected", busy_conn_err: "Connection error",
reset_count_changed: "Person count changed, re-editing",
reset_person_returned: "Person detected again, restarting the edit",
reset_kv: "Editing for a while — recalibrating, please keep your face centered 😊",
gate_settling: "Hold still, focusing",
gate_no_face_wait: "No face detected, please face the camera 😊",
toast_done: "✨ Done — click the bubble at bottom-right to download",
download_retry: "Retry download",
download_failed: "Download failed: ",
toast_finalizing: "✨ Done — generating the downloadable video",
edit_processing: "✨ Editing",
q_downgrade_auto: " · auto-lowered to low quality", q_downgrade_hint: " · consider lowering quality",
flow_drop_tail: " · bandwidth limited, dropping frames",
up_auto_tail: " · uplink auto-lowered to mid",
res_notice: "This demo runs at {res}, not the model's native 1248×720 resolution; visual quality and edit fidelity are reduced.",
},
};
let currentLang = localStorage.getItem("joyomni_lang") || "zh";
function t(key) {
const d = I18N[currentLang] || I18N.zh;
return (key in d) ? d[key] : (I18N.zh[key] !== undefined ? I18N.zh[key] : key);
}
function sceneField(obj, name) {
if (currentLang === "en") {
const v = obj[name + "_en"];
if (v !== undefined && v !== null && v !== "") return v;
}
return obj[name];
}
function applyLang() {
document.documentElement.lang = currentLang === "en" ? "en" : "zh-CN";
for (const el of document.querySelectorAll("[data-i18n]")) {
el.textContent = t(el.getAttribute("data-i18n"));
}
for (const el of document.querySelectorAll("[data-i18n-ph]")) {
el.setAttribute("placeholder", t(el.getAttribute("data-i18n-ph")));
}
for (const el of document.querySelectorAll("[data-i18n-alt]")) {
el.setAttribute("alt", t(el.getAttribute("data-i18n-alt")));
}
const langBtn = document.getElementById("langToggle");
if (langBtn) {
for (const b of langBtn.querySelectorAll("button")) {
b.classList.toggle("on", b.getAttribute("data-lang") === currentLang);
}
}
if (typeof renderPromptCases === "function") { try { renderPromptCases(); } catch (err) {} }
if (typeof updateMetrics === "function") { try { updateMetrics(); } catch (err) {} }
if (typeof updateResBanner === "function") { try { updateResBanner(); } catch (err) {} }
}
function setLang(lang) {
if (lang !== "en" && lang !== "zh") return;
if (lang === currentLang) return;
currentLang = lang;
localStorage.setItem("joyomni_lang", currentLang);
applyLang();
}
let REF_IMAGES = {};
const camera = document.getElementById("camera");
const capture = document.getElementById("capture");
const outputImg = document.getElementById("outputImg");
const outputCanvas = document.getElementById("outputCanvas");
const outputCanvasCtx = outputCanvas.getContext("2d");
const stageTimings = document.getElementById("stageTimings");
const stageTimingsPanel = document.getElementById("stageTimingsPanel");
const peResultEl = document.getElementById("peResult");
function showPeResult(text, model) {
if (!peResultEl) return;
const txt = (text || "").trim();
if (!txt) { peResultEl.style.display = "none"; peResultEl.textContent = ""; return; }
peResultEl.innerHTML = "";
const lbl = document.createElement("span");
lbl.className = "pe-result-label";
lbl.textContent = t("pe_enhanced_label") + (model ? " (" + model + ")" : "");
peResultEl.appendChild(lbl);
peResultEl.appendChild(document.createTextNode(txt));
peResultEl.style.display = "block";
}
function hidePeResult() {
if (peResultEl) { peResultEl.style.display = "none"; peResultEl.textContent = ""; }
}
const sendBtn = document.getElementById("send");
const startCameraBtn = document.getElementById("startCameraBtn");
const cameraOverlay = document.getElementById("cameraOverlay");
const outputIdleOverlay = document.getElementById("outputIdleOverlay");
const outputWaitOverlay = document.getElementById("outputWaitOverlay");
const outputWaitIco = document.getElementById("outputWaitIco");
const outputWaitHint = document.getElementById("outputWaitHint");
const outputToast = document.getElementById("outputToast");
const sendHintEl = document.getElementById("sendHint");
const netReadoutEl = document.getElementById("netReadout");
const dispLatencyEl = document.getElementById("dispLatency");
const fpsReadoutEl = document.getElementById("fpsReadout");
const refSlot = document.getElementById("refSlot");
const refThumbImg = document.getElementById("refThumbImg");
const sceneChipsEl = document.getElementById("sceneChips");
const cardsPop = document.getElementById("cardsPop");
const peToggleEl = document.getElementById("peToggle");
function setSendBusy(busy, hint) {
if (sendBtn) {
sendBtn.disabled = busy;
sendBtn.classList.toggle("busy", busy);
}
if (hint !== undefined && sendHintEl) sendHintEl.textContent = hint;
if (!busy) { const _so = document.getElementById("outputStartOverlay"); if (_so) _so.style.display = "none"; }
}
let outputToastTimer = null;
function showOutputToast(text, ms) {
if (!outputToast || !text) return;
outputToast.textContent = text;
outputToast.classList.add("show");
if (outputToastTimer) clearTimeout(outputToastTimer);
outputToastTimer = setTimeout(() => { outputToast.classList.remove("show"); }, ms || 2600);
}
function hideOutputToast() {
if (outputToastTimer) { clearTimeout(outputToastTimer); outputToastTimer = null; }
if (outputToast) outputToast.classList.remove("show");
}
function showCameraOverlay(show) {
if (cameraOverlay) cameraOverlay.style.display = show ? "" : "none";
updateCropMask();
}
function showOutputIdle(show) {
if (outputIdleOverlay) outputIdleOverlay.style.display = show ? "" : "none";
if (show && outputWaitOverlay) { outputWaitOverlay.style.display = "none"; outputWaitingFace = false; waitHintReason = null; }
}
function updateResBanner() {
const el = document.getElementById("resBanner");
const txt = document.getElementById("resBannerText");
if (!el || !txt) return;
const w = sessionConfig.width;
const h = sessionConfig.height;
if (!w || !h || w * h >= 720 * 1248 * 0.9) { el.style.display = "none"; return; }
txt.textContent = t("res_notice").replace("{res}", w + "×" + h);
el.style.display = "";
}
function showOutputStart(show, text) {
const ov = document.getElementById("outputStartOverlay");
if (!ov) return;
if (show) {
if (outputIdleOverlay) outputIdleOverlay.style.display = "none";
if (outputWaitOverlay) outputWaitOverlay.style.display = "none";
const el = document.getElementById("outputStartHint");
if (el && text !== undefined) el.textContent = text;
ov.style.display = "";
} else {
ov.style.display = "none";
}
}
const cropMaskEl = document.getElementById("cropMask");
function updateCropMask() {
if (!cropMaskEl) return;
const vw = camera.videoWidth || 0;
const vh = camera.videoHeight || 0;
const boxW = camera.clientWidth || 0;
const boxH = camera.clientHeight || 0;
if (!vw || !vh || !boxW || !boxH || usingVideoFile ||
(cameraOverlay && cameraOverlay.style.display !== "none")) {
cropMaskEl.classList.remove("show");
return;
}
const scale = Math.min(boxW / vw, boxH / vh);
const dispW = vw * scale;
const dispH = vh * scale;
const dispLeft = camera.offsetLeft + (boxW - dispW) / 2;
const dispTop = camera.offsetTop + (boxH - dispH) / 2;
const tW = Number(sessionConfig.width) || vw;
const tH = Number(sessionConfig.height) || vh;
const dstAr = tW / tH;
const srcAr = vw / vh;
let fw, fh;
if (srcAr > dstAr) { fh = 1; fw = (vh * dstAr) / vw; }
else { fw = 1; fh = (vw / dstAr) / vh; }
const cropW = dispW * fw;
const cropH = dispH * fh;
const cropLeft = dispLeft + (dispW - cropW) / 2;
const cropTop = dispTop + (dispH - cropH) / 2;
cropMaskEl.style.left = cropLeft + "px";
cropMaskEl.style.top = cropTop + "px";
cropMaskEl.style.width = cropW + "px";
cropMaskEl.style.height = cropH + "px";
cropMaskEl.classList.add("show");
}
window.addEventListener("resize", updateCropMask);
if (window.ResizeObserver && camera) {
try { new ResizeObserver(() => updateCropMask()).observe(camera); } catch (e) {}
}
// the camera track rotates with the device; re-pick orientation while idle
camera.addEventListener("resize", () => {
if (usingVideoFile || startingRun || sessionGranted) return;
const vw = camera.videoWidth, vh = camera.videoHeight;
if (!vw || !vh) return;
const want = vh > vw ? "portrait" : "landscape";
if (currentOrientation() !== want) applyOrientation(want);
});
let ws = null;
let stream = null;
let videoFileUrl = null;
let terminalHold = false;
let usingVideoFile = false;
const sourceDetectionModes = { camera: "near", file: "off" };
let timer = null;
let tickInFlight = false;
let pePausedSend = false;
let peDeferThisSend = false;
let videoPullActive = false;
let resultSourceStart = 0;
let videoPullGeneration = 0;
let videoPullTimer = null;
let videoPullIdx = 0;
const videoPullFps = 24;
let videoPullTotal = 0;
const outputResult = document.getElementById("outputResult");
let resultBlobUrl = null;
let finalizeIsForResult = false;
let resultEpoch = 0;
let resultSyncActive = false;
let resultSyncHandlers = null;
let finalizeWatchdog = null;
function stopRecordDrain() {
if (finalizeWatchdog) { clearTimeout(finalizeWatchdog); finalizeWatchdog = null; }
}
let startingRun = false;
let sessionGranted = false;
let activeSessionId = null;
let outputPlaybackTimer = null;
let outputPlaybackIntervalMs = null;
function targetOutputQueueDelayMs() {
return Math.min(sessionConfig.target_queue_delay_ms, maxOutputQueueDelayMs());
}
function maxOutputQueueDelayMs() {
return sessionConfig.max_queue_delay_ms;
}
const MAX_BACKEND_PENDING_FRAMES = 32;
let sendRealtimeSkips = 0;
let sentFrames = 0;
let backendAckedFrames = 0;
let backendOutFrames = 0;
let receivedFrames = 0;
let displayedFrames = 0;
let lastObjectUrl = null;
let alignMaxAgeMs = 0;
let lastShownCaptureMs = 0;
let pendingRevokeUrl = null;
let refImagePreviewDataUrl = null;
let injectedRefImageDataUrl = null;
let outputWaitingFace = false;
let waitHintReason = null;
let waitHintAt = 0;
let autoQuality = true;
const WAIT_HINT_MIN_MS = 500;
let pendingOutputMeta = null;
let outputFrameQueue = [];
let outputQueueDroppedFrames = 0;
let cameraTrackFps = null;
let cameraMeasuredFps = 0;
let cameraMeasuredFrames = 0;
let cameraMeasureStartedAt = performance.now();
let lastVideoQualityFrames = null;
let sendActualFps = 0;
let receiveActualFps = 0;
let editActualFps = 0;
let sentFramesWindow = 0;
let receivedFramesWindow = 0;
let displayedFramesWindow = 0;
let meterStartedAt = performance.now();
let editReceiveLatencyLastMs = null;
let editLatencyLastMs = null;
let netRttLastMs = null;
let netPingTimer = null;
let flowDropTotal = 0;
let flowDropAt = 0;
let upDelayMs = null;
let upDelayHist = [];
let downDelayMs = null;
let downSkewMin = null;
let downSkewAt = 0;
let upAuto = true;
let upAutoHigh = true;
let upAutoClear = 0;
let upResumeKeyframe = false;
let serverChunkLastSec = null;
let chunkProfileLast = null;
let sessionProfileTimings = false;
let lastWsSendMs = null;
let lastServerResidenceMs = null;
function fmt(value, suffix = "") {
if (value === null || value === undefined || Number.isNaN(value)) return "-";
if (typeof value === "number") return `${value.toFixed(value >= 10 ? 1 : 2)}${suffix}`;
return `${value}${suffix}`;
}
function backendPendingFrames() {
return Math.max(0, sentFrames - backendAckedFrames);
}
function noteBackendAck(msg) {
if (msg && msg.session_id && msg.session_id !== activeSessionId) return;
const framesIn = Number(msg && msg.frames_in);
if (Number.isFinite(framesIn)) {
backendAckedFrames = Math.max(backendAckedFrames, framesIn);
}
const framesOut = Number(msg && msg.frames_out);
if (Number.isFinite(framesOut)) {
backendOutFrames = Math.max(backendOutFrames, framesOut);
if (usingVideoFile && (videoPullActive || finalizeIsForResult)) updateUploadProgress();
}
}
function smoothPlaybackEnabled() {
return sessionConfig.smooth_playback;
}
function currentOrientation() {
const w = Number(sessionConfig.width) || 0;
const h = Number(sessionConfig.height) || 0;
return h > w ? "portrait" : "landscape";
}
function defaultCameraOrientation() {
const isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent || "");
const portraitViewport = window.innerHeight > window.innerWidth;
return (isMobile || portraitViewport) ? "portrait" : "landscape";
}
function orientDims(o) {
const a = SERVER_DEFAULTS.width;
const b = SERVER_DEFAULTS.height;
const long = Math.max(a, b), short = Math.min(a, b);
return o === "portrait" ? { width: short, height: long } : { width: long, height: short };
}
function applyOrientation(o) {
const dims = orientDims(o);
sessionConfig.width = dims.width;
sessionConfig.height = dims.height;
updateCropMask();
updateMetrics();
}
function refImageSelected() {
const el = document.getElementById("refImage");
return !!(el && el.files && el.files[0]);
}
function maxTemporalIdsValue() {
const m = Math.floor(Number(sessionConfig.max_temporal_ids));
return Number.isFinite(m) && m > 0 ? m : null;
}
function startFrameTimer() {
if (timer) clearInterval(timer);
timer = null;
stopVideoPull();
if (usingVideoFile) {
startVideoPull();
} else {
const fps = sendTargetFps();
timer = setInterval(tick, Math.floor(1000 / fps));
}
ensureOutputPlaybackTimer(true);
startNetPingTimer();
}
function startNetPingTimer() {
if (netPingTimer) clearInterval(netPingTimer);
netPingTimer = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
try { ws.send(JSON.stringify({ session_id: activeSessionId, type: "ping", t: Date.now(), recv: receivedFrames, rtt: netRttLastMs })); } catch (err) {}
}
autoQualityTick();
}, 1000);
}
function readRefImageDataUrl() {
const el = document.getElementById("refImage");
const file = el && el.files && el.files[0];
if (!file) {
if (injectedRefImageDataUrl) {
refImagePreviewDataUrl = injectedRefImageDataUrl;
return Promise.resolve(injectedRefImageDataUrl);
}
refImagePreviewDataUrl = null;
return Promise.resolve(null);
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
refImagePreviewDataUrl = String(reader.result || "");
resolve(refImagePreviewDataUrl);
};
reader.onerror = () => reject(reader.error || new Error("failed to read ref image"));
reader.readAsDataURL(file);
});
}
async function handleRefImageChange() {
injectedRefImageDataUrl = null;
if (!refImageSelected()) {
refImagePreviewDataUrl = null;
syncRefThumb();
updateMetrics();
return;
}
try {
await readRefImageDataUrl();
} catch (err) {
}
syncRefThumb();
updateMetrics();
}
function cameraTargetFps() {
return Math.max(1, sessionConfig.camera_fps);
}
function sendTargetFps() {
return Math.max(1, sessionConfig.fps);
}
function outputPlaybackFps() {
const configured = sendTargetFps();
const measured = cameraMeasuredFps > 0.5 ? cameraMeasuredFps : (cameraTrackFps || configured);
return Math.max(1, Math.min(configured, measured || configured));
}
function outputQueueLimitFrames() {
return Math.max(4, Math.ceil(outputPlaybackFps() * maxOutputQueueDelayMs() / 1000));
}
function outputQueueTargetFrames() {
return Math.max(2, Math.ceil(outputPlaybackFps() * targetOutputQueueDelayMs() / 1000));
}
function getCameraSettings() {
const track = stream ? stream.getVideoTracks()[0] : null;
return track ? track.getSettings() : {};
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (ch) => ({
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
}[ch]));
}
function statCell(label, value, tone = "") {
const cls = tone ? ` ${tone}` : "";
return `<div class="stat"><div class="stat-label">${escapeHtml(label)}</div><div class="stat-value${cls}">${escapeHtml(value)}</div></div>`;
}
function renderStageTimings() {
if (!sessionProfileTimings) return;
const p = chunkProfileLast;
if (!p || p.dit_denoise_s === undefined) return;
const ms = (key) => {
const v = Number(p[key]);
return Number.isFinite(v) ? v * 1000 : null;
};
const cards = [];
const push = (label, key, tone = "") => cards.push(statCell(label, fmt(ms(key), "ms"), tone));
const _encQ = (typeof upEncoder !== "undefined" && upEncoder) ? (upEncoder.encodeQueueSize || 0) : 0;
const _wsBuf = (ws && ws.readyState === WebSocket.OPEN) ? (ws.bufferedAmount || 0) : 0;
cards.push(statCell(t("stage_enc_q"), String(_encQ), _encQ >= 3 ? "warn" : ""));
cards.push(statCell(t("stage_ws_buf"), (_wsBuf / 1024).toFixed(0) + " KB", _wsBuf > 131072 ? "warn" : ""));
const _recvQ = (typeof outputFrameQueue !== "undefined" && outputFrameQueue) ? outputFrameQueue.length : 0;
cards.push(statCell(t("stage_recv_q"), _recvQ + " · " + (outCodecH264 ? "h264" : "mjpeg"), _recvQ >= 16 ? "warn" : ""));
if (Number.isFinite(Number(p.jpeg_decode_ms))) {
cards.push(statCell(t("stage_jpeg"), fmt(Number(p.jpeg_decode_ms), "ms")));
}
push(t("stage_pil"), "frames_to_tensor_s");
push(t("stage_vae_enc"), "vae_encode_s");
push(t("stage_dit"), "dit_denoise_s", "accent");
push(t("stage_kv"), "kv_store_s");
push(t("stage_vae_dec"), "vae_decode_s");
if (Number.isFinite(Number(p.h264_encode_s))) {
cards.push(statCell(t("stage_h264"), fmt(Number(p.h264_encode_s) * 1000, "ms")));
}
push(t("stage_q_enc"), "q_wait_encode_s");
push(t("stage_q_dit"), "q_wait_dit_s");
push(t("stage_q_dec"), "q_wait_decode_s");
push(t("stage_q_pseudo"), "q_wait_pseudo_s");
push(t("stage_q_post"), "q_wait_postprocess_s");
const wsSendMs = Number.isFinite(Number(p.ws_send_s)) ? ms("ws_send_s") : lastWsSendMs;
const residenceMs = Number.isFinite(Number(p.server_residence_s)) ? ms("server_residence_s") : lastServerResidenceMs;
cards.push(statCell(t("stage_ws"), fmt(wsSendMs, "ms")));
cards.push(statCell(t("stage_total"), fmt(ms("total_server_chunk_s"), "ms"), "warn"));
cards.push(statCell(t("stage_residence"), fmt(residenceMs, "ms"), "warn"));
stageTimings.innerHTML = cards.join("");
}
function updateMetrics() {
renderStageTimings();
if (netReadoutEl) {
const _rtt = netRttLastMs;
const _hi = _rtt !== null && _rtt !== undefined && _rtt > 300;
const _fd = flowDropAt > 0 && Date.now() - flowDropAt < 3000;
const _ua = upAuto && !upAutoHigh && ws && ws.readyState === WebSocket.OPEN;
let _tail = "";
if (_hi) _tail = autoQuality ? (autoQTier < 2 ? t("q_downgrade_auto") : "") : t("q_downgrade_hint");
if (_ua) _tail = t("up_auto_tail");
if (_fd) _tail = t("flow_drop_tail");
netReadoutEl.textContent = t("net_label") + " " + fmt(_rtt, "ms") + _tail;
netReadoutEl.classList.toggle("net-warn", _hi || _fd || _ua);
}
if (dispLatencyEl) {
dispLatencyEl.textContent = t("disp_label") + " " + fmt(editLatencyLastMs, "ms");
}
fpsReadoutEl.textContent = t("fps_label") + " " + sendActualFps.toFixed(1) + "/" + receiveActualFps.toFixed(1) + "/" + editActualFps.toFixed(1);
}
function updateRateMeters() {
const now = performance.now();
const elapsed = Math.max((now - meterStartedAt) / 1000, 0.001);
sendActualFps = sentFramesWindow / elapsed;
receiveActualFps = receivedFramesWindow / elapsed;
editActualFps = displayedFramesWindow / elapsed;
sentFramesWindow = 0;
receivedFramesWindow = 0;
displayedFramesWindow = 0;
meterStartedAt = now;
ensureOutputPlaybackTimer();
updateMetrics();
}
function disposeItem(item) {
if (!item) return;
if (item.bmp) { try { item.bmp.close(); } catch (e) {} item.bmp = null; }
if (item.url) { try { URL.revokeObjectURL(item.url); } catch (e) {} item.url = null; }
}
function blankOutputPane() {
if (lastObjectUrl) URL.revokeObjectURL(lastObjectUrl);
lastObjectUrl = null;
if (pendingRevokeUrl) { URL.revokeObjectURL(pendingRevokeUrl); pendingRevokeUrl = null; }
outputImg.onload = outputImg.onerror = null;
outputImg.removeAttribute("src");
outputCanvasCtx.clearRect(0, 0, outputCanvas.width, outputCanvas.height);
}
function displayOutputFrame(item) { if (!item) return;
if (item.meta && item.meta.t_capture_ms) lastShownCaptureMs = Number(item.meta.t_capture_ms);
if (outputWaitingFace) {
outputWaitingFace = false;
waitHintReason = null;
if (outputWaitOverlay) outputWaitOverlay.style.display = "none";
}
if (item.bmp) {
if (outputCanvas.width !== item.bmp.width || outputCanvas.height !== item.bmp.height) {
outputCanvas.width = item.bmp.width;
outputCanvas.height = item.bmp.height;
}
outputCanvasCtx.drawImage(item.bmp, 0, 0);
item.bmp.close();
item.bmp = null;
} else {
if (pendingRevokeUrl) { URL.revokeObjectURL(pendingRevokeUrl); pendingRevokeUrl = null; }
pendingRevokeUrl = lastObjectUrl;
lastObjectUrl = item.url;
outputImg.onload = outputImg.onerror = function() {
if (pendingRevokeUrl) { URL.revokeObjectURL(pendingRevokeUrl); pendingRevokeUrl = null; }
outputImg.onload = outputImg.onerror = null;
};
outputImg.src = item.url;
}
displayedFrames += 1;
displayedFramesWindow += 1;
const meta = item.meta;
if (meta) {
const latency = Date.now() - Number(meta.t_capture_ms || Date.now());
editLatencyLastMs = latency;
serverChunkLastSec = Number(meta.server_elapsed || 0);
chunkProfileLast = meta.profile || chunkProfileLast;
} else {
}
updateMetrics();
}
function effectivePlaybackIntervalMs() {
const base = Math.max(16, Math.round(1000 / outputPlaybackFps()));
const q = outputFrameQueue.length;
const target = outputQueueTargetFrames();
if (q <= target) return base;
const over = (q - target) / Math.max(1, target);
const speed = Math.min(2, 1 + over);
return Math.max(8, Math.round(base / speed));
}
function alignTargetMs() {
return Math.min(2500, Math.max(200, alignMaxAgeMs + targetOutputQueueDelayMs()));
}
function pumpOutputFrame() {
if (!smoothPlaybackEnabled()) return;
const T = alignTargetMs();
const now = Date.now();
let shown = 0;
while (outputFrameQueue.length > 0 && shown < 4) {
const head = outputFrameQueue[0];
const tcap = head.meta && Number(head.meta.t_capture_ms);
if (tcap && (now - tcap) < T) break;
displayOutputFrame(outputFrameQueue.shift());
shown++;
}
ensureOutputPlaybackTimer();
}
function ensureOutputPlaybackTimer(force = false) {
if (!smoothPlaybackEnabled()) {
if (outputPlaybackTimer) clearInterval(outputPlaybackTimer);
outputPlaybackTimer = null;
outputPlaybackIntervalMs = null;
return;
}
const intervalMs = effectivePlaybackIntervalMs();
if (!force && outputPlaybackTimer && outputPlaybackIntervalMs === intervalMs) return;
if (outputPlaybackTimer) clearInterval(outputPlaybackTimer);
outputPlaybackIntervalMs = intervalMs;
outputPlaybackTimer = setInterval(pumpOutputFrame, intervalMs);
}
function clearOutputQueue() {
for (const item of outputFrameQueue) disposeItem(item);
outputFrameQueue = [];
}
function resetRunMetrics() {
sentFrames = 0;
backendAckedFrames = 0;
backendOutFrames = 0;
videoPullTotal = 0;
receivedFrames = 0;
displayedFrames = 0;
sendRealtimeSkips = 0;
tickInFlight = false;
sentFramesWindow = 0;
receivedFramesWindow = 0;
displayedFramesWindow = 0;
sendActualFps = 0;
receiveActualFps = 0;
editActualFps = 0;
meterStartedAt = performance.now();
editReceiveLatencyLastMs = null;
editLatencyLastMs = null;
netRttLastMs = null;
upDelayMs = null;
upDelayHist = [];
downDelayMs = null;
downSkewMin = null;
downSkewAt = 0;
upAutoHigh = true;
upAutoClear = 0;
syncUpQualityUi();
upResumeKeyframe = false;
serverChunkLastSec = null;
chunkProfileLast = null;
lastWsSendMs = null;
lastServerResidenceMs = null;
pendingOutputMeta = null;
outputQueueDroppedFrames = 0;
lastShownCaptureMs = 0;
clearOutputQueue();
hideOutputToast();
blankOutputPane();
hidePeResult();
sessionProfileTimings = false;
stageTimings.innerHTML = "";
stageTimingsPanel.style.display = "none";
}
function trimOutputQueueForLatency() {
const limitFrames = outputQueueLimitFrames();
if (outputFrameQueue.length <= limitFrames) return;
const targetFrames = outputQueueTargetFrames();
while (outputFrameQueue.length > targetFrames) {
const dropped = outputFrameQueue.shift();
disposeItem(dropped);
outputQueueDroppedFrames += 1;
}
}
let h264DecodeOk = false;
if (typeof VideoDecoder !== "undefined") {
VideoDecoder.isConfigSupported({ codec: "avc1.640028", optimizeForLatency: true })
.then((s) => { h264DecodeOk = !!(s && s.supported); })
.catch(() => {});
}
let outCodecH264 = false;
let outDecoder = null;
let outDecSeq = 0;
const outMetaByTs = new Map();
let upCodecH264 = false;
let upEncoder = null;
const UPLINK_KEYFRAME_INTERVAL = 8;
let upSeq = 0;
const upPendingByTs = new Map();
function effectiveUpQuality() {
if (upAuto) return upAutoHigh ? 0.6 : 0.4;
return sessionConfig.uplink_quality;
}
function upEncoderBitrate(width, height) {
const REF_PX = 1248 * 720;
const REF_BITRATE = effectiveUpQuality() * 4000000;
const REF_FPS = 16;
return Math.round(REF_BITRATE * (width * height / REF_PX) * Math.sqrt(sendTargetFps() / REF_FPS));
}
function uplinkCongestionTick() {
if (!upAuto) return;
if (!(ws && ws.readyState === WebSocket.OPEN && sessionGranted)) return;
if (usingVideoFile) return;
const buf = ws.bufferedAmount || 0;
const upLow = upDelayHist.length
? Math.min.apply(null, upDelayHist.slice(-3))
: null;
if (upAutoHigh) {
if ((upLow !== null && upLow > 350) || buf > 262144) {
upAutoHigh = false;
upAutoClear = 0;
resetUplinkEncoder();
highlightUpqTier("auto");
updateMetrics();
}
} else {
const clear = (upLow === null || upLow < 120) && buf < 65536;
upAutoClear = clear ? upAutoClear + 1 : 0;
if (upAutoClear >= 8) {
upAutoHigh = true;
upAutoClear = 0;
resetUplinkEncoder();
highlightUpqTier("auto");
updateMetrics();
}
}
}
function upEncoderConfig(width, height) {
return {
codec: "avc1.42E028",
width,
height,
bitrate: upEncoderBitrate(width, height),
framerate: sendTargetFps(),
latencyMode: "realtime",
avc: { format: "annexb" },
};
}
function resetUplinkEncoder() {
if (upEncoder && upEncoder.state !== "closed") {
try { upEncoder.close(); } catch (e) {}
}
upEncoder = null;
upSeq = 0;
upPendingByTs.clear();
}
function ensureUplinkEncoder(width, height) {
if (upEncoder && upEncoder.state === "configured") return;
const sessionId = activeSessionId;
upEncoder = new VideoEncoder({
output: (chunk) => {
if (sessionId !== activeSessionId) return;
const info = upPendingByTs.get(chunk.timestamp);
upPendingByTs.delete(chunk.timestamp);
if (!info || !ws || ws.readyState !== WebSocket.OPEN) return;
if ((ws.bufferedAmount || 0) > 786432) {
upResumeKeyframe = true;
return;
}
const buf = new ArrayBuffer(chunk.byteLength);
chunk.copyTo(buf);
try {
ws.send(JSON.stringify({ session_id: activeSessionId, type: "frame_meta", seq: info.seq, t_capture_ms: info.t_capture_ms }));
ws.send(buf);
} catch (e) {
return;
}
sentFrames += 1;
sentFramesWindow += 1;
updateMetrics();
},
error: (e) => { console.error("VideoEncoder", e); },
});
upEncoder.configure(upEncoderConfig(width, height));
}
function encodeUplinkFrame(width, height) {
drawCaptureFrame(width, height);
ensureUplinkEncoder(width, height);
const seq = ++upSeq;
upPendingByTs.set(seq, { seq, t_capture_ms: Date.now() });
let forceKey = false;
if (upResumeKeyframe && ws && (ws.bufferedAmount || 0) < 131072) {
forceKey = true;
upResumeKeyframe = false;
}
const vf = new VideoFrame(capture, { timestamp: seq });
upEncoder.encode(vf, { keyFrame: forceKey || seq === 1 || seq % UPLINK_KEYFRAME_INTERVAL === 0 });
vf.close();
}
function resetOutputDecoder() {
if (outDecoder && outDecoder.state !== "closed") {
try { outDecoder.close(); } catch (e) {}
}
outDecoder = null;
outDecSeq = 0;
outMetaByTs.clear();
}
function feedOutputH264(buf, meta) {
if (!outDecoder || outDecoder.state === "closed") {
const sessionId = activeSessionId;
outDecoder = new VideoDecoder({
output: (frame) => {
if (sessionId !== activeSessionId) { frame.close(); return; }
const m = outMetaByTs.get(frame.timestamp);
outMetaByTs.delete(frame.timestamp);
createImageBitmap(frame).then((bmp) => {
frame.close();
if (sessionId !== activeSessionId) { bmp.close(); return; }
enqueueOutputFrame(bmp, m);
}, () => { frame.close(); });
},
error: (e) => { console.error("VideoDecoder", e); },
});
outDecoder.configure({ codec: "avc1.640028", optimizeForLatency: true });
outDecSeq = 0;
outMetaByTs.clear();
}
const key = !!(meta && meta.key);
if (outDecSeq === 0 && !key) return;
const ts = ++outDecSeq;
if (meta) outMetaByTs.set(ts, meta);
outDecoder.decode(new EncodedVideoChunk({ type: key ? "key" : "delta", timestamp: ts, data: buf }));
}
function enqueueOutputFrame(frame, meta) {
if (meta && meta.session_id && meta.session_id !== activeSessionId) {
if (frame instanceof ImageBitmap) frame.close();
return;
}
const item = frame instanceof ImageBitmap
? { bmp: frame, meta, receivedAtMs: Date.now() }
: { url: URL.createObjectURL(frame), meta, receivedAtMs: Date.now() };
if (!smoothPlaybackEnabled()) {
displayOutputFrame(item);
return;
}
const tc = Number(item.meta && item.meta.t_capture_ms) || 0;
if (tc && tc <= lastShownCaptureMs) {
disposeItem(item); outputQueueDroppedFrames += 1;
return;
}
if (meta && meta.t_capture_ms && !(meta.profile && meta.profile.passthrough)) {
const age = Date.now() - Number(meta.t_capture_ms);
if (age > 0 && age < 5000) alignMaxAgeMs = Math.max(age, alignMaxAgeMs * 0.99);
}
outputFrameQueue.push(item);
if (tc) {
let i = outputFrameQueue.length - 1;
while (i > 0 && (Number(outputFrameQueue[i - 1].meta && outputFrameQueue[i - 1].meta.t_capture_ms) || 0) > tc) {
outputFrameQueue[i] = outputFrameQueue[i - 1];
i--;
}
outputFrameQueue[i] = item;
}
while (outputFrameQueue.length > (outCodecH264 ? 32 : 120)) {
const dropped = outputFrameQueue.shift();
if (dropped) { disposeItem(dropped); outputQueueDroppedFrames += 1; }
}
ensureOutputPlaybackTimer();
updateMetrics();
}
function flushOutputQueueToLatest() {
while (outputFrameQueue.length > 1) {
const dropped = outputFrameQueue.shift();
disposeItem(dropped);
outputQueueDroppedFrames += 1;
}
if (outputFrameQueue.length === 1) displayOutputFrame(outputFrameQueue.shift());
}
function measureCameraFrame(now) {
cameraMeasuredFrames += 1;
const elapsed = Math.max((now - cameraMeasureStartedAt) / 1000, 0.001);
if (elapsed >= 1) {
cameraMeasuredFps = cameraMeasuredFrames / elapsed;
cameraMeasuredFrames = 0;
cameraMeasureStartedAt = now;
updateMetrics();
}
if (camera.requestVideoFrameCallback) {
camera.requestVideoFrameCallback(measureCameraFrame);
}
}
function measureCameraFallback() {
if (!camera.getVideoPlaybackQuality) return;
const quality = camera.getVideoPlaybackQuality();
if (!quality || quality.totalVideoFrames === undefined) return;
if (lastVideoQualityFrames === null) {
lastVideoQualityFrames = quality.totalVideoFrames;
return;
}
cameraMeasuredFps = Math.max(0, quality.totalVideoFrames - lastVideoQualityFrames);
lastVideoQualityFrames = quality.totalVideoFrames;
updateMetrics();
}
setInterval(updateRateMeters, 1000);
setInterval(measureCameraFallback, 1000);
updateMetrics();
async function ensureCamera() {
if (usingVideoFile) {
cameraTrackFps = null;
updateMetrics();
return;
}
if (stream) {
const settings = getCameraSettings();
cameraTrackFps = settings.frameRate || null;
updateMetrics();
return;
}
if (window.isSecureContext === false) {
throw new Error("Camera access requires HTTPS or localhost; public HTTP origins cannot use getUserMedia.");
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error("navigator.mediaDevices.getUserMedia is unavailable");
}
stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: cameraTargetFps(), max: Math.max(cameraTargetFps(), 24) }
},
audio: false
});
camera.muted = true;
camera.playsInline = true;
camera.srcObject = stream;
await new Promise((resolve) => {
if (camera.readyState >= 2) resolve();
else camera.onloadedmetadata = resolve;
setTimeout(resolve, 2000);
});
await camera.play();
const settings = getCameraSettings();
cameraTrackFps = settings.frameRate || null;
const camVW = camera.videoWidth || 0;
const camVH = camera.videoHeight || 0;
if (camVW && camVH) {
const want = camVH > camVW ? "portrait" : "landscape";
if (currentOrientation() !== want) applyOrientation(want);
}
updateMetrics();
updateCropMask();
if (camera.requestVideoFrameCallback) {
camera.requestVideoFrameCallback(measureCameraFrame);
}
}
function drawCaptureFrame(width, height) {
capture.width = width;
capture.height = height;
const ctx = capture.getContext("2d");
const selfieMirror = !usingVideoFile;
ctx.save();
if (selfieMirror) {
ctx.translate(width, 0);
ctx.scale(-1, 1);
}
const sw = camera.videoWidth || width;
const sh = camera.videoHeight || height;
const dstAr = width / height;
const srcAr = sw / sh;
let sx, sy, cropW, cropH;
if (srcAr > dstAr) {
cropH = sh;
cropW = Math.round(sh * dstAr);
sx = Math.round((sw - cropW) / 2);
sy = 0;
} else {
cropW = sw;
cropH = Math.round(sw / dstAr);
sx = 0;
sy = Math.round((sh - cropH) / 2);
}
ctx.drawImage(camera, sx, sy, cropW, cropH, 0, 0, width, height);
ctx.restore();
}
function frameBlob(width, height, quality) {
drawCaptureFrame(width, height);
return new Promise(resolve => capture.toBlob(resolve, "image/jpeg", quality));
}
async function tick() {
const generation = videoPullGeneration;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (pePausedSend) return;
if (tickInFlight) {
sendRealtimeSkips += 1;
updateMetrics();
return;
}
tickInFlight = true;
try {
if (backendPendingFrames() >= MAX_BACKEND_PENDING_FRAMES) {
sendRealtimeSkips += 1;
updateMetrics();
return;
}
const width = Number(sessionConfig.width);
const height = Number(sessionConfig.height);
if (upCodecH264) {
encodeUplinkFrame(width, height);
updateMetrics();
return;
}
const blob = await frameBlob(width, height, effectiveUpQuality());
if (generation !== videoPullGeneration || !blob || !ws || ws.readyState !== WebSocket.OPEN) return;
const seq = sentFrames + 1;
const tCaptureMs = Date.now();
try {
ws.send(JSON.stringify({ session_id: activeSessionId, type: "frame_meta", seq, t_capture_ms: tCaptureMs }));
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(blob);
} catch (err) {
return;
}
sentFrames += 1;
sentFramesWindow += 1;
updateMetrics();
} finally {
if (generation === videoPullGeneration) tickInFlight = false;
}
}
function seekVideoTo(timeSec) {
return new Promise((resolve) => {
let done = false;
const finish = () => { if (done) return; done = true; camera.removeEventListener("seeked", finish); resolve(); };
camera.addEventListener("seeked", finish, { once: true });
try { camera.currentTime = timeSec; } catch (err) { finish(); }
setTimeout(finish, 400);
});
}
async function sendCurrentVideoFrame(mediaTimeSec, generation = videoPullGeneration) {
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
const width = Number(sessionConfig.width);
const height = Number(sessionConfig.height);
const quality = effectiveUpQuality();
const blob = await frameBlob(width, height, quality);
if (generation !== videoPullGeneration || !blob || !ws || ws.readyState !== WebSocket.OPEN) return false;
const seq = sentFrames + 1;
const tCaptureMs = Math.round(mediaTimeSec * 1000);
try {
ws.send(JSON.stringify({ session_id: activeSessionId, type: "frame_meta", seq, t_capture_ms: tCaptureMs }));
if (ws.readyState !== WebSocket.OPEN) return false;
ws.send(blob);
} catch (err) {
return false;
}
sentFrames += 1;
sentFramesWindow += 1;
updateMetrics();
return true;
}
async function pullNextVideoFrame(generation) {
if (generation !== videoPullGeneration || !videoPullActive) return;
if (!ws || ws.readyState !== WebSocket.OPEN) { videoPullActive = false; return; }
if (pePausedSend) return;
if (videoPullIdx >= videoPullTotal) {
videoPullActive = false;
handleVideoEnded();
return;
}
const frameStartMs = Date.now();
const intervalMs = 1000 / Math.max(1, videoPullFps);
if (backendPendingFrames() >= (peDeferThisSend ? 1 : MAX_BACKEND_PENDING_FRAMES)) {
videoPullTimer = setTimeout(() => pullNextVideoFrame(generation), 30);
return;
}
const t = videoPullIdx / videoPullFps;
await seekVideoTo(t);
if (generation !== videoPullGeneration || !videoPullActive || pePausedSend) return;
const ok = await sendCurrentVideoFrame(t, generation);
if (generation !== videoPullGeneration || !videoPullActive) return;
if (ok) videoPullIdx += 1;
const spent = Date.now() - frameStartMs;
const wait = ok ? Math.max(0, intervalMs - spent) : 30;
videoPullTimer = setTimeout(() => pullNextVideoFrame(generation), wait);
}
function startVideoPull(resume = false) {
if (videoPullActive && !resume) return;
stopVideoPull();
const dur = (Number.isFinite(camera.duration) && camera.duration > 0) ? camera.duration : 0;
videoPullTotal = dur > 0 ? Math.max(1, Math.ceil(dur * videoPullFps)) : Number.MAX_SAFE_INTEGER;
if (!resume) videoPullIdx = 0;
videoPullActive = true;
pullNextVideoFrame(videoPullGeneration);
}
function stopVideoPull() {
videoPullActive = false;
videoPullGeneration += 1;
if (videoPullTimer !== null) clearTimeout(videoPullTimer);
videoPullTimer = null;
}
async function beginSession() {
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
stopVideoPull();
activeSessionId = String(Number(activeSessionId) + 1);
const sessionId = activeSessionId;
const socket = ws;
resetRunMetrics();
pePausedSend = false;
clearResultVideo();
if (usingVideoFile) {
camera.loop = false;
try { camera.pause(); } catch (err) {}
try { camera.currentTime = 0; } catch (err) {}
}
let refImage = null;
try {
refImage = await readRefImageDataUrl();
} catch (err) {
}
if (sessionId !== activeSessionId || socket !== ws) return false;
const rawPrompt = document.getElementById("prompt").value;
const usePe = document.getElementById("usePe").checked;
const width = Number(sessionConfig.width);
const height = Number(sessionConfig.height);
peDeferThisSend = usePe;
hidePeResult();
upCodecH264 = false;
if (!usingVideoFile && typeof VideoEncoder !== "undefined") {
try {
const s = await VideoEncoder.isConfigSupported(upEncoderConfig(width, height));
upCodecH264 = !!(s && s.supported);
} catch (e) {}
}
const startPayload = {
type: "start",
session_id: activeSessionId,
prompt: rawPrompt,
width,
height,
num_inference_steps: Number(sessionConfig.num_inference_steps),
use_pe: usePe,
detection_mode: detectionMode(),
seed: SERVER_DEFAULTS.seed,
kv_reset_frames: Math.max(0, Math.floor(Number(sessionConfig.kv_reset_frames) || 0)),
output_quality: Number(downQualityUi.value),
fps: sendTargetFps(),
output_codec: h264DecodeOk ? "h264" : "mjpeg",
input_codec: upCodecH264 ? "h264" : "mjpeg"
};
if (usingVideoFile) {
startPayload.source = "file";
}
if (refImage) startPayload.ref_image = refImage;
startPayload.max_temporal_ids = maxTemporalIdsValue();
startPayload.freeze_kv_on_static = !usingVideoFile && sessionConfig.freeze_kv_on_static;
startPayload.static_diff_thresh = sessionConfig.static_diff_thresh;
startPayload.profile_timings = document.getElementById("profileTimings").checked;
sessionProfileTimings = startPayload.profile_timings;
stageTimingsPanel.style.display = sessionProfileTimings ? "" : "none";
if (sessionId !== activeSessionId || socket !== ws || !ws || ws.readyState !== WebSocket.OPEN) return false;
ws.send(JSON.stringify(startPayload));
return true;
}
async function start() {
startingRun = true;
terminalHold = false;
setSendBusy(true, t("busy_starting"));
showOutputStart(true, t("busy_starting"));
try {
await ensureCamera();
} catch (err) {
startingRun = false;
setSendBusy(false, t("busy_camera_err"));
return;
}
const protocol = location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocket(`${protocol}://${location.host}/ws`);
ws = socket;
socket.binaryType = "arraybuffer";
socket.onopen = async () => {
};
socket.onmessage = async (event) => {
if (ws !== socket) return;
if (typeof event.data === "string") {
const msg = JSON.parse(event.data);
if (msg.session_id && msg.session_id !== activeSessionId) return;
noteBackendAck(msg);
if (msg.type === "queue_position") {
const ahead = Number(msg.ahead != null ? msg.ahead : msg.position) || 0;
setSendBusy(true, t("busy_queue") + ahead);
return;
}
if (msg.type === "session_granted") {
sessionGranted = true;
setSendBusy(true, t("busy_starting"));
showOutputStart(true, t("busy_starting"));
beginSession();
return;
}
if (msg.type === "started") {
peDeferThisSend = msg.pe_deferred;
if (Number(msg.width) > 0 && Number(msg.height) > 0) {
sessionConfig.width = msg.width;
sessionConfig.height = msg.height;
updateCropMask();
updateResBanner();
}
outCodecH264 = msg.output_codec === "h264";
resetOutputDecoder();
outputImg.style.display = outCodecH264 ? "none" : "";
outputCanvas.style.display = outCodecH264 ? "" : "none";
upCodecH264 = msg.input_codec === "h264";
resetUplinkEncoder();
if (usingVideoFile) {
try { camera.pause(); } catch (err) {}
try { camera.currentTime = 0; } catch (err) {}
}
startingRun = false;
pePausedSend = false;
setSendBusy(false, t("busy_running"));
outputWaitingFace = false;
waitHintReason = null;
if (outputWaitOverlay) outputWaitOverlay.style.display = "none";
showOutputIdle(false);
startFrameTimer();
if (usingVideoFile) updateUploadProgress();
return;
}
if (msg.type === "pong") {
const t = Number(msg.t);
if (Number.isFinite(t)) {
netRttLastMs = Math.max(0, Date.now() - t);
}
const up = Number(msg.up_ms);
upDelayMs = Number.isFinite(up) ? up : null;
if (upDelayMs !== null) {
upDelayHist.push(upDelayMs);
if (upDelayHist.length > 4) upDelayHist.shift();
}
const ts = Number(msg.ts);
if (Number.isFinite(ts)) {
let skew = Date.now() - ts;
if (downSkewMin !== null) downSkewMin += 0.5 * Math.max(0, Date.now() - downSkewAt) / 1000;
if (downSkewMin === null || skew < downSkewMin) downSkewMin = skew;
downSkewAt = Date.now();
downDelayMs = Math.max(0, skew - downSkewMin);
}
uplinkCongestionTick();
updateMetrics();
return;
}
if (msg.type === "flow_drop") {
flowDropTotal = Number(msg.dropped_total) || (flowDropTotal + 1);
flowDropAt = Date.now();
updateMetrics();
return;
}
if (msg.type === "output_frame") {
pendingOutputMeta = msg;
return;
}
if (msg.type === "recording_finalized") {
onRecordingFinalized(msg);
return;
}
if (msg.type === "prompt_enhanced") {
pePausedSend = false;
clearOutputQueue();
lastShownCaptureMs = Date.now();
alignMaxAgeMs = 0;
if (usingVideoFile && peDeferThisSend) {
peDeferThisSend = false;
try { camera.pause(); } catch (err) {}
startVideoPull(true);
}
const enhanced = msg.enhanced_prompt || msg.raw_prompt || "";
showPeResult((msg.error || msg.fallback) ? "" : enhanced, msg.model);
return;
}
if (msg.type === "session_reset") {
clearOutputQueue();
pendingOutputMeta = null;
serverChunkLastSec = null;
chunkProfileLast = null;
lastWsSendMs = null;
lastServerResidenceMs = null;
const resetMsg = {
person_count_changed: t("reset_count_changed"),
person_returned: t("reset_person_returned"),
kv_reset_frames: t("reset_kv"),
}[msg.reason];
if (resetMsg) {
if (msg.reason === "person_count_changed") {
showOutputToast(resetMsg);
}
} else {
}
updateMetrics();
return;
}
if (msg.type === "accepted") {
updateMetrics();
return;
}
if (msg.type === "no_person") {
outputWaitingFace = true;
clearOutputQueue();
pendingOutputMeta = null;
if (outputIdleOverlay) outputIdleOverlay.style.display = "none";
if (outputWaitOverlay) outputWaitOverlay.style.display = "";
const hints = {
no_face: { ico: "🙈", hint: t("hint_no_face") },
no_person: { ico: "🚫", hint: t("hint_no_person") },
full_body_needed: { ico: "🧍", hint: t("gate_full_body") },
full_body_unavailable: { ico: "⚠", hint: t("gate_full_body_unavailable") },
face_unavailable: { ico: "⚠", hint: t("gate_face_unavailable") },
};
const hint = hints[msg.reason] || hints.no_person;
if (outputWaitIco) outputWaitIco.textContent = hint.ico;
if (outputWaitHint) outputWaitHint.textContent = hint.hint;
waitHintReason = msg.reason || "no_person"; waitHintAt = Date.now();
return;
}
if (msg.type === "waiting_face") {
outputWaitingFace = true;
if (outputIdleOverlay) outputIdleOverlay.style.display = "none";
if (outputWaitOverlay) outputWaitOverlay.style.display = "";
const R = msg.reason || "no_face";
const now = Date.now();
if (waitHintReason === null || (R !== waitHintReason && now - waitHintAt >= WAIT_HINT_MIN_MS)) {
const map = {
too_close: { ico: "👕", hint: t("gate_too_close") },
too_small: { ico: "🔍", hint: t("gate_too_small") },
face_cut: { ico: "🙂", hint: t("gate_face_cut") },
off_center: { ico: "🎯", hint: t("gate_off_center") },
settling: { ico: "✋", hint: t("gate_settling") },
no_face: { ico: "👤", hint: t("gate_no_face_wait") },
face_unavailable: { ico: "⚠", hint: t("gate_face_unavailable") },
full_body_needed: { ico: "🧍", hint: t("gate_full_body") },
full_body_unavailable: { ico: "⚠", hint: t("gate_full_body_unavailable") },
};
const m = map[R] || map.no_face;
if (outputWaitIco) outputWaitIco.textContent = m.ico;
if (outputWaitHint) outputWaitHint.textContent = m.hint;
waitHintReason = R; waitHintAt = now;
}
return;
}
if (msg.type === "pe_running") {
pePausedSend = true;
if (usingVideoFile) {
try { camera.pause(); } catch (err) {}
stopVideoPull();
}
outputWaitingFace = true;
if (outputIdleOverlay) outputIdleOverlay.style.display = "none";
if (outputWaitOverlay) outputWaitOverlay.style.display = "";
if (outputWaitIco) outputWaitIco.textContent = "✨";
if (outputWaitHint) outputWaitHint.textContent = t("pe_running");
waitHintReason = "pe_running"; waitHintAt = Date.now();
return;
}
if (msg.type === "chunk_done") {
try { ws.send(JSON.stringify({ session_id: activeSessionId, type: "ack", recv: receivedFrames })); } catch (err) {}
if (
chunkProfileLast &&
(msg.ws_send_s !== undefined || msg.server_residence_s !== undefined) &&
(msg.chunk_idx === undefined || chunkProfileLast.chunk_idx === msg.chunk_idx)
) {
if (msg.ws_send_s !== undefined) chunkProfileLast.ws_send_s = msg.ws_send_s;
if (msg.server_residence_s !== undefined) chunkProfileLast.server_residence_s = msg.server_residence_s;
if (msg.ws_send_s !== undefined && Number.isFinite(Number(msg.ws_send_s))) lastWsSendMs = Number(msg.ws_send_s) * 1000;
if (msg.server_residence_s !== undefined && Number.isFinite(Number(msg.server_residence_s))) lastServerResidenceMs = Number(msg.server_residence_s) * 1000;
updateMetrics();
}
return;
}
if (msg.type === "session_timeout") {
stopVideoPull();
stopRecordDrain();
sessionGranted = false;
if (!terminalHold) setSendBusy(false, t("busy_released"));
return;
}
if (msg.type === "error") {
stopVideoPull();
stopRecordDrain();
sessionGranted = false;
startingRun = false;
console.error("[server error]", msg.message || msg);
const hint = String(msg.message || "server error");
terminalHold = true;
setSendBusy(false, hint);
showOutputStart(true, hint);
return;
}
return;
}
if (startingRun || !pendingOutputMeta ||
(pendingOutputMeta.session_id && pendingOutputMeta.session_id !== activeSessionId)) return;
const meta = pendingOutputMeta;
pendingOutputMeta = null;
receivedFrames += 1;
if (receivedFrames % 4 === 0) {
try { ws.send(JSON.stringify({ session_id: activeSessionId, type: "ack", recv: receivedFrames })); } catch (err) {}
}
receivedFramesWindow += 1;
if (meta) {
const latency = Date.now() - Number(meta.t_capture_ms || Date.now());
editReceiveLatencyLastMs = latency;
serverChunkLastSec = Number(meta.server_elapsed || 0);
chunkProfileLast = meta.profile || chunkProfileLast;
updateMetrics();
}
if (outCodecH264) {
feedOutputH264(event.data, meta);
return;
}
enqueueOutputFrame(new Blob([event.data]), meta);
};
socket.onclose = () => {
if (ws !== socket) return;
if (timer) clearInterval(timer);
timer = null;
stopVideoPull();
resetOutputDecoder();
resetUplinkEncoder();
if (netPingTimer) clearInterval(netPingTimer);
netPingTimer = null;
if (outputPlaybackTimer) clearInterval(outputPlaybackTimer);
outputPlaybackTimer = null;
outputPlaybackIntervalMs = null;
clearOutputQueue();
stopRecordDrain();
finalizeIsForResult = false;
if (!lastRecId) resultEpoch += 1;
ws = null;
startingRun = false;
sessionGranted = false;
if (!terminalHold && !lastRecId) {
setSendBusy(false, t("busy_disconnected"));
showOutputIdle(true);
}
updateMetrics();
};
socket.onerror = () => {
if (ws === socket) {
startingRun = false;
setSendBusy(false, t("busy_conn_err"));
}
};
}
function stopActiveRun() {
if (timer) clearInterval(timer);
timer = null;
stopVideoPull();
stopRecordDrain();
try { if (usingVideoFile) camera.pause(); } catch (err) {}
if (netPingTimer) clearInterval(netPingTimer);
netPingTimer = null;
if (outputPlaybackTimer) clearInterval(outputPlaybackTimer);
outputPlaybackTimer = null;
outputPlaybackIntervalMs = null;
clearOutputQueue();
const currentWs = ws;
ws = null;
if (currentWs) {
try {
if (currentWs.readyState === WebSocket.OPEN) currentWs.send(JSON.stringify({ session_id: activeSessionId, type: "stop" }));
} catch (err) {}
currentWs.onopen = null;
currentWs.onmessage = null;
currentWs.onerror = null;
currentWs.onclose = null;
try { currentWs.close(); } catch (err) {}
}
}
async function send() {
if (startingRun) return;
if (ws && ws.readyState === WebSocket.OPEN && sessionGranted) {
startingRun = true;
setSendBusy(true, t("busy_resetting"));
if (timer) clearInterval(timer);
timer = null;
stopVideoPull();
try { if (usingVideoFile) camera.pause(); } catch (err) {}
clearResultVideo();
clearOutputQueue();
showOutputStart(true, t("busy_resetting"));
const ok = await beginSession();
if (ok) return;
startingRun = false;
sessionGranted = false;
}
stopActiveRun();
await start();
}
sendBtn.onclick = send;
const PROMPT_GROUPS = [
{
key: "change_clothes",
label: "实时换装", label_en: "Real-Time Outfit Change",
cases: [
{ title: "白T恤", title_en: "White Tee", desc: "上衣换成白色圆领纯棉T恤", desc_en: "Top → white crew-neck cotton tee", text: "将主角的上衣换成白色圆领纯棉 T 恤。", text_en: "Replace the subject's top with a white crew-neck cotton T-shirt." },
{ title: "皮夹克", title_en: "Leather Jacket", desc: "外套换成哑光黑色皮质机车夹克", desc_en: "Jacket → matte black leather biker jacket", text: "将主角的外套换成哑光黑色皮质机车夹克。", text_en: "Replace the subject's jacket with a matte black leather biker jacket." },
{ title: "汉服", title_en: "Hanfu", desc: "换成传统汉服交领右衽上衣", desc_en: "Traditional cross-collar Hanfu top", text: "将主角的服装换成传统汉服交领右衽上衣。", text_en: "Replace the subject's outfit with a traditional cross-collar (right-lapel) Hanfu top." },
{ title: "学院风衬衫", title_en: "Preppy Shirt", desc: "白衬衫配藏青V领马甲加酒红领带", desc_en: "White shirt, navy V-vest, wine tie", text: "将主角的上衣换成白衬衫搭配藏青色 V 领马甲和酒红色领带。", text_en: "Replace the subject's top with a white shirt paired with a navy V-neck vest and a wine-red tie." },
{ title: "京东定制帽 + 墨镜", title_en: "Custom JD Cap + Shades", desc: "戴上“京东”的红色鸭舌帽和墨镜", desc_en: "Red cap reading “京东” plus sunglasses", text: "给主角戴上写着“京东”两个汉字的红色鸭舌帽和墨镜。", text_en: "Put a red cap printed with the Chinese characters \"京东\" and a pair of sunglasses on the subject." },
],
},
{
key: "ref_tryon",
label: "参考图编辑", label_en: "Edit from Reference",
cases: [
{ title: "NY 棒球帽", title_en: "NY Cap", desc: "戴上参考图里的黑色 NY 棒球帽", desc_en: "Black NY cap from the reference image", ref: "hat", text: "将参考图中的黑色 NY 棒球帽戴到主角头上,帽子正面朝前。", text_en: "Put the black NY baseball cap from the reference image on the subject's head, facing forward." },
{ title: "红色围巾", title_en: "Red Scarf", desc: "围上参考图里的红色流苏围巾", desc_en: "Red tasseled scarf from the reference image", ref: "scarf", text: "将参考图中的红色流苏围巾围到主角的颈部。", text_en: "Wrap the red tasseled scarf from the reference image around the subject's neck." },
{ title: "粉色T恤", title_en: "Pink Tee", desc: "换上参考图里的粉色短袖 T 恤", desc_en: "Pink short-sleeve tee from the reference image", ref: "pink_tee", text: "将主角的衣服换成参考图里的粉色短袖 T 恤。", text_en: "Replace the subject's clothing with the pink short-sleeve T-shirt from the reference image." },
{ title: "橙色眼镜", title_en: "Orange Glasses", desc: "戴上参考图里的橙色镜片眼镜", desc_en: "Orange-tinted glasses from the reference image", ref: "orange_glasses", text: "给主角戴上参考图里的橙色镜片眼镜。", text_en: "Put the orange-tinted glasses from the reference image on the subject." },
{ title: "奶龙", title_en: "Nailong", desc: "主角换成参考图里的黄色玩偶", desc_en: "Subject → yellow toy from the reference image", ref: "nailong", text: "将视频中的主角替换为参考图中的黄色玩偶。", text_en: "Replace the main subject in the video with the yellow toy from the reference image." },
{ title: "微信正太", title_en: "WeChat Shota", desc: "主角换成参考图里的正太角色", desc_en: "Replace the subject with the boy character in the reference image", ref: "wechat_shota", text: "将视频中的主角替换为参考图中的少年角色:脸部、发型、服装和身份都要与参考图一致,保留原有动作和背景。", text_en: "Replace the main subject with the young character from the reference image: match the face, hair, outfit and identity, keeping the original motion and background." }, { title: "人物替换", title_en: "Person Swap", desc: "主角换成参考图里的人物,脸部/发型/身份一致", desc_en: "Replace the subject with the person from the reference image", text: "将视频中的主角替换为参考图中的人物:脸部、发型和身份都要与参考图一致,同时保留原有动作、背景和其他人物。", text_en: "Replace the main subject in the video with the person from the reference image: match the face, hair and identity to the reference, keeping the original motion, background and other people unchanged." },
],
},
{
key: "style",
label: "风格演绎", label_en: "Style Transfer",
cases: [
{ title: "3D动画", title_en: "3D Animation", desc: "渲染为3D动画电影风格", desc_en: "Render as 3D animated-film style", text: "将整个画面渲染为3D动画电影风格。应用平滑的三维建模质感、柔和的全局光照和景深,以及皮克斯风格的角色与场景渲染。", text_en: "Render the entire scene in a 3D animated-film style. Apply smooth 3D-modeled surfaces, soft global illumination and depth of field, and Pixar-style character and scene rendering." },
{ title: "油画质感", title_en: "Oil Painting", desc: "转成梵高厚涂油画风格", desc_en: "Van Gogh impasto oil-painting style", text: "将整个画面转换为梵高的画的风格。应用厚重、富有表现力的笔触,鲜艳且强烈的色彩,天空中和风景中动态旋转的纹理,以及梵高画作中特有的粗放油彩(厚涂法)效果。", text_en: "Convert the entire scene into the style of a Van Gogh painting. Apply thick, expressive brushstrokes, vivid and intense colors, dynamic swirling textures in the sky and landscape, and the coarse impasto oil effect characteristic of Van Gogh's work." },
{ title: "像素画", title_en: "Pixel Art", desc: "渲染为16色像素画风格", desc_en: "Render as 16-color pixel art", text: "将整个画面渲染为16色像素画风格。", text_en: "Render the entire scene in a 16-color pixel-art style." },
{ title: "日漫赛璐璐", title_en: "Anime Cel", desc: "转成日式动漫赛璐璐风格", desc_en: "Japanese anime cel-shaded style", text: "将整个画面转换为日式动漫赛璐璐风格:干净的黑色描边、大面积平涂的纯色色块、明确的明暗二分硬边阴影和简洁的高光。", text_en: "Convert the entire scene into a Japanese anime cel-shaded style: clean black outlines, large flat areas of solid color, clear hard-edged two-tone shading, and simple highlights." },
],
},
{
key: "face_edit",
label: "外貌改造", label_en: "Look Edit",
cases: [
{ title: "戴上墨镜", title_en: "Sunglasses", desc: "戴一副粉色飞行员墨镜", desc_en: "Add pink aviator sunglasses", text: "给主角添加一副粉色飞行员墨镜架在鼻梁上。", text_en: "Add a pair of pink aviator sunglasses resting on the bridge of the subject's nose." },
{ title: "戴口罩", title_en: "Face Mask", desc: "脸上戴一个医用口罩", desc_en: "Add a medical face mask", text: "给主角的脸上戴一个医用口罩。", text_en: "Put a medical face mask on the subject's face." },
{ title: "加耳环", title_en: "Earrings", desc: "双耳加一对金色圆环耳环", desc_en: "Add a pair of gold hoop earrings", text: "给主角的双耳耳垂添加一对抛光金色圆环耳环。", text_en: "Add a pair of polished gold hoop earrings to the subject's earlobes." },
{ title: "加络腮胡", title_en: "Beard", desc: "下颌脸颊加浓密络腮胡", desc_en: "Add a thick full beard", text: "给主角的下颌和脸颊添加浓密的深黄色络腮胡须,自然卷曲,覆盖下巴和两颊。", text_en: "Add a thick dark-yellow full beard to the subject's jaw and cheeks, naturally curly, covering the chin and both cheeks." },
],
},
{
key: "material",
label: "质感变身", label_en: "Material Morph",
cases: [
{ title: "大理石雕塑", title_en: "Marble Statue", desc: "变成白色大理石雕塑", desc_en: "Turn into a white marble statue", text: "将主角整体转化为白色大理石雕塑:可见石材纹理和细微灰色脉络、表面呈抛光光泽、光线在曲面形成柔和高光,眼睛和头发同为石质,保持原有动作但带有雕塑般的质感。", text_en: "Transform the subject entirely into a white marble statue: visible stone texture and fine gray veining, a polished glossy surface, soft highlights on the curved surfaces, eyes and hair also made of stone, keeping the original motion but with a sculptural quality." },
{ title: "乐高小人", title_en: "LEGO Figure", desc: "变成乐高积木小人", desc_en: "Turn into a LEGO minifigure", text: "将主角转化为乐高积木小人风格:黄色圆柱形头部、表面为塑料亚光质感,五官简化为印刷贴纸式的黑色圆点眼睛和弧线微笑。", text_en: "Transform the subject into a LEGO minifigure style: a yellow cylindrical head with a matte plastic surface, facial features simplified into printed-sticker-style black dot eyes and a curved smile." },
{ title: "赛博机械体", title_en: "Cyborg", desc: "变成赛博机械体", desc_en: "Turn into a cyborg", text: "将主角转化为赛博机械体:面部露出银色金属骨架和精密齿轮、眼睛发出冷蓝光、皮肤接缝处可见荧光线路板纹路,关节处有可见的液压结构。", text_en: "Transform the subject into a cyborg: a silver metal skeleton and precision gears exposed on the face, eyes glowing cold blue, glowing circuit-board traces along the skin seams, and visible hydraulic structures at the joints." },
{ title: "黏土动画", title_en: "Claymation", desc: "变成定格黏土动画角色", desc_en: "Turn into a stop-motion clay character", text: "将主角转化为定格黏土动画角色:表面可见手工捏制的指纹压痕和不规则质感、色彩饱和但略带哑光、比例微微卡通化(头部略大)。", text_en: "Transform the subject into a stop-motion claymation character: visible hand-molded fingerprint dents and irregular texture, saturated but slightly matte colors, and mildly cartoonish proportions (a slightly larger head)." },
],
},
{
key: "bg_swap",
label: "背景切换", label_en: "Swap Background",
cases: [
{ title: "热带海滩", title_en: "Tropical Beach", desc: "背景换成阳光热带海滩", desc_en: "Background → sunny tropical beach", text: "将背景场景替换为阳光明媚的热带海滩。", text_en: "Replace the background scene with a sunny tropical beach." },
{ title: "荒凉沙漠", title_en: "Barren Desert", desc: "背景换成荒凉空旷沙漠", desc_en: "Background → barren empty desert", text: "将背景场景替换为荒凉空旷的沙漠。", text_en: "Replace the background scene with a barren, empty desert." },
{ title: "新中式客厅", title_en: "Modern Chinese Living Room", desc: "背景换成新中式客厅", desc_en: "Background → modern Chinese living room", text: "将背景替换为新中式风格的客厅。", text_en: "Replace the background with a modern Chinese-style living room." },
],
},
{
key: "swap_hero",
label: "主角变身", label_en: "Transform Subject",
cases: [
{ title: "梅西", title_en: "Messi", desc: "主角变成球星梅西", desc_en: "Turn the subject into Lionel Messi", text: "将主角变成足球明星梅西(Lionel Messi)的样貌。", text_en: "Change the subject's appearance into that of the football star Lionel Messi." },
{ title: "詹姆斯", title_en: "LeBron", desc: "主角变成球星詹姆斯", desc_en: "Turn the subject into LeBron James", text: "将主角变成篮球明星勒布朗·詹姆斯(LeBron James)的样貌。", text_en: "Change the subject's appearance into that of the basketball star LeBron James." },
{ title: "特朗普", title_en: "Trump", desc: "主角变成特朗普", desc_en: "Turn the subject into Donald Trump", text: "将主角变成美国总统特朗普(Donald Trump)的样貌。", text_en: "Change the subject's appearance into that of U.S. President Donald Trump." },
{ title: "奥巴马", title_en: "Obama", desc: "主角变成奥巴马", desc_en: "Turn the subject into Barack Obama", text: "将主角变成美国前总统奥巴马(Barack Obama)的样貌。", text_en: "Change the subject's appearance into that of former U.S. President Barack Obama." },
{ title: "奥特曼", title_en: "Ultraman", desc: "主角变成奥特曼", desc_en: "Turn the subject into Ultraman", text: "将主角的脸和身体变成奥特曼。", text_en: "Change the subject's face and body into Ultraman." },
{ title: "拉布拉多", title_en: "Labrador", desc: "主角变成拉布拉多", desc_en: "Turn the subject into a Labrador", text: "将主角的脸和身体变成一只拉布拉多犬。", text_en: "Change the subject's face and body into a Labrador dog." },
],
},
];
const promptBox = document.getElementById("prompt");
function autosizePrompt() {
promptBox.style.height = "auto";
promptBox.style.height = promptBox.scrollHeight + "px";
}
promptBox.addEventListener("input", autosizePrompt);
function applyCaseSelection(item, group) {
promptBox.value = sceneField(item, "text");
autosizePrompt();
if (item.ref && REF_IMAGES && REF_IMAGES[item.ref]) {
const el = document.getElementById("refImage");
if (el) el.value = "";
injectedRefImageDataUrl = REF_IMAGES[item.ref];
refImagePreviewDataUrl = injectedRefImageDataUrl;
} else if (!refImageSelected()) {
injectedRefImageDataUrl = null;
refImagePreviewDataUrl = null;
}
syncRefThumb();
closeCardsPop();
updateMetrics();
send();
}
function syncRefThumb() {
if (!refSlot) return;
const url = refImagePreviewDataUrl || injectedRefImageDataUrl || null;
if (url) {
if (refThumbImg) refThumbImg.src = url;
refSlot.classList.add("has-thumb");
refSlot.style.display = "";
} else {
if (refThumbImg) refThumbImg.removeAttribute("src");
refSlot.classList.remove("has-thumb");
refSlot.style.display = "";
}
syncPeAvailability();
}
let peDefaultsApplied = false;
function syncPeAvailability() {
const usePeEl = document.getElementById("usePe");
const peAvailable = !(SERVER_DEFAULTS && SERVER_DEFAULTS.pe_available === false);
const locked = !peAvailable;
if (usePeEl) {
usePeEl.disabled = locked;
if (!peAvailable) usePeEl.checked = false;
else if (!peDefaultsApplied && SERVER_DEFAULTS) {
usePeEl.checked = SERVER_DEFAULTS.use_pe !== false;
peDefaultsApplied = true;
}
}
if (peToggleEl) {
peToggleEl.setAttribute("aria-disabled", locked ? "true" : "false");
}
}
let activeChipKey = null;
function closeCardsPop() {
if (cardsPop) { cardsPop.classList.remove("open"); cardsPop.innerHTML = ""; }
activeChipKey = null;
if (sceneChipsEl) {
for (const c of sceneChipsEl.querySelectorAll(".chip")) c.classList.remove("active");
}
}
function openCategory(group, chipEl) {
if (!cardsPop) return;
if (activeChipKey === group.key) { closeCardsPop(); return; }
activeChipKey = group.key;
if (sceneChipsEl) {
for (const c of sceneChipsEl.querySelectorAll(".chip")) c.classList.toggle("active", c === chipEl);
}
cardsPop.innerHTML = "";
const grid = document.createElement("div");
grid.className = "pop-grid";
for (const item of group.cases) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "prompt-case";
if (item.ref) btn.classList.add("has-ref");
const tEl = document.createElement("span");
tEl.className = "pc-title";
const _ttl = sceneField(item, "title"); tEl.textContent = item.ref ? ("📎 " + _ttl) : _ttl;
const d = document.createElement("span");
d.className = "pc-desc";
d.textContent = sceneField(item, "desc");
btn.appendChild(tEl);
btn.appendChild(d);
btn.addEventListener("click", () => applyCaseSelection(item, group));
grid.appendChild(btn);
}
cardsPop.appendChild(grid);
cardsPop.classList.add("open");
}
function renderPromptCases() {
if (!sceneChipsEl) return;
sceneChipsEl.innerHTML = "";
for (const group of PROMPT_GROUPS) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "chip";
if (group.cases.some((c) => c.ref)) chip.classList.add("chip-ref");
chip.textContent = sceneField(group, "label");
chip.addEventListener("click", () => openCategory(group, chip));
sceneChipsEl.appendChild(chip);
}
}
renderPromptCases();
fetch("/ref-images").then((r) => (r.ok ? r.json() : {})).then((d) => { REF_IMAGES = d || {}; }).catch(() => {});
syncPeAvailability();
function handleVideoFileChange(event) {
const file = event.target.files && event.target.files[0];
if (!file) return;
stopActiveRun();
camera.onended = null;
camera.onloadedmetadata = null;
startingRun = false;
sessionGranted = false;
setSendBusy(false, "");
clearResultVideo();
showCameraOverlay(false);
blankOutputPane();
showOutputIdle(true);
if (stream) {
for (const track of stream.getTracks()) { try { track.stop(); } catch (err) {} }
stream = null;
}
if (videoFileUrl) { try { URL.revokeObjectURL(videoFileUrl); } catch (err) {} }
videoFileUrl = URL.createObjectURL(file);
switchDetectionSource(true);
camera.srcObject = null;
camera.classList.add("no-mirror");
camera.src = videoFileUrl;
camera.loop = false;
camera.muted = true;
camera.playsInline = true;
camera.onended = handleVideoEnded;
camera.onloadedmetadata = () => {
const vw = camera.videoWidth || 0;
const vh = camera.videoHeight || 0;
if (vw > 0 && vh > 0) {
const want = vh > vw ? "portrait" : "landscape";
if (currentOrientation() !== want) {
applyOrientation(want);
}
}
};
setSourceSeg("mp4");
if (cropMaskEl) cropMaskEl.classList.remove("show");
camera.pause();
try { camera.currentTime = 0; } catch (err) {}
updateMetrics();
}
function handleVideoEnded() {
if (!usingVideoFile) return;
if (resultSyncActive) return;
if (finalizeIsForResult) return;
stopVideoPull();
if (timer) { clearInterval(timer); timer = null; }
if (netPingTimer) { clearInterval(netPingTimer); netPingTimer = null; }
updateMetrics();
if (ws && ws.readyState === WebSocket.OPEN && sessionGranted) {
finalizeIsForResult = true;
lastRecId = null;
updateUploadProgress();
if (finalizeWatchdog) clearTimeout(finalizeWatchdog);
finalizeWatchdog = setTimeout(() => {
finalizeWatchdog = null;
if (!finalizeIsForResult) return;
finalizeIsForResult = false;
stopResultSync();
if (outputWaitOverlay) outputWaitOverlay.style.display = "none";
showOutputIdle(true);
setSendBusy(false, "");
}, 30000);
try { ws.send(JSON.stringify({ session_id: activeSessionId, type: "finalize_recording" })); }
catch (err) {}
}
}
function switchToCamera() {
stopActiveRun();
startingRun = false;
sessionGranted = false;
setSendBusy(false, "");
clearResultVideo();
blankOutputPane();
showOutputIdle(true);
switchDetectionSource(false);
camera.classList.remove("no-mirror");
if (videoFileUrl) { try { URL.revokeObjectURL(videoFileUrl); } catch (err) {} }
videoFileUrl = null;
camera.onended = null;
camera.onloadedmetadata = null;
camera.loop = false;
camera.removeAttribute("src");
camera.load();
document.getElementById("videoFile").value = "";
setSourceSeg("camera");
applyOrientation(defaultCameraOrientation());
camera.srcObject = null;
ensureCamera().then(() => {
showCameraOverlay(false);
}).catch((err) => {
showCameraOverlay(true);
});
}
document.getElementById("refImage").addEventListener("change", handleRefImageChange);
document.getElementById("videoFile").addEventListener("change", handleVideoFileChange);
function setSourceSeg(src) {
const segCam = document.getElementById("segCamera");
const segM = document.getElementById("segMp4");
if (segCam) segCam.classList.toggle("on", src === "camera");
if (segM) segM.classList.toggle("on", src === "mp4");
}
{
const segCam = document.getElementById("segCamera");
const segM = document.getElementById("segMp4");
if (segCam) segCam.addEventListener("click", () => { if (!usingVideoFile) return; switchToCamera(); });
if (segM) segM.addEventListener("click", () => { document.getElementById("videoFile").click(); });
}
const downloadBubble = document.getElementById("downloadBubble");
let lastRecId = null;
function showDownloadBubble(show) {
if (!downloadBubble) return;
if (show && !(SERVER_DEFAULTS && SERVER_DEFAULTS.record_enabled)) return;
if (show) { downloadBubble.style.display = ""; requestAnimationFrame(() => downloadBubble.classList.add("show")); }
else { downloadBubble.classList.remove("show"); downloadBubble.style.display = "none"; }
}
function updateUploadProgress() {
if (!usingVideoFile) return;
if (outputIdleOverlay) outputIdleOverlay.style.display = "none";
if (outputWaitOverlay) outputWaitOverlay.style.display = "";
if (outputWaitIco) outputWaitIco.textContent = "✨";
let label = t("edit_processing");
if (videoPullTotal > 0 && videoPullTotal < Number.MAX_SAFE_INTEGER) {
const pct = Math.max(0, Math.min(99, Math.floor(backendOutFrames / videoPullTotal * 100)));
label = label + " " + pct + "%";
}
if (outputWaitHint) outputWaitHint.textContent = label;
}
function startResultSync() {
if (!outputResult) return;
stopResultSync();
resultSyncActive = true;
camera.muted = true;
camera.loop = false;
outputResult.loop = false;
const mirror = () => { try { camera.currentTime = resultSourceStart + outputResult.currentTime; } catch (e) {} };
const onPlay = () => { camera.play().catch(() => {}); };
const onPause = () => { try { camera.pause(); } catch (e) {} };
const onRate = () => { camera.playbackRate = outputResult.playbackRate; };
const onTimeUpdate = () => {
if (Math.abs(camera.currentTime - resultSourceStart - outputResult.currentTime) > 0.15) mirror();
};
const onEnded = () => {
try { camera.currentTime = resultSourceStart; } catch (e) {}
try { outputResult.currentTime = 0; } catch (e) {}
camera.play().catch(() => {});
outputResult.play().catch(() => {});
};
resultSyncHandlers = { onPlay, onPause, onRate, onTimeUpdate, onEnded, mirror };
outputResult.addEventListener("play", onPlay);
outputResult.addEventListener("pause", onPause);
outputResult.addEventListener("ratechange", onRate);
outputResult.addEventListener("seeking", mirror);
outputResult.addEventListener("seeked", mirror);
outputResult.addEventListener("timeupdate", onTimeUpdate);
outputResult.addEventListener("ended", onEnded);
try { camera.currentTime = resultSourceStart; } catch (e) {}
try { outputResult.currentTime = 0; } catch (e) {}
camera.play().catch(() => {});
outputResult.play().catch(() => {});
}
function stopResultSync() {
resultSyncActive = false;
if (resultSyncHandlers && outputResult) {
const h = resultSyncHandlers;
outputResult.removeEventListener("play", h.onPlay);
outputResult.removeEventListener("pause", h.onPause);
outputResult.removeEventListener("ratechange", h.onRate);
outputResult.removeEventListener("seeking", h.mirror);
outputResult.removeEventListener("seeked", h.mirror);
outputResult.removeEventListener("timeupdate", h.onTimeUpdate);
outputResult.removeEventListener("ended", h.onEnded);
}
resultSyncHandlers = null;
try { if (usingVideoFile) camera.pause(); } catch (e) {}
}
function clearResultVideo() {
if (downloadBubble) downloadBubble.disabled = false;
stopResultSync();
finalizeIsForResult = false;
lastRecId = null;
resultEpoch += 1;
if (typeof showDownloadBubble === "function") showDownloadBubble(false);
if (outputResult) {
try { outputResult.pause(); } catch (e) {}
outputResult.removeAttribute("src");
outputResult.load();
outputResult.style.display = "none";
}
if (resultBlobUrl) { try { URL.revokeObjectURL(resultBlobUrl); } catch (e) {} resultBlobUrl = null; }
}
function showResultVideo(url) {
if (!outputResult) return;
outputResult.src = url;
outputResult.style.display = "";
if (outputWaitOverlay) outputWaitOverlay.style.display = "none";
if (outputIdleOverlay) outputIdleOverlay.style.display = "none";
outputResult.play().catch(() => {});
}
function triggerBrowserDownload(url, filename) {
const a = document.createElement("a");
a.href = url;
a.download = filename || "joyomni_output.mp4";
document.body.appendChild(a);
a.click();
a.remove();
}
function fetchAndSaveRecording() {
if (resultBlobUrl) triggerBrowserDownload(resultBlobUrl, "joyomni_output.mp4");
else if (lastRecId) fetchAndShowResult();
}
async function fetchAndShowResult() {
const myEpoch = resultEpoch;
setSendBusy(true, t("toast_finalizing"));
if (downloadBubble) downloadBubble.disabled = true;
try {
const resp = await fetch("/download_last?rec=" + encodeURIComponent(lastRecId), { cache: "no-store" });
if (myEpoch !== resultEpoch) return;
if (!resp.ok) {
let detail = "";
try { const j = await resp.json(); detail = j.error || ""; } catch (e) {}
throw new Error(detail || `HTTP ${resp.status}`);
}
const blob = await resp.blob();
if (myEpoch !== resultEpoch) return;
if (resultBlobUrl) { try { URL.revokeObjectURL(resultBlobUrl); } catch (e) {} }
resultBlobUrl = URL.createObjectURL(blob);
showResultVideo(resultBlobUrl);
if (usingVideoFile) startResultSync();
showDownloadBubble(true);
if (downloadBubble) downloadBubble.textContent = t("download_done");
showOutputToast(t("toast_done"), 4000);
} catch (err) {
if (myEpoch !== resultEpoch) return;
if (outputWaitOverlay) outputWaitOverlay.style.display = "none";
showOutputToast(t("download_failed") + String(err.message || err), 8000);
if (lastRecId) {
showDownloadBubble(true);
if (downloadBubble) downloadBubble.textContent = t("download_retry");
}
} finally {
if (myEpoch === resultEpoch) {
setSendBusy(false, "");
if (downloadBubble) downloadBubble.disabled = false;
}
}
}
function onRecordingFinalized(msg) {
if (finalizeWatchdog) { clearTimeout(finalizeWatchdog); finalizeWatchdog = null; }
resultSourceStart = Math.max(0, Number(msg.source_start_s) || 0);
lastRecId = msg.ok === false ? null : (msg.rec || null);
if (!lastRecId) {
finalizeIsForResult = false;
stopResultSync();
if (outputWaitOverlay) outputWaitOverlay.style.display = "none";
showOutputIdle(true);
setSendBusy(false, "");
if (msg.message) showOutputToast(msg.message, 8000);
return;
}
finalizeIsForResult = false;
if (outputWaitOverlay) outputWaitOverlay.style.display = "";
if (outputWaitIco) outputWaitIco.textContent = "✨";
if (outputWaitHint) outputWaitHint.textContent = t("toast_finalizing");
setSendBusy(true, t("toast_finalizing"));
fetchAndShowResult();
}
if (downloadBubble) {
downloadBubble.addEventListener("click", () => {
fetchAndSaveRecording();
});
}
if (startCameraBtn) {
startCameraBtn.addEventListener("click", () => {
startCameraBtn.disabled = true;
ensureCamera().then(() => {
showCameraOverlay(false);
}).catch((err) => {
startCameraBtn.disabled = false;
});
});
}
{
const refClear = document.getElementById("refClear");
if (refClear) refClear.addEventListener("click", (e) => {
e.preventDefault();
const el = document.getElementById("refImage");
if (el) el.value = "";
injectedRefImageDataUrl = null;
handleRefImageChange();
});
}
{
const lb = document.getElementById("refLightbox");
const lbImg = document.getElementById("refLightboxImg");
if (refThumbImg && lb && lbImg) {
refThumbImg.addEventListener("click", () => {
const src = refThumbImg.getAttribute("src");
if (!src) return;
lbImg.src = src;
lb.classList.add("show");
});
lb.addEventListener("click", () => lb.classList.remove("show"));
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") lb.classList.remove("show");
});
}
}
document.addEventListener("click", (e) => {
if (!cardsPop || !cardsPop.classList.contains("open")) return;
if (cardsPop.contains(e.target)) return;
if (sceneChipsEl && sceneChipsEl.contains(e.target)) return;
closeCardsPop();
});
for (const id of ["usePe", "refImage", "profileTimings", "faceDetection", "fullBodyDetection"]) {
document.getElementById(id).addEventListener("input", updateMetrics);
document.getElementById(id).addEventListener("change", updateMetrics);
}
var upqTierEl = document.getElementById("upqTier");
function highlightUpqTier(which) {
for (const b of upqTierEl.querySelectorAll("button")) {
b.classList.toggle("on", b.getAttribute("data-upq") === String(which));
b.classList.remove("auto-active");
}
if (which === "auto") {
const cur = upAutoHigh ? "0.6" : "0.4";
for (const b of upqTierEl.querySelectorAll("button")) {
if (b.getAttribute("data-upq") === cur) b.classList.add("auto-active");
}
}
}
function syncUpQualityUi() {
highlightUpqTier(upAuto ? "auto" : sessionConfig.uplink_quality);
}
for (const b of upqTierEl.querySelectorAll("button")) {
b.addEventListener("click", () => {
const v = b.getAttribute("data-upq");
if (v === "auto") {
upAuto = true;
upAutoHigh = true;
upAutoClear = 0;
} else {
upAuto = false;
sessionConfig.uplink_quality = Number(v);
}
highlightUpqTier(v);
resetUplinkEncoder();
updateMetrics();
});
}
const Q_TIERS = [20, 40, 60];
const downQualityUi = document.getElementById("downQualityUi");
const qtierEl = document.getElementById("qtier");
let autoQTier = 2;
let autoQLowStreak = 0;
let autoQHoldTicks = 0;
function sendOutputQuality(v) {
if (ws && ws.readyState === WebSocket.OPEN && sessionGranted) {
try { ws.send(JSON.stringify({ session_id: activeSessionId, type: "set_output_quality", value: v })); } catch (err) {}
}
}
function setDownQuality(v, opts) {
const q = Math.max(1, Math.min(100, Math.floor(v)));
if (downQualityUi) downQualityUi.value = String(q);
if (opts && opts.live) sendOutputQuality(q);
updateMetrics();
}
function highlightQTier(which) {
if (!qtierEl) return;
for (const b of qtierEl.querySelectorAll("button")) {
b.classList.toggle("on", b.getAttribute("data-q") === String(which));
b.classList.remove("auto-active");
}
if (which === "auto") {
const cur = String(Q_TIERS[autoQTier]);
for (const b of qtierEl.querySelectorAll("button")) {
if (b.getAttribute("data-q") === cur) b.classList.add("auto-active");
}
}
}
if (qtierEl) {
for (const b of qtierEl.querySelectorAll("button")) {
b.addEventListener("click", () => {
const d = b.getAttribute("data-q");
if (d === "auto") {
autoQuality = true; autoQTier = 2; autoQLowStreak = 0; autoQHoldTicks = 0;
highlightQTier("auto");
setDownQuality(Q_TIERS[autoQTier], { live: true });
} else {
autoQuality = false;
highlightQTier(Number(d));
setDownQuality(Number(d), { live: true });
}
});
}
}
const fpsTierEl = document.getElementById("fpsTier");
if (fpsTierEl) {
for (const b of fpsTierEl.querySelectorAll("button")) {
b.addEventListener("click", () => {
const v = b.getAttribute("data-fps");
sessionConfig.fps = Number(v);
for (const x of fpsTierEl.querySelectorAll("button")) x.classList.toggle("on", x === b);
if (timer) { clearInterval(timer); timer = setInterval(tick, Math.floor(1000 / sendTargetFps())); }
updateMetrics();
});
}
}
function autoQualityTick() {
if (!autoQuality) return;
if (!(ws && ws.readyState === WebSocket.OPEN && sessionGranted)) return;
const rtt = (downDelayMs !== null && downDelayMs !== undefined) ? downDelayMs : netRttLastMs;
if (rtt === null || rtt === undefined) return;
if (autoQHoldTicks > 0) autoQHoldTicks--;
let target = autoQTier;
if (rtt > 300) {
if (autoQHoldTicks === 0) target = Math.max(1, autoQTier - 1);
autoQLowStreak = 0;
} else if (rtt < 150) {
autoQLowStreak++;
if (autoQLowStreak >= 5 && autoQHoldTicks === 0) { target = Math.min(2, autoQTier + 1); autoQLowStreak = 0; }
} else {
autoQLowStreak = 0;
}
if (target !== autoQTier) {
autoQTier = target;
autoQHoldTicks = 2;
setDownQuality(Q_TIERS[autoQTier], { live: true });
highlightQTier("auto");
}
}
function faceDetectionEnabled() {
const el = document.getElementById("faceDetection");
return !el || el.checked;
}
function detectionMode() {
if (document.getElementById("fullBodyDetection").checked) return "full";
return faceDetectionEnabled() ? "near" : "off";
}
function setDetectionMode(mode) {
document.getElementById("faceDetection").checked = mode === "near";
document.getElementById("fullBodyDetection").checked = mode === "full";
}
function switchDetectionSource(fileInput) {
sourceDetectionModes[usingVideoFile ? "file" : "camera"] = detectionMode();
usingVideoFile = fileInput;
setDetectionMode(sourceDetectionModes[fileInput ? "file" : "camera"]);
}
for (const id of ["faceDetection", "fullBodyDetection"]) {
document.getElementById(id).addEventListener("change", () => {
const input = document.getElementById(id);
if (input.checked) setDetectionMode(id === "faceDetection" ? "near" : "full");
sourceDetectionModes[usingVideoFile ? "file" : "camera"] = detectionMode();
updateMetrics();
});
}
function applyServerDefaults() {
for (const b of fpsTierEl.querySelectorAll("button")) {
b.classList.toggle("on", Number(b.getAttribute("data-fps")) === sessionConfig.fps);
}
document.getElementById("profileTimings").checked = sessionConfig.profile_timings;
sourceDetectionModes.camera = sessionConfig.detection_mode;
setDetectionMode(sourceDetectionModes[usingVideoFile ? "file" : "camera"]);
if (sessionConfig.output_quality !== "auto") {
autoQuality = false;
highlightQTier(Number(sessionConfig.output_quality));
setDownQuality(Number(sessionConfig.output_quality), { live: false });
} else {
highlightQTier("auto");
setDownQuality(Q_TIERS[autoQTier], { live: false });
}
syncUpQualityUi();
}
window.addEventListener("load", () => {
applyServerDefaults();
const langBtn = document.getElementById("langToggle");
if (langBtn) {
for (const b of langBtn.querySelectorAll("button")) {
b.addEventListener("click", () => setLang(b.getAttribute("data-lang")));
}
}
applyLang();
applyOrientation(defaultCameraOrientation());
syncRefThumb();
showOutputIdle(true);
updateMetrics();
ensureCamera().then(() => {
showCameraOverlay(false);
}).catch((err) => {
showCameraOverlay(true);
});
});
</script>
</body>
</html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>实时视频编辑</title>
<style>
:root {
color-scheme: dark;
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
--bg: #0a0a0c;
--panel: #131316;
--panel-2: #1a1a1e;
--line: #2b2b31;
--text: #ededf0;
--muted: #8f8f98;
--accent: #f2643f;
--accent-2: #d7a64a;
--media-bg: #000000;
--shadow: 0 1px 2px rgba(0,0,0,.55), 0 10px 28px rgba(0,0,0,.45);
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
.topbar { flex: 0 0 auto; display: flex; align-items: center; gap: 12px; padding: 16px 28px; }
.topbar .brand-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--accent); flex: 0 0 auto; }
.topbar .brand { font-size: 18px; font-weight: 760; letter-spacing: .2px; }
.topbar .tagline { font-size: 13px; color: var(--muted); }
.lang-seg { margin-left: auto; display: inline-flex; gap: 3px; }
.lang-seg button { cursor: pointer; font-size: 12px; font-weight: 600; color: var(--muted); background: var(--panel-2); border: 1px solid var(--line); border-radius: 999px; padding: 4px 12px; }
.lang-seg button:hover { border-color: var(--accent); color: var(--accent); }
.lang-seg button.on { color: #fff; background: var(--accent); border-color: var(--accent); }
.stage { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 22px; padding: 4px 28px 10px; }
.vpanel { min-width: 0; min-height: 0; display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--line); border-radius: 16px; box-shadow: var(--shadow); overflow: hidden; }
.vpanel-head { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 16px; }
.vpanel-head .vp-title { display: flex; align-items: center; gap: 7px; font-size: 14px; font-weight: 720; }
.vpanel-head .vp-title .ico { font-size: 14px; color: var(--accent); }
.src-controls { display: inline-flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.media-box { position: relative; flex: 1 1 auto; min-height: 0; margin: 0 12px 12px; overflow: hidden; border-radius: 12px; background: var(--media-bg); }
.media-box img:not([src]) { visibility: hidden; }
video, canvas, img { width: 100%; height: 100%; display: block; object-fit: contain; background: var(--media-bg); }
.overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; text-align: center; padding: 20px; color: #cfcfca; }
.overlay .hint { font-size: 13px; color: #9a9a95; max-width: 320px; line-height: 1.5; text-wrap: balance; }
.overlay .idle-ico { font-size: 30px; color: #6f6f6a; }
.res-banner { text-align: center; font-size: 14px; font-weight: 700; color: #e0b877; background: #1b1610; border-top: 1px solid #33280f; border-bottom: 1px solid #33280f; padding: 9px 12px; line-height: 1.45; }
.res-banner .res-ico { margin-right: 6px; font-size: 15px; }
@keyframes joy-spin { to { transform: rotate(360deg); } }
.idle-ico.spin { display: inline-block; animation: joy-spin 1.1s linear infinite; }
.wait-overlay { background: var(--media-bg); z-index: 3; }
.wait-overlay .idle-ico { font-size: 40px; color: var(--accent); }
.wait-overlay .hint { font-size: 15px; color: #d9d9d4; font-weight: 600; max-width: 380px; }
.toast { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
z-index: 4; padding: 10px 18px; border-radius: 999px; max-width: 80%;
background: rgba(20,20,22,.66); color: #fff; font-size: 14px; font-weight: 600;
text-align: center; line-height: 1.4; box-shadow: 0 4px 20px rgba(0,0,0,.4);
backdrop-filter: blur(2px); pointer-events: none; opacity: 0;
transition: opacity .25s ease; }
.toast.show { opacity: 1; }
.download-bubble { position: absolute; right: 14px; bottom: 14px; z-index: 5; cursor: pointer;
padding: 9px 16px; border: none; border-radius: 999px; font: inherit; font-size: 13px; font-weight: 700;
color: #fff; background: var(--accent); box-shadow: 0 4px 18px rgba(242,100,63,.45);
opacity: 0; transform: translateY(8px); transition: opacity .3s ease, transform .3s ease;
animation: bubblePulse 2s ease-in-out infinite; }
.download-bubble.show { opacity: 1; transform: translateY(0); }
.download-bubble:hover { filter: brightness(1.06); }
@keyframes bubblePulse { 0%,100% { box-shadow: 0 4px 18px rgba(242,100,63,.45); } 50% { box-shadow: 0 4px 26px rgba(242,100,63,.75); } }
.start-btn { cursor: pointer; display: inline-flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 640; color: #fff; background: rgba(40,40,40,.92); border: 1px solid rgba(255,255,255,.14); border-radius: 12px; padding: 11px 20px; }
.start-btn:hover { background: rgba(64,64,64,.95); }
.crop-mask { position: absolute; display: none; pointer-events: none; border-radius: 8px; outline: 2px solid rgba(255,255,255,.9); outline-offset: -1px; box-shadow: 0 0 0 9999px rgba(0,0,0,.5); z-index: 1; }
.crop-mask.show { display: block; }
#camera { transform: scaleX(-1); }
#camera.no-mirror { transform: none; }
.cmdbar { position: relative; flex: 0 0 auto; margin: 0 28px 20px; background: var(--panel); border: 1px solid var(--line); border-radius: 18px; box-shadow: var(--shadow); padding: 12px 14px; }
.cmd-row { display: flex; align-items: center; gap: 12px; }
.ref-slot { flex: 0 0 auto; position: relative; }
.ref-add { display: flex; align-items: center; justify-content: center; width: 46px; height: 46px; border-radius: 12px; border: 1px dashed var(--line); color: var(--muted); font-size: 22px; line-height: 1; cursor: pointer; user-select: none; }
.ref-add:hover { border-color: var(--muted); color: var(--text); }
.ref-slot.has-thumb .ref-add { display: none; }
.ref-thumb { display: none; position: relative; width: 46px; height: 46px; border-radius: 12px; overflow: hidden; border: 1px solid var(--line); }
.ref-slot.has-thumb .ref-thumb { display: block; }
.ref-thumb img { width: 100%; height: 100%; object-fit: cover; background: var(--panel-2); cursor: zoom-in; }
.ref-thumb .ref-clear { position: absolute; top: 2px; right: 2px; z-index: 2; box-sizing: border-box; width: 18px; height: 18px; border-radius: 50%; background: rgba(0,0,0,.6); color: #fff; border: 1px solid rgba(255,255,255,.55); font-size: 12px; line-height: 15px; text-align: center; cursor: pointer; padding: 0; }
.ref-lightbox { display: none; position: fixed; inset: 0; z-index: 100; background: rgba(0,0,0,.82); align-items: center; justify-content: center; cursor: zoom-out; padding: 24px; }
.ref-lightbox.show { display: flex; }
.ref-lightbox img { max-width: 92vw; max-height: 92vh; object-fit: contain; border-radius: 10px; box-shadow: 0 8px 40px rgba(0,0,0,.5); }
.cmd-input { flex: 1 1 auto; min-width: 0; border: none; outline: none; background: transparent; resize: none; font: inherit; font-size: 15px; color: var(--text); line-height: 1.4; max-height: 150px; padding: 12px 4px; }
.cmd-input::placeholder { color: var(--muted); }
.send-fab { flex: 0 0 auto; width: 46px; height: 46px; border-radius: 50%; border: none; cursor: pointer; background: var(--accent); color: #fff; font-size: 0; display: inline-flex; align-items: center; justify-content: center; }
.send-fab::before { content: "\2191"; font-size: 20px; font-weight: 700; line-height: 1; }
.send-fab:hover { filter: brightness(1.05); }
.send-fab:disabled, .send-fab.busy { opacity: .5; cursor: not-allowed; }
.send-fab.busy::before { content: "\22EF"; }
.latency-row { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; margin: 8px 6px 0; font-size: 12px; color: var(--muted); }
.latency-row #netReadout, .latency-row #dispLatency, .latency-row #fpsReadout { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.cmd-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; margin: 8px 6px 0; font-size: 12px; color: var(--muted); }
.cmd-foot .res-readout { white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.cmd-foot .pe-toggle { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
.seg { display: inline-flex; padding: 2px; gap: 2px; background: var(--panel-2); border: 1px solid var(--line); border-radius: 999px; }
.seg button { margin: 0; padding: 3px 12px; font-size: 12px; font-weight: 600; color: var(--muted); background: transparent; border: none; border-radius: 999px; cursor: pointer; white-space: nowrap; transition: background .15s, color .15s; }
.seg button:hover { color: var(--accent); }
.seg button.on { color: #fff; background: var(--accent); }