mirror of
https://github.com/thunderheronight/splat-workshop.git
synced 2026-10-19 23:01:48 +02:00
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>
1930 lines
125 KiB
HTML
1930 lines
125 KiB
HTML
<!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 => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[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")}&place=${x.place}&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)}&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>
|