Files
thunderheronightandClaude Opus 5.5 bfe3939754 Import: pictures per orientation and a guess which way is up
The dialog shows a small picture per choice of up, as the scene would stand
on the Frame, with the likely one preselected. Labels by symptom. Spirula
Studio's photo-only scenes are Z up, not Y down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:21:28 +02:00

1930 lines
125 KiB
HTML
Raw Permalink 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; }
.dlpick { position: absolute; right: 6px; top: 6px; padding: 4px; border-radius: 8px; background: rgba(11,13,18,.6); line-height: 0; cursor: pointer; }
.dlpick input { width: 18px; height: 18px; margin: 0; cursor: pointer; accent-color: var(--accent); }
.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; }
.invisible { visibility: hidden; }
.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; }
.group .group { background: var(--panel); grid-column: 1 / -1; margin-bottom: 0; }
.group .group .gthumb { width: 96px; height: 96px; font-size: 34px; }
.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; }
.vgrid { display: grid; grid-template-columns: auto 1fr; gap: 8px 14px; align-items: center; font-size: 13.5px; }
.vlist .vrow { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--line); }
.vlist .vwhat { display: flex; flex-direction: column; gap: 2px; font-size: 13px; min-width: 0; }
.vlist .vacts { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.vinline { grid-column: 1 / -1; margin-top: 8px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 10px; }
.vinline .vhead { display: flex; justify-content: space-between; align-items: center; gap: 10px; font-size: 13px; }
.scene .acts.rows { flex-direction: column; align-items: flex-start; gap: 6px; }
.acts .arow { display: flex; gap: 6px; flex-wrap: wrap; }
.acts .sep { width: 1px; align-self: stretch; margin: 2px 3px; background: var(--line); }
.acts button.icon { min-width: 34px; }
.toggles { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
.toggles button.link { background: none; border-color: transparent; color: var(--muted, #9aa3b2); padding-left: 2px; padding-right: 8px; }
.toggles button.link:hover { color: inherit; border-color: var(--line); }
.vsizes { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 6px; }
.vpreview { font-size: 13px; margin: 14px 0 0; min-height: 1.4em; }
.cleanup .slide { display: grid; grid-template-columns: 7.5em 1fr 4.5em; gap: 10px; align-items: center; margin: 2px 0; }
.cleanup .slide output { text-align: right; color: var(--muted, #9aa3b2); font-variant-numeric: tabular-nums; }
.vgrid label.chk { display: inline-flex; gap: 5px; align-items: center; padding: 4px 9px; border: 1px solid var(--line); border-radius: 8px; }
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; }
.opts.pics { grid-template-columns: repeat(3, 1fr); }
.opts.pics .opt { flex-direction: column; gap: 8px; }
.opts.pics .pic { width: 100%; aspect-ratio: 8 / 5; border-radius: 6px; background: #181b22; object-fit: cover; display: block; }
.opts.pics .pic.wait { display: grid; place-items: center; font-size: 12px; color: var(--muted); text-align: center; padding: 6px; box-sizing: border-box; }
.opts.pics .guess { font-size: 11px; color: var(--accent); border: 1px solid var(--accent); border-radius: 99px; padding: 0 6px; margin-left: 4px; white-space: nowrap; }
@media (max-width: 620px) { .opts.pics { grid-template-columns: 1fr; } }
.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); }
.meshfiles { margin-top: 10px; }
.meshfiles a.btnlink .faint { margin-left: 6px; }
.foot { text-align: center; font-size: 12.5px; color: var(--muted, #9aa3b2); padding: 18px 16px 28px; }
.foot a { color: inherit; }
.update { border-color: color-mix(in srgb, var(--accent, #7c9cff) 45%, var(--line)); padding-top: 12px; padding-bottom: 12px; }
.update code { background: var(--bg); padding: 1px 6px; border-radius: 6px; font-size: 13px; }
a.btnlink { display: inline-flex; align-items: center; padding: 4px 10px; border: 1px solid var(--line); border-radius: 8px; color: inherit; text-decoration: none; font-size: 13px; }
.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 update hidden" id="updateCard">
<div class="row" style="justify-content:space-between;gap:10px">
<span id="updateText"></span>
<span class="row" style="gap:6px"><a class="small btnlink" id="updateLink" target="_blank" rel="noopener" data-i18n="update.notes"></a>
<button class="small" id="updateLater" data-i18n="update.later"></button></span>
</div>
<p class="faint" style="font-size:13px;margin:6px 0 0" id="updateHow"></p>
</section>
<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>
<label class="chk" id="standbyBox" data-i18n-title="job.standby.title"><input type="checkbox" id="standbyChk"> <span id="standbyText" data-i18n="job.standby"></span></label>
<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>
<div id="resultExtra" class="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 hidden" id="dlSelBtn" data-i18n-title="dl.sel.title"></button>
<button class="small hidden" id="dlAllBtn" data-i18n-title="dl.all.title"></button>
<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="dlBar" class="hidden" style="display:flex;gap:10px;align-items:center;margin:0 0 12px">
<div style="flex:1"><span class="muted" id="dlText"></span><div class="bar"><div id="dlFill" style="width:0"></div></div></div>
<button class="small" id="dlCancel" data-i18n="dl.cancel"></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 id="splitBox" class="hidden" style="margin-top:16px">
<div class="field" style="margin-bottom:6px" data-i18n="split"></div>
<div class="target">
<label class="opt" id="optSplit"><input type="radio" name="split" value="parts">
<div><b id="splitTitle"></b> <input type="number" id="splitN" min="2" max="10" step="1" style="width:58px;padding:3px 6px;margin:0 4px"><em id="splitRec" class="faint" style="font-size:12.5px;font-style:normal"></em>
<span id="splitPro"></span><span id="splitCon"></span></div></label>
<label class="opt" id="optWhole"><input type="radio" name="split" value="whole">
<div><b data-i18n="split.whole"></b><span id="wholePro"></span><span id="wholeCon"></span></div></label>
</div>
</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 pc-only" id="importBtn" data-i18n="imp.button" data-i18n-title="imp.button.title"></button><button class="small" id="groupNewBtn" data-i18n="grp.new" data-i18n-title="grp.new.tip"></button></div>
</div>
<div class="tabs" id="sceneTabs">
<button class="tab active" data-stab="pc" data-i18n="stab.pc"></button>
<button class="tab" data-stab="frame" data-i18n="stab.frame"></button>
<button class="small hidden" id="frameRefresh" title="↻" aria-label="↻">↻</button>
</div>
<div id="pcView">
<div id="groupSuggest" class="suggest hidden"></div>
<div id="groupList"></div>
<div id="sceneGrid" class="grid"></div>
</div>
<div id="frameView" class="hidden">
<p class="faint" style="font-size:12.5px;margin:0 0 12px" data-i18n="fg.hint"></p>
<div id="frameMove" class="hidden" style="font-size:12.5px;margin:0 0 12px"><span id="frameMoveText"></span>
<button class="small" id="frameMoveBtn" data-i18n="fg.move"></button></div>
<div id="frameGrid" class="grid"></div>
</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>
<label class="chk" style="display:flex;gap:8px;align-items:flex-start;margin:12px 0 0;font-size:14px">
<input type="checkbox" id="groupRoom" style="margin-top:3px"><span><b data-i18n="grp.room"></b><br>
<span class="faint" style="font-size:12.5px" data-i18n="grp.room.hint"></span></span></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" id="cmbHint"></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>
<div class="row"><select id="cmbSize"></select>
<input id="cmbSizeCustom" class="hidden" type="number" min="0.5" max="10" step="0.05" style="width:90px"> <span class="hidden" id="cmbSizeUnit" data-i18n="ver.million"></span></div>
<p class="faint" style="font-size:12px;margin:6px 0 0" data-i18n="ver.measured"></p>
<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="importDlg" class="wide">
<h2 style="margin-top:0" data-i18n="imp.title"></h2>
<p class="muted" style="font-size:13px;margin-top:0" data-i18n="imp.hint"></p>
<div class="row" style="gap:8px"><button class="small" id="impPick" data-i18n="imp.pick"></button><span id="impFile" class="muted" style="overflow-wrap:anywhere"></span></div>
<label class="row" style="gap:8px;margin-top:12px"><span data-i18n="imp.name"></span><input id="impName" type="text" style="flex:1"></label>
<h3 data-i18n="imp.up"></h3>
<p class="muted" style="font-size:13px;margin-top:-4px" data-i18n="imp.up.hint"></p>
<div class="opts pics" id="impUp"></div>
<label class="row" style="gap:8px;margin-top:12px"><span data-i18n="imp.scale"></span><input id="impScale" type="number" min="0.0001" max="10000" step="any" value="1" style="width:110px"><span class="faint" style="font-size:12.5px" data-i18n="imp.scale.hint"></span></label>
<div class="row" style="justify-content:flex-end;margin-top:16px">
<button id="impClose" data-i18n="clean.close"></button>
<button class="primary" id="impMake" data-i18n="imp.make" disabled></button>
</div>
</dialog>
<dialog id="meshDlg" class="wide">
<h2 style="margin-top:0" id="meshTitle"></h2>
<p class="muted" style="font-size:13px;margin-top:0" data-i18n="mesh.hint"></p>
<div id="meshHave" class="vlist"></div>
<h3 data-i18n="mesh.new"></h3>
<div id="meshFormats" class="opts"></div>
<label class="chk" id="meshTexBox" style="margin-top:10px"><input type="checkbox" id="meshTex"> <span id="meshTexText"></span></label>
<p class="faint" style="font-size:12px;margin:10px 0 0" data-i18n="mesh.time"></p>
<div class="row" style="justify-content:flex-end;margin-top:16px">
<button id="meshClose" data-i18n="clean.close"></button>
<button class="primary" id="meshMake" data-i18n="mesh.make"></button>
</div>
</dialog>
<dialog id="verDlg" class="wide">
<h2 style="margin-top:0" id="verTitle"></h2>
<p class="muted" style="font-size:13px;margin-top:0" data-i18n="ver.hint"></p>
<div id="verList" class="vlist"></div>
<h3 data-i18n="ver.new"></h3>
<div class="vgrid">
<b data-i18n="ver.for"></b>
<div class="row" id="verFor">
<label class="chk"><input type="radio" name="verFor" value="frame" checked> <span data-i18n="ver.for.frame"></span></label>
<label class="chk"><input type="radio" name="verFor" value="pc"> <span data-i18n="ver.for.pc"></span></label>
</div>
<b id="verSizeHead" data-i18n="ver.size.head"></b><div id="verSizes" class="vsizes"></div>
<b class="hidden" id="verScaleHead" data-i18n="ver.scales"></b><div class="row hidden" id="verScales"></div>
<b data-i18n="ver.clean"></b>
<div><div id="verClean" class="cleanup"></div>
<label class="chk hidden" id="verPlainBox" style="margin-top:6px"><input type="checkbox" id="verPlain"> <span data-i18n="ver.plain"></span></label></div>
</div>
<p id="verPreview" class="vpreview"></p>
<div class="row" style="justify-content:flex-end;margin-top:12px">
<button id="verClose" data-i18n="clean.close"></button>
<button class="primary" id="verMake"></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>
<footer class="foot"><span id="footVersion"></span> · <a href="https://github.com/thunderheronight/splat-workshop" target="_blank" rel="noopener">GitHub</a>
· <a href="https://github.com/thunderheronight/splat-workshop/releases" target="_blank" rel="noopener" data-i18n="foot.releases"></a>
· <a href="#" id="footCheck" data-i18n="foot.check"></a></footer>
<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, picked: new Set(), 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));
}
renderUpdate();
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>`}) : "";
}
}
// A newer release on GitHub (the server asks once a day, see check_updates in the config)
function renderUpdate() {
const u = (S.ping || {}).update;
let later = "";
try { later = localStorage.getItem("updateLater") || ""; } catch {}
const show = !!u && later !== u.latest && !document.body.classList.contains("remote");
$("#updateCard").classList.toggle("hidden", !show);
if (!show) return;
$("#updateText").innerHTML = tr("update.text", {latest: `<b>${esc(u.latest)}</b>`, current: esc(u.current)});
$("#updateLink").href = u.url;
$("#updateHow").innerHTML = tr("update.how", {cmd: "<code>git pull</code>"});
$("#updateLater").onclick = () => { try { localStorage.setItem("updateLater", u.latest); } catch {} renderUpdate(); };
}
/* ---------- 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 pick = S.tab === "frame" && !f.local && !queued(f.name)
? `<label class="dlpick" title="${tr("dl.pick")}"><input type="checkbox" data-pick="${esc(f.name)}"${S.picked.has(f.name) ? " checked" : ""}></label>` : "";
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}${pick}</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}${queued(f.name) ? `<span class="badge">${tr("dl.queued")}</span>` : ""}</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.insertAdjacentHTML("afterbegin", "<span>◌</span>"); };
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); });
g.querySelectorAll(".dlpick").forEach(b => { b.onclick = e => e.stopPropagation(); b.onkeydown = e => e.stopPropagation(); });
g.querySelectorAll("[data-pick]").forEach(c => c.onchange = () => {
c.checked ? S.picked.add(c.dataset.pick) : S.picked.delete(c.dataset.pick); renderDlButtons();
});
renderDlButtons();
}
/* ---------- fetching several recordings from the Frame, one after the other ---------- */
function queued(name) { const d = S.dl; return !!(d && d.busy && ((d.name === name && !d.finished) || (d.queue || []).includes(name))); }
function fetchable() { return S.recs && S.recs.frame_online ? (S.recs.frame || []).filter(f => !f.local && !queued(f.name)) : []; }
function renderDlButtons() {
const onFrame = S.tab === "frame", all = fetchable();
for (const n of [...S.picked]) if (!all.some(f => f.name === n)) S.picked.delete(n);
const picked = all.filter(f => S.picked.has(f.name));
const size = list => mb(list.reduce((a, f) => a + f.size, 0));
$("#dlAllBtn").classList.toggle("hidden", !onFrame || !all.length);
$("#dlAllBtn").textContent = "⬇ " + tr("dl.all", {n: all.length, size: size(all)});
$("#dlSelBtn").classList.toggle("hidden", !onFrame || !picked.length);
$("#dlSelBtn").textContent = "⬇ " + tr("dl.sel", {n: picked.length, size: size(picked)});
}
async function fetchMany(list) {
if (!list.length) return;
const r = await api("/api/download", {names: list.map(f => f.name).join("\n")});
if (!r.ok) { toast(trErr(r.error)); return; }
S.picked.clear();
S.dl = {...(S.dl || {}), busy: true, queue: [...((S.dl && S.dl.queue) || []), ...list.map(f => f.name)]};
renderRecs(); renderDlBar();
}
$("#dlAllBtn").onclick = () => fetchMany(fetchable());
$("#dlSelBtn").onclick = () => fetchMany(fetchable().filter(f => S.picked.has(f.name)));
$("#dlCancel").onclick = async () => {
const r = await api("/api/download/cancel", {});
if (!r.ok) toast(trErr(r.error));
};
function renderDlBar() {
const d = S.dl, b = d && d.batch, on = !!(d && d.busy && b && b.files);
$("#dlBar").classList.toggle("hidden", !on);
if (!on) return;
const cur = d.finished ? 0 : d.done || 0, done = (b.done_bytes || 0) + cur;
$("#dlText").textContent = tr("dl.progress", {i: Math.min(b.done_files + 1, b.files), n: b.files, done: mb(done), total: mb(b.bytes)});
$("#dlFill").style.width = (done / Math.max(b.bytes, 1) * 100).toFixed(1) + "%";
}
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>${delButton(f)}`;
const b = $("#fetchBtn"); if (b) b.onclick = () => fetchSel();
if ($("#delSel")) $("#delSel").onclick = () => deleteRecording(f);
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>` : ""}${delButton(f)}</div>`);
if ($("#delSel")) $("#delSel").onclick = () => deleteRecording(f);
$("#playSel").onclick = () => play(f.name);
if ($("#renSel")) $("#renSel").onclick = () => renameRecording(f);
$("#checkSel").onclick = () => runCheck(f.name);
loadCheck(f.name);
}
/* ---------- deleting one recording: on the Frame (for good) or on this PC (trash) ---------- */
function delWhere(f) {
if (f.source === "frame") return S.recs && S.recs.frame_online && !queued(f.name) ? "frame" : null;
return S.recs?.local.find(x => x.name === f.name)?.renamable && !queued(f.name) ? "pc" : null;
}
function delButton(f) {
const where = delWhere(f);
return where ? `<button class="small" id="delSel" title="${tr("rdel." + where)}">${tr("rdel." + where)}</button>` : "";
}
async function deleteRecording(f) {
const where = delWhere(f);
if (!where) return;
const loc = S.recs.local.find(x => x.name === f.name), orig = loc ? loc.orig : f.name;
const other = where === "frame" ? !!(f.local || loc) : !!(S.recs.frame || []).find(x => x.name === orig);
const name = f.label || fmtDate(f.date) || f.name;
if (!confirm(tr(`rdel.confirm.${where}${other ? "" : ".only"}`, {name, size: mb(f.size)}))) return;
let r = await api("/api/recording/delete", {name: f.name, where});
if (!r.ok && r.error && r.error.code === "err.recording_has_parts") { // the original of cut parts
if (!confirm(trErr(r.error) + "\n\n" + tr("rdel.parts.confirm"))) return;
r = await api("/api/recording/delete", {name: f.name, where, force: true});
}
if (!r.ok) { toast(trErr(r.error)); return; }
toast(tr("rdel.done." + where, {name}));
S.sel = null; S.probe = null; $("#checkRes").className = "hidden";
renderCheck(); updateStart(); loadRecs(true);
}
/* ---------- 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; }
if (!S.dl || !S.dl.busy) S.dl = {name: S.sel.name, done: 0, total: S.sel.size, finished: false, busy: true, queue: []};
else S.dl.queue = [...(S.dl.queue || []), S.sel.name];
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,
};
}
/* ---------- a long recording in parts: each part gets up to 500 image pairs (the pipeline's limit) ---------- */
const PAIR_RATE = {high: 5, max: 8}, PAIRS_MAX = 500;
function splitInfo() {
const d = S.probe && S.probe.ok ? S.probe.duration || 0 : 0, rate = PAIR_RATE[S.quality];
if (!S.sel || !d || !rate || $("#frames").value.trim() || d * rate <= PAIRS_MAX) return null;
const suggested = clamp(Math.ceil(d * rate / PAIRS_MAX), 2, 10);
return {d, rate, suggested, max: Math.max(2, Math.min(10, Math.floor(d / 20)))};
}
function renderSplit() {
const info = splitInfo();
$("#splitBox").classList.toggle("hidden", !info);
if (!info) { S.split = null; return; }
const key = S.sel.name + "|" + S.quality;
if (!S.split || S.split.key !== key) S.split = {key, parts: true, n: Math.min(info.suggested, info.max)};
const n = S.split.n, part = info.d / n, perPart = Math.min(PAIRS_MAX, Math.round(part * info.rate));
const rateParts = num(Math.min(info.rate, PAIRS_MAX / part), 1), rateWhole = num(PAIRS_MAX / info.d, 1);
$("#splitN").value = n; $("#splitN").max = info.max;
$("#splitTitle").textContent = tr("split.parts", {rec: ""}).trim();
$("#splitPro").textContent = "+ " + tr("split.parts.pro", {rate: rateParts, whole: rateWhole, pairs: perPart, len: mmss(Math.round(part))});
$("#splitRec").textContent = n === info.suggested ? tr("ver.recommended") : "";
$("#splitCon").textContent = "- " + tr("split.parts.con", {n});
$("#wholePro").textContent = "+ " + tr("split.whole.pro");
$("#wholeCon").textContent = "- " + tr("split.whole.con", {rate: rateWhole, full: info.rate});
$("#optSplit").classList.toggle("active", S.split.parts); $("#optWhole").classList.toggle("active", !S.split.parts);
document.querySelector(`input[name=split][value=${S.split.parts ? "parts" : "whole"}]`).checked = true;
}
document.querySelectorAll("input[name=split]").forEach(r => r.onchange = () => { S.split.parts = r.value === "parts"; updateStart(); });
$("#splitN").oninput = () => {
const info = splitInfo(), n = parseInt($("#splitN").value, 10);
if (info && n >= 2 && n <= info.max) { S.split.n = n; S.split.parts = true; updateStart(); }
};
$("#splitN").onclick = e => e.preventDefault(); // inside the label: typing, not choosing
function updateStart() {
renderSplit();
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 parts = S.split && S.split.parts ? S.split.n : 1;
const frames = parts > 1 ? Math.min(PAIRS_MAX, Math.round(S.probe.duration / parts * PAIR_RATE[S.quality])) : s.frames;
const total = Math.round((train + frames * 2.5 / 60 + 1) * parts);
const busy = S.job && S.job.running;
const ready = S.sel && (!S.probe || S.probe.ok) && $("#name").value.trim();
$("#startBtn").disabled = !ready || busy || !!(S.sel && queued(S.sel.name));
$("#estimate").innerHTML = busy ? tr("est.busy") : !S.sel ? tr("est.none") :
parts > 1 ? tr("est.parts", {n: parts, frames, maxg: num(s.maxg, 1), iter: s.iter / 1000, mins: total})
: 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[data-tab]").forEach(x => x.classList.toggle("active", x.dataset.tab === tab)); }
document.querySelectorAll(".tab[data-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};
if (S.split && S.split.parts && !existing) { // in parts, then one room
const r = await api("/api/parts", {...body, parts: S.split.n});
if (!r.ok) { toast(trErr(r.error)); return; }
S.dismissed = null; toast(tr("started.parts", {n: S.split.n})); poll(); window.scrollTo({top: 0, behavior: "smooth"});
return;
}
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(); };
$("#standbyChk").onchange = async e => { S.standby = await api("/api/standby", {on: e.target.checked}); renderJob(); };
function renderStandby() {
const sb = S.standby || {}, j = S.job;
$("#standbyChk").checked = !!sb.on;
$("#standbyBox").classList.toggle("hidden", !(j && j.running) && !sb.on);
$("#standbyText").textContent = sb.on && sb.at ? tr("job.standby.in", {s: Math.max(0, Math.round(sb.at - Date.now() / 1000))}) : tr("job.standby");
}
/* ---------- 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");
renderStandby();
$("#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);
const versions = j.kind === "versions" || j.kind === "mesh" || j.kind === "import";
$("#jobSub").textContent = j.kind === "mesh" ? tr("job.sub.mesh", {time}) : j.kind === "import" ? tr("job.sub.import", {time})
: versions ? tr((j.steps || []).length === 1 ? "job.sub.versions.1" : "job.sub.versions", {n: (j.steps || []).length, time})
: combine ? tr(j.rooms === 1 && j.recordings > 1 ? "job.sub.combine.room" : "job.sub.combine",
{n: j.rooms === 1 && j.recordings > 1 ? j.recordings : j.rooms, align: ALIGN_LETTER[j.quality] || "?", target, time})
: j.chain ? tr("job.sub.part", {i: j.chain.i + 1, n: j.chain.n, room: j.chain.room, q: tr("q." + qk), 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 = versions ? (j.steps || [])
: [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(j.kind === "mesh" ? "res.ok.mesh" : "res.ok.local", {name: j.name, path: "runs/" + j.slug + (j.kind === "mesh" ? "/mesh" : "")}); }
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 = i.hint ? tr(i.hint) : tr("res.failed"); }
}
// after a mesh: its files to download, Blender, the folder
const extra = $("#resultExtra"), meshDone = !j.running && j.result === "ok" && j.kind === "mesh";
extra.classList.toggle("hidden", !meshDone);
if (meshDone && extra.dataset.for !== j.out) {
extra.dataset.for = j.out;
api("/api/mesh?slug=" + encodeURIComponent(j.slug)).then(m => { extra.innerHTML = meshFiles(j.slug, m); bindMeshFiles(extra, j.slug); });
}
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">${u.total ? tr("sc.uploading.pct", {pct: u.pct, done: mb(u.done), total: mb(u.total)}) : 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 rows${s.replacing ? " hidden" : ""}">
${S.frameConfigured ? `<div class="arow">${uploadBtn(s)}</div>` : ""}
<div class="arow">${s.ply ? `<button class="small pc-only" data-spirula="${esc(s.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}${s.ply ? `<button class="small" data-mesh="${esc(s.slug)}" title="${tr("mesh.button.title")}">${tr(s.mesh ? "mesh.button.has" : "mesh.button")}</button>` : ""}${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></div>
<div class="arow"><button class="small icon" data-rename="${esc(s.slug)}" title="${tr("ren.button")}" aria-label="${tr("ren.button")}">✎</button><button class="small icon" data-scale="${esc(s.slug)}" title="${tr("scale.button.title")}">📏${s.scale !== 1 ? " " + ratioText(s.scale) : ""}</button><button class="small icon" data-clean="${esc(s.slug)}" title="${tr("sc.clean.title")}" aria-label="${tr("sc.clean.title")}">${tr("sc.clean")}</button><button class="small icon" data-delete="${esc(s.slug)}" title="${tr("sc.delete.title")}" aria-label="${tr("sc.delete.title")}">${tr("sc.delete")}</button></div></div>
<div class="toggles${s.replacing ? " hidden" : ""}">${versionsToggle(s.slug, s.versions)}</div>
</div></div>
${s.versions && versionsOpen(s.slug) ? `<div class="vinline"><div class="vhead"><b>${tr("ver.of", {name: esc(s.name)})}</b><button class="small" data-versions="${esc(s.slug)}">${tr("ver.new.button")}</button></div>
<div class="vlist" data-vlist="${esc(s.slug)}"></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, byGroup) {
const scenes = (g.scenes || g.members).map(m => bySlug[m]).filter(Boolean), r = g.result, open = groupOpen(g.slug);
const inner = g.members.map(m => byGroup[m]).filter(Boolean); // rooms inside a home
const own = g.members.map(m => bySlug[m]).filter(Boolean);
const disk = scenes.reduce((a, s) => a + (s.disk ? s.disk.total : 0), 0) + (r && r.disk ? r.disk.total : 0)
+ inner.reduce((a, x) => a + (x.result && x.result.disk ? x.result.disk.total : 0), 0);
const count = g.room ? tr("grp.room.recordings", {n: scenes.length})
: inner.length ? tr("grp.home.count", {rooms: g.rooms, n: scenes.length}) : tr("grp.rooms", {n: scenes.length});
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>` : "";
const icon = g.room ? "🚪" : "📁";
return `<div class="group${g.room ? " room" : ""}">
<div class="ghead">
<div class="gthumb" ${thumb}>${r && r.preview ? "" : icon}</div>
<div class="gmeta"><b>${icon} ${esc(g.name)}</b>
<span>${count} · ${tr("sc.disk", {size: mb(disk)})}</span>
${scaleLine(g)}
${tour}${busy}
<div class="acts">
<button class="small${!r || r.stale ? " primary" : ""}" data-combine="${esc(g.slug)}" ${g.busy || scenes.length < 2 ? "disabled" : ""}>${tr(r ? "grp.recombine" : g.room ? "grp.room.combine" : "grp.combine")}</button>
${r ? uploadBtn({slug: g.slug, on_frame: r.on_frame, upload: r.upload}) : ""}<i class="sep"></i>
${r && r.ply ? `<button class="small pc-only" data-spirula="${esc(g.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}
${r && r.ply ? `<button class="small" data-mesh="${esc(g.slug)}" title="${tr("mesh.button.title")}">${tr(g.mesh ? "mesh.button.has" : "mesh.button")}</button>` : ""}
${scenes.some(s => s.ply) ? `<button class="small" data-gplan="${esc(g.slug)}">${tr("sc.plan")}</button>` : ""}
${r ? `<button class="small pc-only" data-open="${esc(g.slug)}">${tr("sc.folder")}</button>` : ""}<i class="sep"></i>
<button class="small icon" data-gedit="${esc(g.slug)}" title="${tr("grp.edit")}" aria-label="${tr("grp.edit")}">✎</button>
<button class="small icon" data-scale="${esc(g.slug)}" title="${tr("scale.button.title")}" ${g.busy ? "disabled" : ""}>📏${g.scale !== 1 ? " " + ratioText(g.scale) : ""}</button>
<button class="small icon" 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 icon" data-gdelete="${esc(g.slug)}" title="${tr("grp.delete.title")}" aria-label="${tr("grp.delete.title")}">${tr("sc.delete")}</button>
</div>
<div class="toggles">
<button class="small link" data-gtoggle="${esc(g.slug)}" aria-expanded="${open}">${tr(open ? "grp.hide" : g.room ? "grp.show.recordings" : inner.length ? "grp.show.rooms" : "grp.show", {n: inner.length + own.length})}</button>
${r && r.ply ? versionsToggle(g.slug, g.versions) : ""}
</div>
</div>
</div>
${g.versions && versionsOpen(g.slug) ? `<div class="vinline"><div class="vhead"><b>${tr("ver.of", {name: esc(g.name)})}</b><button class="small" data-versions="${esc(g.slug)}">${tr("ver.new.button")}</button></div>
<div class="vlist" data-vlist="${esc(g.slug)}"></div></div>` : ""}
${open ? `<div class="grid ginner">${inner.map(x => groupBlock(x, bySlug, byGroup)).join("")}${own.map(sceneCard).join("")}</div>` : ""}
</div>`;
}
async function loadScenes() {
const seq = S.sceneSeq = (S.sceneSeq || 0) + 1;
const [list, gr] = await Promise.all([api("/api/scenes"), api("/api/groups")]);
if (seq !== S.sceneSeq) return; // a newer load is on its way: its answer counts
S.scenes = Array.isArray(list) ? list : Object.values(list).filter(x => x && typeof x === "object" && x.slug);
S.sceneData = gr;
renderScenes();
}
// Draws the list from what was loaded last – opening or closing a group or the versions needs no new
// load (the scene list can take seconds on a slow disk; clicks during it used to toggle back and forth)
function renderScenes() {
const gr = S.sceneData || {}, arr = S.scenes || [];
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(); };
}
const byGroup = Object.fromEntries(S.groups.map(g => [g.slug, g]));
$("#groupList").innerHTML = S.groups.filter(g => !g.parent).map(g => groupBlock(g, bySlug, byGroup)).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)); renderScenes(); });
document.querySelectorAll("[data-vtoggle]").forEach(b => b.onclick = () => { versionsOpen(b.dataset.vtoggle, !versionsOpen(b.dataset.vtoggle)); renderScenes(); });
fillVersionLists();
document.querySelectorAll("[data-scale]").forEach(b => b.onclick = () => openScale(b.dataset.scale));
document.querySelectorAll("[data-versions]").forEach(b => b.onclick = () => openVersions(b.dataset.versions));
document.querySelectorAll("[data-mesh]").forEach(b => b.onclick = () => openMesh(b.dataset.mesh));
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.scenes || g.members).filter(m => bySlug[m] && bySlug[m].ply), g.name, g.slug);
});
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) || "";
// A room (several recordings of one room) inside a home stays a room; a home with rooms can't become one
$("#groupRoom").checked = !!(g ? g.room : preset && preset.room);
$("#groupRoom").disabled = !!(g && (g.parent || (S.groups || []).some(x => x.room && g.members.includes(x.slug))));
const pick = () => {
const keep = new Set([...document.querySelectorAll("#groupPick input:checked")].map(i => i.value));
if (keep.size) { members.clear(); keep.forEach(m => members.add(m)); }
// One group per scene: scenes of other groups are not offered at all; a home may also hold free rooms
const free = S.scenes.filter(sc => !owner[sc.slug]).map(sc => ({slug: sc.slug, name: sc.name, room: false}));
const rooms = $("#groupRoom").checked ? [] : (S.groups || []).filter(x => x.room && !owner[x.slug] && (!g || x.slug !== g.slug))
.map(x => ({slug: x.slug, name: x.name, room: true, n: x.scenes.length}));
const all = [...rooms, ...free];
$("#groupPick").innerHTML = all.length ? all.map(x => `<label><input type="checkbox" value="${esc(x.slug)}" ${members.has(x.slug) ? "checked" : ""}>
${x.room ? "🚪 " : ""}${esc(x.name)}${x.room ? `<span>${tr("grp.room.recordings", {n: x.n})}</span>` : ""}</label>`).join("")
: `<p class="faint" style="font-size:13px;margin:0">${tr("grp.noFree")}</p>`;
};
$("#groupPick").innerHTML = ""; pick();
$("#groupRoom").onchange = pick;
$("#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(","),
room: $("#groupRoom").checked});
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});
$("#cmbHint").textContent = tr(g.room ? "cmb.hint.room" : "cmb.hint");
const last = o.frame_splats || 1000000, known = FRAME_SIZES.some(m => m * 1e6 === last);
$("#cmbSize").innerHTML = FRAME_SIZES.map(m => `<option value="${m * 1e6}">${sizeText(m)}${m === FRAME_RECOMMENDED ? " " + tr("ver.recommended") : ""}</option>`).join("")
+ `<option value="custom">${tr("ver.custom")}</option>`;
$("#cmbSize").value = known ? String(last) : "custom";
$("#cmbSizeCustom").value = known ? "" : last / 1e6;
const showCustom = () => ["#cmbSizeCustom", "#cmbSizeUnit"].forEach(id => $(id).classList.toggle("hidden", $("#cmbSize").value !== "custom"));
$("#cmbSize").onchange = showCustom; showCustom();
// 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 === "custom" ? String(Math.round(parseFloat($("#cmbSizeCustom").value) * 1e6)) : $("#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"});
};
}
/* ---------- what is on the Frame ---------- */
function setSceneTab(tab) {
S.stab = tab;
document.querySelectorAll(".tab[data-stab]").forEach(x => x.classList.toggle("active", x.dataset.stab === tab));
$("#pcView").classList.toggle("hidden", tab !== "pc");
$("#frameView").classList.toggle("hidden", tab !== "frame");
$("#frameRefresh").classList.toggle("hidden", tab !== "frame");
$("#groupNewBtn").classList.toggle("invisible", tab !== "pc");
if (tab === "frame") loadFrameGallery();
}
document.querySelectorAll(".tab[data-stab]").forEach(t => t.onclick = () => setSceneTab(t.dataset.stab));
$("#frameRefresh").onclick = () => loadFrameGallery(true);
$("#frameMoveBtn").onclick = async () => {
const res = await api("/api/frame/move", {});
if (!res.ok) { toast(trErr(res.error)); return; }
toast(tr("fg.moved")); loadFrameGallery(true); loadScenes();
};
async function loadFrameGallery(refresh) {
const grid = $("#frameGrid");
if (!grid.children.length) grid.innerHTML = `<div class="empty" style="grid-column:1/-1">${tr("fg.loading")}</div>`;
const r = await api("/api/frame/gallery" + (refresh ? "?refresh=1" : ""));
if (!r.configured) { grid.innerHTML = `<div class="empty" style="grid-column:1/-1">${tr("frame.none")}</div>`; return; }
if (!r.online) { grid.innerHTML = `<div class="empty" style="grid-column:1/-1">${tr("frame.unreachable")}</div>`; return; }
const list = (r.scenes || []).filter(x => x && x.folder);
S.frameGallery = list;
const old = list.filter(x => x.place === "steam" && x.kind !== "other").length;
$("#frameMove").classList.toggle("hidden", !old);
$("#frameMoveText").textContent = tr("fg.move.hint", {n: old}) + " ";
grid.innerHTML = list.length ? list.map(x => `
<div class="rec scene" style="cursor:default">
<div class="thumb" style="background-image:url('/api/frame/preview?folder=${encodeURIComponent(x.folder).replace(/'/g, "%27")}&amp;place=${x.place}&amp;s=${x.size}')"></div>
<div class="meta"><b>${esc(x.name)}</b>
<span>${tr("fg.kind." + x.kind)} · ${mb(x.size)}</span>
<span class="faint" style="overflow-wrap:anywhere" title="${tr("fg.place." + x.place + ".title")}">${tr("fg.place." + x.place)} · ${esc(x.folder)}</span>
<div class="acts"><button class="small" data-fgdel="${esc(x.place + "/" + x.folder)}" title="${tr("fg.remove.title")}">🗑 ${tr("fg.remove")}</button></div>
</div></div>`).join("") : `<div class="empty" style="grid-column:1/-1">${tr("fg.empty")}</div>`;
grid.querySelectorAll("[data-fgdel]").forEach(b => b.onclick = async () => {
const x = S.frameGallery.find(y => y.place + "/" + y.folder === b.dataset.fgdel);
if (!confirm(tr(x.kind === "other" ? "fg.confirm.other" : "fg.confirm", {name: x.name, size: mb(x.size)}))) return;
const res = await api("/api/frame/remove", {folder: x.folder, place: x.place});
if (!res.ok) { toast(trErr(res.error)); return; }
toast(tr("fg.removed", {name: x.name})); loadFrameGallery(true); loadScenes();
});
}
/* ---------- your own splats ---------- */
$("#importBtn").onclick = () => {
let file = null;
$("#impFile").textContent = tr("imp.none"); $("#impName").value = ""; $("#impScale").value = "1"; $("#impMake").disabled = true;
let shown = 0; // a newer pick wins over an older preview still on its way
const ups = (pics, guess) => {
const keep = document.querySelector('input[name="impUp"]:checked')?.value;
const sel = guess || keep || "y-down";
$("#impUp").innerHTML = ["z-up", "y-down", "y-up"].map(u => `<label class="opt${u === sel ? " active" : ""}">
${pics === null ? "" : pics === "wait" || typeof pics === "string" ? `<div class="pic wait">${esc(pics === "wait" ? tr("imp.preview.wait") : pics)}</div>`
: `<img class="pic" alt="" src="${pics[u]}">`}
<div class="row" style="gap:8px;align-items:flex-start"><input type="radio" name="impUp" value="${u}" ${u === sel ? "checked" : ""}>
<div><b>${tr("imp.up." + u)}</b>${u === guess ? `<span class="guess" style="display:inline">${tr("imp.guess")}</span>` : ""}<span>${tr("imp.up." + u + ".text")}</span></div></div></label>`).join("");
$("#impUp").querySelectorAll("input").forEach(i => i.onchange = () =>
$("#impUp").querySelectorAll(".opt").forEach(l => l.classList.toggle("active", l.querySelector("input").checked)));
};
$("#impUp").innerHTML = ""; ups(null);
$("#impPick").onclick = async () => {
const r = await api("/api/pick", {kind: "splat"});
if (!r.path) return;
file = r.path;
$("#impFile").textContent = file;
if (!$("#impName").value) $("#impName").value = file.split("/").pop().replace(/(\.compressed)?\.[^.]+$/, "").replace(/[_-]+/g, " ");
$("#impMake").disabled = false;
const me = ++shown;
ups("wait");
const p = await api("/api/import/preview", {path: file});
if (me !== shown) return;
if (p.ok) ups(p.images, p.guess);
else ups(tr("imp.preview.none", {detail: p.error ? trErr(p.error) : ""}));
};
$("#impClose").onclick = () => $("#importDlg").close();
$("#impMake").onclick = async () => {
const r = await api("/api/import", {path: file, name: $("#impName").value.trim(),
up: document.querySelector('input[name="impUp"]:checked').value, scale: $("#impScale").value || "1"});
if (!r.ok) { toast(trErr(r.error)); return; }
$("#importDlg").close(); toast(tr("imp.started")); await poll();
$("#jobCard").scrollIntoView({behavior: "smooth", block: "start"});
};
$("#importDlg").showModal();
};
/* ---------- meshes ---------- */
const MESH_FORMATS = ["glb", "stl", "obj", "ply"];
// A mesh's files to download (also from other devices at home), Blender and the folder (on this PC)
function meshFiles(slug, m) {
if (!m || !m.files || !m.files.length) return "";
const link = f => `<a class="btnlink" href="/api/mesh/file?slug=${encodeURIComponent(slug)}&amp;name=${encodeURIComponent(f.name)}" download="${esc(f.name)}" title="${tr("mesh.download.title")}">⬇ ${esc(f.name)} <span class="faint">${mb(f.size)}</span></a>`;
return `<div class="meshfiles"><div class="row" style="gap:6px;flex-wrap:wrap">${m.files.map(link).join("")}</div>
<div class="row" style="gap:6px;margin-top:8px">${m.blender ? `<button class="small pc-only" data-mblender>${tr("mesh.blender")}</button>` : ""}
<button class="small pc-only" data-mfolder>${tr("sc.folder")}</button></div></div>`;
}
function bindMeshFiles(box, slug) {
box.querySelectorAll("[data-mblender]").forEach(b => b.onclick = async () => {
const r = await api("/api/open", {slug, what: "blender"});
toast(r.ok === false ? trErr(r.error) : tr("mesh.blender.starting"));
});
box.querySelectorAll("[data-mfolder]").forEach(b => b.onclick = () => api("/api/open", {slug, what: "mesh"}));
}
async function openMesh(slug) {
const g = (S.groups || []).find(x => x.slug === slug), x = g || S.scenes.find(y => y.slug === slug);
if (!x) return;
$("#meshTitle").textContent = tr("mesh.title", {name: x.name});
const draw = async () => {
const m = await api("/api/mesh?slug=" + encodeURIComponent(slug));
$("#meshHave").innerHTML = m.made ? `<div class="vrow"><div class="vwhat">
<b>${tr("mesh.have")}${m.made.stale ? " · " + tr("ver.stale") : ""}</b></div>
<div class="vacts"><button class="small" id="meshDel" title="${tr("mesh.delete")}" aria-label="${tr("mesh.delete")}">${tr("sc.delete")}</button></div></div>
${meshFiles(slug, m)}` : "";
if (m.made) {
bindMeshFiles($("#meshHave"), slug);
$("#meshDel").onclick = async () => {
if (!confirm(tr("mesh.confirm", {name: x.name}))) return;
const r = await api("/api/mesh/delete", {slug});
if (!r.ok) { toast(trErr(r.error)); return; }
draw(); loadScenes();
};
}
$("#meshTex").disabled = !m.texture_possible;
$("#meshTex").classList.toggle("hidden", !m.texture_possible); // not possible: only the note
$("#meshTex").checked = m.texture_possible;
$("#meshTexText").textContent = tr(m.texture_possible ? "mesh.texture" : "mesh.texture.none");
};
const was = S.meshFormats || ["glb"];
$("#meshFormats").innerHTML = MESH_FORMATS.map(f => `<label class="opt${was.includes(f) ? " active" : ""}">
<input type="checkbox" value="${f}" ${was.includes(f) ? "checked" : ""}><div><b>${tr("mesh.f." + f)}</b><span>${tr("mesh.f." + f + ".text")}</span></div></label>`).join("");
$("#meshFormats").querySelectorAll("input").forEach(i => i.onchange = () => i.closest(".opt").classList.toggle("active", i.checked));
await draw();
$("#meshDlg").showModal();
$("#meshClose").onclick = () => $("#meshDlg").close();
$("#meshMake").onclick = async () => {
const formats = [...$("#meshFormats").querySelectorAll("input:checked")].map(i => i.value);
if (!formats.length) { toast(tr("mesh.pick")); return; }
S.meshFormats = formats;
const r = await api("/api/mesh", {slug, formats: formats.join(","), texture: $("#meshTex").checked});
if (!r.ok) { toast(trErr(r.error)); return; }
$("#meshDlg").close(); toast(tr("mesh.started")); await poll();
$("#jobCard").scrollIntoView({behavior: "smooth", block: "start"});
};
}
/* ---------- versions ---------- */
// Frame sizes as measured on a Steam Frame (Gaussian XR, 10/2026): 1 M is nearly as fast as 0.75 M and clearly sharper
const FRAME_SIZES = [[0.75, 36], [1, 35], [1.5, 29], [2, 24]], FRAME_RECOMMENDED = 1; // million splats, fps
const CLEANUPS = ["ceiling", "floor", "haze", "outside"]; // scripts/clean.py
function sizeText(m) { return m === "all" ? tr("ver.all") : tr("million", {n: num(m, Math.round(m * 100) % 10 ? 2 : m % 1 ? 1 : 0)}); }
function versionKey(splats, scale, clean) { // as version_key() in server.py
let key = splats === "all" ? "all" : `${+(splats / 1e6).toPrecision(6)}m`;
if (scale !== 1) key += `-1x${scale}`;
const parts = CLEANUPS.filter(k => clean[k]).map(k => k[0] + Math.round(clean[k] * 100));
return parts.length ? key + "-" + parts.join("") : key;
}
function cleanText(clean) {
return CLEANUPS.filter(k => clean && clean[k]).map(k => `${tr("ver.clean." + k)} ${Math.round(clean[k] * 100)} %`).join(" · ");
}
function versionRow(v) {
const what = (v.purpose === "pc" ? tr("ver.all") : tr("million", {n: num(v.splats / 1e6, 2)})) + (v.scale !== 1 ? " · " + ratioText(v.scale) : "");
const slow = v.purpose === "pc" && v.splats > 2.5e6;
return `<div class="vrow">
<div class="vwhat"><b>${v.purpose === "pc" ? "🖥️" : "🥽"} ${esc(what)}</b>
<span class="muted">${cleanText(v.clean) || tr("ver.clean.none")}</span>
<span class="faint">${tr("million", {n: num(v.splats / 1e6, 2)})} · ${mb(v.size)}${v.stale ? " · " + tr("ver.stale") : ""}</span></div>
<div class="vacts">${v.on_frame === null && !(v.upload && (v.upload.running || v.upload.error)) ? "" : frameState({upload: v.upload, on_frame: v.on_frame})}
${v.ply ? `<button class="small pc-only" data-vspirula="${esc(v.key)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}
${S.frameConfigured ? `<button class="small" data-vup="${esc(v.key)}" ${(v.upload && v.upload.running) || v.on_frame === null ? "disabled" : ""} ${slow ? `title="${tr("ver.slow", {n: num(v.splats / 1e6, 1)})}"` : ""}>${tr(v.on_frame ? "sc.reupload" : "sc.toFrame")}${slow ? " ⚠️" : ""}</button>` : ""}
${v.on_frame ? `<button class="small" data-vunframe="${esc(v.key)}" title="${tr("ver.unframe.title")}">${tr("ver.unframe")}</button>` : ""}
<button class="small" data-vtemplate="${esc(v.key)}" title="${tr("ver.template.title")}">${tr("ver.template")}</button>
<button class="small" data-vdel="${esc(v.key)}" title="${tr("ver.delete")}" aria-label="${tr("ver.delete")}">${tr("sc.delete")}</button></div>
</div>`;
}
function bindVersionRows(box, slug, list, redraw) {
const find = key => list.find(x => x.key === key);
box.querySelectorAll("[data-vspirula]").forEach(b => b.onclick = async () => {
const res = await api("/api/open", {slug, version: b.dataset.vspirula, what: "spirula"});
toast(res.ok === false ? trErr(res.error) : tr("sc.spirula.starting"));
});
box.querySelectorAll("[data-vup]").forEach(b => b.onclick = async () => {
const res = await api("/api/upload", {slug, version: b.dataset.vup});
if (!res.ok) { toast(trErr(res.error)); return; }
toast(tr("sc.uploadStarted")); setTimeout(redraw, 1500);
});
box.querySelectorAll("[data-vunframe]").forEach(b => b.onclick = async () => {
const v = find(b.dataset.vunframe);
if (!confirm(tr("ver.unframe.confirm", {name: v.label}))) return;
const res = await api("/api/frame/remove", {slug, version: v.key});
if (!res.ok) { toast(trErr(res.error)); return; }
toast(tr("ver.unframed", {name: v.label})); redraw();
});
box.querySelectorAll("[data-vtemplate]").forEach(b => b.onclick = async () => {
if (!$("#verDlg").open) await openVersions(slug);
useTemplate(find(b.dataset.vtemplate));
});
box.querySelectorAll("[data-vdel]").forEach(b => b.onclick = async () => {
const v = find(b.dataset.vdel);
if (!confirm(tr("ver.confirm", {name: v.label, size: mb(v.size)}))) return;
const res = await api("/api/version/delete", {slug, version: v.key});
if (!res.ok) { toast(trErr(res.error)); return; }
redraw(); loadScenes();
});
}
function verForm() { // what the form would make
const pc = document.querySelector('input[name="verFor"]:checked').value === "pc";
let size = "all";
if (!pc) {
const pick = document.querySelector('input[name="verSize"]:checked');
size = pick.value === "custom" ? Math.round(parseFloat($("#verCustom").value) * 1e6) || 0 : Math.round(parseFloat(pick.value) * 1e6);
}
const sc = document.querySelector('input[name="verScale"]:checked');
const clean = {};
document.querySelectorAll("#verClean input").forEach(i => { if (+i.value) clean[i.dataset.clean] = i.value / 100; });
const any = Object.keys(clean).length > 0;
return {pc, size, scale: sc ? parseFloat(sc.value) : 1, clean, plain: any && $("#verPlain").checked, any};
}
function verPreview() {
const f = verForm(), V = S.verList || [], made = [];
$("#verPlainBox").classList.toggle("hidden", !f.any);
$("#verSizes").classList.toggle("hidden", f.pc); $("#verSizeHead").classList.toggle("hidden", f.pc);
$("#verSizes").querySelectorAll("label.opt").forEach(l => l.classList.toggle("active", l.querySelector("input").checked));
if (!f.pc && !(f.size >= 10000)) { $("#verPreview").textContent = tr("ver.pick"); $("#verMake").disabled = true; return; }
for (const cl of f.plain ? [f.clean, {}] : [f.clean]) {
const key = versionKey(f.size, f.scale, cl), old = V.find(v => v.key === key);
const text = (f.pc ? tr("ver.all") : sizeText(f.size / 1e6)) + (f.scale !== 1 ? " · " + ratioText(f.scale) : "") + " · " + (cleanText(cl) || tr("ver.clean.none"));
made.push(old ? tr("ver.replaces", {what: text}) : text);
}
const same = !f.any && f.pc && S.verScene.hasAll; // all splats, not cleaned: the scene itself
$("#verPreview").innerHTML = same ? esc(tr("ver.same.all"))
: esc(tr(made.length > 1 ? "ver.makes.n" : "ver.makes.1", {n: made.length})) + made.map(x => "<br>• " + esc(x)).join("");
$("#verMake").disabled = same;
$("#verMake").textContent = tr(made.length > 1 ? "ver.make.n" : "ver.make.1", {n: made.length});
}
function useTemplate(v) {
if (!v) return;
document.querySelector(`input[name="verFor"][value="${v.purpose}"]`).checked = true;
if (v.purpose === "frame") {
const m = v.splats / 1e6, known = FRAME_SIZES.find(([x]) => Math.abs(x - m) < 0.01);
document.querySelector(`input[name="verSize"][value="${known ? known[0] : "custom"}"]`).checked = true;
if (!known) $("#verCustom").value = num(m, 2).replace(",", ".");
}
const sc = document.querySelector(`input[name="verScale"][value="${v.scale}"]`);
if (sc) sc.checked = true;
document.querySelectorAll("#verClean input").forEach(i => { i.value = Math.round((v.clean[i.dataset.clean] || 0) * 100); i.oninput(); });
verPreview();
toast(tr("ver.template.done"));
}
async function openVersions(slug) {
const g = (S.groups || []).find(x => x.slug === slug), x = g || S.scenes.find(y => y.slug === slug);
if (!x) return;
const total = (g ? g.result && g.result.splats : x.gaussians) || 0, scale = x.scale || 1;
S.verScene = {slug, hasAll: true};
$("#verTitle").textContent = tr("ver.title", {name: x.name, n: tr("million", {n: num(total / 1e6, 2)})});
$("#verSizes").innerHTML = FRAME_SIZES.map(([m, fps]) => `<label class="opt${m === FRAME_RECOMMENDED ? " active" : ""}">
<input type="radio" name="verSize" value="${m}" ${m === FRAME_RECOMMENDED ? "checked" : ""}>
<div><b>${sizeText(m)}${m === FRAME_RECOMMENDED ? " " + tr("ver.recommended") : ""}</b><span>${tr("ver.fps." + String(m).replace(".", "_"), {fps})}</span></div></label>`).join("")
+ `<label class="opt"><input type="radio" name="verSize" value="custom"><div><b>${tr("ver.custom")}</b>
<span><input id="verCustom" type="number" min="0.1" max="50" step="0.05" style="width:80px"> ${tr("ver.million")}</span></div></label>`;
$("#verClean").innerHTML = `<p class="faint" style="font-size:12px;margin:0 0 6px">${tr("ver.clean.hint")}</p>` + CLEANUPS.map(k => `
<label class="slide" title="${esc(tr("ver.clean." + k + ".title"))}"><span>${tr("ver.clean." + k)}</span>
<input type="range" min="0" max="100" step="10" value="0" data-clean="${k}"><output>${tr("ver.clean.off")}</output></label>`).join("");
$("#verClean").querySelectorAll("input").forEach(i => i.oninput = () => {
i.nextElementSibling.textContent = +i.value ? i.value + " %" : tr("ver.clean.off"); verPreview(); });
$("#verPlain").checked = false;
$("#verScaleHead").classList.toggle("hidden", scale === 1);
$("#verScales").classList.toggle("hidden", scale === 1);
$("#verScales").innerHTML = scale === 1 ? "" : `<label class="chk"><input type="radio" name="verScale" value="1" checked> ${tr("scale.original")}</label>`
+ `<label class="chk"><input type="radio" name="verScale" value="${scale}"> ${tr("ver.walk", {ratio: ratioText(scale)})}</label>`;
$("#verDlg").querySelectorAll('input[type="radio"], #verPlain, #verCustom').forEach(i => { i.onchange = verPreview; i.oninput = verPreview; });
$("#verCustom").onfocus = () => { document.querySelector('input[name="verSize"][value="custom"]').checked = true; verPreview(); };
await drawVersions(slug);
verPreview();
if (!$("#verDlg").open) $("#verDlg").showModal();
$("#verClose").onclick = () => $("#verDlg").close();
$("#verMake").onclick = async () => {
const f = verForm();
const clean = Object.entries(f.clean).map(([k, v]) => `${k}=${v}`).join(",");
const r = await api("/api/versions", {slug, sizes: String(f.size), scales: String(f.scale), clean, also_plain: f.plain});
if (!r.ok) { toast(trErr(r.error)); return; }
$("#verDlg").close(); toast(tr("ver.started")); await poll();
$("#jobCard").scrollIntoView({behavior: "smooth", block: "start"});
};
}
async function drawVersions(slug) {
const r = await api("/api/versions?slug=" + encodeURIComponent(slug));
const list = S.verList = r.versions || [];
$("#verList").innerHTML = list.length ? list.map(versionRow).join("") : `<p class="faint" style="font-size:13px;margin:0">${tr("ver.none")}</p>`;
bindVersionRows($("#verList"), slug, list, () => drawVersions(slug).then(verPreview));
}
async function fillVersionLists() { // the opened lists under scenes and groups
for (const box of document.querySelectorAll("[data-vlist]")) {
const slug = box.dataset.vlist, r = await api("/api/versions?slug=" + encodeURIComponent(slug)), list = r.versions || [];
box.innerHTML = list.map(versionRow).join("") || `<p class="faint" style="font-size:13px;margin:0">${tr("ver.none")}</p>`;
bindVersionRows(box, slug, list, fillVersionLists);
}
}
function versionsOpen(slug, set) { return groupOpen("ver:" + slug, set); }
function versionsToggle(slug, n) {
if (!n) return `<button class="small link" data-versions="${esc(slug)}" title="${tr("ver.button.title")}">${tr("ver.first")}</button>`;
const open = versionsOpen(slug);
return `<button class="small link" data-vtoggle="${esc(slug)}" aria-expanded="${open}" title="${tr("ver.button.title")}">${tr(open ? "ver.hide" : n === 1 ? "ver.show.1" : "ver.show", {n})}</button>`;
}
/* ---------- 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.scenes || 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 (P.group) q.set("group", P.group);
if (download) { q.set("download", "1"); q.set("name", `${P.name}-${tr("plan.file")}`); }
return "/api/floorplan?" + q;
}
function openPlan(slugs, name, group) {
P.slugs = slugs; P.name = name; P.group = group || "";
$("#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;
const wasFetching = !!(S.dl && S.dl.busy), doneFiles = S.dl && S.dl.batch ? S.dl.batch.done_files : 0;
S.job = r.job; S.dl = r.download; S.standby = r.standby;
renderDlBar();
const nowFetching = !!(S.dl && S.dl.busy);
if (wasFetching && (!nowFetching || (S.dl.batch && S.dl.batch.done_files !== doneFiles))) {
await loadRecs();
if (!nowFetching && S.dl && S.dl.batch) {
const b = S.dl.batch, failed = (b.failed || []).length;
toast(b.cancelled ? tr("dl.cancelled") : failed ? tr("dl.failed", {n: failed}) : tr("dl.done", {n: b.done_files}));
}
}
// The chosen recording was fetched (also as part of several – the next one may already be on its way)
if (S.dlWait && S.sel && !queued(S.sel.name)) {
const res = S.dlWait; S.dlWait = null;
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 { toast(tr("err.generic") + ": " + ((S.dl && S.dl.name === S.sel.name && S.dl.error) || tr("dl.failed", {n: 1}))); renderCheck(); res(false); }
} 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);
$("#footVersion").textContent = tr("foot.version", {v: r.version}); // after the language is known
loadRecs(); poll();
});
$("#footCheck").onclick = async e => {
e.preventDefault();
const r = await api("/api/update/check", {});
if (r.update) { try { localStorage.removeItem("updateLater"); } catch {} S.ping.update = r.update; renderUpdate(); window.scrollTo({top: 0, behavior: "smooth"}); }
toast(r.update ? tr("update.text.plain", {latest: r.update.latest}) : r.reached ? tr("update.none", {current: r.current}) : tr("update.offline"));
};
// the check for a newer version runs in the background: ask again a little later, then every few hours
const repingForUpdate = () => api("/api/ping").then(r => { if (r && r.app) { S.ping = r; renderUpdate(); } });
setTimeout(repingForUpdate, 15000);
setInterval(repingForUpdate, 3 * 3600 * 1000);
setInterval(poll, 2000);
document.addEventListener("visibilitychange", () => { if (!document.hidden) poll(); });
</script>
</body>
</html>