[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:
parent
20af1b20c2
commit
128e24a7cb
19
CLAUDE.md
19
CLAUDE.md
@ -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 +
|
||||
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
|
||||
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
|
||||
|
||||
11
deploy/Dockerfile
Normal file
11
deploy/Dockerfile
Normal 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
9
deploy/deploy.sh
Executable 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
27
deploy/docker-compose.yml
Normal 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
|
||||
15
deploy/nginx-scenegod.conf
Normal file
15
deploy/nginx-scenegod.conf
Normal 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
|
||||
}
|
||||
@ -228,9 +228,21 @@ def scene_get(name: str):
|
||||
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}")
|
||||
async def scene_save(name: str, request: Request):
|
||||
body = await request.body()
|
||||
body = await capped_body(request, 5 * 1024 * 1024)
|
||||
try:
|
||||
scene = json.loads(body)
|
||||
except json.JSONDecodeError as e:
|
||||
@ -286,9 +298,13 @@ async def render_begin(request: Request):
|
||||
rid = uuid.uuid4().hex
|
||||
(RENDERS / rid / "frames").mkdir(parents=True)
|
||||
try:
|
||||
meta = json.loads(await request.body() or b"{}")
|
||||
meta = json.loads(await capped_body(request, 64 * 1024) or b"{}")
|
||||
except json.JSONDecodeError:
|
||||
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] = {"state": "queued", "log": ""}
|
||||
return {"renderId": rid}
|
||||
@ -299,7 +315,9 @@ async def render_frame(rid: str, n: int, request: Request):
|
||||
frames = _render_dir(rid) / "frames"
|
||||
if not frames.is_dir():
|
||||
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:
|
||||
raise HTTPException(400, "empty frame 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):
|
||||
fps = 30
|
||||
try:
|
||||
body = json.loads(await request.body() or b"{}")
|
||||
body = json.loads(await capped_body(request, 1024 * 1024) or b"{}")
|
||||
except json.JSONDecodeError:
|
||||
body = {}
|
||||
audio = []
|
||||
@ -450,7 +468,10 @@ async def director(request: Request):
|
||||
if not url:
|
||||
raise HTTPException(503, "no LLM configured (set SCENEGOD_LLM_URL, e.g. "
|
||||
"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()
|
||||
if not text:
|
||||
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."""
|
||||
if os.environ.get("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()
|
||||
if not op:
|
||||
raise HTTPException(400, "operator required")
|
||||
|
||||
@ -5,6 +5,9 @@ import { stats } from './room3d.js';
|
||||
|
||||
const TABS = ['characters', 'props', 'backdrops', 'animations'];
|
||||
|
||||
const esc = s => String(s).replace(/[&<>"']/g,
|
||||
c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); // ship-check: XSS
|
||||
|
||||
export class Dock {
|
||||
constructor({ stage, dockEl, inspectorEl, viewEl }){
|
||||
this.stage = stage; this.dock = dockEl; this.insp = inspectorEl; this.view = viewEl;
|
||||
@ -21,7 +24,7 @@ export class Dock {
|
||||
|
||||
async load(){
|
||||
try {
|
||||
const r = await fetch('/assets/tree');
|
||||
const r = await fetch('assets/tree');
|
||||
if(!r.ok) throw new Error(r.status);
|
||||
this.tree = { characters:[], props:[], backdrops:[], animations:[], ...(await r.json()) };
|
||||
} catch(e){
|
||||
@ -101,10 +104,10 @@ export class Dock {
|
||||
for(const it of items){
|
||||
const c = document.createElement('div'); c.className = 'card'; c.draggable = true;
|
||||
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>`;
|
||||
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.ondragstart = ev => ev.dataTransfer.setData('application/scenegod',
|
||||
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; }
|
||||
const t = this.stage.entityTransform(en.id);
|
||||
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>`;
|
||||
el.appendChild(h);
|
||||
|
||||
@ -230,7 +233,7 @@ export class Dock {
|
||||
}
|
||||
|
||||
_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;
|
||||
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;
|
||||
@ -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); }
|
||||
s.onchange = () => cb(s.value); return this._field(label, s); }
|
||||
_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];
|
||||
['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); });
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SCENEGOD — machinima stage</title>
|
||||
<link rel="stylesheet" href="/web/style.css">
|
||||
<link rel="stylesheet" href="web/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@ -25,12 +25,12 @@
|
||||
}}
|
||||
</script>
|
||||
<script type="module">
|
||||
import { Stage } from '/web/stage.js';
|
||||
import { Dock } from '/web/dock.js';
|
||||
import { Stage } from './web/stage.js';
|
||||
import { Dock } from './web/dock.js';
|
||||
|
||||
// retarget self-check (?selftest=1) — proves the crown-jewel bake before anything else
|
||||
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 el = document.getElementById('selftest');
|
||||
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
|
||||
try {
|
||||
const { mountDirectPanel } = await import('/web/presets.js');
|
||||
const { mountDirectPanel } = await import('./web/presets.js');
|
||||
mountDirectPanel(stage, document.getElementById('direct'));
|
||||
} catch(e){ console.warn('direct panel not mounted:', e); }
|
||||
|
||||
// Timeline is optional — the page must never break when Lane B's file is absent
|
||||
try {
|
||||
const { Timeline } = await import('/web/timeline.js');
|
||||
const { TimelineUI } = await import('/web/tlui.js');
|
||||
const { Timeline } = await import('./web/timeline.js');
|
||||
const { TimelineUI } = await import('./web/tlui.js');
|
||||
window.timeline = new Timeline(stage);
|
||||
window.tlui = new TimelineUI(window.timeline, document.getElementById('timeline'));
|
||||
stage.setMixerAuto(false); // timeline owns mixer stepping from here on
|
||||
|
||||
@ -228,7 +228,7 @@ export function mountDirectPanel(stage, el){
|
||||
const text = inp.value.trim(); if(!text) return;
|
||||
note('directing…');
|
||||
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,
|
||||
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'); }
|
||||
@ -242,7 +242,7 @@ export function mountDirectPanel(stage, el){
|
||||
};
|
||||
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
|
||||
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);
|
||||
return { state: st };
|
||||
|
||||
@ -34,7 +34,7 @@ export async function finalRender(stage, timeline, opts = {}) {
|
||||
|
||||
try {
|
||||
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();
|
||||
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
|
||||
const blob = await new Promise(res => canvas.toBlob(res, 'image/png'));
|
||||
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}`); })
|
||||
.finally(() => inflight.delete(p));
|
||||
inflight.add(p);
|
||||
@ -51,12 +51,12 @@ export async function finalRender(stage, timeline, opts = {}) {
|
||||
}
|
||||
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;
|
||||
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');
|
||||
if (st.state !== 'done') throw new Error('encode failed: ' + (st.log || st.state));
|
||||
return `/render/${renderId}/out.mp4`;
|
||||
return `render/${renderId}/out.mp4`;
|
||||
} finally {
|
||||
stage.renderer.setPixelRatio(prev.dpr);
|
||||
stage.renderer.setSize(prev.w, prev.h, false);
|
||||
|
||||
@ -6,7 +6,7 @@ import { TransformControls } from 'three/addons/controls/TransformControls.js';
|
||||
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.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
|
||||
// "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
|
||||
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; })
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
@ -492,7 +492,7 @@ export class TimelineUI {
|
||||
if (this._audioBuf.has(path)) return this._audioBuf.get(path);
|
||||
const ctx = this._ensureCtx(); if (!ctx) return null;
|
||||
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);
|
||||
const buf = await ctx.decodeAudioData(await res.arrayBuffer());
|
||||
this._audioBuf.set(path, buf);
|
||||
@ -524,7 +524,7 @@ export class TimelineUI {
|
||||
|
||||
async _pickAudio() {
|
||||
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';
|
||||
if (!list.length) menu.innerHTML = '<div class="empty">no audio in /assets/tree</div>';
|
||||
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);
|
||||
if (!id) return;
|
||||
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; }
|
||||
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 */ }
|
||||
this._toast(msg); return; // 503 carries the rhubarb install hint
|
||||
}
|
||||
@ -597,7 +597,7 @@ export class TimelineUI {
|
||||
this.tl.scene.name = name;
|
||||
const json = this.tl.toJSON();
|
||||
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),
|
||||
});
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
@ -608,7 +608,7 @@ export class TimelineUI {
|
||||
async load(name) {
|
||||
name = (name || '').trim(); if (!name) return;
|
||||
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) return;
|
||||
if (this.stage.applyState) await this.stage.applyState(json);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user