[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 +
|
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
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())
|
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")
|
||||||
|
|||||||
@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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); });
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user