[deploy] digalot.fyi hosting: prefix-agnostic frontend, ship-check hardening, docker+nginx kit

- web: all URLs relative (imports ./web/..., fetches assets/...) so the app
  serves at / locally and under /scenegod/ behind nginx; esc() on dock
  innerHTML interpolations (ship-check XSS class)
- server: capped_body on every POST (scenes 5MB, frames 25MB, meta/director
  256KB-1MB), render begin sanity (fps<=60, dims<=4096, frame idx<=20000)
- deploy/: Dockerfile (slim+ffmpeg), compose (project 'scenegod' — avoids
  the shared-'deploy'-project orphan trap with meshgod), nginx location
  (suite basic-auth), deploy.sh (rsync model, meshgod-style)
- live: digalot.fyi/scenegod/ -> 401 unauthed, app healthy on dealgod_default

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-21 02:43:26 +10:00
parent 20af1b20c2
commit 128e24a7cb
12 changed files with 142 additions and 34 deletions

View File

@ -4,6 +4,25 @@ Drop characters + animation clips + image backdrops on a 3D stage, keyframe
transforms/cameras/lights on a timeline, render mp4. Web app: FastAPI + transforms/cameras/lights on a timeline, render mp4. Web app: FastAPI +
three.js, same conventions as MESHGOD (`~/Documents/MESHGOD`). three.js, same conventions as MESHGOD (`~/Documents/MESHGOD`).
## Deploy model (digalot.fyi suite)
- Prod: dealgod VPS `root@100.94.195.115`, `/opt/scenegod` — NOT a git
checkout: `./deploy/deploy.sh` rsyncs from a dev Mac and rebuilds
(`deploy/docker-compose.yml`, project name `scenegod` — do NOT drop the
`name:` key; meshgod+scenegod both compose from dirs called "deploy" and
would otherwise share a project and orphan each other). Container joins the
external `dealgod_default` net; data volumes under `/opt/scenegod-data/`.
- nginx: `deploy/nginx-scenegod.conf` block lives in the digalot.fyi server
in `/opt/dealgod/nginx/dealgod-servers.conf` (dealgod-nginx container) —
suite basic-auth (`meshgod.htpasswd`). Live: digalot.fyi/scenegod/.
- Frontend is PREFIX-AGNOSTIC: all web URLs are relative (module imports
`./web/...`, fetches `assets/...`) so the app works at `/` locally AND
under `/scenegod/`. Never reintroduce a leading `/` in a web-side URL.
- VPS env keeps `SCENEGOD_LLM_URL`/`SCENEGOD_MB` UNSET (features 503/hide;
they shell to tailnet boxes — ultra-only). rhubarb not in the image.
- Server has ship-check hardening: `capped_body` on every POST (frames 25MB,
scenes 5MB), render dims ≤4096/fps ≤60/frame index ≤20000, `esc()` on all
dock innerHTML interpolations. Keep new endpoints behind the same rules.
## You are one of THREE parallel lane agents ## You are one of THREE parallel lane agents
1. **Read, in order:** this file → `PLAN.md` → your lane file in `lanes/` 1. **Read, in order:** this file → `PLAN.md` → your lane file in `lanes/`
your log in `logs/`. Your lane file is your source of truth — the your log in `logs/`. Your lane file is your source of truth — the

11
deploy/Dockerfile Normal file
View File

@ -0,0 +1,11 @@
# scenegod — godverse-standard slim Python service + ffmpeg (render encode).
# rhubarb NOT installed (lip-sync 503s cleanly; use the ultra instance for that).
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8020
CMD ["uvicorn", "scenegod.server:app", "--host", "0.0.0.0", "--port", "8020"]

9
deploy/deploy.sh Executable file
View File

@ -0,0 +1,9 @@
#!/bin/bash
# rsync-deploy scenegod to the dealgod VPS (NOT a git checkout there — meshgod model).
set -e
VPS=root@100.94.195.115
rsync -az --delete \
--exclude .git --exclude .venv --exclude assets --exclude scenes \
--exclude renders --exclude '*.pyc' --exclude __pycache__ --exclude .env \
"$(cd "$(dirname "$0")/.." && pwd)/" "$VPS":/opt/scenegod/
ssh "$VPS" 'mkdir -p /opt/scenegod-data/{assets,scenes,renders} && cd /opt/scenegod/deploy && docker compose up -d --build'

27
deploy/docker-compose.yml Normal file
View File

@ -0,0 +1,27 @@
# Add scenegod to the dealgod stack (same pattern as meshgod). Deploy from a dev Mac:
# ./deploy/deploy.sh (rsync → VPS /opt/scenegod, compose build on the box)
# nginx proxies digalot.fyi/scenegod/ → scenegod:8020 behind the suite basic-auth.
name: scenegod # explicit — both meshgod+scenegod compose from dirs named "deploy";
# without this they share one project and see each other as orphans
services:
scenegod:
build:
context: ..
dockerfile: deploy/Dockerfile
container_name: scenegod
restart: unless-stopped
environment:
- SCENEGOD_ASSETS=/app/assets # mounted volume — synced from the Macs
- SCENEGOD_SCENES=/app/scenes
- SCENEGOD_RENDERS=/app/renders
# deliberately UNSET on the VPS (each 503s cleanly / hides its UI):
# SCENEGOD_LLM_URL / SCENEGOD_LLM_MODEL (tailnet Ollama — ultra-side feature)
# SCENEGOD_MB (MODELBEAST proxy — NEVER on the VPS)
volumes:
- /opt/scenegod-data/assets:/app/assets
- /opt/scenegod-data/scenes:/app/scenes
- /opt/scenegod-data/renders:/app/renders
networks: [dealgod_default]
networks:
dealgod_default:
external: true

View File

@ -0,0 +1,15 @@
# Lives in /opt/dealgod/nginx/dealgod-servers.conf inside the digalot.fyi server
# block (dealgod-nginx container). Same suite login as meshgod.
location = /scenegod { return 301 /scenegod/; }
location /scenegod/ {
auth_basic "meshgod - mates only";
auth_basic_user_file /opt/dealgod/le/meshgod.htpasswd;
proxy_pass http://scenegod:8020/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Remote-User $remote_user;
client_max_body_size 30m; # render frames <=25MB + slack
proxy_read_timeout 300s; # encode status polling
}

View File

@ -228,9 +228,21 @@ def scene_get(name: str):
return json.loads(p.read_text()) return json.loads(p.read_text())
# ship-check: body caps — this server now fronts digalot.fyi behind nginx
# basic-auth; never trust content-length alone, check the read bytes too.
async def capped_body(request: Request, limit: int) -> bytes:
cl = request.headers.get("content-length")
if cl and cl.isdigit() and int(cl) > limit:
raise HTTPException(413, f"body over {limit} bytes")
body = await request.body()
if len(body) > limit:
raise HTTPException(413, f"body over {limit} bytes")
return body
@app.post("/scenes/{name}") @app.post("/scenes/{name}")
async def scene_save(name: str, request: Request): async def scene_save(name: str, request: Request):
body = await request.body() body = await capped_body(request, 5 * 1024 * 1024)
try: try:
scene = json.loads(body) scene = json.loads(body)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
@ -286,9 +298,13 @@ async def render_begin(request: Request):
rid = uuid.uuid4().hex rid = uuid.uuid4().hex
(RENDERS / rid / "frames").mkdir(parents=True) (RENDERS / rid / "frames").mkdir(parents=True)
try: try:
meta = json.loads(await request.body() or b"{}") meta = json.loads(await capped_body(request, 64 * 1024) or b"{}")
except json.JSONDecodeError: except json.JSONDecodeError:
meta = {} meta = {}
if not (1 <= int(meta.get("fps", 30)) <= 60) or \
not (16 <= int(meta.get("width", 1280)) <= 4096) or \
not (16 <= int(meta.get("height", 720)) <= 4096):
raise HTTPException(400, "fps/width/height out of range")
(RENDERS / rid / "meta.json").write_text(json.dumps(meta)) (RENDERS / rid / "meta.json").write_text(json.dumps(meta))
_renders[rid] = {"state": "queued", "log": ""} _renders[rid] = {"state": "queued", "log": ""}
return {"renderId": rid} return {"renderId": rid}
@ -299,7 +315,9 @@ async def render_frame(rid: str, n: int, request: Request):
frames = _render_dir(rid) / "frames" frames = _render_dir(rid) / "frames"
if not frames.is_dir(): if not frames.is_dir():
raise HTTPException(404, "unknown renderId") raise HTTPException(404, "unknown renderId")
body = await request.body() if not (0 <= n <= 20000):
raise HTTPException(400, "frame index out of range")
body = await capped_body(request, 25 * 1024 * 1024)
if not body: if not body:
raise HTTPException(400, "empty frame body") raise HTTPException(400, "empty frame body")
(frames / f"{n:06d}.png").write_bytes(body) (frames / f"{n:06d}.png").write_bytes(body)
@ -318,7 +336,7 @@ async def render_end(rid: str, request: Request):
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
fps = 30 fps = 30
try: try:
body = json.loads(await request.body() or b"{}") body = json.loads(await capped_body(request, 1024 * 1024) or b"{}")
except json.JSONDecodeError: except json.JSONDecodeError:
body = {} body = {}
audio = [] audio = []
@ -450,7 +468,10 @@ async def director(request: Request):
if not url: if not url:
raise HTTPException(503, "no LLM configured (set SCENEGOD_LLM_URL, e.g. " raise HTTPException(503, "no LLM configured (set SCENEGOD_LLM_URL, e.g. "
"http://100.69.21.128:11434, and SCENEGOD_LLM_MODEL)") "http://100.69.21.128:11434, and SCENEGOD_LLM_MODEL)")
body = await request.json() try:
body = json.loads(await capped_body(request, 256 * 1024))
except json.JSONDecodeError:
raise HTTPException(400, "invalid JSON")
text = (body.get("text") or "").strip() text = (body.get("text") or "").strip()
if not text: if not text:
raise HTTPException(400, "text required") raise HTTPException(400, "text required")
@ -506,7 +527,10 @@ async def mb_submit(request: Request):
/api/assets first, then /api/jobs the meshgod/ops.py contract.""" /api/assets first, then /api/jobs the meshgod/ops.py contract."""
if os.environ.get("SCENEGOD_MB") != "1": if os.environ.get("SCENEGOD_MB") != "1":
raise HTTPException(503, "MODELBEAST proxy disabled (set SCENEGOD_MB=1)") raise HTTPException(503, "MODELBEAST proxy disabled (set SCENEGOD_MB=1)")
body = await request.json() try:
body = json.loads(await capped_body(request, 256 * 1024))
except json.JSONDecodeError:
raise HTTPException(400, "invalid JSON")
op = (body.get("operator") or "").strip() op = (body.get("operator") or "").strip()
if not op: if not op:
raise HTTPException(400, "operator required") raise HTTPException(400, "operator required")

View File

@ -5,6 +5,9 @@ import { stats } from './room3d.js';
const TABS = ['characters', 'props', 'backdrops', 'animations']; const TABS = ['characters', 'props', 'backdrops', 'animations'];
const esc = s => String(s).replace(/[&<>"']/g,
c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); // ship-check: XSS
export class Dock { export class Dock {
constructor({ stage, dockEl, inspectorEl, viewEl }){ constructor({ stage, dockEl, inspectorEl, viewEl }){
this.stage = stage; this.dock = dockEl; this.insp = inspectorEl; this.view = viewEl; this.stage = stage; this.dock = dockEl; this.insp = inspectorEl; this.view = viewEl;
@ -21,7 +24,7 @@ export class Dock {
async load(){ async load(){
try { try {
const r = await fetch('/assets/tree'); const r = await fetch('assets/tree');
if(!r.ok) throw new Error(r.status); if(!r.ok) throw new Error(r.status);
this.tree = { characters:[], props:[], backdrops:[], animations:[], ...(await r.json()) }; this.tree = { characters:[], props:[], backdrops:[], animations:[], ...(await r.json()) };
} catch(e){ } catch(e){
@ -101,10 +104,10 @@ export class Dock {
for(const it of items){ for(const it of items){
const c = document.createElement('div'); c.className = 'card'; c.draggable = true; const c = document.createElement('div'); c.className = 'card'; c.draggable = true;
const th = it.thumb const th = it.thumb
? `<img class="th" loading="lazy" src="/assets/file?path=${encodeURIComponent(it.thumb)}">` ? `<img class="th" loading="lazy" src="assets/file?path=${encodeURIComponent(it.thumb)}">`
: `<div class="th">${icon}</div>`; : `<div class="th">${icon}</div>`;
const vp = this.tab === 'backdrops' ? this._videoPath(it) : null; const vp = this.tab === 'backdrops' ? this._videoPath(it) : null;
c.innerHTML = `${th}<div class="nm">${vp ? '🎞 ' : ''}${this._name(it)}</div>`; c.innerHTML = `${th}<div class="nm">${vp ? '🎞 ' : ''}${esc(this._name(it))}</div>`;
c.onclick = () => this._addFromLibrary(it); c.onclick = () => this._addFromLibrary(it);
c.ondragstart = ev => ev.dataTransfer.setData('application/scenegod', c.ondragstart = ev => ev.dataTransfer.setData('application/scenegod',
JSON.stringify({ tab: this.tab, path: this._path(it), name: this._name(it), video: vp })); JSON.stringify({ tab: this.tab, path: this._path(it), name: this._name(it), video: vp }));
@ -182,7 +185,7 @@ export class Dock {
if(!en){ el.innerHTML = '<div class="empty">nothing selected</div>'; return; } if(!en){ el.innerHTML = '<div class="empty">nothing selected</div>'; return; }
const t = this.stage.entityTransform(en.id); const t = this.stage.entityTransform(en.id);
const h = document.createElement('div'); h.className = 'ihd'; const h = document.createElement('div'); h.className = 'ihd';
h.innerHTML = `<b>${en.label}</b><span class="kind">${en.kind}${ h.innerHTML = `<b>${esc(en.label)}</b><span class="kind">${esc(en.kind)}${
en.source && en.source.type === 'upload' ? ' · <span class="warn">upload</span>' : ''}</span>`; en.source && en.source.type === 'upload' ? ' · <span class="warn">upload</span>' : ''}</span>`;
el.appendChild(h); el.appendChild(h);
@ -230,7 +233,7 @@ export class Dock {
} }
_field(label, inputEl){ const w = document.createElement('label'); w.className = 'fld'; _field(label, inputEl){ const w = document.createElement('label'); w.className = 'fld';
w.innerHTML = `<span>${label}</span>`; w.appendChild(inputEl); return w; } w.innerHTML = `<span>${esc(label)}</span>`; w.appendChild(inputEl); return w; }
_num(label, val, cb){ const i = document.createElement('input'); i.type='number'; i.step='0.1'; i.value=val; _num(label, val, cb){ const i = document.createElement('input'); i.type='number'; i.step='0.1'; i.value=val;
i.oninput = () => cb(parseFloat(i.value) || 0); return this._field(label, i); } i.oninput = () => cb(parseFloat(i.value) || 0); return this._field(label, i); }
_color(label, val, cb){ const i = document.createElement('input'); i.type='color'; i.value=val; _color(label, val, cb){ const i = document.createElement('input'); i.type='color'; i.value=val;
@ -239,7 +242,7 @@ export class Dock {
for(const o of opts){ const op = document.createElement('option'); op.value=op.textContent=o; if(o===val) op.selected=true; s.appendChild(op); } for(const o of opts){ const op = document.createElement('option'); op.value=op.textContent=o; if(o===val) op.selected=true; s.appendChild(op); }
s.onchange = () => cb(s.value); return this._field(label, s); } s.onchange = () => cb(s.value); return this._field(label, s); }
_vec3(label, arr, cb){ const w = document.createElement('div'); w.className = 'fld vec'; _vec3(label, arr, cb){ const w = document.createElement('div'); w.className = 'fld vec';
w.innerHTML = `<span>${label}</span>`; w.innerHTML = `<span>${esc(label)}</span>`;
const cur = [...arr]; const cur = [...arr];
['x','y','z'].forEach((_, k) => { const i = document.createElement('input'); i.type='number'; i.step='0.1'; i.value=+arr[k].toFixed(3); ['x','y','z'].forEach((_, k) => { const i = document.createElement('input'); i.type='number'; i.step='0.1'; i.value=+arr[k].toFixed(3);
i.oninput = () => { cur[k] = parseFloat(i.value) || 0; cb([...cur]); }; w.appendChild(i); }); i.oninput = () => { cur[k] = parseFloat(i.value) || 0; cb([...cur]); }; w.appendChild(i); });

View File

@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>SCENEGOD — machinima stage</title> <title>SCENEGOD — machinima stage</title>
<link rel="stylesheet" href="/web/style.css"> <link rel="stylesheet" href="web/style.css">
</head> </head>
<body> <body>
<header> <header>
@ -25,12 +25,12 @@
}} }}
</script> </script>
<script type="module"> <script type="module">
import { Stage } from '/web/stage.js'; import { Stage } from './web/stage.js';
import { Dock } from '/web/dock.js'; import { Dock } from './web/dock.js';
// retarget self-check (?selftest=1) — proves the crown-jewel bake before anything else // retarget self-check (?selftest=1) — proves the crown-jewel bake before anything else
if(new URLSearchParams(location.search).has('selftest')){ if(new URLSearchParams(location.search).has('selftest')){
const { _selftest } = await import('/web/room3d.js'); const { _selftest } = await import('./web/room3d.js');
const r = _selftest(); const r = _selftest();
const el = document.getElementById('selftest'); const el = document.getElementById('selftest');
el.textContent = `selftest: ${r.ok ? 'PASS' : 'FAIL'} (maxErr=${r.maxErr.toExponential(2)})`; el.textContent = `selftest: ${r.ok ? 'PASS' : 'FAIL'} (maxErr=${r.maxErr.toExponential(2)})`;
@ -48,14 +48,14 @@ const dock = new Dock({
// DIRECT panel (M5 director mode) — guarded like the timeline: page survives its absence // DIRECT panel (M5 director mode) — guarded like the timeline: page survives its absence
try { try {
const { mountDirectPanel } = await import('/web/presets.js'); const { mountDirectPanel } = await import('./web/presets.js');
mountDirectPanel(stage, document.getElementById('direct')); mountDirectPanel(stage, document.getElementById('direct'));
} catch(e){ console.warn('direct panel not mounted:', e); } } catch(e){ console.warn('direct panel not mounted:', e); }
// Timeline is optional — the page must never break when Lane B's file is absent // Timeline is optional — the page must never break when Lane B's file is absent
try { try {
const { Timeline } = await import('/web/timeline.js'); const { Timeline } = await import('./web/timeline.js');
const { TimelineUI } = await import('/web/tlui.js'); const { TimelineUI } = await import('./web/tlui.js');
window.timeline = new Timeline(stage); window.timeline = new Timeline(stage);
window.tlui = new TimelineUI(window.timeline, document.getElementById('timeline')); window.tlui = new TimelineUI(window.timeline, document.getElementById('timeline'));
stage.setMixerAuto(false); // timeline owns mixer stepping from here on stage.setMixerAuto(false); // timeline owns mixer stepping from here on

View File

@ -228,7 +228,7 @@ export function mountDirectPanel(stage, el){
const text = inp.value.trim(); if(!text) return; const text = inp.value.trim(); if(!text) return;
note('directing…'); note('directing…');
try { try {
const r = await fetch('/director', { method:'POST', headers:{ 'Content-Type':'application/json' }, const r = await fetch('director', { method:'POST', headers:{ 'Content-Type':'application/json' },
body: JSON.stringify({ text, selected: st.sel, body: JSON.stringify({ text, selected: st.sel,
entities: stage.entities().map(e => ({ id:e.id, kind:e.kind, label:e.label })) }) }); entities: stage.entities().map(e => ({ id:e.id, kind:e.kind, label:e.label })) }) });
if(r.status === 503){ nl.style.display = 'none'; return note('director LLM offline'); } if(r.status === 503){ nl.style.display = 'none'; return note('director LLM offline'); }
@ -242,7 +242,7 @@ export function mountDirectPanel(stage, el){
}; };
btn(nl, '➤', send); inp.addEventListener('keydown', e => { if(e.key === 'Enter') send(); }); btn(nl, '➤', send); inp.addEventListener('keydown', e => { if(e.key === 'Enter') send(); });
// probe: FastAPI answers 405 on GET when only POST exists → endpoint present, show the box // probe: FastAPI answers 405 on GET when only POST exists → endpoint present, show the box
fetch('/director').then(r => { if(r.status === 405 || r.ok) nl.style.display = ''; }).catch(() => {}); fetch('director').then(r => { if(r.status === 405 || r.ok) nl.style.display = ''; }).catch(() => {});
const info = document.createElement('span'); info.className = 'dinfo'; el.appendChild(info); const info = document.createElement('span'); info.className = 'dinfo'; el.appendChild(info);
return { state: st }; return { state: st };

View File

@ -34,7 +34,7 @@ export async function finalRender(stage, timeline, opts = {}) {
try { try {
const total = Math.max(1, Math.round(timeline.duration * fps)); const total = Math.max(1, Math.round(timeline.duration * fps));
const { renderId } = await postJSON('/render/begin', { name, fps, width, height }); const { renderId } = await postJSON('render/begin', { name, fps, width, height });
const inflight = new Set(); const inflight = new Set();
for (let f = 0; f < total; f++) { for (let f = 0; f < total; f++) {
@ -43,7 +43,7 @@ export async function finalRender(stage, timeline, opts = {}) {
stage.renderActiveCamera(null); // draw active cam (or director cam) to main canvas stage.renderActiveCamera(null); // draw active cam (or director cam) to main canvas
const blob = await new Promise(res => canvas.toBlob(res, 'image/png')); const blob = await new Promise(res => canvas.toBlob(res, 'image/png'));
while (inflight.size >= 4) await Promise.race(inflight); // backpressure: <=4 in flight while (inflight.size >= 4) await Promise.race(inflight); // backpressure: <=4 in flight
const p = fetch(`/render/${renderId}/frame/${f}`, { method: 'POST', body: blob }) const p = fetch(`render/${renderId}/frame/${f}`, { method: 'POST', body: blob })
.then(r => { if (!r.ok) throw new Error(`frame ${f}: ${r.status}`); }) .then(r => { if (!r.ok) throw new Error(`frame ${f}: ${r.status}`); })
.finally(() => inflight.delete(p)); .finally(() => inflight.delete(p));
inflight.add(p); inflight.add(p);
@ -51,12 +51,12 @@ export async function finalRender(stage, timeline, opts = {}) {
} }
await Promise.all(inflight); await Promise.all(inflight);
await postJSON(`/render/${renderId}/end`, { audio }); // scene audio[] muxed by ffmpeg await postJSON(`render/${renderId}/end`, { audio }); // scene audio[] muxed by ffmpeg
let st; let st;
do { await sleep(500); st = await (await fetch(`/render/${renderId}/status`)).json(); } do { await sleep(500); st = await (await fetch(`render/${renderId}/status`)).json(); }
while (st.state === 'queued' || st.state === 'encoding'); while (st.state === 'queued' || st.state === 'encoding');
if (st.state !== 'done') throw new Error('encode failed: ' + (st.log || st.state)); if (st.state !== 'done') throw new Error('encode failed: ' + (st.log || st.state));
return `/render/${renderId}/out.mp4`; return `render/${renderId}/out.mp4`;
} finally { } finally {
stage.renderer.setPixelRatio(prev.dpr); stage.renderer.setPixelRatio(prev.dpr);
stage.renderer.setSize(prev.w, prev.h, false); stage.renderer.setSize(prev.w, prev.h, false);

View File

@ -6,7 +6,7 @@ import { TransformControls } from 'three/addons/controls/TransformControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { parseAny, captureRest, disposeRoot, stats, bakeRetarget } from './room3d.js'; import { parseAny, captureRest, disposeRoot, stats, bakeRetarget } from './room3d.js';
const ASSET_URL = p => '/assets/file?path=' + encodeURIComponent(p); const ASSET_URL = p => 'assets/file?path=' + encodeURIComponent(p);
// Loose viseme matcher: map a character's morph-target names (CC "V_Open", ARKit "jawOpen", plain // Loose viseme matcher: map a character's morph-target names (CC "V_Open", ARKit "jawOpen", plain
// "AA"…) to a canonical set the timeline drives. Detection only — Rhubarb→keyframes is Lane B's. // "AA"…) to a canonical set the timeline drives. Detection only — Rhubarb→keyframes is Lane B's.
@ -302,7 +302,7 @@ export class Stage {
}); });
} }
_posterTex(src){ // same-stem jpg via the server's sidecar route; null when absent _posterTex(src){ // same-stem jpg via the server's sidecar route; null when absent
return new THREE.TextureLoader().loadAsync('/assets/thumb?path=' + encodeURIComponent(src)) return new THREE.TextureLoader().loadAsync('assets/thumb?path=' + encodeURIComponent(src))
.then(t => { t.colorSpace = THREE.SRGBColorSpace; return t; }) .then(t => { t.colorSpace = THREE.SRGBColorSpace; return t; })
.catch(() => null); .catch(() => null);
} }

View File

@ -492,7 +492,7 @@ export class TimelineUI {
if (this._audioBuf.has(path)) return this._audioBuf.get(path); if (this._audioBuf.has(path)) return this._audioBuf.get(path);
const ctx = this._ensureCtx(); if (!ctx) return null; const ctx = this._ensureCtx(); if (!ctx) return null;
try { try {
const res = await fetch('/assets/file?path=' + encodeURIComponent(path)); const res = await fetch('assets/file?path=' + encodeURIComponent(path));
if (!res.ok) throw new Error(res.status); if (!res.ok) throw new Error(res.status);
const buf = await ctx.decodeAudioData(await res.arrayBuffer()); const buf = await ctx.decodeAudioData(await res.arrayBuffer());
this._audioBuf.set(path, buf); this._audioBuf.set(path, buf);
@ -524,7 +524,7 @@ export class TimelineUI {
async _pickAudio() { async _pickAudio() {
let list = []; let list = [];
try { const r = await fetch('/assets/tree'); if (r.ok) list = (await r.json()).audio || []; } catch { /* offline */ } try { const r = await fetch('assets/tree'); if (r.ok) list = (await r.json()).audio || []; } catch { /* offline */ }
const menu = document.createElement('div'); menu.className = 'tl-menu'; const menu = document.createElement('div'); menu.className = 'tl-menu';
if (!list.length) menu.innerHTML = '<div class="empty">no audio in /assets/tree</div>'; if (!list.length) menu.innerHTML = '<div class="empty">no audio in /assets/tree</div>';
for (const it of list) { for (const it of list) {
@ -557,10 +557,10 @@ export class TimelineUI {
: await this._choose(chars.map((c) => ({ label: c.label || c.id, value: c.id })), anchorEl); : await this._choose(chars.map((c) => ({ label: c.label || c.id, value: c.id })), anchorEl);
if (!id) return; if (!id) return;
let res; let res;
try { res = await fetch('/rhubarb?path=' + encodeURIComponent(clip.path)); } try { res = await fetch('rhubarb?path=' + encodeURIComponent(clip.path)); }
catch { this._toast('server unreachable — /rhubarb needs Lane C running'); return; } catch { this._toast('server unreachable — /rhubarb needs Lane C running'); return; }
if (!res.ok) { if (!res.ok) {
let msg = '/rhubarb failed: ' + res.status; let msg = 'rhubarb failed: ' + res.status;
try { const j = await res.json(); if (j.detail) msg = j.detail; } catch { /* non-json body */ } try { const j = await res.json(); if (j.detail) msg = j.detail; } catch { /* non-json body */ }
this._toast(msg); return; // 503 carries the rhubarb install hint this._toast(msg); return; // 503 carries the rhubarb install hint
} }
@ -597,7 +597,7 @@ export class TimelineUI {
this.tl.scene.name = name; this.tl.scene.name = name;
const json = this.tl.toJSON(); const json = this.tl.toJSON();
try { try {
const res = await fetch(`/scenes/${encodeURIComponent(name)}`, { const res = await fetch(`scenes/${encodeURIComponent(name)}`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(json), method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(json),
}); });
if (!res.ok) throw new Error(res.status); if (!res.ok) throw new Error(res.status);
@ -608,7 +608,7 @@ export class TimelineUI {
async load(name) { async load(name) {
name = (name || '').trim(); if (!name) return; name = (name || '').trim(); if (!name) return;
let json = null; let json = null;
try { const res = await fetch(`/scenes/${encodeURIComponent(name)}`); if (res.ok) json = await res.json(); } catch { /* fall through */ } try { const res = await fetch(`scenes/${encodeURIComponent(name)}`); if (res.ok) json = await res.json(); } catch { /* fall through */ }
if (!json) { const raw = localStorage.getItem('scenegod:scene:' + name); if (raw) json = JSON.parse(raw); } if (!json) { const raw = localStorage.getItem('scenegod:scene:' + name); if (raw) json = JSON.parse(raw); }
if (!json) return; if (!json) return;
if (this.stage.applyState) await this.stage.applyState(json); if (this.stage.applyState) await this.stage.applyState(json);