mirror of
https://github.com/thunderheronight/splat-workshop.git
synced 2026-10-27 17:01:21 +01:00
Follows the Frame status until the user picks a target themselves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
899 lines
54 KiB
HTML
899 lines
54 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: #6b7283;
|
||
--accent: #7c9cff; --accent2: #46d3b5; --warn: #ffb86b; --bad: #ff6b81; --ok: #46d3b5;
|
||
--radius: 14px;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; background: var(--bg); color: var(--text);
|
||
font: 15px/1.45 "Inter", "Noto Sans", system-ui, sans-serif; }
|
||
header { display: flex; align-items: center; gap: 14px; padding: 18px 28px; border-bottom: 1px solid var(--line);
|
||
position: sticky; top: 0; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(8px); z-index: 5; }
|
||
header h1 { font-size: 19px; margin: 0; font-weight: 650; letter-spacing: .2px; }
|
||
.logo { width: 28px; height: 28px; }
|
||
.pill { font-size: 12.5px; padding: 4px 10px; border-radius: 99px; background: var(--panel2); color: var(--muted);
|
||
display: inline-flex; align-items: center; gap: 6px; }
|
||
.pill .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); }
|
||
.pill.on .dot { background: var(--ok); box-shadow: 0 0 8px var(--ok); }
|
||
.pill.off .dot { background: var(--bad); }
|
||
header .spacer { flex: 1; }
|
||
main { max-width: 1180px; margin: 0 auto; padding: 24px 28px 60px; display: grid; gap: 22px; }
|
||
section.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px 22px; }
|
||
h2 { font-size: 15px; margin: 0 0 14px; font-weight: 650; color: var(--text); display: flex; align-items: center; gap: 10px; }
|
||
h2 .num { width: 22px; height: 22px; border-radius: 50%; background: var(--panel2); color: var(--muted); font-size: 12px;
|
||
display: inline-grid; place-items: center; }
|
||
.muted { color: var(--muted); } .faint { color: var(--faint); }
|
||
button { font: inherit; color: var(--text); background: var(--panel2); border: 1px solid var(--line); border-radius: 10px;
|
||
padding: 8px 14px; cursor: pointer; transition: border-color .15s, background .15s; }
|
||
button:hover:not(:disabled) { border-color: var(--accent); }
|
||
button:disabled { opacity: .45; cursor: not-allowed; }
|
||
button.primary { background: var(--accent); border-color: var(--accent); color: #0b0d12; font-weight: 650; padding: 11px 22px; }
|
||
button.primary:hover:not(:disabled) { filter: brightness(1.08); }
|
||
button.danger { border-color: color-mix(in srgb, var(--bad) 50%, var(--line)); color: var(--bad); }
|
||
button.small { padding: 5px 10px; font-size: 13px; }
|
||
input[type=text], input[type=number] { font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line);
|
||
border-radius: 10px; padding: 9px 12px; width: 100%; }
|
||
input:focus { outline: none; border-color: var(--accent); }
|
||
label.field { display: grid; gap: 6px; font-size: 13px; color: var(--muted); align-content: start; }
|
||
label.field small { font-size: 12px; color: var(--faint); line-height: 1.4; }
|
||
|
||
/* Tabs */
|
||
.tabs { display: flex; gap: 6px; margin-bottom: 14px; align-items: center; flex-wrap: wrap; }
|
||
.tab { background: transparent; border-color: transparent; color: var(--muted); }
|
||
.tab.active { background: var(--panel2); border-color: var(--line); color: var(--text); }
|
||
|
||
/* recordings */
|
||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px; }
|
||
.rec { background: var(--panel2); border: 2px solid transparent; border-radius: 12px; overflow: hidden; cursor: pointer;
|
||
transition: border-color .15s, transform .15s; text-align: left; padding: 0; }
|
||
.rec:hover { transform: translateY(-2px); }
|
||
.rec.sel { border-color: var(--accent); }
|
||
.thumb { aspect-ratio: 1 / 1; background: #0b0d12 center/cover no-repeat; display: grid; place-items: center; color: var(--faint); font-size: 28px; }
|
||
.rec .meta { padding: 9px 11px 10px; display: grid; gap: 2px; }
|
||
.rec .thumb { position: relative; }
|
||
.play { position: absolute; right: 8px; bottom: 8px; width: 38px; height: 38px; padding: 0; border-radius: 50%;
|
||
display: grid; place-items: center; background: rgba(11,13,18,.72); border-color: transparent; backdrop-filter: blur(4px);
|
||
opacity: .8; transition: opacity .15s, transform .15s; }
|
||
.play svg { width: 16px; height: 16px; margin-left: 2px; fill: var(--text); }
|
||
.rec:hover .play, .play:focus-visible { opacity: 1; }
|
||
.play:hover { transform: scale(1.08); }
|
||
.cbadge { position: absolute; left: 8px; top: 8px; width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center;
|
||
font-size: 13px; font-weight: 700; color: #0b0d12; }
|
||
.cbadge.ok { background: var(--ok); } .cbadge.warn { background: var(--warn); } .cbadge.bad { background: var(--bad); }
|
||
|
||
/* recording check */
|
||
.cres { margin-top: 12px; padding: 14px 16px; border-radius: 12px; background: var(--panel2); }
|
||
.cres .head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||
.cres .head b { font-size: 15px; }
|
||
.cres.ok .head b { color: var(--ok); } .cres.warn .head b { color: var(--warn); } .cres.bad .head b { color: var(--bad); }
|
||
.tl { display: flex; height: 18px; border-radius: 5px; overflow: hidden; margin: 12px 0 4px; gap: 1px; background: var(--bg); }
|
||
.tl i { flex: 1; min-width: 1px; }
|
||
.tl .ok { background: color-mix(in srgb, var(--ok) 45%, var(--panel2)); }
|
||
.tl .flat { background: var(--warn); } .tl .dark { background: #6b5cff; } .tl .fast { background: #ff8fd0; }
|
||
.tl .eye { background: var(--bad); } .tl .over { background: #f4f1c9; }
|
||
.tlscale { display: flex; justify-content: space-between; font-size: 11.5px; color: var(--faint); }
|
||
.legend { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--muted); margin-top: 8px; }
|
||
.legend span::before { content: ""; display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 5px; vertical-align: -1px; background: var(--c); }
|
||
.finding { margin-top: 10px; font-size: 13.5px; line-height: 1.45; }
|
||
.finding b { display: block; font-size: 13.5px; }
|
||
.finding.bad b { color: var(--bad); } .finding.warn b { color: var(--warn); } .finding.info b { color: var(--muted); }
|
||
.cres .note { margin-top: 12px; font-size: 12px; color: var(--faint); }
|
||
.rec .meta b { font-size: 14px; font-weight: 600; }
|
||
.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; }
|
||
body.remote .pc-only { display: none !important; }
|
||
/* floor plan */
|
||
dialog.plan { max-width: min(1200px, 94vw); width: 94vw; }
|
||
.plan-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
||
.plan-head h2 { margin: 0; flex: 1; }
|
||
.btnlink { display: inline-block; text-decoration: none; color: var(--text); background: var(--panel2); border: 1px solid var(--line);
|
||
border-radius: 10px; padding: 5px 10px; font-size: 13px; }
|
||
.btnlink:hover { border-color: var(--accent); }
|
||
.seg2 { display: inline-flex; background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 3px; gap: 3px; }
|
||
.seg2 button { border: 0; background: transparent; padding: 5px 12px; font-size: 13px; color: var(--muted); }
|
||
.seg2 button.active { background: var(--accent); color: #0b0d12; font-weight: 600; }
|
||
.plan-img { position: relative; background: #0f1115; border-radius: 12px; min-height: 260px; display: grid; place-items: center; overflow: hidden; }
|
||
.plan-stage { position: relative; display: inline-block; line-height: 0; }
|
||
.plan-img img { max-width: 100%; max-height: 74vh; width: auto; height: auto; display: block; }
|
||
.plan-stage svg { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
|
||
.plan-stage.measuring svg { pointer-events: auto; cursor: crosshair; }
|
||
.measure-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: -2px 0 10px; font-size: 13.5px; }
|
||
.measure-bar .total { font-weight: 650; font-variant-numeric: tabular-nums; background: color-mix(in srgb, #ffd166 18%, var(--panel2));
|
||
color: #ffd166; padding: 4px 10px; border-radius: 99px; }
|
||
.seg2 button.on, #planMeasure.on { background: #ffd166; color: #0b0d12; border-color: #ffd166; font-weight: 600; }
|
||
.plan-img .wait { color: var(--muted); font-size: 14px; padding: 30px; text-align: center; }
|
||
.pick { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 6px; margin: 10px 0 14px; }
|
||
.pick label { display: flex; gap: 8px; align-items: center; padding: 8px 10px; background: var(--panel2); border-radius: 10px; font-size: 14px; cursor: pointer; }
|
||
.pick label span { color: var(--faint); font-size: 12px; margin-left: auto; }
|
||
.pick input { accent-color: var(--accent); }
|
||
#lanPill { cursor: pointer; } #lanPill:hover { color: var(--text); }
|
||
.setup { border-color: color-mix(in srgb, var(--warn) 45%, var(--line)); }
|
||
.setup h2 { color: var(--warn); }
|
||
.setup li { margin: 4px 0; font-size: 14px; }
|
||
.setup code { background: var(--bg); padding: 1px 6px; border-radius: 6px; font-size: 13px; }
|
||
select.lang { font: inherit; font-size: 12.5px; color: var(--muted); background: var(--panel2); border: 1px solid var(--line);
|
||
border-radius: 99px; padding: 4px 8px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<svg class="logo" viewBox="0 0 32 32"><circle cx="11" cy="13" r="7" fill="#7c9cff" opacity=".85"/><circle cx="20" cy="19" r="8" fill="#46d3b5" opacity=".8"/><circle cx="21" cy="10" r="4" fill="#ffb86b" opacity=".9"/></svg>
|
||
<h1 data-i18n="app">Splat Workshop</h1>
|
||
<span class="spacer"></span>
|
||
<span id="lanPill" class="pill hidden" data-i18n-title="lan.title"></span>
|
||
<span id="framePill" class="pill"><span class="dot"></span><span data-i18n="frame.checking">Frame …</span></span>
|
||
<select class="lang" id="langSel" aria-label="Language"></select>
|
||
</header>
|
||
|
||
<main>
|
||
<!-- missing prerequisites -->
|
||
<section class="card setup hidden" id="setupCard">
|
||
<h2 data-i18n="setup.title"></h2>
|
||
<ul id="setupList"></ul>
|
||
<p class="faint" id="setupHint" style="font-size:13px;margin:8px 0 0"></p>
|
||
</section>
|
||
|
||
<!-- current conversion -->
|
||
<section class="card hidden" id="jobCard">
|
||
<div class="job-head">
|
||
<span class="title" id="jobName"></span>
|
||
<span class="muted" id="jobSub"></span>
|
||
<span class="spacer" style="flex:1"></span>
|
||
<button class="danger small" id="cancelBtn" data-i18n="job.cancel"></button>
|
||
<button class="small hidden" id="dismissBtn" data-i18n="job.dismiss"></button>
|
||
</div>
|
||
<div class="steps" id="steps"></div>
|
||
<div class="row" style="justify-content:space-between">
|
||
<span id="phaseText" class="muted"></span><span id="pctText" class="muted"></span>
|
||
</div>
|
||
<div class="bar" id="bar"><div style="width:0%"></div></div>
|
||
<div class="tiles" id="tiles"></div>
|
||
<div class="spark hidden" id="spark">
|
||
<div class="row" style="justify-content:space-between"><span class="faint" style="font-size:12px" data-i18n="job.psnr"></span><span class="faint" style="font-size:12px" id="sparkRange"></span></div>
|
||
<svg viewBox="0 0 600 70" preserveAspectRatio="none"><path id="sparkArea" fill="url(#g)" opacity=".25"/><path id="sparkLine" fill="none" stroke="#7c9cff" stroke-width="2" vector-effect="non-scaling-stroke"/>
|
||
<defs><linearGradient id="g" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#7c9cff"/><stop offset="1" stop-color="#7c9cff" stop-opacity="0"/></linearGradient></defs></svg>
|
||
</div>
|
||
<div id="result" class="result hidden"></div>
|
||
<details style="margin-top:12px"><summary class="muted" style="cursor:pointer;font-size:13.5px" data-i18n="job.log"></summary><pre class="log" id="log"></pre></details>
|
||
</section>
|
||
|
||
<!-- 1. recording -->
|
||
<section class="card" id="newCard">
|
||
<h2><span class="num">1</span><span data-i18n="s1.title"></span></h2>
|
||
<div class="tabs">
|
||
<button class="tab active" data-tab="frame" data-i18n="tab.frame"></button>
|
||
<button class="tab" data-tab="local" data-i18n="tab.local"></button>
|
||
<span style="flex:1"></span>
|
||
<button class="small" id="checkAllBtn" data-i18n="checkAll" data-i18n-title="checkAll.title"></button>
|
||
<button class="small pc-only" id="pickBtn" data-i18n="pick"></button>
|
||
<button class="small" id="refreshBtn" data-i18n-title="refresh.title">↻</button>
|
||
</div>
|
||
<div id="recGrid" class="grid"></div>
|
||
<div id="checkBox" class="hidden"></div>
|
||
<div id="checkRes" class="hidden"></div>
|
||
</section>
|
||
|
||
<!-- 2. settings -->
|
||
<section class="card" id="setCard">
|
||
<h2><span class="num">2</span><span data-i18n="s2.title"></span></h2>
|
||
<div class="settings">
|
||
<div>
|
||
<label class="field"><span data-i18n="name.label"></span>
|
||
<input type="text" id="name" maxlength="40" data-i18n-placeholder="name.placeholder">
|
||
</label>
|
||
<div style="height:16px"></div>
|
||
<div class="field" style="margin-bottom:6px" data-i18n="quality"></div>
|
||
<div class="seg" id="quality">
|
||
<button data-q="fast"><b data-i18n="q.fast"></b><span data-i18n="q.fast.sub"></span></button>
|
||
<button data-q="high" class="active"><b data-i18n="q.high"></b><span data-i18n="q.high.sub"></span></button>
|
||
<button data-q="max"><b data-i18n="q.max"></b><span data-i18n="q.max.sub"></span></button>
|
||
</div>
|
||
<details class="adv">
|
||
<summary data-i18n="adv"></summary>
|
||
<p class="faint" style="font-size:12.5px;margin:10px 0 0" data-i18n="adv.hint"></p>
|
||
<div class="advgrid">
|
||
<label class="field"><span data-i18n="adv.frames"></span><input type="number" id="frames" min="100" max="500" step="10">
|
||
<small data-i18n="adv.frames.help"></small></label>
|
||
<label class="field"><span data-i18n="adv.scale"></span><input type="number" id="scale" min="0.25" max="1" step="0.05">
|
||
<small data-i18n="adv.scale.help"></small></label>
|
||
<label class="field"><span data-i18n="adv.maxg"></span><input type="number" id="maxg" min="0.2" max="6" step="0.1">
|
||
<small data-i18n="adv.maxg.help"></small></label>
|
||
<label class="field"><span data-i18n="adv.iter"></span><input type="number" id="iter" min="1000" max="100000" step="1000">
|
||
<small data-i18n="adv.iter.help"></small></label>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
<div>
|
||
<div class="field" style="margin-bottom:6px" data-i18n="target"></div>
|
||
<div class="target">
|
||
<label class="opt active" id="optFrame"><input type="radio" name="target" value="frame" checked>
|
||
<div><b data-i18n="t.frame"></b><span data-i18n="t.frame.sub"></span></div></label>
|
||
<label class="opt" id="optPc"><input type="radio" name="target" value="pc">
|
||
<div><b data-i18n="t.pc"></b><span data-i18n="t.pc.sub"></span></div></label>
|
||
</div>
|
||
<div id="targetWarn" class="msg warn hidden" data-i18n="t.offline"></div>
|
||
</div>
|
||
</div>
|
||
<div class="startrow">
|
||
<button class="primary" id="startBtn" disabled data-i18n="start"></button>
|
||
<span class="estimate" id="estimate"></span>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- finished scenes -->
|
||
<section class="card">
|
||
<div class="row" style="justify-content:space-between;margin-bottom:14px">
|
||
<h2 data-i18n="scenes.title" style="margin:0"></h2>
|
||
<button class="small" id="planAllBtn" data-i18n="plan.all" data-i18n-title="plan.all.title"></button>
|
||
</div>
|
||
<div id="sceneGrid" class="grid"></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="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 id="planPick" class="hidden">
|
||
<p class="muted" style="margin:0;font-size:13.5px" data-i18n="plan.pick.hint"></p>
|
||
<div class="pick" id="planPickList"></div>
|
||
<div class="row"><button class="primary" id="planDraw" data-i18n="plan.draw"></button></div>
|
||
</div>
|
||
<div class="measure-bar hidden" id="measureBar">
|
||
<span class="total" id="measureTotal"></span>
|
||
<span class="muted" id="measureHint" data-i18n="plan.measure.hint"></span>
|
||
<span style="flex:1"></span>
|
||
<button class="small" id="measureUndo" data-i18n="plan.measure.undo"></button>
|
||
<button class="small" id="measureReset" data-i18n="plan.measure.reset"></button>
|
||
</div>
|
||
<div class="plan-img hidden" id="planBox"><div class="wait" id="planWait"></div>
|
||
<div class="plan-stage hidden" id="planStage"><img id="planImg" alt=""><svg id="planSvg" xmlns="http://www.w3.org/2000/svg"></svg></div></div>
|
||
<p class="faint" style="font-size:12px;margin:10px 0 0" id="planNote"></p>
|
||
</dialog>
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script src="/i18n.js"></script>
|
||
<script>
|
||
const $ = s => document.querySelector(s);
|
||
const QUALITY_ALIAS = {schnell: "fast", hoch: "high"}; // scenes made with older versions
|
||
const PRESETS = {
|
||
fast: d => ({frames: 100, scale: 0.5, maxg: 1.5, iter: 30000}),
|
||
high: d => ({frames: clamp(Math.round(d * 5), 100, 500), scale: 1, maxg: 2, iter: 30000}),
|
||
max: d => ({frames: clamp(Math.round(d * 8), 100, 500), scale: 1, maxg: 3, iter: 50000}),
|
||
};
|
||
const S = { tab: "frame", recs: null, sel: null, probe: null, quality: "high", job: null, dl: null, dismissed: null,
|
||
frameOnline: null, frameConfigured: true };
|
||
|
||
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
|
||
function mb(b) { return b >= 1e9 ? num(b / 1e9, 2) + " GB" : num(Math.round(b / 1e6)) + " MB"; }
|
||
function mins(s) { if (s == null) return "–"; s = Math.round(s); const h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60);
|
||
return h ? `${h} h ${String(m).padStart(2, "0")} min` : m ? `${m} min` : `${s % 60} s`; }
|
||
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[c])); }
|
||
function toast(t) { const el = $("#toast"); el.textContent = t; el.classList.add("show"); clearTimeout(toast.t); toast.t = setTimeout(() => el.classList.remove("show"), 3500); }
|
||
function fmtDate(iso, short) {
|
||
if (!iso) return "";
|
||
const d = new Date(iso);
|
||
return short ? d.toLocaleString(LANG, {day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit"})
|
||
: d.toLocaleString(LANG, {dateStyle: "medium", timeStyle: "short"});
|
||
}
|
||
async function api(path, body) {
|
||
const r = await fetch(path, body === undefined ? {} : {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(body)});
|
||
let j = {}; try { j = await r.json(); } catch {}
|
||
return {ok: r.ok, status: r.status, ...j};
|
||
}
|
||
|
||
/* ---------- language ---------- */
|
||
function setupLang(setting) {
|
||
LANG = pickLang(setting);
|
||
const sel = $("#langSel");
|
||
sel.innerHTML = Object.keys(I18N).map(l => `<option value="${l}" ${l === LANG ? "selected" : ""}>${I18N[l]["lang.name"]}</option>`).join("");
|
||
sel.onchange = () => { LANG = sel.value; try { localStorage.setItem("lang", LANG); } catch {} rerender(); };
|
||
rerender();
|
||
}
|
||
function rerender() {
|
||
applyI18n();
|
||
renderPing();
|
||
autoName();
|
||
renderFramePill(); renderRecs(); renderCheck(); renderJob(); updateStart(); loadScenes();
|
||
}
|
||
|
||
function renderPing() {
|
||
const r = S.ping || {};
|
||
if (r.lan_url) {
|
||
const pill = $("#lanPill");
|
||
pill.textContent = tr("lan.pill", {addr: r.lan_url.replace("http://", "").replace(/\/$/, "")});
|
||
pill.classList.remove("hidden");
|
||
pill.onclick = () => navigator.clipboard?.writeText(r.lan_url).then(() => toast(tr("copied")), () => toast(r.lan_url));
|
||
}
|
||
if (r.problems && r.problems.length) {
|
||
$("#setupCard").classList.remove("hidden");
|
||
$("#setupList").innerHTML = r.problems.map(p => `<li>${esc(trErr(p))}</li>`).join("");
|
||
$("#setupHint").innerHTML = r.config ? tr("setup.hint", {file: `<code>${esc(r.config)}</code>`}) : "";
|
||
}
|
||
}
|
||
|
||
/* ---------- recordings ---------- */
|
||
async function loadRecs(refresh) {
|
||
if (refresh) $("#recGrid").innerHTML = `<div class="empty">${tr("loading")}</div>`;
|
||
const r = await api("/api/recordings" + (refresh ? "?refresh=1" : ""));
|
||
S.recs = r; S.frameOnline = r.frame_online; S.frameConfigured = r.frame_configured;
|
||
renderFramePill();
|
||
if (!loadRecs.done && !r.frame_online && r.local.length) setTab("local");
|
||
loadRecs.done = true;
|
||
renderRecs();
|
||
}
|
||
function renderFramePill() {
|
||
if (!S.recs) return;
|
||
const p = $("#framePill");
|
||
p.className = "pill " + (S.frameOnline ? "on" : "off");
|
||
p.lastElementChild.textContent = tr(!S.frameConfigured ? "frame.none" : S.frameOnline ? "frame.connected" : "frame.offline");
|
||
$("#optFrame").classList.toggle("hidden", !S.frameConfigured);
|
||
// Default target follows the Frame: on the Frame when it is reachable, otherwise PC only – until the user picks one
|
||
const auto = !S.frameConfigured ? "pc" : S.targetTouched ? null : S.frameOnline ? "frame" : S.frameOnline === false ? "pc" : null;
|
||
if (auto && auto !== target()) { $(`input[name=target][value=${auto}]`).checked = true; targetChanged(); }
|
||
$("#targetWarn").classList.toggle("hidden", S.frameOnline !== false || !S.frameConfigured || target() !== "frame");
|
||
}
|
||
function currentList() {
|
||
if (!S.recs) return [];
|
||
if (S.tab === "frame") return S.recs.frame || [];
|
||
return S.recs.local;
|
||
}
|
||
function renderRecs() {
|
||
const g = $("#recGrid");
|
||
if (!S.recs) return;
|
||
if (S.tab === "frame" && !S.recs.frame_online) {
|
||
g.innerHTML = S.frameConfigured
|
||
? `<div class="empty" style="grid-column:1/-1">${tr("frame.unreachable")}<br><span class="faint">${tr("frame.unreachable.hint")}</span></div>`
|
||
: `<div class="empty" style="grid-column:1/-1">${tr("frame.notConfigured")}</div>`;
|
||
return;
|
||
}
|
||
const list = currentList();
|
||
if (!list.length) { g.innerHTML = `<div class="empty" style="grid-column:1/-1">${tr("noRecordings")}</div>`; return; }
|
||
g.innerHTML = list.map((f, i) => {
|
||
const sel = S.sel && S.sel.name === f.name ? " sel" : "";
|
||
const local = S.tab === "frame" && f.local ? `<span class="badge">${tr("onPc")}</span>` : "";
|
||
const onPc = S.tab === "local" || f.local;
|
||
const play = onPc ? `<button class="play pc-only" data-play="${esc(f.name)}" title="${tr("play.title")}" aria-label="${tr("play.title")}">${PLAY}</button>` : "";
|
||
return `<div class="rec${sel}" data-i="${i}" role="button" tabindex="0">
|
||
<div class="thumb" data-src="/api/thumb?source=${onPc ? "local" : "frame"}&name=${encodeURIComponent(f.name)}">${badge(f)}${play}</div>
|
||
<div class="meta"><b>${esc(fmtDate(f.date) || f.name)}</b><span>${mb(f.size)} ${local}</span></div></div>`;
|
||
}).join("");
|
||
g.querySelectorAll(".thumb").forEach(t => {
|
||
const url = t.dataset.src, img = new Image();
|
||
img.onload = () => { t.style.backgroundImage = `url('${url}')`; };
|
||
img.onerror = () => { if (!t.querySelector(".play")) t.textContent = "◌"; };
|
||
img.src = url;
|
||
});
|
||
g.querySelectorAll(".rec").forEach(b => {
|
||
b.onclick = () => select(list[+b.dataset.i]);
|
||
b.onkeydown = e => { if ((e.key === "Enter" || e.key === " ") && e.target === b) { e.preventDefault(); b.click(); } };
|
||
});
|
||
g.querySelectorAll("[data-play]").forEach(b => b.onclick = e => { e.stopPropagation(); play(b.dataset.play); });
|
||
}
|
||
function badge(f) {
|
||
const lv = f.check || (S.recs?.local.find(x => x.name === f.name) || {}).check;
|
||
if (!lv) return "";
|
||
return `<span class="cbadge ${lv}" title="${tr("badge." + lv)}">${{ok: "✓", warn: "!", bad: "✕"}[lv]}</span>`;
|
||
}
|
||
const PLAY = `<svg viewBox="0 0 16 16"><path d="M3 1.5v13l11-6.5z"/></svg>`;
|
||
async function play(name) {
|
||
const r = await api("/api/play", {name});
|
||
toast(r.ok ? tr("play.opening") : trErr(r.error));
|
||
}
|
||
async function select(f) {
|
||
S.sel = {...f, source: S.tab}; S.probe = null;
|
||
$("#checkRes").className = "hidden";
|
||
renderRecs();
|
||
autoName();
|
||
const path = f.path || f.local;
|
||
if (path) { S.sel.path = path; await runProbe(path); }
|
||
else renderCheck();
|
||
updateStart();
|
||
}
|
||
function autoName() {
|
||
const f = S.sel;
|
||
if (!f || ($("#name").value && $("#name").dataset.auto !== "1")) return;
|
||
$("#name").value = f.date ? tr("name.auto", {date: fmtDate(f.date, true)}) : f.name.replace(/_stereo\.mp4$|\.mp4$/, "");
|
||
$("#name").dataset.auto = "1";
|
||
}
|
||
async function runProbe(path) {
|
||
$("#checkBox").className = "check"; $("#checkBox").innerHTML = `<span class="muted">${tr("probe.running")}</span>`;
|
||
S.probe = await api("/api/probe?path=" + encodeURIComponent(path));
|
||
renderCheck(); updateStart();
|
||
}
|
||
function renderCheck() {
|
||
const box = $("#checkBox"), f = S.sel;
|
||
if (!f) { box.className = "hidden"; return; }
|
||
box.className = "check";
|
||
if (!S.probe) {
|
||
const dl = S.dl && S.dl.name === f.name && !S.dl.finished ? S.dl : null;
|
||
box.innerHTML = dl
|
||
? `<div style="flex:1"><span class="muted">${tr("probe.fetching", {done: mb(dl.done), total: mb(dl.total)})}</span><div class="bar"><div style="width:${(dl.done / dl.total * 100).toFixed(1)}%"></div></div></div>`
|
||
: `<span class="muted">${tr("probe.onFrame", {size: mb(f.size)})}</span><button class="small" id="fetchBtn">${esc(tr("probe.fetch"))}</button>`;
|
||
const b = $("#fetchBtn"); if (b) b.onclick = () => fetchSel();
|
||
return;
|
||
}
|
||
const p = S.probe;
|
||
const items = [[tr("probe.length"), p.duration ? Math.round(p.duration) + " s" : "–"], [tr("probe.format"), p.hdr ? "HDR (HLG)" : "sRGB"],
|
||
[tr("probe.fps"), p.fps ? num(p.fps, 1) + " fps" : "–"], [tr("probe.size"), p.size || "–"]];
|
||
box.innerHTML = items.map(([k, v]) => `<div><span class="k">${k}</span>${v}</div>`).join("") +
|
||
`<div style="flex-basis:100%;order:10">${(p.errors || []).map(e => `<div class="msg err">✕ ${esc(trErr(e))}</div>`).join("")}
|
||
${(p.warnings || []).map(e => `<div class="msg warn">! ${esc(trErr(e))}</div>`).join("")}
|
||
${p.ok && !(p.warnings || []).length ? `<div class="msg ok">${tr("probe.ok")}</div>` : ""}</div>`;
|
||
if (!p.ok && !p.duration) return;
|
||
box.insertAdjacentHTML("afterbegin", `<div class="row" style="order:9;margin-left:auto">
|
||
<button class="small" id="checkSel" title="${tr("check.title")}">${tr("check.run")}</button>
|
||
<button class="small pc-only" id="playSel">${tr("play.button")}</button></div>`);
|
||
$("#playSel").onclick = () => play(f.name);
|
||
$("#checkSel").onclick = () => runCheck(f.name);
|
||
loadCheck(f.name);
|
||
}
|
||
|
||
/* ---------- recording check ---------- */
|
||
const TL_COLOR = {ok: "var(--ok)", flat: "var(--warn)", dark: "#6b5cff", fast: "#ff8fd0", eye: "var(--bad)", over: "#f4f1c9"};
|
||
function mmss(s) { return Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0"); }
|
||
function findingText(f) {
|
||
const [title, text] = I18N[LANG]["f." + f.code] || I18N.en["f." + f.code] || [f.code, ""];
|
||
const where = (f.spans || []).map(([a, b]) => a === b ? mmss(a) : `${mmss(a)}–${mmss(b)}`).join(", ") +
|
||
(f.more ? tr("and_more", {n: f.more}) : "");
|
||
const p = {...f, where, tone: f.tone ? tr("tone." + f.tone) : ""};
|
||
return [title, text.replace(/\{(\w+)\}/g, (_, k) => p[k] ?? "")];
|
||
}
|
||
async function runCheck(name) {
|
||
const r = await api("/api/check", {name});
|
||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||
loadCheck(name);
|
||
}
|
||
async function loadCheck(name) {
|
||
clearTimeout(loadCheck.t);
|
||
const box = $("#checkRes");
|
||
if (!S.sel || S.sel.name !== name) return;
|
||
const c = await api("/api/check?name=" + encodeURIComponent(name));
|
||
if (!S.sel || S.sel.name !== name) return;
|
||
const btn = $("#checkSel");
|
||
if (c.state === "queued" || c.state === "running") {
|
||
if (btn) btn.disabled = true;
|
||
const pct = c.total ? Math.min(99, Math.round(c.done / c.total * 100)) : 0;
|
||
box.className = "cres";
|
||
box.innerHTML = c.state === "queued" ? `<span class="muted">${tr("check.queued")}</span>`
|
||
: `<span class="muted">${tr("check.progress", {done: c.done, total: c.total})}</span><div class="bar"><div style="width:${pct}%"></div></div>`;
|
||
loadCheck.t = setTimeout(() => loadCheck(name), 1000);
|
||
return;
|
||
}
|
||
if (btn) { btn.disabled = false; btn.textContent = tr(c.state === "done" ? "check.again" : "check.run"); }
|
||
if (c.state === "error") { box.className = "cres bad"; box.innerHTML = `<div class="finding bad"><b>${tr("check.failed")}</b>${esc(c.error)}</div>`; return; }
|
||
if (c.state !== "done") { box.className = "hidden"; return; }
|
||
const r = c.result, n = r.timeline.length;
|
||
const used = [...new Set(r.timeline)];
|
||
box.className = "cres " + r.level;
|
||
box.innerHTML = `<div class="head"><b>${tr("check.head." + r.level)}</b><span class="faint" style="font-size:12.5px">${tr("check.summary", {bright: r.summary.bright, detail: num(r.summary.detail, 1), secs: n})}</span></div>
|
||
<div class="tl">${r.timeline.map((k, i) => `<i class="${k}" title="${mmss(i)} – ${tr("tl." + k)}"></i>`).join("")}</div>
|
||
<div class="tlscale"><span>0:00</span><span>${mmss(Math.round(n / 2))}</span><span>${mmss(n)}</span></div>
|
||
<div class="legend">${used.map(k => `<span style="--c:${TL_COLOR[k]}">${tr("tl." + k)}</span>`).join("")}</div>
|
||
${r.findings.length ? r.findings.map(f => { const [title, text] = findingText(f); return `<div class="finding ${f.level}"><b>${esc(title)}</b>${esc(text)}</div>`; }).join("")
|
||
: `<div class="finding"><b style="color:var(--ok)">${tr("check.none")}</b>${tr("check.none.text")}</div>`}
|
||
<div class="note">${tr("check.note")}</div>`;
|
||
const rec = S.recs?.local.find(x => x.name === name);
|
||
if (rec && rec.check !== r.level) { rec.check = r.level; renderRecs(); }
|
||
}
|
||
$("#checkAllBtn").onclick = async () => {
|
||
const todo = (S.recs?.local || []).filter(f => !f.check);
|
||
if (!todo.length) { toast(tr("check.allChecked")); return; }
|
||
for (const f of todo) await api("/api/check", {name: f.name});
|
||
toast(tr("check.queuedN", {n: todo.length}));
|
||
clearInterval(checkAll.t); checkAll.t = setInterval(checkAll, 4000);
|
||
};
|
||
async function checkAll() {
|
||
await loadRecs();
|
||
const pending = await Promise.all((S.recs?.local || []).filter(f => !f.check).map(f => api("/api/check?name=" + encodeURIComponent(f.name))));
|
||
if (!pending.some(c => c.state === "queued" || c.state === "running")) { clearInterval(checkAll.t); toast(tr("check.finished")); }
|
||
}
|
||
|
||
async function fetchSel() {
|
||
const r = await api("/api/download", {name: S.sel.name});
|
||
if (!r.ok) { toast(trErr(r.error)); return false; }
|
||
S.dl = {name: S.sel.name, done: 0, total: S.sel.size, finished: false};
|
||
renderCheck(); updateStart();
|
||
return new Promise(res => { S.dlWait = res; });
|
||
}
|
||
|
||
/* ---------- settings ---------- */
|
||
function target() { return document.querySelector("input[name=target]:checked").value; }
|
||
function targetChanged() {
|
||
$("#optFrame").classList.toggle("active", target() === "frame"); $("#optPc").classList.toggle("active", target() === "pc");
|
||
$("#targetWarn").classList.toggle("hidden", S.frameOnline !== false || target() !== "frame");
|
||
}
|
||
function settings() {
|
||
const d = S.probe?.duration || 30, pre = PRESETS[S.quality](d);
|
||
const v = id => $("#" + id).value.trim();
|
||
return {
|
||
frames: v("frames") ? +v("frames") : pre.frames, scale: v("scale") ? +v("scale") : pre.scale,
|
||
maxg: v("maxg") ? +v("maxg") : pre.maxg, iter: v("iter") ? +v("iter") : pre.iter, pre,
|
||
};
|
||
}
|
||
function updateStart() {
|
||
const s = settings();
|
||
for (const k of ["frames", "scale", "maxg", "iter"]) $("#" + k).placeholder = num(s.pre[k], k === "scale" || k === "maxg" ? 1 : 0);
|
||
// rough time estimate (measured on an RTX 4070 SUPER: 30k steps, 2 M splats, full resolution ≈ 30 min;
|
||
// preparation ≈ 2.5 s per image pair)
|
||
const res = s.scale >= 1 ? 1 : s.scale >= 0.5 ? 0.33 + (s.scale - 0.5) * 1.34 : 0.2;
|
||
const train = s.iter / 1000 * res * Math.pow(s.maxg / 2, 0.8);
|
||
const total = Math.round(train + s.frames * 2.5 / 60 + 1);
|
||
const busy = S.job && S.job.running;
|
||
const ready = S.sel && (!S.probe || S.probe.ok) && $("#name").value.trim();
|
||
$("#startBtn").disabled = !ready || busy || (S.dl && !S.dl.finished);
|
||
$("#estimate").innerHTML = busy ? tr("est.busy") : !S.sel ? tr("est.none") :
|
||
tr("est", {frames: s.frames, maxg: num(s.maxg, 1), iter: s.iter / 1000, mins: total});
|
||
}
|
||
document.querySelectorAll("#quality button").forEach(b => b.onclick = () => {
|
||
S.quality = b.dataset.q; document.querySelectorAll("#quality button").forEach(x => x.classList.toggle("active", x === b)); updateStart();
|
||
});
|
||
document.querySelectorAll("input[name=target]").forEach(r => r.onchange = () => { S.targetTouched = true; targetChanged(); });
|
||
["frames", "scale", "maxg", "iter"].forEach(id => $("#" + id).oninput = updateStart);
|
||
$("#name").oninput = () => { $("#name").dataset.auto = "0"; updateStart(); };
|
||
function setTab(tab) { S.tab = tab; document.querySelectorAll(".tab").forEach(x => x.classList.toggle("active", x.dataset.tab === tab)); }
|
||
document.querySelectorAll(".tab").forEach(t => t.onclick = () => { setTab(t.dataset.tab); renderRecs(); });
|
||
$("#refreshBtn").onclick = () => loadRecs(true);
|
||
$("#pickBtn").onclick = async () => {
|
||
const r = await api("/api/pick", {});
|
||
if (!r.path) return;
|
||
const name = r.path.split("/").pop();
|
||
setTab("local");
|
||
await loadRecs();
|
||
const f = S.recs.local.find(x => x.path === r.path) || {name, path: r.path, size: 0, date: ""};
|
||
if (!S.recs.local.includes(f)) S.recs.local.unshift(f);
|
||
renderRecs(); select(f);
|
||
};
|
||
|
||
/* ---------- start ---------- */
|
||
async function start(existing) {
|
||
if (!S.sel.path) {
|
||
$("#startBtn").disabled = true;
|
||
const ok = await fetchSel();
|
||
if (!ok) return;
|
||
}
|
||
if (!S.probe) await runProbe(S.sel.path);
|
||
if (!S.probe.ok) { toast(tr("unusable")); return; }
|
||
const v = id => $("#" + id).value.trim();
|
||
const body = {path: S.sel.path, name: $("#name").value.trim(), quality: S.quality, upload: target() === "frame", existing,
|
||
frames: v("frames") || null, scale: v("scale") || null, maxg: v("maxg") ? Math.round(+v("maxg") * 1e6) : null, iter: v("iter") || null};
|
||
const r = await api("/api/start", body);
|
||
if (r.status === 409) {
|
||
$("#existsText").textContent = tr("exists.text", {name: body.name});
|
||
$("#existsDlg").showModal(); return;
|
||
}
|
||
if (!r.ok) { toast(trErr(r.error)); return; }
|
||
S.dismissed = null; toast(tr("started")); poll(); window.scrollTo({top: 0, behavior: "smooth"});
|
||
}
|
||
$("#startBtn").onclick = () => start();
|
||
$("#exCancel").onclick = () => $("#existsDlg").close();
|
||
$("#exResume").onclick = () => { $("#existsDlg").close(); start("resume"); };
|
||
$("#exFresh").onclick = () => { $("#existsDlg").close(); start("fresh"); };
|
||
$("#cancelBtn").onclick = async () => {
|
||
if (!confirm(tr("confirm.cancel"))) return;
|
||
const r = await api("/api/cancel", {}); if (!r.ok) toast(trErr(r.error));
|
||
};
|
||
$("#dismissBtn").onclick = () => { S.dismissed = S.job?.out; renderJob(); };
|
||
|
||
/* ---------- current conversion ---------- */
|
||
function renderJob() {
|
||
const j = S.job, card = $("#jobCard");
|
||
if (!j || (!j.running && S.dismissed === j.out)) { card.classList.add("hidden"); return; }
|
||
card.classList.remove("hidden");
|
||
$("#jobName").textContent = j.name;
|
||
const qk = QUALITY_ALIAS[j.quality] || j.quality;
|
||
$("#jobSub").textContent = tr("job.sub", {rec: j.recording, q: tr("q." + qk), target: tr(j.upload ? "job.toFrame" : "job.pcOnly"),
|
||
time: mins(Date.now() / 1000 - j.started)});
|
||
$("#cancelBtn").classList.toggle("hidden", !j.running);
|
||
$("#dismissBtn").classList.toggle("hidden", j.running);
|
||
const phases = [0, 1, 2, 3, 4].slice(0, j.upload ? 5 : 4).map(i => tr("phase." + i));
|
||
const finished = !j.running && j.result === "ok";
|
||
$("#steps").style.gridTemplateColumns = `repeat(${phases.length}, 1fr)`;
|
||
$("#steps").innerHTML = phases.map((p, i) => {
|
||
const cls = finished || i < j.phase ? "done" : i === j.phase && j.running ? "now" : "";
|
||
return `<div class="step ${cls}"><i></i>${p}</div>`;
|
||
}).join("");
|
||
const bar = $("#bar");
|
||
bar.classList.toggle("hidden", !j.running);
|
||
bar.classList.toggle("indet", j.pct == null);
|
||
bar.firstElementChild.style.width = (j.pct ?? 0) + "%";
|
||
$("#phaseText").textContent = j.running ? phases[j.phase] + " …" : "";
|
||
$("#pctText").textContent = j.running ? (j.pct != null ? j.pct + " %" : "") + (j.eta != null ? tr("job.left", {time: mins(j.eta + 60)}) : "") : "";
|
||
const i = j.info || {}, tiles = [];
|
||
if (i.cameras != null) tiles.push([tr("tile.cameras"), `${i.cameras}${i.images ? " / " + i.images : ""}`]);
|
||
if (i.rms_cm != null) tiles.push([tr("tile.rms"), num(i.rms_cm, 1) + " cm"]);
|
||
if (i.psnr != null) tiles.push([tr("tile.psnr"), num(i.psnr, 1)]);
|
||
const splats = i.gaussians ?? i.splats;
|
||
if (splats != null) tiles.push([tr("tile.splats"), tr("million", {n: num(splats / 1e6, 2)})]);
|
||
if (i.step != null && j.running) tiles.push([tr("tile.step"), `${num(i.step / 1000, 1)}k / ${i.steps / 1000}k`]);
|
||
$("#tiles").innerHTML = tiles.map(([k, v]) => `<div class="tile"><div class="k">${k}</div><div class="v">${v}</div></div>`).join("");
|
||
const c = i.psnr_curve;
|
||
$("#spark").classList.toggle("hidden", !(c && c.length > 2));
|
||
if (c && c.length > 2) {
|
||
const lo = Math.min(...c), hi = Math.max(...c), n = c.length;
|
||
const pts = c.map((v, k) => [k / (n - 1) * 600, 66 - (v - lo) / ((hi - lo) || 1) * 60]);
|
||
const line = "M" + pts.map(p => p[0].toFixed(1) + "," + p[1].toFixed(1)).join("L");
|
||
$("#sparkLine").setAttribute("d", line);
|
||
$("#sparkArea").setAttribute("d", line + `L600,70L0,70Z`);
|
||
$("#sparkRange").textContent = `${num(lo, 1)} – ${num(hi, 1)}`;
|
||
}
|
||
const res = $("#result");
|
||
res.classList.toggle("hidden", j.running);
|
||
if (!j.running) {
|
||
if (j.result === "ok") { res.className = "result ok";
|
||
res.textContent = j.upload ? tr("res.ok.frame", {name: j.name, folder: i.folder ? " (" + i.folder + ")" : ""})
|
||
: tr("res.ok.local", {name: j.name, path: "runs/" + j.slug}); }
|
||
else if (j.result === "cancelled") { res.className = "result warn"; res.textContent = tr("res.cancelled"); }
|
||
else if (j.phase === 4) { res.className = "result warn"; res.textContent = tr("res.upload_failed"); }
|
||
else { res.className = "result bad"; res.textContent = tr("res.failed"); }
|
||
}
|
||
const log = $("#log"), atBottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 20;
|
||
log.textContent = j.tail || ""; if (atBottom) log.scrollTop = log.scrollHeight;
|
||
}
|
||
|
||
/* ---------- finished scenes ---------- */
|
||
function frameState(s) {
|
||
const u = s.upload;
|
||
if (u && u.running) return `<div class="fstate">${tr("sc.uploading")}</div>`;
|
||
if (u && u.error) return `<div class="fstate err" title="${esc(u.error)}">${tr("sc.uploadFailed")}</div>`;
|
||
if (s.on_frame === true) return `<div class="fstate yes">${tr("sc.onFrame")}</div>`;
|
||
if (s.on_frame === false) return `<div class="fstate no">${tr("sc.notOnFrame")}</div>`;
|
||
return S.frameConfigured ? `<div class="fstate faint">${tr("sc.frameOffline")}</div>` : "";
|
||
}
|
||
function uploadBtn(s) {
|
||
if (!S.frameConfigured) return "";
|
||
const busy = s.upload && s.upload.running;
|
||
const label = tr(s.on_frame ? "sc.reupload" : "sc.toFrame"), title = tr(s.on_frame ? "sc.reupload.title" : "sc.toFrame.title");
|
||
return `<button class="small${s.on_frame ? "" : " primary"}" data-upload="${esc(s.slug)}" title="${title}" ${busy || s.on_frame === null ? "disabled" : ""}>${label}</button>`;
|
||
}
|
||
async function loadScenes() {
|
||
const list = await api("/api/scenes");
|
||
const arr = Array.isArray(list) ? list : Object.values(list).filter(x => typeof x === "object");
|
||
S.scenes = arr;
|
||
$("#planAllBtn").classList.toggle("hidden", arr.filter(s => s.ply).length < 2);
|
||
$("#sceneGrid").innerHTML = arr.length ? arr.map(s => `
|
||
<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>
|
||
${frameState(s)}
|
||
<div class="acts">${uploadBtn(s)}${s.ply ? `<button class="small" data-plan="${esc(s.slug)}">${tr("sc.plan")}</button>` : ""}<button class="small pc-only" data-open="${esc(s.slug)}">${tr("sc.folder")}</button>${s.ply ? `<button class="small pc-only" data-spirula="${esc(s.slug)}" title="${tr("sc.spirula.title")}">${tr("sc.spirula")}</button>` : ""}</div>
|
||
</div></div>`).join("") : `<div class="empty" style="grid-column:1/-1">${tr("scenes.none")}</div>`;
|
||
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")); });
|
||
}
|
||
|
||
/* ---------- floor plan ---------- */
|
||
const P = {slugs: [], name: "", style: "pretty"};
|
||
function planUrl(download) {
|
||
const q = new URLSearchParams({slugs: P.slugs.join(","), style: P.style, lang: LANG,
|
||
title: P.slugs.length > 1 ? P.name : ""});
|
||
if (download) { q.set("download", "1"); q.set("name", `${P.name}-${tr("plan.file")}`); }
|
||
return "/api/floorplan?" + q;
|
||
}
|
||
function openPlan(slugs, name) {
|
||
P.slugs = slugs; P.name = name;
|
||
$("#planTitle").textContent = tr(slugs.length > 1 ? "plan.title.all" : "plan.title", {name});
|
||
$("#planPick").classList.add("hidden");
|
||
$("#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 => { if (e.target === $("#planDlg")) $("#planDlg").close(); });
|
||
$("#planAllBtn").onclick = () => {
|
||
const list = (S.scenes || []).filter(s => s.ply);
|
||
const day = s => (s.recording || "").slice(0, 10);
|
||
const newest = list.map(day).filter(Boolean).sort().pop();
|
||
$("#planTitle").textContent = tr("plan.all");
|
||
$("#planPickList").innerHTML = list.map(s => `<label><input type="checkbox" value="${esc(s.slug)}" ${day(s) === newest ? "checked" : ""}>
|
||
${esc(s.name)}<span>${day(s) ? new Date(day(s)).toLocaleDateString(LANG) : ""}</span></label>`).join("");
|
||
$("#planPick").classList.remove("hidden"); $("#planBox").classList.add("hidden");
|
||
$("#planStyle").classList.add("hidden"); $("#planSave").classList.add("hidden"); $("#planMeasure").classList.add("hidden");
|
||
$("#planNote").textContent = ""; M.on = false; renderMeasure();
|
||
$("#planDlg").showModal();
|
||
};
|
||
$("#planDraw").onclick = () => {
|
||
const slugs = [...document.querySelectorAll("#planPickList input:checked")].map(i => i.value);
|
||
if (!slugs.length) return;
|
||
openPlan(slugs, slugs.length > 1 ? tr("plan.default.name") : S.scenes.find(s => s.slug === slugs[0]).name);
|
||
};
|
||
|
||
/* ---------- polling ---------- */
|
||
let wasRunning = null;
|
||
async function poll() {
|
||
const r = await api("/api/status");
|
||
S.job = r.job; S.dl = r.download;
|
||
if (S.dl && S.dl.finished && S.dlWait && S.sel && S.dl.name === S.sel.name) {
|
||
const res = S.dlWait; S.dlWait = null;
|
||
if (S.dl.error) { toast(tr("err.generic") + ": " + S.dl.error); renderCheck(); res(false); }
|
||
else { await loadRecs(); const f = S.recs.local.find(x => x.name === S.sel.name);
|
||
if (f) { S.sel.path = f.path; await runProbe(f.path); } res(true); }
|
||
} else if (S.dl && !S.dl.finished && S.sel && S.dl.name === S.sel.name) renderCheck();
|
||
renderJob(); updateStart();
|
||
const running = !!(S.job && S.job.running);
|
||
const ups = r.uploads || {}, upBusy = Object.values(ups).some(u => u && u.running);
|
||
if ((wasRunning && !running) || upBusy || poll.upBusy) loadScenes();
|
||
poll.upBusy = upBusy;
|
||
wasRunning = running;
|
||
}
|
||
api("/api/ping").then(r => {
|
||
document.body.classList.toggle("remote", r.local === false);
|
||
S.ping = r;
|
||
setupLang(r.language);
|
||
loadRecs(); poll();
|
||
});
|
||
setInterval(poll, 2000);
|
||
</script>
|
||
</body>
|
||
</html>
|