Files
thunderheronight--splat-wor…/web/index.html
T
thunderheronightandClaude Opus 5.5 fb1c0ff515 0.2.3: scale for models (e.g. a model railway), for scenes and groups
Floor plan, sizes, scale bar and measuring tool in the original's size; the floor plan measures
walls and floors in the model's scale. Optionally Gaussian XR's worldScale is set when copying to
the Frame (not tested on the Frame yet). The floor plan no longer fails without walls.
13 new tests (226 in all).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:44:10 +02:00

1264 lines
79 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Splat Workshop</title>
<link rel="icon" href="/icon.svg">
<style>
:root {
--bg: #0f1115; --panel: #171a21; --panel2: #1e222b; --line: #2a2f3a;
--text: #e6e8ee; --muted: #9aa1b1; --faint: #848b9c;
--accent: #7c9cff; --accent2: #46d3b5; --warn: #ffb86b; --bad: #ff6b81; --ok: #46d3b5;
--radius: 14px;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text);
font: 15px/1.45 "Inter", "Noto Sans", system-ui, sans-serif; }
header { display: flex; align-items: center; gap: 14px; padding: 18px 28px; border-bottom: 1px solid var(--line);
position: sticky; top: 0; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(8px); z-index: 5; }
header h1 { font-size: 19px; margin: 0; font-weight: 650; letter-spacing: .2px; }
.logo { width: 28px; height: 28px; }
.pill { font-size: 12.5px; padding: 4px 10px; border-radius: 99px; background: var(--panel2); color: var(--muted);
display: inline-flex; align-items: center; gap: 6px; }
.pill .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); }
.pill.on .dot { background: var(--ok); box-shadow: 0 0 8px var(--ok); }
.pill.off .dot { background: var(--bad); }
header .spacer { flex: 1; }
main { max-width: 1180px; margin: 0 auto; padding: 24px 28px 60px; display: grid; gap: 22px; }
section.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px 22px; }
h2 { font-size: 15px; margin: 0 0 14px; font-weight: 650; color: var(--text); display: flex; align-items: center; gap: 10px; }
h2 .num { width: 22px; height: 22px; border-radius: 50%; background: var(--panel2); color: var(--muted); font-size: 12px;
display: inline-grid; place-items: center; }
.muted { color: var(--muted); } .faint { color: var(--faint); }
button { font: inherit; color: var(--text); background: var(--panel2); border: 1px solid var(--line); border-radius: 10px;
padding: 8px 14px; cursor: pointer; transition: border-color .15s, background .15s; }
button:hover:not(:disabled) { border-color: var(--accent); }
button:disabled { opacity: .45; cursor: not-allowed; }
button.primary { background: var(--accent); border-color: var(--accent); color: #0b0d12; font-weight: 650; padding: 11px 22px; }
button.primary:hover:not(:disabled) { filter: brightness(1.08); }
button.danger { border-color: color-mix(in srgb, var(--bad) 50%, var(--line)); color: var(--bad); }
button.small { padding: 5px 10px; font-size: 13px; }
input[type=text], input[type=number] { font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line);
border-radius: 10px; padding: 9px 12px; width: 100%; }
input:focus { outline: none; border-color: var(--accent); }
label.field { display: grid; gap: 6px; font-size: 13px; color: var(--muted); align-content: start; }
label.field small { font-size: 12px; color: var(--faint); line-height: 1.4; }
/* Tabs */
.tabs { display: flex; gap: 6px; margin-bottom: 14px; align-items: center; flex-wrap: wrap; }
.tab { background: transparent; border-color: transparent; color: var(--muted); }
.tab.active { background: var(--panel2); border-color: var(--line); color: var(--text); }
/* recordings */
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px; }
.rec { background: var(--panel2); border: 2px solid transparent; border-radius: 12px; overflow: hidden; cursor: pointer;
transition: border-color .15s, transform .15s; text-align: left; padding: 0; }
.rec:hover { transform: translateY(-2px); }
.rec.sel { border-color: var(--accent); }
.thumb { aspect-ratio: 1 / 1; background: #0b0d12 center/cover no-repeat; display: grid; place-items: center; color: var(--faint); font-size: 28px; }
.rec .meta { padding: 9px 11px 10px; display: grid; gap: 2px; }
.rec .thumb { position: relative; }
.play { position: absolute; right: 8px; bottom: 8px; width: 38px; height: 38px; padding: 0; border-radius: 50%;
display: grid; place-items: center; background: rgba(11,13,18,.72); border-color: transparent; backdrop-filter: blur(4px);
opacity: .8; transition: opacity .15s, transform .15s; }
.play svg { width: 16px; height: 16px; margin-left: 2px; fill: var(--text); }
.rec:hover .play, .play:focus-visible { opacity: 1; }
.play:hover { transform: scale(1.08); }
.cbadge { position: absolute; left: 8px; top: 8px; width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center;
font-size: 13px; font-weight: 700; color: #0b0d12; }
.cbadge.ok { background: var(--ok); } .cbadge.warn { background: var(--warn); } .cbadge.bad { background: var(--bad); }
/* recording check */
.cres { margin-top: 12px; padding: 14px 16px; border-radius: 12px; background: var(--panel2); }
.cres .head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.cres .head b { font-size: 15px; }
.cres.ok .head b { color: var(--ok); } .cres.warn .head b { color: var(--warn); } .cres.bad .head b { color: var(--bad); }
.tl { display: flex; height: 18px; border-radius: 5px; overflow: hidden; margin: 12px 0 4px; gap: 1px; background: var(--bg); }
.tl i { flex: 1; min-width: 1px; }
.tl .ok { background: color-mix(in srgb, var(--ok) 45%, var(--panel2)); }
.tl .flat { background: var(--warn); } .tl .dark { background: #6b5cff; } .tl .fast { background: #ff8fd0; }
.tl .eye { background: var(--bad); } .tl .over { background: #f4f1c9; }
.tlscale { display: flex; justify-content: space-between; font-size: 11.5px; color: var(--faint); }
.legend { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--muted); margin-top: 8px; }
.legend span::before { content: ""; display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 5px; vertical-align: -1px; background: var(--c); }
.finding { margin-top: 10px; font-size: 13.5px; line-height: 1.45; }
.finding b { display: block; font-size: 13.5px; }
.finding.bad b { color: var(--bad); } .finding.warn b { color: var(--warn); } .finding.info b { color: var(--muted); }
.cres .note { margin-top: 12px; font-size: 12px; color: var(--faint); }
.rec .meta b { font-size: 14px; font-weight: 600; overflow-wrap: anywhere; }
.rec .meta span { font-size: 12.5px; color: var(--muted); }
.badge { font-size: 11px; padding: 2px 7px; border-radius: 99px; background: color-mix(in srgb, var(--accent2) 18%, transparent); color: var(--accent2); }
.empty { padding: 26px; text-align: center; color: var(--muted); border: 1px dashed var(--line); border-radius: 12px; }
/* probe */
.check { margin-top: 14px; padding: 12px 14px; border-radius: 12px; background: var(--panel2); display: flex; gap: 18px; flex-wrap: wrap; align-items: center; font-size: 14px; }
.check .k { color: var(--faint); font-size: 12px; display: block; }
.msg { margin-top: 8px; font-size: 13.5px; }
.msg.err { color: var(--bad); } .msg.warn { color: var(--warn); } .msg.ok { color: var(--ok); }
/* settings */
.settings { display: grid; grid-template-columns: 1.1fr 1fr; gap: 22px; }
@media (max-width: 860px) { .settings { grid-template-columns: 1fr; } }
.seg { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.seg button { text-align: left; padding: 11px 13px; display: grid; gap: 3px; }
.seg button b { font-size: 14px; }
.seg button span { font-size: 12px; color: var(--muted); }
.seg button.active { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--panel2)); }
details.adv { margin-top: 14px; }
details.adv summary { cursor: pointer; color: var(--muted); font-size: 13.5px; }
.advgrid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-top: 12px; }
.target { display: grid; gap: 8px; }
.opt { display: flex; gap: 10px; align-items: flex-start; padding: 11px 13px; border: 1px solid var(--line); border-radius: 10px; cursor: pointer; background: var(--panel2); }
.opt.active { border-color: var(--accent); }
.opt input { margin-top: 3px; accent-color: var(--accent); }
.opt span { font-size: 12.5px; color: var(--muted); display: block; }
.startrow { display: flex; align-items: center; gap: 16px; margin-top: 20px; flex-wrap: wrap; }
.estimate { font-size: 13.5px; color: var(--muted); }
/* job */
.job-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.job-head .title { font-size: 18px; font-weight: 650; }
.steps { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; margin: 16px 0 10px; }
.step { font-size: 12px; color: var(--faint); display: grid; gap: 6px; }
.step i { height: 4px; border-radius: 4px; background: var(--line); display: block; }
.step.done { color: var(--muted); } .step.done i { background: var(--accent2); }
.step.now { color: var(--text); } .step.now i { background: linear-gradient(90deg, var(--accent), var(--accent2)); animation: pulse 1.6s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: .55; } }
.bar { height: 10px; border-radius: 10px; background: var(--panel2); overflow: hidden; margin: 8px 0 4px; }
.bar > div { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent2)); border-radius: 10px; transition: width .6s; }
.bar.indet > div { width: 30% !important; animation: slide 1.4s ease-in-out infinite; }
@keyframes slide { 0% { transform: translateX(-100%); } 100% { transform: translateX(340%); } }
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin-top: 14px; }
.tile { background: var(--panel2); border-radius: 12px; padding: 10px 13px; }
.tile .k { font-size: 12px; color: var(--faint); }
.tile .v { font-size: 20px; font-weight: 650; font-variant-numeric: tabular-nums; }
.spark { margin-top: 12px; background: var(--panel2); border-radius: 12px; padding: 10px 13px; }
.spark svg { width: 100%; height: 70px; display: block; }
.result { margin-top: 14px; padding: 13px 15px; border-radius: 12px; font-size: 14.5px; }
.result.ok { background: color-mix(in srgb, var(--ok) 14%, transparent); color: var(--ok); }
.result.bad { background: color-mix(in srgb, var(--bad) 13%, transparent); color: var(--bad); }
.result.warn { background: color-mix(in srgb, var(--warn) 13%, transparent); color: var(--warn); }
pre.log { background: #0b0d12; border-radius: 10px; padding: 12px; max-height: 320px; overflow: auto; font-size: 12px; color: var(--muted); white-space: pre-wrap; }
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
/* scenes */
.scene .meta { padding: 10px 12px 12px; }
.scene .acts { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
.fstate { font-size: 12px; margin-top: 4px; }
.fstate.yes { color: var(--ok); } .fstate.no { color: var(--warn); } .fstate.err { color: var(--bad); white-space: pre-wrap; }
/* Dialog */
dialog { background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: var(--radius); padding: 22px; max-width: 440px; }
dialog::backdrop { background: rgba(0,0,0,.55); }
.toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); background: var(--panel2); border: 1px solid var(--line);
padding: 10px 16px; border-radius: 10px; font-size: 14px; opacity: 0; transition: opacity .2s; pointer-events: none; z-index: 10; }
.toast.show { opacity: 1; }
.hidden { display: none !important; }
.group { background: var(--panel2); border: 1px solid var(--line); border-radius: 12px; padding: 12px; margin-bottom: 12px; }
.ghead { display: flex; gap: 14px; flex-wrap: wrap; }
.gthumb { width: 132px; height: 132px; flex: none; border-radius: 10px; background: #0b0d12 center/cover no-repeat; display: grid; place-items: center; font-size: 44px; }
.gmeta { flex: 1; min-width: 220px; display: grid; gap: 3px; align-content: start; }
.gmeta b { font-size: 15px; overflow-wrap: anywhere; } .gmeta span { font-size: 12.5px; color: var(--muted); }
.gmeta .warn { color: var(--warn); }
.group .acts { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
.group .ginner { margin-top: 12px; }
.suggest { border: 1px dashed var(--accent); border-radius: 12px; padding: 12px 14px; margin-bottom: 12px; font-size: 13.5px; }
.suggest .row { margin-top: 8px; }
dialog.wide { max-width: 560px; }
dialog select { background: var(--panel2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; font: inherit; }
dialog h3 { font-size: 13.5px; margin: 16px 0 8px; }
.opts { display: grid; gap: 6px; }
.unfin { display: grid; gap: 8px; }
.unfin .row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; }
.unfin .row b { overflow-wrap: anywhere; } .unfin .row .sp { flex: 1; }
.offline-note { border-color: var(--bad); }
body.offline main > :not(#offline), body.offline #framePill { opacity: .45; pointer-events: none; }
body.remote .pc-only { display: none !important; }
/* floor plan */
dialog.plan { max-width: min(1200px, 94vw); width: 94vw; }
.plan-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.plan-head h2 { margin: 0; flex: 1; }
.btnlink { display: inline-block; text-decoration: none; color: var(--text); background: var(--panel2); border: 1px solid var(--line);
border-radius: 10px; padding: 5px 10px; font-size: 13px; }
.btnlink:hover { border-color: var(--accent); }
.seg2 { display: inline-flex; background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 3px; gap: 3px; }
.seg2 button { border: 0; background: transparent; padding: 5px 12px; font-size: 13px; color: var(--muted); }
.seg2 button.active { background: var(--accent); color: #0b0d12; font-weight: 600; }
.plan-img { position: relative; background: #0f1115; border-radius: 12px; min-height: 260px; display: grid; place-items: center; overflow: hidden; }
.plan-stage { position: relative; display: inline-block; line-height: 0; }
.plan-img img { max-width: 100%; max-height: 74vh; width: auto; height: auto; display: block; }
.plan-stage svg { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.plan-stage.measuring svg { pointer-events: auto; cursor: crosshair; }
.measure-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: -2px 0 10px; font-size: 13.5px; }
.measure-bar .total { font-weight: 650; font-variant-numeric: tabular-nums; background: color-mix(in srgb, #ffd166 18%, var(--panel2));
color: #ffd166; padding: 4px 10px; border-radius: 99px; }
.seg2 button.on, #planMeasure.on { background: #ffd166; color: #0b0d12; border-color: #ffd166; font-weight: 600; }
.plan-img .wait { color: var(--muted); font-size: 14px; padding: 30px; text-align: center; }
.pick { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 6px; margin: 10px 0 14px; }
.pick label { display: flex; gap: 8px; align-items: center; padding: 8px 10px; background: var(--panel2); border-radius: 10px; font-size: 14px; cursor: pointer; }
.pick label span { color: var(--faint); font-size: 12px; margin-left: auto; }
.pick input { accent-color: var(--accent); }
#lanPill { cursor: pointer; } #lanPill:hover { color: var(--text); }
.setup { border-color: color-mix(in srgb, var(--warn) 45%, var(--line)); }
.setup h2 { color: var(--warn); }
.setup li { margin: 4px 0; font-size: 14px; }
.setup code { background: var(--bg); padding: 1px 6px; border-radius: 6px; font-size: 13px; }
select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background: var(--panel2); border: 1px solid var(--line);
border-radius: 99px; padding: 4px 8px; }
</style>
</head>
<body>
<header>
<svg class="logo" viewBox="0 0 32 32"><circle cx="11" cy="13" r="7" fill="#7c9cff" opacity=".85"/><circle cx="20" cy="19" r="8" fill="#46d3b5" opacity=".8"/><circle cx="21" cy="10" r="4" fill="#ffb86b" opacity=".9"/></svg>
<h1 data-i18n="app">Splat Workshop</h1>
<span class="spacer"></span>
<span id="lanPill" class="pill hidden" data-i18n-title="lan.title"></span>
<span id="framePill" class="pill"><span class="dot"></span><span data-i18n="frame.checking">Frame …</span></span>
<select class="lang" id="langSel" aria-label="Language"></select>
</header>
<main>
<div class="card offline-note hidden" id="offline" role="alert"><b data-i18n="offline.title"></b> <span class="muted" data-i18n="offline.text"></span></div>
<!-- missing prerequisites -->
<section class="card setup hidden" id="setupCard">
<h2 data-i18n="setup.title"></h2>
<ul id="setupList"></ul>
<p class="faint" id="setupHint" style="font-size:13px;margin:8px 0 0"></p>
</section>
<!-- current conversion -->
<section class="card hidden" id="jobCard">
<div class="job-head">
<span class="title" id="jobName"></span>
<span class="muted" id="jobSub"></span>
<span class="spacer" style="flex:1"></span>
<button class="danger small" id="cancelBtn" data-i18n="job.cancel"></button>
<button class="small hidden" id="dismissBtn" data-i18n="job.dismiss"></button>
</div>
<div class="steps" id="steps"></div>
<div class="row" style="justify-content:space-between">
<span id="phaseText" class="muted"></span><span id="pctText" class="muted"></span>
</div>
<div class="bar" id="bar"><div style="width:0%"></div></div>
<div class="tiles" id="tiles"></div>
<div class="spark hidden" id="spark">
<div class="row" style="justify-content:space-between"><span class="faint" style="font-size:12px" data-i18n="job.psnr"></span><span class="faint" style="font-size:12px" id="sparkRange"></span></div>
<svg viewBox="0 0 600 70" preserveAspectRatio="none"><path id="sparkArea" fill="url(#g)" opacity=".25"/><path id="sparkLine" fill="none" stroke="#7c9cff" stroke-width="2" vector-effect="non-scaling-stroke"/>
<defs><linearGradient id="g" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#7c9cff"/><stop offset="1" stop-color="#7c9cff" stop-opacity="0"/></linearGradient></defs></svg>
</div>
<div id="result" class="result hidden"></div>
<details style="margin-top:12px"><summary class="muted" style="cursor:pointer;font-size:13.5px" data-i18n="job.log"></summary><pre class="log" id="log"></pre></details>
</section>
<!-- 1. recording -->
<section class="card" id="newCard">
<h2><span class="num">1</span><span data-i18n="s1.title"></span></h2>
<div class="tabs">
<button class="tab active" data-tab="frame" data-i18n="tab.frame"></button>
<button class="tab" data-tab="local" data-i18n="tab.local"></button>
<span style="flex:1"></span>
<button class="small" id="checkAllBtn" data-i18n="checkAll" data-i18n-title="checkAll.title"></button>
<button class="small pc-only" id="pickBtn" data-i18n="pick"></button>
<button class="small" id="refreshBtn" data-i18n-title="refresh.title">↻</button>
</div>
<div id="recGrid" class="grid"></div>
<div id="checkBox" class="hidden"></div>
<div id="checkRes" class="hidden"></div>
</section>
<!-- 2. settings -->
<section class="card" id="setCard">
<h2><span class="num">2</span><span data-i18n="s2.title"></span></h2>
<div class="settings">
<div>
<label class="field"><span data-i18n="name.label"></span>
<input type="text" id="name" maxlength="40" data-i18n-placeholder="name.placeholder">
</label>
<div style="height:16px"></div>
<div class="field" style="margin-bottom:6px" data-i18n="quality"></div>
<div class="seg" id="quality">
<button data-q="fast"><b data-i18n="q.fast"></b><span data-i18n="q.fast.sub"></span></button>
<button data-q="high" class="active"><b data-i18n="q.high"></b><span data-i18n="q.high.sub"></span></button>
<button data-q="max"><b data-i18n="q.max"></b><span data-i18n="q.max.sub"></span></button>
</div>
<details class="adv">
<summary data-i18n="adv"></summary>
<p class="faint" style="font-size:12.5px;margin:10px 0 0" data-i18n="adv.hint"></p>
<div class="advgrid">
<label class="field"><span data-i18n="adv.frames"></span><input type="number" id="frames" min="100" max="500" step="10">
<small data-i18n="adv.frames.help"></small></label>
<label class="field"><span data-i18n="adv.scale"></span><input type="number" id="scale" min="0.25" max="1" step="0.05">
<small data-i18n="adv.scale.help"></small></label>
<label class="field"><span data-i18n="adv.maxg"></span><input type="number" id="maxg" min="0.2" max="6" step="0.1">
<small data-i18n="adv.maxg.help"></small></label>
<label class="field"><span data-i18n="adv.iter"></span><input type="number" id="iter" min="1000" max="100000" step="1000">
<small data-i18n="adv.iter.help"></small></label>
</div>
</details>
</div>
<div>
<div class="field" style="margin-bottom:6px" data-i18n="target"></div>
<div class="target">
<label class="opt active" id="optFrame"><input type="radio" name="target" value="frame" checked>
<div><b data-i18n="t.frame"></b><span data-i18n="t.frame.sub"></span></div></label>
<label class="opt" id="optPc"><input type="radio" name="target" value="pc">
<div><b data-i18n="t.pc"></b><span data-i18n="t.pc.sub"></span></div></label>
</div>
<div id="targetWarn" class="msg warn hidden" data-i18n="t.offline"></div>
</div>
</div>
<div class="startrow">
<button class="primary" id="startBtn" disabled data-i18n="start"></button>
<span class="estimate" id="estimate"></span>
</div>
</section>
<!-- finished scenes -->
<section class="card">
<div class="row" style="justify-content:space-between;margin-bottom:14px">
<h2 data-i18n="scenes.title" style="margin:0"></h2>
<div class="row"><button class="small" id="groupNewBtn" data-i18n="grp.new" data-i18n-title="grp.new.tip"></button></div>
</div>
<div id="groupSuggest" class="suggest hidden"></div>
<div id="groupList"></div>
<div id="sceneGrid" class="grid"></div>
</section>
<!-- cancelled / failed runs -->
<section class="card hidden" id="unfinCard">
<h2 data-i18n="unfin.title" style="margin:0 0 4px"></h2>
<p class="faint" style="font-size:12.5px;margin:0 0 12px" data-i18n="unfin.hint"></p>
<div id="unfinList" class="unfin"></div>
</section>
</main>
<dialog id="existsDlg">
<h2 style="margin-top:0" data-i18n="exists.title"></h2>
<p class="muted" id="existsText"></p>
<div class="row" style="justify-content:flex-end;margin-top:16px">
<button id="exCancel" data-i18n="exists.cancel"></button>
<button id="exResume" data-i18n="exists.resume"></button>
<button class="primary" id="exFresh" data-i18n="exists.fresh"></button>
</div>
</dialog>
<dialog id="groupDlg">
<h2 style="margin-top:0" id="groupTitle"></h2>
<label class="field"><span data-i18n="grp.name"></span><input id="groupName" type="text" maxlength="80" style="width:100%" autocomplete="off"></label>
<p class="muted" style="font-size:13px;margin:12px 0 6px" data-i18n="grp.pick"></p>
<div id="groupPick" class="pick"></div>
<div class="row" style="justify-content:flex-end;margin-top:16px">
<button id="groupCancel" data-i18n="grp.cancel"></button>
<button class="primary" id="groupSave" data-i18n="grp.save"></button>
</div>
</dialog>
<dialog id="combineDlg" class="wide">
<h2 style="margin-top:0" id="cmbTitle"></h2>
<p class="muted" style="font-size:13px;margin-top:0" data-i18n="cmb.hint"></p>
<h3 data-i18n="cmb.align"></h3>
<div class="opts" id="cmbAlign"></div>
<h3 data-i18n="cmb.part"></h3>
<div class="opts" id="cmbPart"></div>
<h3 data-i18n="cmb.size"></h3>
<select id="cmbSize"><option value="2000000" data-i18n="cmb.size.2"></option><option value="3500000" data-i18n="cmb.size.35"></option><option value="5000000" data-i18n="cmb.size.5"></option></select>
<label class="row" style="margin-top:14px;gap:8px;font-size:13.5px"><input type="checkbox" id="cmbFull"> <span data-i18n="cmb.full"></span></label>
<p class="faint" id="cmbMem" style="font-size:12.5px;margin:6px 0 0"></p>
<div id="cmbMemLow" class="msg warn hidden" data-i18n="cmb.mem.low"></div>
<label class="row" id="cmbUploadRow" style="margin-top:10px;gap:8px;font-size:13.5px"><input type="checkbox" id="cmbUpload"> <span data-i18n="cmb.upload"></span></label>
<div class="row" style="justify-content:flex-end;margin-top:18px">
<button id="cmbCancel" data-i18n="grp.cancel"></button>
<button class="primary" id="cmbStart" data-i18n="cmb.start"></button>
</div>
</dialog>
<dialog id="scaleDlg">
<h2 style="margin-top:0" id="scaleTitle"></h2>
<p class="muted" style="font-size:13px;margin-top:0" data-i18n="scale.hint"></p>
<div class="opts" id="scaleOpts"></div>
<label class="row" style="margin-top:10px;gap:8px;font-size:13.5px">1 : <input id="scaleCustom" type="number" min="1" max="1000" step="any" style="width:110px"></label>
<label class="row" id="scaleXrRow" style="flex-wrap:nowrap;align-items:flex-start;margin-top:12px;gap:8px;font-size:13.5px"><input type="checkbox" id="scaleXr" style="margin-top:3px"> <span data-i18n="scale.xr"></span></label>
<label class="row hidden" id="scaleMembersRow" style="flex-wrap:nowrap;align-items:flex-start;margin-top:8px;gap:8px;font-size:13.5px"><input type="checkbox" id="scaleMembers" checked> <span data-i18n="scale.members"></span></label>
<label class="row hidden" id="scaleFrameRow" style="flex-wrap:nowrap;align-items:flex-start;margin-top:8px;gap:8px;font-size:13.5px"><input type="checkbox" id="scaleFrame" checked> <span data-i18n="scale.frame"></span></label>
<div class="row" style="justify-content:flex-end;margin-top:16px">
<button id="scaleCancel" data-i18n="grp.cancel"></button>
<button class="primary" id="scaleOk" data-i18n="grp.save"></button>
</div>
</dialog>
<dialog id="renDlg">
<h2 style="margin-top:0" id="renTitle"></h2>
<p class="muted" id="renHint" style="font-size:13px"></p>
<input id="renInput" type="text" maxlength="80" style="width:100%" autocomplete="off">
<label id="renFrameRow" class="row hidden" style="margin-top:10px;gap:8px;font-size:13.5px"><input type="checkbox" id="renFrame"> <span data-i18n="ren.frame"></span></label>
<div class="row" style="justify-content:flex-end;margin-top:16px">
<button id="renCancel" data-i18n="ren.cancel"></button>
<button class="primary" id="renOk" data-i18n="ren.ok"></button>
</div>
</dialog>
<dialog id="cleanDlg">
<h2 style="margin-top:0" id="cleanTitle"></h2>
<p class="muted" style="font-size:13px" id="cleanIntro"></p>
<div id="cleanList"></div>
<div class="row" style="justify-content:space-between;align-items:center;margin-top:16px">
<span class="faint" id="cleanTotal" style="font-size:13px"></span>
<button id="cleanClose" data-i18n="clean.close"></button>
</div>
</dialog>
<dialog id="planDlg" class="plan">
<div class="plan-head">
<h2 id="planTitle"></h2>
<div class="seg2" id="planStyle">
<button data-style="pretty" class="active" data-i18n="plan.pretty"></button>
<button data-style="classic" data-i18n="plan.classic"></button>
</div>
<button class="small" id="planMeasure" data-i18n="plan.measure" data-i18n-title="plan.measure.title"></button>
<a class="btnlink" id="planSave" download data-i18n="plan.save"></a>
<button class="small" id="planClose" data-i18n="plan.close"></button>
</div>
<div class="measure-bar hidden" id="measureBar">
<span class="total" id="measureTotal"></span>
<span class="muted" id="measureHint" data-i18n="plan.measure.hint"></span>
<span style="flex:1"></span>
<button class="small" id="measureUndo" data-i18n="plan.measure.undo"></button>
<button class="small" id="measureReset" data-i18n="plan.measure.reset"></button>
</div>
<div class="plan-img hidden" id="planBox"><div class="wait" id="planWait"></div>
<div class="plan-stage hidden" id="planStage"><img id="planImg" alt=""><svg id="planSvg" xmlns="http://www.w3.org/2000/svg"></svg></div></div>
<p class="faint" style="font-size:12px;margin:10px 0 0" id="planNote"></p>
</dialog>
<div class="toast" id="toast"></div>
<script src="/i18n.js"></script>
<script>
const $ = s => document.querySelector(s);
const QUALITY_ALIAS = {schnell: "fast", hoch: "high"}; // scenes made with older versions
const PRESETS = {
fast: d => ({frames: 100, scale: 0.5, maxg: 1.5, iter: 30000}),
high: d => ({frames: clamp(Math.round(d * 5), 100, 500), scale: 1, maxg: 2, iter: 30000}),
max: d => ({frames: clamp(Math.round(d * 8), 100, 500), scale: 1, maxg: 3, iter: 50000}),
};
const S = { tab: "frame", recs: null, sel: null, probe: null, quality: "high", job: null, dl: null, dismissed: null,
frameOnline: null, frameConfigured: true };
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
function mb(b) { return b >= 1e9 ? num(b / 1e9, 2) + " GB" : num(Math.round(b / 1e6)) + " MB"; }
function mins(s) { if (s == null) return "–"; s = Math.round(s); const h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60);
return h ? `${h} h ${String(m).padStart(2, "0")} min` : m ? `${m} min` : `${s % 60} s`; }
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"}[c])); }
function toast(t) { const el = $("#toast"); el.textContent = t; el.classList.add("show"); clearTimeout(toast.t); toast.t = setTimeout(() => el.classList.remove("show"), 3500); }
function fmtDate(iso, short) {
if (!iso) return "";
const d = new Date(iso);
return short ? d.toLocaleString(LANG, {day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit"})
: d.toLocaleString(LANG, {dateStyle: "medium", timeStyle: "short"});
}
async function api(path, body) {
const r = await fetch(path, body === undefined ? {} : {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(body)});
let j = {}; try { j = await r.json(); } catch {}
return {ok: r.ok, status: r.status, ...j};
}
/* ---------- language ---------- */
function setupLang(setting) {
LANG = pickLang(setting);
const sel = $("#langSel");
sel.innerHTML = Object.keys(I18N).map(l => `<option value="${l}" ${l === LANG ? "selected" : ""}>${I18N[l]["lang.name"]}</option>`).join("");
sel.onchange = () => { LANG = sel.value; try { localStorage.setItem("lang", LANG); } catch {} rerender(); };
rerender();
}
function rerender() {
applyI18n();
renderPing();
autoName();
renderFramePill(); renderRecs(); renderCheck(); renderJob(); updateStart(); loadScenes();
}
function renderPing() {
const r = S.ping || {};
if (r.lan_url) {
const pill = $("#lanPill");
pill.textContent = tr("lan.pill", {addr: r.lan_url.replace("http://", "").replace(/\/$/, "")});
pill.classList.remove("hidden");
pill.onclick = () => navigator.clipboard?.writeText(r.lan_url).then(() => toast(tr("copied")), () => toast(r.lan_url));
}
if (r.problems && r.problems.length) {
$("#setupCard").classList.remove("hidden");
$("#setupList").innerHTML = r.problems.map(p => `<li>${esc(trErr(p))}</li>`).join("");
$("#setupHint").innerHTML = r.config ? tr("setup.hint", {file: `<code>${esc(r.config)}</code>`}) : "";
}
}
/* ---------- recordings ---------- */
async function loadRecs(refresh) {
if (refresh) $("#recGrid").innerHTML = `<div class="empty">${tr("loading")}</div>`;
const r = await api("/api/recordings" + (refresh ? "?refresh=1" : ""));
S.recs = r; S.frameOnline = r.frame_online; S.frameConfigured = r.frame_configured;
renderFramePill();
if (!loadRecs.done && !r.frame_online && r.local.length) setTab("local");
loadRecs.done = true;
renderRecs();
}
function renderFramePill() {
if (!S.recs) return;
const p = $("#framePill");
p.className = "pill " + (S.frameOnline ? "on" : "off");
p.lastElementChild.textContent = tr(!S.frameConfigured ? "frame.none" : S.frameOnline ? "frame.connected" : "frame.offline");
$("#optFrame").classList.toggle("hidden", !S.frameConfigured);
// Default target follows the Frame: on the Frame when it is reachable, otherwise PC only – until the user picks one
const auto = !S.frameConfigured ? "pc" : S.targetTouched ? null : S.frameOnline ? "frame" : S.frameOnline === false ? "pc" : null;
if (auto && auto !== target()) { $(`input[name=target][value=${auto}]`).checked = true; targetChanged(); }
$("#targetWarn").classList.toggle("hidden", S.frameOnline !== false || !S.frameConfigured || target() !== "frame");
}
function currentList() {
if (!S.recs) return [];
if (S.tab === "frame") return S.recs.frame || [];
return S.recs.local;
}
function renderRecs() {
const g = $("#recGrid");
if (!S.recs) return;
if (S.tab === "frame" && !S.recs.frame_online) {
g.innerHTML = S.frameConfigured
? `<div class="empty" style="grid-column:1/-1">${tr("frame.unreachable")}<br><span class="faint">${tr("frame.unreachable.hint")}</span></div>`
: `<div class="empty" style="grid-column:1/-1">${tr("frame.notConfigured")}</div>`;
return;
}
const list = currentList();
if (!list.length) { g.innerHTML = `<div class="empty" style="grid-column:1/-1">${tr("noRecordings")}</div>`; return; }
g.innerHTML = list.map((f, i) => {
const sel = S.sel && S.sel.name === f.name ? " sel" : "";
const local = S.tab === "frame" && f.local ? `<span class="badge">${tr("onPc")}</span>` : "";
const onPc = S.tab === "local" || f.local;
const play = onPc ? `<button class="play pc-only" data-play="${esc(f.name)}" title="${tr("play.title")}" aria-label="${tr("play.title")}">${PLAY}</button>` : "";
return `<div class="rec${sel}" data-i="${i}" role="button" tabindex="0">
<div class="thumb" data-src="/api/thumb?source=${onPc ? "local" : "frame"}&name=${encodeURIComponent(f.name)}">${badge(f)}${play}</div>
<div class="meta"><b>${esc(f.label || fmtDate(f.date) || f.name)}</b><span>${f.label && f.date ? esc(fmtDate(f.date)) + " · " : ""}${mb(f.size)} ${local}</span></div></div>`;
}).join("");
g.querySelectorAll(".thumb").forEach(t => {
const url = t.dataset.src, img = new Image();
img.onload = () => { t.style.backgroundImage = `url(${JSON.stringify(url)})`; };
img.onerror = () => { if (!t.querySelector(".play")) t.textContent = "◌"; };
img.src = url;
});
g.querySelectorAll(".rec").forEach(b => {
b.onclick = () => select(list[+b.dataset.i]);
b.onkeydown = e => { if ((e.key === "Enter" || e.key === " ") && e.target === b) { e.preventDefault(); b.click(); } };
});
g.querySelectorAll("[data-play]").forEach(b => b.onclick = e => { e.stopPropagation(); play(b.dataset.play); });
}
function badge(f) {
const lv = f.check || (S.recs?.local.find(x => x.name === f.name) || {}).check;
if (!lv) return "";
return `<span class="cbadge ${lv}" title="${tr("badge." + lv)}">${{ok: "✓", warn: "!", bad: "✕"}[lv]}</span>`;
}
const PLAY = `<svg viewBox="0 0 16 16"><path d="M3 1.5v13l11-6.5z"/></svg>`;
async function play(name) {
const r = await api("/api/play", {name});
toast(r.ok ? tr("play.opening") : trErr(r.error));
}
async function select(f) {
S.sel = {...f, source: S.tab}; S.probe = null;
$("#checkRes").className = "hidden";
renderRecs();
autoName();
const path = f.path || f.local;
if (path) { S.sel.path = path; await runProbe(path); }
else renderCheck();
updateStart();
}
function autoName() {
const f = S.sel;
if (!f || ($("#name").value && $("#name").dataset.auto !== "1")) return;
const label = f.label || S.recs?.local.find(x => x.name === f.name)?.label;
$("#name").value = label || (f.date ? tr("name.auto", {date: fmtDate(f.date, true)}) : f.name.replace(/_stereo\.mp4$|\.mp4$/, ""));
$("#name").dataset.auto = "1";
}
async function runProbe(path) {
$("#checkBox").className = "check"; $("#checkBox").innerHTML = `<span class="muted">${tr("probe.running")}</span>`;
S.probe = await api("/api/probe?path=" + encodeURIComponent(path));
renderCheck(); updateStart();
}
function renderCheck() {
const box = $("#checkBox"), f = S.sel;
if (!f) { box.className = "hidden"; return; }
box.className = "check";
if (!S.probe) {
const dl = S.dl && S.dl.name === f.name && !S.dl.finished ? S.dl : null;
box.innerHTML = dl
? `<div style="flex:1"><span class="muted">${tr("probe.fetching", {done: mb(dl.done), total: mb(dl.total)})}</span><div class="bar"><div style="width:${(dl.done / dl.total * 100).toFixed(1)}%"></div></div></div>`
: `<span class="muted">${tr("probe.onFrame", {size: mb(f.size)})}</span><button class="small" id="fetchBtn">${esc(tr("probe.fetch"))}</button>`;
const b = $("#fetchBtn"); if (b) b.onclick = () => fetchSel();
return;
}
const p = S.probe;
const items = [[tr("probe.length"), p.duration ? Math.round(p.duration) + " s" : "–"], [tr("probe.format"), p.hdr ? "HDR (HLG)" : "sRGB"],
[tr("probe.fps"), p.fps ? num(p.fps, 1) + " fps" : "–"], [tr("probe.size"), p.size || "–"]];
box.innerHTML = items.map(([k, v]) => `<div><span class="k">${k}</span>${v}</div>`).join("") +
`<div style="flex-basis:100%;order:10">${(p.errors || []).map(e => `<div class="msg err">✕ ${esc(trErr(e))}</div>`).join("")}
${(p.warnings || []).map(e => `<div class="msg warn">! ${esc(trErr(e))}</div>`).join("")}
${p.ok && !(p.warnings || []).length ? `<div class="msg ok">${tr("probe.ok")}</div>` : ""}</div>`;
if (!p.ok && !p.duration) return;
box.insertAdjacentHTML("afterbegin", `<div class="row" style="order:9;margin-left:auto">
<button class="small" id="checkSel" title="${tr("check.title")}">${tr("check.run")}</button>
<button class="small pc-only" id="playSel">${tr("play.button")}</button>
${S.recs?.local.find(x => x.name === f.name)?.renamable ? `<button class="small" id="renSel">${tr("ren.button")}</button>` : ""}</div>`);
$("#playSel").onclick = () => play(f.name);
if ($("#renSel")) $("#renSel").onclick = () => renameRecording(f);
$("#checkSel").onclick = () => runCheck(f.name);
loadCheck(f.name);
}
/* ---------- recording check ---------- */
const TL_COLOR = {ok: "var(--ok)", flat: "var(--warn)", dark: "#6b5cff", fast: "#ff8fd0", eye: "var(--bad)", over: "#f4f1c9"};
function mmss(s) { return Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0"); }
function findingText(f) {
const [title, text] = I18N[LANG]["f." + f.code] || I18N.en["f." + f.code] || [f.code, ""];
const where = (f.spans || []).map(([a, b]) => a === b ? mmss(a) : `${mmss(a)}–${mmss(b)}`).join(", ") +
(f.more ? tr("and_more", {n: f.more}) : "");
const p = {...f, where, tone: f.tone ? tr("tone." + f.tone) : ""};
return [title, text.replace(/\{(\w+)\}/g, (_, k) => p[k] ?? "")];
}
async function runCheck(name) {
const r = await api("/api/check", {name});
if (!r.ok) { toast(trErr(r.error)); return; }
loadCheck(name);
}
async function loadCheck(name) {
clearTimeout(loadCheck.t);
const box = $("#checkRes");
if (!S.sel || S.sel.name !== name) return;
const c = await api("/api/check?name=" + encodeURIComponent(name));
if (!S.sel || S.sel.name !== name) return;
const btn = $("#checkSel");
if (c.state === "queued" || c.state === "running") {
if (btn) btn.disabled = true;
const pct = c.total ? Math.min(99, Math.round(c.done / c.total * 100)) : 0;
box.className = "cres";
box.innerHTML = c.state === "queued" ? `<span class="muted">${tr("check.queued")}</span>`
: `<span class="muted">${tr("check.progress", {done: c.done, total: c.total})}</span><div class="bar"><div style="width:${pct}%"></div></div>`;
loadCheck.t = setTimeout(() => loadCheck(name), 1000);
return;
}
if (btn) { btn.disabled = false; btn.textContent = tr(c.state === "done" ? "check.again" : "check.run"); }
if (c.state === "error") { box.className = "cres bad"; box.innerHTML = `<div class="finding bad"><b>${tr("check.failed")}</b>${esc(c.error)}</div>`; return; }
if (c.state !== "done") { box.className = "hidden"; return; }
const r = c.result, n = r.timeline.length;
const used = [...new Set(r.timeline)];
box.className = "cres " + r.level;
box.innerHTML = `<div class="head"><b>${tr("check.head." + r.level)}</b><span class="faint" style="font-size:12.5px">${tr("check.summary", {bright: r.summary.bright, detail: num(r.summary.detail, 1), secs: n})}</span></div>
<div class="tl">${r.timeline.map((k, i) => `<i class="${k}" title="${mmss(i)} – ${tr("tl." + k)}"></i>`).join("")}</div>
<div class="tlscale"><span>0:00</span><span>${mmss(Math.round(n / 2))}</span><span>${mmss(n)}</span></div>
<div class="legend">${used.map(k => `<span style="--c:${TL_COLOR[k]}">${tr("tl." + k)}</span>`).join("")}</div>
${r.findings.length ? r.findings.map(f => { const [title, text] = findingText(f); return `<div class="finding ${f.level}"><b>${esc(title)}</b>${esc(text)}</div>`; }).join("")
: `<div class="finding"><b style="color:var(--ok)">${tr("check.none")}</b>${tr("check.none.text")}</div>`}
<div class="note">${tr("check.note")}</div>`;
const rec = S.recs?.local.find(x => x.name === name);
if (rec && rec.check !== r.level) { rec.check = r.level; renderRecs(); }
}
$("#checkAllBtn").onclick = async () => {
const todo = (S.recs?.local || []).filter(f => !f.check);
if (!todo.length) { toast(tr("check.allChecked")); return; }
for (const f of todo) await api("/api/check", {name: f.name});
toast(tr("check.queuedN", {n: todo.length}));
clearInterval(checkAll.t); checkAll.t = setInterval(checkAll, 4000);
};
async function checkAll() {
await loadRecs();
const pending = await Promise.all((S.recs?.local || []).filter(f => !f.check).map(f => api("/api/check?name=" + encodeURIComponent(f.name))));
if (!pending.some(c => c.state === "queued" || c.state === "running")) { clearInterval(checkAll.t); toast(tr("check.finished")); }
}
async function fetchSel() {
const r = await api("/api/download", {name: S.sel.name});
if (!r.ok) { toast(trErr(r.error)); return false; }
S.dl = {name: S.sel.name, done: 0, total: S.sel.size, finished: false};
renderCheck(); updateStart();
return new Promise(res => { S.dlWait = res; });
}
/* ---------- settings ---------- */
function target() { return document.querySelector("input[name=target]:checked").value; }
function targetChanged() {
$("#optFrame").classList.toggle("active", target() === "frame"); $("#optPc").classList.toggle("active", target() === "pc");
$("#targetWarn").classList.toggle("hidden", S.frameOnline !== false || target() !== "frame");
}
function settings() {
const d = S.probe?.duration || 30, pre = PRESETS[S.quality](d);
const v = id => $("#" + id).value.trim();
return {
frames: v("frames") ? +v("frames") : pre.frames, scale: v("scale") ? +v("scale") : pre.scale,
maxg: v("maxg") ? +v("maxg") : pre.maxg, iter: v("iter") ? +v("iter") : pre.iter, pre,
};
}
function updateStart() {
const s = settings();
for (const k of ["frames", "scale", "maxg", "iter"]) $("#" + k).placeholder = num(s.pre[k], k === "scale" || k === "maxg" ? 1 : 0);
// rough time estimate (measured on an RTX 4070 SUPER: 30k steps, 2 M splats, full resolution ≈ 30 min;
// preparation ≈ 2.5 s per image pair)
const res = s.scale >= 1 ? 1 : s.scale >= 0.5 ? 0.33 + (s.scale - 0.5) * 1.34 : 0.2;
const train = s.iter / 1000 * res * Math.pow(s.maxg / 2, 0.8);
const total = Math.round(train + s.frames * 2.5 / 60 + 1);
const busy = S.job && S.job.running;
const ready = S.sel && (!S.probe || S.probe.ok) && $("#name").value.trim();
$("#startBtn").disabled = !ready || busy || (S.dl && !S.dl.finished);
$("#estimate").innerHTML = busy ? tr("est.busy") : !S.sel ? tr("est.none") :
tr("est", {frames: s.frames, maxg: num(s.maxg, 1), iter: s.iter / 1000, mins: total});
}
document.querySelectorAll("#quality button").forEach(b => b.onclick = () => {
S.quality = b.dataset.q; document.querySelectorAll("#quality button").forEach(x => x.classList.toggle("active", x === b)); updateStart();
});
document.querySelectorAll("input[name=target]").forEach(r => r.onchange = () => { S.targetTouched = true; targetChanged(); });
["frames", "scale", "maxg", "iter"].forEach(id => $("#" + id).oninput = updateStart);
$("#name").oninput = () => { $("#name").dataset.auto = "0"; updateStart(); };
function setTab(tab) { S.tab = tab; document.querySelectorAll(".tab").forEach(x => x.classList.toggle("active", x.dataset.tab === tab)); }
document.querySelectorAll(".tab").forEach(t => t.onclick = () => { setTab(t.dataset.tab); renderRecs(); });
$("#refreshBtn").onclick = () => loadRecs(true);
$("#pickBtn").onclick = async () => {
const r = await api("/api/pick", {});
if (!r.path) return;
const name = r.path.split("/").pop();
setTab("local");
await loadRecs();
const f = S.recs.local.find(x => x.path === r.path) || {name, path: r.path, size: 0, date: ""};
if (!S.recs.local.includes(f)) S.recs.local.unshift(f);
renderRecs(); select(f);
};
/* ---------- start ---------- */
async function start(existing) {
if (!S.sel.path) {
$("#startBtn").disabled = true;
const ok = await fetchSel();
if (!ok) return;
}
if (!S.probe) await runProbe(S.sel.path);
if (!S.probe.ok) { toast(tr("unusable")); return; }
const v = id => $("#" + id).value.trim();
const body = {path: S.sel.path, name: $("#name").value.trim(), quality: S.quality, upload: target() === "frame", existing,
frames: v("frames") || null, scale: v("scale") || null, maxg: v("maxg") ? Math.round(+v("maxg") * 1e6) : null, iter: v("iter") || null};
const r = await api("/api/start", body);
if (r.status === 409) {
$("#existsText").textContent = tr(r.no_resume ? "exists.noresume." + r.no_resume : "exists.text", {name: body.name});
$("#exResume").classList.toggle("hidden", !!r.no_resume);
$("#existsDlg").showModal(); return;
}
if (!r.ok) { toast(trErr(r.error)); return; }
S.dismissed = null; toast(tr("started")); poll(); window.scrollTo({top: 0, behavior: "smooth"});
}
$("#startBtn").onclick = () => start();
$("#exCancel").onclick = () => $("#existsDlg").close();
$("#exResume").onclick = () => { $("#existsDlg").close(); start("resume"); };
$("#exFresh").onclick = () => { $("#existsDlg").close(); start("fresh"); };
$("#cancelBtn").onclick = async () => {
if (!confirm(tr("confirm.cancel"))) return;
const r = await api("/api/cancel", {}); if (!r.ok) toast(trErr(r.error));
};
$("#dismissBtn").onclick = () => { S.dismissed = S.job?.out; renderJob(); };
/* ---------- current conversion ---------- */
function renderJob() {
const j = S.job, card = $("#jobCard");
if (!j || (!j.running && S.dismissed === j.out)) { card.classList.add("hidden"); return; }
card.classList.remove("hidden");
$("#jobName").textContent = j.name;
const qk = QUALITY_ALIAS[j.quality] || j.quality, combine = j.kind === "combine";
const target = tr(j.upload ? "job.toFrame" : "job.pcOnly"), time = mins(Date.now() / 1000 - j.started);
$("#jobSub").textContent = combine ? tr("job.sub.combine", {n: j.rooms, align: ALIGN_LETTER[j.quality] || "?", target, time})
: tr("job.sub", {rec: j.recording, q: tr("q." + qk), target, time});
$("#cancelBtn").classList.toggle("hidden", !j.running);
$("#dismissBtn").classList.toggle("hidden", j.running);
const phases = [0, 1, 2, 3, 4].slice(0, j.upload ? 5 : 4).map(i => tr((combine && i < 4 ? "cphase." : "phase.") + i));
const finished = !j.running && j.result === "ok";
$("#steps").style.gridTemplateColumns = `repeat(${phases.length}, 1fr)`;
$("#steps").innerHTML = phases.map((p, i) => {
const cls = finished || i < j.phase ? "done" : i === j.phase && j.running ? "now" : "";
return `<div class="step ${cls}"><i></i>${p}</div>`;
}).join("");
const bar = $("#bar");
bar.classList.toggle("hidden", !j.running);
bar.classList.toggle("indet", j.pct == null);
bar.firstElementChild.style.width = (j.pct ?? 0) + "%";
$("#phaseText").textContent = j.running ? phases[j.phase] + " …" : "";
$("#pctText").textContent = j.running ? (j.pct != null ? j.pct + " %" : "") + (j.eta != null ? tr("job.left", {time: mins(j.eta + 60)}) : "") : "";
const i = j.info || {}, tiles = [];
if (i.cameras != null) tiles.push([tr("tile.cameras"), `${i.cameras}${i.images ? " / " + i.images : ""}`]);
if (i.rms_cm != null) tiles.push([tr("tile.rms"), num(i.rms_cm, 1) + " cm"]);
if (i.psnr != null) tiles.push([tr("tile.psnr"), num(i.psnr, 1)]);
const splats = i.gaussians ?? i.splats;
if (splats != null) tiles.push([tr("tile.splats"), tr("million", {n: num(splats / 1e6, 2)})]);
if (i.step != null && j.running) tiles.push([tr("tile.step"), `${num(i.step / 1000, 1)}k / ${i.steps / 1000}k`]);
$("#tiles").innerHTML = tiles.map(([k, v]) => `<div class="tile"><div class="k">${k}</div><div class="v">${v}</div></div>`).join("");
const c = i.psnr_curve;
$("#spark").classList.toggle("hidden", !(c && c.length > 2));
if (c && c.length > 2) {
const lo = Math.min(...c), hi = Math.max(...c), n = c.length;
const pts = c.map((v, k) => [k / (n - 1) * 600, 66 - (v - lo) / ((hi - lo) || 1) * 60]);
const line = "M" + pts.map(p => p[0].toFixed(1) + "," + p[1].toFixed(1)).join("L");
$("#sparkLine").setAttribute("d", line);
$("#sparkArea").setAttribute("d", line + `L600,70L0,70Z`);
$("#sparkRange").textContent = `${num(lo, 1)} – ${num(hi, 1)}`;
}
const res = $("#result");
res.classList.toggle("hidden", j.running);
if (!j.running) {
if (j.result === "ok") { res.className = "result ok";
res.textContent = j.upload ? tr("res.ok.frame", {name: j.name, folder: i.folder ? " (" + i.folder + ")" : ""})
: tr("res.ok.local", {name: j.name, path: "runs/" + j.slug}); }
else if (j.result === "cancelled") { res.className = "result warn"; res.textContent = tr("res.cancelled"); }
else if (j.phase === 4) { res.className = "result warn"; res.textContent = tr("res.upload_failed"); }
else { res.className = "result bad"; res.textContent = tr("res.failed"); }
}
const log = $("#log"), atBottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 20;
log.textContent = j.tail || ""; if (atBottom) log.scrollTop = log.scrollHeight;
}
/* ---------- finished scenes ---------- */
function frameState(s) {
const u = s.upload;
if (u && u.running) return `<div class="fstate">${tr("sc.uploading")}</div>`;
if (u && u.error) return `<div class="fstate err" title="${esc(u.error)}">${tr("sc.uploadFailed")}</div>`;
if (s.on_frame === true) return `<div class="fstate yes">${tr("sc.onFrame")}</div>`;
if (s.on_frame === false) return `<div class="fstate no">${tr("sc.notOnFrame")}</div>`;
return S.frameConfigured ? `<div class="fstate faint">${tr("sc.frameOffline")}</div>` : "";
}
function uploadBtn(s) {
if (!S.frameConfigured) return "";
const busy = s.upload && s.upload.running;
const label = tr(s.on_frame ? "sc.reupload" : "sc.toFrame"), title = tr(s.on_frame ? "sc.reupload.title" : "sc.toFrame.title");
return `<button class="small${s.on_frame ? "" : " primary"}" data-upload="${esc(s.slug)}" title="${title}" ${busy || s.on_frame === null ? "disabled" : ""}>${label}</button>`;
}
function sceneCard(s) {
return `
<div class="rec scene" style="cursor:default">
<div class="thumb" style="${s.preview ? `background-image:url('/api/preview?slug=${encodeURIComponent(s.slug)}&t=${s.mtime}')` : ""}">${s.preview ? "" : "◌"}</div>
<div class="meta"><b>${esc(s.name)}</b>
<span>${new Date(s.mtime * 1000).toLocaleDateString(LANG)} · ${s.gaussians ? tr("sc.splats", {n: num(s.gaussians / 1e6, 2)}) + " · " : ""}${mb(s.size)}</span>
${s.disk ? `<span class="faint">${tr("sc.disk", {size: mb(s.disk.total)})}</span>` : ""}
${scaleLine(s)}
${s.replacing ? `<div class="fstate faint">${tr("sc.replacing")}</div>` : frameState(s)}
<div class="acts${s.replacing ? " hidden" : ""}">${uploadBtn(s)}${s.ply ? `<button class="small" data-plan="${esc(s.slug)}">${tr("sc.plan")}</button>` : ""}<button class="small pc-only" data-open="${esc(s.slug)}">${tr("sc.folder")}</button>${s.ply ? `<button class="small pc-only" data-spirula="${esc(s.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}<button class="small" data-rename="${esc(s.slug)}">${tr("ren.button")}</button><button class="small" data-scale="${esc(s.slug)}" title="${tr("scale.button.title")}">📏${s.scale !== 1 ? " " + ratioText(s.scale) : ""}</button><button class="small" data-clean="${esc(s.slug)}" title="${tr("sc.clean.title")}" aria-label="${tr("sc.clean.title")}">${tr("sc.clean")}</button><button class="small" data-delete="${esc(s.slug)}" title="${tr("sc.delete.title")}" aria-label="${tr("sc.delete.title")}">${tr("sc.delete")}</button></div>
</div></div>`;
}
const ALIGN_LETTER = {frame: "A", images: "B", shift: "C", rigid: "D"};
function groupOpen(slug, set) {
let open = [];
try { open = JSON.parse(localStorage.getItem("openGroups") || "[]"); } catch {}
if (set === undefined) return open.includes(slug);
open = open.filter(x => x !== slug); if (set) open.push(slug);
try { localStorage.setItem("openGroups", JSON.stringify(open)); } catch {}
}
function groupBlock(g, bySlug) {
const rooms = g.members.map(m => bySlug[m]).filter(Boolean), r = g.result, open = groupOpen(g.slug);
const disk = rooms.reduce((a, s) => a + (s.disk ? s.disk.total : 0), 0) + (r && r.disk ? r.disk.total : 0);
let tour = `<span>${tr("grp.none")}</span>`;
if (r) {
const frameN = Math.min(r.frame_splats || r.splats || 0, r.splats || 0), seams = Object.values(r.seams || {}).map(x => x.cm).sort((a, b) => a - b);
const med = seams.length ? seams[Math.floor(seams.length / 2)] : null, max = seams.length ? seams[seams.length - 1] : null;
const detail = Object.entries(r.seams || {}).map(([k, v]) => `${k.replace("|", " – ")}: ${num(v.cm, 1)} cm`).join("\n");
tour = `<span>${tr("grp.tour", {frame: tr("million", {n: num(frameN / 1e6, 1)}), fsize: mb(r.size),
full: r.full_size ? tr("grp.tour.full", {n: tr("million", {n: num((r.splats || 0) / 1e6, 1)}), size: mb(r.full_size)}) : ""})}</span>
<span title="${esc(detail)}">${med != null ? tr("grp.seams", {med: num(med, 1), max: num(max, 1), align: ALIGN_LETTER[r.align] || "?"}) : ""}</span>
${r.stale ? `<span class="warn">${tr("grp.stale")}</span>` : ""}
${frameState({upload: r.upload, on_frame: r.on_frame})}`;
}
const thumb = r && r.preview ? `style="background-image:url('/api/preview?slug=${encodeURIComponent(g.slug)}&t=${r.mtime}')"` : "";
const busy = g.busy ? `<span>${tr("grp.busy")}</span>` : "";
return `<div class="group">
<div class="ghead">
<div class="gthumb" ${thumb}>${r && r.preview ? "" : "📁"}</div>
<div class="gmeta"><b>📁 ${esc(g.name)}</b>
<span>${tr("grp.rooms", {n: rooms.length})} · ${tr("sc.disk", {size: mb(disk)})}</span>
${scaleLine(g)}
${tour}${busy}
<div class="acts">
<button class="small" data-gtoggle="${esc(g.slug)}" aria-expanded="${open}">${tr(open ? "grp.hide" : "grp.show", {n: rooms.length})}</button>
<button class="small${!r || r.stale ? " primary" : ""}" data-combine="${esc(g.slug)}" ${g.busy || rooms.length < 2 ? "disabled" : ""}>${tr(r ? "grp.recombine" : "grp.combine")}</button>
${r ? uploadBtn({slug: g.slug, on_frame: r.on_frame, upload: r.upload}) : ""}
${rooms.some(s => s.ply) ? `<button class="small" data-gplan="${esc(g.slug)}">${tr("sc.plan")}</button>` : ""}
${r && r.ply ? `<button class="small pc-only" data-spirula="${esc(g.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}
${r ? `<button class="small pc-only" data-open="${esc(g.slug)}">${tr("sc.folder")}</button>` : ""}
<button class="small" data-gedit="${esc(g.slug)}">${tr("grp.edit")}</button>
<button class="small" data-scale="${esc(g.slug)}" title="${tr("scale.button.title")}" ${g.busy ? "disabled" : ""}>📏${g.scale !== 1 ? " " + ratioText(g.scale) : ""}</button>
<button class="small" data-clean="${esc(g.slug)}" title="${tr("grp.clean.title")}" aria-label="${tr("grp.clean.title")}" ${g.busy ? "disabled" : ""}>${tr("sc.clean")}</button>
<button class="small" data-gdelete="${esc(g.slug)}" title="${tr("grp.delete.title")}" aria-label="${tr("grp.delete.title")}">${tr("sc.delete")}</button>
</div>
</div>
</div>
${open ? `<div class="grid ginner">${rooms.map(sceneCard).join("")}</div>` : ""}
</div>`;
}
async function loadScenes() {
const [list, gr] = await Promise.all([api("/api/scenes"), api("/api/groups")]);
const arr = Array.isArray(list) ? list : Object.values(list).filter(x => x && typeof x === "object" && x.slug);
S.scenes = arr; S.groups = gr.groups || []; S.memGB = gr.mem_available_gb;
const bySlug = Object.fromEntries(arr.map(s => [s.slug, s]));
const inGroup = new Set(S.groups.flatMap(g => g.members));
$("#groupNewBtn").classList.toggle("hidden", !arr.some(s => !inGroup.has(s.slug)));
const sug = gr.suggestion;
S.suggestion = sug;
$("#groupSuggest").classList.toggle("hidden", !sug);
if (sug) {
const day = new Date(sug.date).toLocaleDateString(LANG, {day: "numeric", month: "numeric"});
$("#groupSuggest").innerHTML = `${esc(tr("grp.suggest", {n: sug.members.length, date: day,
names: sug.members.map(m => bySlug[m] ? bySlug[m].name : m).join(", ")}))}
<div class="row"><button class="small primary" id="sugYes">${tr("grp.suggest.yes")}</button><button class="small" id="sugNo">${tr("grp.suggest.no")}</button></div>`;
$("#sugYes").onclick = () => openGroupDlg(null, {members: sug.members, name: tr("grp.suggest.name", {date: day})});
$("#sugNo").onclick = async () => { await api("/api/group/dismiss", {key: sug.key}); loadScenes(); };
}
$("#groupList").innerHTML = S.groups.map(g => groupBlock(g, bySlug)).join("");
const loose = arr.filter(s => !inGroup.has(s.slug));
$("#sceneGrid").innerHTML = loose.length ? loose.map(sceneCard).join("")
: S.groups.length ? "" : `<div class="empty" style="grid-column:1/-1">${tr("scenes.none")}</div>`;
document.querySelectorAll("[data-gtoggle]").forEach(b => b.onclick = () => { groupOpen(b.dataset.gtoggle, !groupOpen(b.dataset.gtoggle)); loadScenes(); });
document.querySelectorAll("[data-scale]").forEach(b => b.onclick = () => openScale(b.dataset.scale));
document.querySelectorAll("[data-gedit]").forEach(b => b.onclick = () => openGroupDlg(S.groups.find(g => g.slug === b.dataset.gedit)));
document.querySelectorAll("[data-gdelete]").forEach(b => b.onclick = () => deleteGroup(S.groups.find(g => g.slug === b.dataset.gdelete)));
document.querySelectorAll("[data-combine]").forEach(b => b.onclick = () => openCombine(S.groups.find(g => g.slug === b.dataset.combine)));
document.querySelectorAll("[data-gplan]").forEach(b => b.onclick = () => {
const g = S.groups.find(x => x.slug === b.dataset.gplan);
openPlan(g.members.filter(m => bySlug[m] && bySlug[m].ply), g.name);
});
document.querySelectorAll("[data-upload]").forEach(b => b.onclick = async () => {
const r = await api("/api/upload", {slug: b.dataset.upload});
if (!r.ok) { toast(trErr(r.error)); return; }
toast(tr("sc.uploadStarted")); loadScenes();
});
document.querySelectorAll("[data-plan]").forEach(b => b.onclick = () => {
const sc = S.scenes.find(x => x.slug === b.dataset.plan);
openPlan([sc.slug], sc.name);
});
document.querySelectorAll("[data-open]").forEach(b => b.onclick = () => api("/api/open", {slug: b.dataset.open, what: "folder"}));
document.querySelectorAll("[data-spirula]").forEach(b => b.onclick = () => { api("/api/open", {slug: b.dataset.spirula, what: "spirula"}); toast(tr("sc.spirula.starting")); });
document.querySelectorAll("[data-rename]").forEach(b => b.onclick = () => renameScene(S.scenes.find(x => x.slug === b.dataset.rename)));
document.querySelectorAll("[data-clean]").forEach(b => b.onclick = () => openClean(b.dataset.clean));
document.querySelectorAll("[data-delete]").forEach(b => b.onclick = () => deleteScene(S.scenes.find(x => x.slug === b.dataset.delete)));
loadUnfinished();
}
async function loadUnfinished() {
const list = await api("/api/unfinished"); // api() spreads the array into an object
const arr = Object.values(list).filter(x => x && typeof x === "object");
S.unfinished = arr;
$("#unfinCard").classList.toggle("hidden", !arr.length);
const state = u => u.phase === null ? tr(u.folder ? "unfin.empty" : "unfin.logonly")
: tr("unfin.stopped", {state: tr("phase." + u.phase) + (u.pct != null ? ` ${u.pct} %` : "")});
$("#unfinList").innerHTML = arr.map(u => `<div class="row">
<b>${esc(u.name)}</b>
<span class="muted" style="font-size:12.5px">${new Date(u.mtime * 1000).toLocaleDateString(LANG)} · ${state(u)} · ${mb(u.size)}</span>
<span class="sp"></span>
${u.resumable ? `<button class="small" data-resume="${esc(u.slug)}">${tr("unfin.resume")}</button>`
: u.restartable ? `<button class="small" data-resume="${esc(u.slug)}" data-fresh="1" title="${tr("unfin.restart.title")}">${tr("unfin.restart")}</button>` : ""}
<button class="small" data-udelete="${esc(u.slug)}" title="${tr("unfin.delete.title")}" aria-label="${tr("unfin.delete.title")}">${tr("sc.delete")}</button>
</div>`).join("");
document.querySelectorAll("[data-resume]").forEach(b => b.onclick = async () => {
const r = await api("/api/resume", {slug: b.dataset.resume, fresh: !!b.dataset.fresh});
if (!r.ok) { toast(trErr(r.error)); return; }
toast(tr("unfin.resumed")); poll(); loadUnfinished();
});
document.querySelectorAll("[data-udelete]").forEach(b => b.onclick = async () => {
const u = S.unfinished.find(x => x.slug === b.dataset.udelete);
if (!confirm(tr("unfin.confirm", {name: u.name, size: mb(u.size)}))) return;
const r = await api("/api/delete", {slug: u.slug});
if (!r.ok) { toast(trErr(r.error)); return; }
toast(tr("del.done", {name: u.name})); loadUnfinished();
});
}
async function deleteScene(sc) {
const size = mb(sc.disk ? sc.disk.total : sc.size);
if (!confirm(tr("del.confirm", {name: sc.name, size, frame: sc.on_frame ? tr("del.frame") : ""}))) return;
const r = await api("/api/delete", {slug: sc.slug});
if (!r.ok) { toast(trErr(r.error)); return; }
toast(tr("del.done", {name: sc.name})); loadScenes();
}
/* ---------- groups and tours ---------- */
function openGroupDlg(g, preset) {
const members = new Set(g ? g.members : (preset && preset.members) || []);
const owner = {};
(S.groups || []).forEach(x => { if (!g || x.slug !== g.slug) x.members.forEach(m => owner[m] = x.name); });
$("#groupTitle").textContent = tr(g ? "grp.edit.title" : "grp.new.title");
$("#groupName").value = g ? g.name : (preset && preset.name) || "";
// One group per scene: scenes of other groups are not offered at all
const free = S.scenes.filter(sc => !owner[sc.slug]);
$("#groupPick").innerHTML = free.length ? free.map(sc => `<label><input type="checkbox" value="${esc(sc.slug)}" ${members.has(sc.slug) ? "checked" : ""}>
${esc(sc.name)}</label>`).join("") : `<p class="faint" style="font-size:13px;margin:0">${tr("grp.noFree")}</p>`;
$("#groupDlg").showModal(); $("#groupName").select();
$("#groupCancel").onclick = () => $("#groupDlg").close();
$("#groupSave").onclick = async () => {
const picked = [...document.querySelectorAll("#groupPick input:checked")].map(i => i.value);
const r = await api("/api/group", {slug: g ? g.slug : "", name: $("#groupName").value.trim(), members: picked.join(",")});
if (!r.ok) { toast(trErr(r.error)); return; }
$("#groupDlg").close(); groupOpen(r.slug, true); loadScenes();
};
}
async function deleteGroup(g) {
const r = g.result;
if (!confirm(tr("grp.delete.confirm", {name: g.name, tour: r ? tr("grp.delete.tour", {size: mb(r.disk ? r.disk.total : r.size)}) : ""}))) return;
const res = await api("/api/delete", {slug: g.slug});
if (!res.ok) { toast(trErr(res.error)); return; }
toast(tr("grp.deleted")); loadScenes();
}
function openCombine(g) {
const o = g.options || {};
const radios = (box, name, values, cur) => { $(box).innerHTML = values.map(v => `<label class="opt${v === cur ? " active" : ""}">
<input type="radio" name="${name}" value="${v}" ${v === cur ? "checked" : ""}><div><b>${tr(`${name}.${v}`)}</b><span>${tr(`${name}.${v}.text`)}</span></div></label>`).join("");
$(box).querySelectorAll("input").forEach(i => i.onchange = () =>
$(box).querySelectorAll(".opt").forEach(l => l.classList.toggle("active", l.querySelector("input").checked))); };
radios("#cmbAlign", "cmb.align", ["frame", "images", "shift", "rigid"], o.align || "images");
radios("#cmbPart", "cmb.part", ["view", "nearest"], o.partition || "view");
$("#cmbTitle").textContent = tr("cmb.title", {name: g.name});
$("#cmbSize").value = String(o.frame_splats || 3500000);
// memory, measured on six rooms: reduce ~0.45 GB per million splats, full SOG ~0.65 GB per million
const total = (g.splats || 0) * 0.85 / 1e6, needFrame = Math.max(1.5, 0.45 * total), needFull = Math.max(needFrame, 0.65 * total);
const avail = S.memGB || 0, lowFull = avail && needFull > avail;
$("#cmbMem").textContent = tr("cmb.mem", {frame: num(needFrame, 1), full: num(needFull, 1), avail: num(avail, 1)});
$("#cmbFull").checked = o.full !== false && !lowFull;
const updMem = () => $("#cmbMemLow").classList.toggle("hidden", !(avail && ($("#cmbFull").checked ? needFull : needFrame) > avail));
$("#cmbFull").onchange = updMem; updMem();
$("#cmbUploadRow").classList.toggle("hidden", !S.frameConfigured);
$("#cmbUpload").checked = !!S.frameOnline;
$("#combineDlg").showModal();
$("#cmbCancel").onclick = () => $("#combineDlg").close();
$("#cmbStart").onclick = async () => {
const r = await api("/api/combine", {slug: g.slug, align: $("input[name='cmb.align']:checked").value,
partition: $("input[name='cmb.part']:checked").value, frame_splats: $("#cmbSize").value,
full: $("#cmbFull").checked, upload: S.frameConfigured && $("#cmbUpload").checked});
if (!r.ok) { toast(trErr(r.error)); return; }
$("#combineDlg").close(); toast(tr("cmb.started")); await poll(); loadScenes();
$("#jobCard").scrollIntoView({behavior: "smooth", block: "start"});
};
}
/* ---------- scale (models) ---------- */
const GAUGES = [[1, "scale.original"], [220, "Z"], [160, "N"], [120, "TT"], [87, "H0"], [64, "S"], [45, "0"], [22.5, "G"]];
function ratioText(r) { return "1:" + num(r, r % 1 ? 1 : 0); }
function scaleLine(x) {
if (!x.scale || x.scale === 1) return "";
return `<span class="faint">${tr("scale.line", {ratio: ratioText(x.scale)})}${x.scale_xr ? " · " + tr("scale.line.xr") : ""}</span>`;
}
function openScale(slug) {
const g = (S.groups || []).find(x => x.slug === slug), x = g || S.scenes.find(y => y.slug === slug);
if (!x) return;
const cur = x.scale || 1, known = GAUGES.some(([r]) => r === cur);
$("#scaleTitle").textContent = tr("scale.title", {name: x.name});
$("#scaleOpts").innerHTML = GAUGES.map(([r, label]) => `<label class="opt${r === cur ? " active" : ""}">
<input type="radio" name="scaleR" value="${r}" ${r === cur ? "checked" : ""}><div><b>${r === 1 ? tr(label) : label} · ${ratioText(r)}</b></div></label>`).join("")
+ `<label class="opt${known ? "" : " active"}"><input type="radio" name="scaleR" value="custom" ${known ? "" : "checked"}><div><b>${tr("scale.custom")}</b></div></label>`;
$("#scaleCustom").value = known ? "" : cur;
const value = () => { const v = $("input[name=scaleR]:checked").value; return v === "custom" ? parseFloat($("#scaleCustom").value) : parseFloat(v); };
const sync = () => { $("#scaleOpts").querySelectorAll(".opt").forEach(l => l.classList.toggle("active", l.querySelector("input").checked));
const one = value() === 1; $("#scaleXr").disabled = one; if (one) $("#scaleXr").checked = false; };
$("#scaleOpts").querySelectorAll("input").forEach(i => i.onchange = sync);
$("#scaleCustom").oninput = () => { $("input[name=scaleR][value=custom]").checked = true; sync(); };
$("#scaleXr").checked = !!x.scale_xr;
const onFrame = g ? g.result && g.result.on_frame : x.on_frame;
$("#scaleMembersRow").classList.toggle("hidden", !g);
$("#scaleFrameRow").classList.toggle("hidden", !(S.frameConfigured && onFrame));
sync();
$("#scaleDlg").showModal();
$("#scaleCancel").onclick = () => $("#scaleDlg").close();
$("#scaleOk").onclick = async () => {
const r = await api("/api/scale", {slug, scale: String(value()), xr: $("#scaleXr").checked, with_scenes: !!g && $("#scaleMembers").checked,
update_frame: !$("#scaleFrameRow").classList.contains("hidden") && $("#scaleFrame").checked});
if (!r.ok) { toast(trErr(r.error)); return; }
$("#scaleDlg").close(); toast(tr("scale.done")); loadScenes();
};
}
/* ---------- renaming ---------- */
function askName(title, hint, value, frameOption) {
$("#renTitle").textContent = title; $("#renHint").textContent = hint;
$("#renInput").value = value; $("#renFrameRow").classList.toggle("hidden", !frameOption);
$("#renFrame").checked = !!frameOption;
$("#renDlg").showModal(); $("#renInput").select();
return new Promise(res => {
const done = v => { $("#renDlg").close(); res(v); };
$("#renOk").onclick = () => done($("#renInput").value.trim() || null);
$("#renCancel").onclick = () => done(null);
$("#renInput").onkeydown = e => { if (e.key === "Enter") { e.preventDefault(); $("#renOk").click(); } };
$("#renDlg").oncancel = () => res(null);
});
}
async function renameRecording(f) {
const cur = S.recs.local.find(x => x.name === f.name) || f;
const v = await askName(tr("ren.rec.title"), tr("ren.rec.hint"), cur.label || f.name.replace(/\.mp4$/, ""));
if (!v || v + ".mp4" === f.name) return;
const r = await api("/api/rename", {name: f.name, new: v});
if (!r.ok) { toast(trErr(r.error)); return; }
toast(tr("ren.done"));
if ($("#name").dataset.auto === "1") $("#name").value = "";
await loadRecs();
const nf = S.recs.local.find(x => x.name === r.name);
if (nf) select(nf); else { S.sel = null; renderCheck(); updateStart(); }
}
async function renameScene(sc) {
const v = await askName(tr("ren.scene.title"), tr("ren.scene.hint"), sc.name, sc.on_frame === true);
if (!v) return; // the same name again still tidies up the folder name
const upd = sc.on_frame === true && $("#renFrame").checked;
const r = await api("/api/rename", {slug: sc.slug, new: v, update_frame: upd});
if (!r.ok) { toast(trErr(r.error)); return; }
toast(tr(upd ? "ren.frame.started" : "ren.done")); loadScenes();
}
/* ---------- cleaning up ---------- */
function openClean(slug) {
// a scene, or a group: then the same kind of data of all its scenes together
const g = (S.groups || []).find(x => x.slug === slug);
let sc = S.scenes.find(x => x.slug === slug);
if (g) {
const rooms = g.members.map(m => S.scenes.find(x => x.slug === m)).filter(x => x && x.disk);
const sum = k => rooms.reduce((a, x) => a + (x.disk[k] || 0), 0);
sc = {name: g.name, disk: {temp: sum("temp"), images: sum("images"), total: sum("total")}, n: rooms.length};
}
if (!sc || !sc.disk) return;
$("#cleanTitle").textContent = tr("clean.title", {name: sc.name});
$("#cleanIntro").textContent = g ? tr("clean.intro.group", {n: sc.n}) : tr("clean.intro");
$("#cleanTotal").textContent = tr(g ? "clean.total.group" : "clean.total", {size: mb(sc.disk.total)});
$("#cleanList").innerHTML = ["temp", "images"].map(k => {
const size = sc.disk[k] || 0, empty = size < 1e6;
return `<div class="finding" style="display:flex;gap:12px;align-items:center;margin-top:10px">
<div style="flex:1"><b>${tr("clean." + k)} · ${empty ? tr("clean.empty") : mb(size)}</b><span style="display:block;font-size:12.5px" class="muted">${tr("clean." + k + ".text")}</span></div>
<button class="small" data-del="${k}" ${empty ? "disabled" : ""}>${tr("clean.delete")}</button></div>`;
}).join("");
$("#cleanList").querySelectorAll("[data-del]").forEach(b => b.onclick = async () => {
const k = b.dataset.del, size = mb(sc.disk[k]);
if (!confirm(tr(g ? "clean.confirm.group" : "clean.confirm", {what: tr("clean." + k), name: sc.name, size, n: sc.n}))) return;
b.disabled = true;
const r = await api("/api/clean", {slug, what: k});
if (!r.ok) { toast(trErr(r.error)); b.disabled = false; return; }
toast(tr("clean.done", {size}));
await loadScenes(); openClean(slug);
});
if (!$("#cleanDlg").open) $("#cleanDlg").showModal();
}
$("#cleanClose").onclick = () => $("#cleanDlg").close();
/* ---------- floor plan ---------- */
const P = {slugs: [], name: "", style: "pretty"};
function planUrl(download) {
const q = new URLSearchParams({slugs: P.slugs.join(","), style: P.style, lang: LANG,
title: P.slugs.length > 1 ? P.name : ""});
if (download) { q.set("download", "1"); q.set("name", `${P.name}-${tr("plan.file")}`); }
return "/api/floorplan?" + q;
}
function openPlan(slugs, name) {
P.slugs = slugs; P.name = name;
$("#planTitle").textContent = tr(slugs.length > 1 ? "plan.title.all" : "plan.title", {name});
$("#planStyle").classList.remove("hidden"); $("#planSave").classList.remove("hidden"); $("#planMeasure").classList.remove("hidden");
if (!$("#planDlg").open) $("#planDlg").showModal();
drawPlan();
}
async function drawPlan() {
const box = $("#planBox"), img = $("#planImg"), wait = $("#planWait"), stage = $("#planStage");
box.classList.remove("hidden"); stage.classList.add("hidden");
wait.classList.remove("hidden"); wait.textContent = tr("plan.drawing");
$("#planNote").textContent = tr(P.style === "pretty" ? "plan.note.pretty" : "plan.note.classic");
document.querySelectorAll("#planStyle button").forEach(b => b.classList.toggle("active", b.dataset.style === P.style));
$("#planSave").href = planUrl(true);
const url = planUrl(false);
P.want = url;
try {
const r = await fetch(url);
if (!r.ok) throw new Error();
const ppm = parseFloat(r.headers.get("X-Pixels-Per-Metre"));
const blob = await r.blob();
if (P.want !== url) return; // another plan was requested meanwhile
if (img.src.startsWith("blob:")) URL.revokeObjectURL(img.src);
img.onload = () => {
M.ppm = Number.isFinite(ppm) && ppm > 0 ? ppm : 0; M.w = img.naturalWidth; M.h = img.naturalHeight; M.pts = [];
$("#planSvg").setAttribute("viewBox", `0 0 ${M.w} ${M.h}`);
wait.classList.add("hidden"); stage.classList.remove("hidden"); renderMeasure();
};
img.src = URL.createObjectURL(blob);
} catch { if (P.want === url) wait.textContent = tr("err.plan_failed"); }
}
/* measuring: points in image pixels, distances via pixels per metre */
const M = {on: false, pts: [], hover: null, ppm: 0, w: 0, h: 0};
function fmtM(px) {
if (!M.ppm) return "?"; // scale unknown (older server) – still draw the points
const m = px / M.ppm; return m < 1 ? `${Math.round(m * 100)} cm` : `${num(m, 2)} m`;
}
function svgPoint(e) {
const r = $("#planSvg").getBoundingClientRect();
return [(e.clientX - r.left) / r.width * M.w, (e.clientY - r.top) / r.height * M.h];
}
function renderMeasure() {
$("#planMeasure").classList.toggle("on", M.on);
$("#planStage").classList.toggle("measuring", M.on);
$("#measureBar").classList.toggle("hidden", !M.on);
const svg = $("#planSvg"), k = M.w / 1000; // sizes relative to the image width
if (!M.on || !M.w) { svg.innerHTML = ""; return; }
const pts = M.hover && M.pts.length ? [...M.pts, M.hover] : M.pts;
let total = 0, out = "";
const label = (x, y, t) => `<text x="${x}" y="${y}" text-anchor="middle" font-family="Inter, Noto Sans, sans-serif" font-weight="650"
font-size="${15 * k}" fill="#0b0d12" stroke="#0b0d12" stroke-width="${5 * k}" stroke-linejoin="round" opacity=".85">${t}</text>
<text x="${x}" y="${y}" text-anchor="middle" font-family="Inter, Noto Sans, sans-serif" font-weight="650" font-size="${15 * k}" fill="#ffd166">${t}</text>`;
for (let i = 1; i < pts.length; i++) {
const [x1, y1] = pts[i - 1], [x2, y2] = pts[i], d = Math.hypot(x2 - x1, y2 - y1);
const preview = M.hover && i === pts.length - 1;
total += d;
out += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="#0b0d12" stroke-width="${6 * k}" stroke-linecap="round" opacity=".6"/>
<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="#ffd166" stroke-width="${2.6 * k}" stroke-linecap="round" ${preview ? `stroke-dasharray="${8 * k} ${6 * k}"` : ""}/>`;
if (d * 1 > 30 * k) out += label((x1 + x2) / 2, (y1 + y2) / 2 - 9 * k, fmtM(d));
}
M.pts.forEach(([x, y], i) => out += `<circle cx="${x}" cy="${y}" r="${(i === 0 ? 6 : 4.5) * k}" fill="#ffd166" stroke="#0b0d12" stroke-width="${2 * k}"/>`);
svg.innerHTML = out;
const fixed = M.pts.slice(1).reduce((acc, p, i) => acc + Math.hypot(p[0] - M.pts[i][0], p[1] - M.pts[i][1]), 0);
$("#measureTotal").textContent = !M.ppm ? tr("plan.measure.noscale") : M.pts.length < 2 && !M.hover ? tr("plan.measure.start")
: tr("plan.measure.total", {d: fmtM(M.hover && M.pts.length ? total : fixed)});
$("#measureUndo").disabled = !M.pts.length; $("#measureReset").disabled = !M.pts.length;
}
$("#planMeasure").onclick = () => { M.on = !M.on; M.pts = []; M.hover = null; renderMeasure(); };
$("#planSvg").addEventListener("click", e => { if (!M.on) return; M.pts.push(svgPoint(e)); renderMeasure(); });
$("#planSvg").addEventListener("mousemove", e => { if (!M.on || !M.pts.length) return; M.hover = svgPoint(e); renderMeasure(); });
$("#planSvg").addEventListener("mouseleave", () => { M.hover = null; renderMeasure(); });
$("#planSvg").addEventListener("contextmenu", e => { if (!M.on) return; e.preventDefault(); M.pts.pop(); renderMeasure(); });
$("#measureUndo").onclick = () => { M.pts.pop(); renderMeasure(); };
$("#measureReset").onclick = () => { M.pts = []; M.hover = null; renderMeasure(); };
document.querySelectorAll("#planStyle button").forEach(b => b.onclick = () => { P.style = b.dataset.style; drawPlan(); });
$("#planClose").onclick = () => $("#planDlg").close();
$("#planDlg").addEventListener("close", () => { M.on = false; M.pts = []; M.hover = null; renderMeasure(); });
$("#planDlg").addEventListener("click", e => {
// Only a click on the backdrop closes it, not one on the dialog's own padding
const r = $("#planDlg").getBoundingClientRect();
if (e.target === $("#planDlg") && (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom)) $("#planDlg").close();
});
$("#groupNewBtn").onclick = () => openGroupDlg(null);
/* ---------- polling ---------- */
let wasRunning = null;
async function poll() {
if (document.hidden) return; // no polling in a background tab
let r;
try { r = await api("/api/status"); } catch { r = null; }
const offline = !r || r.status >= 500;
$("#offline").classList.toggle("hidden", !offline);
document.body.classList.toggle("offline", offline);
if (offline) return;
S.job = r.job; S.dl = r.download;
if (S.dl && S.dl.finished && S.dlWait && S.sel && S.dl.name === S.sel.name) {
const res = S.dlWait; S.dlWait = null;
if (S.dl.error) { toast(tr("err.generic") + ": " + S.dl.error); renderCheck(); res(false); }
else { await loadRecs(); const f = S.recs.local.find(x => x.name === S.sel.name);
if (f) { S.sel.path = f.path; await runProbe(f.path); } res(true); }
} else if (S.dl && !S.dl.finished && S.sel && S.dl.name === S.sel.name) renderCheck();
renderJob(); updateStart();
const running = !!(S.job && S.job.running);
const ups = r.uploads || {}, upBusy = Object.values(ups).some(u => u && u.running);
if ((wasRunning && !running) || upBusy || poll.upBusy) loadScenes();
poll.upBusy = upBusy;
wasRunning = running;
}
api("/api/ping").then(r => {
document.body.classList.toggle("remote", r.local === false);
S.ping = r; S.frameConfigured = r.frame_configured;
setupLang(r.language);
loadRecs(); poll();
});
setInterval(poll, 2000);
document.addEventListener("visibilitychange", () => { if (!document.hidden) poll(); });
</script>
</body>
</html>