Compare commits

...

5 Commits

Author SHA1 Message Date
type-two
dfd4cebd2b [laneA] M2: PiP + cameras/lights toolbar, eventized clip drops, onChange-on-add
- dock: /assets/tree {name,path,formats,thumb}; +camera/+key/+ambient toolbar;
  clip apply dispatches scenegod:clipdrop when timeline present (else preview);
  drag-from-dock places onto the viewport (screenToFloor / entityAt)
- stage: PiP overlay from active camera; renderActiveCamera hardened as M3 target
  + pause()/resume(); backdrop rebuild-on-param; fire onChange on add+remove
  (fixes tlui empty-names + a gizmo mouseUp id-vs-entity bug); set-active-camera
- verified in the integrated app (:8020, Lane B timeline mounted), zero console
  errors. Found + logged the entity-mirror integration gap (timeline model isn't
  seeded from live stage adds) for the orchestrator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 20:14:52 +10:00
type-two
bbb3f99016 [laneB] log: M2 session 2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 20:11:03 +10:00
type-two
5d9950211c [laneB] M2: SYNC1 row/bar fixes, clipdrop, crossfade, params/cut lanes, box-select
- timeline.js: onLoad hook (rebuilds rows + refreshes scene bar after load()),
  scenegod:clipdrop handler (addClipDrop: block at playhead, out=clip duration,
  auto 0.25 fade where it abuts a neighbour), crossfade now fade-IN + fade-OUT
  (pre-roll pulls successor start earlier for continuous local time).
- tlui.js: reads tracks from the TIMELINE (authoritative) not the stage copy —
  fixes post-load addKey/clipdrop not appearing. Params lanes (per-key color),
  camera-cut lane add via dblclick, snap toggle, box-select + multi-drag +
  Delete, selection highlight, fade wedge on blocks, grow-to-content height.
- timeline_test.mjs: + onLoad, addClipDrop duration, crossfade weight sum=1.

Verified in browser (throwaway harness, not committed): SYNC #3a/#3b repro
fixed, both clip blocks render, clipdrop/add-key show immediately, cut-add and
box-select multi-drag work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 20:10:07 +10:00
type-two
24b7b4d4b7 [laneC] log: M2/M3 render pipeline done
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 20:00:42 +10:00
type-two
b627a0f2ff [laneC] M2/M3: render pipeline (ffmpeg encode + render.js), thumbnails endpoint, reaper
- /render/begin|frame|end|status|out with background ffmpeg thread + on-disk
  status fallback; boot-time reap of dirs >48h
- /assets/thumb sidecar lookup (pass-through, no resize dep)
- web/render.js: draftRecord (MediaRecorder) + finalRender (deterministic
  timeline.step -> PNG -> POST, <=4 in flight) + download helper
- test_server.py: +render pipeline (ffmpeg synthetic frames -> mp4, ffprobe
  ~1s) +thumbnail; 6 groups green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 20:00:14 +10:00
13 changed files with 681 additions and 86 deletions

View File

@ -56,3 +56,62 @@ BLOCKED/REQUESTS:
NEXT (M2, do not start until orchestrator flips the milestone line):
- multiple cameras + PiP overlay via renderActiveCamera, light entity param plumbing polish,
backdrop rebuild-on-param-change, drag-from-dock onto viewport (currently click-to-add).
## 2026-07-18 session 2 (M2)
DONE: M2 Lane-A scope shipped and verified in the *integrated* app (Lane C server :8020 +
Lane B timeline/tlui mounted). Worked the orchestrator's priority order:
- **P0 /assets/tree shape** — dropped the bare-string fallback; dock.js now reads the confirmed
`{name, path, formats, thumb}` and renders `thumb` (→ `/assets/file?path=`) when present.
- **P1 eventize clip drops**`dock._applyClip(id,path,idx)`: when `window.timeline` exists it
dispatches `scenegod:clipdrop {detail:{id,path,clipIndex}}` (Lane B places the block); only
bakes+previews standalone when no timeline. Uploaded/local clip files can't hand off a server
path, so with a timeline up they toast a "use the dock" note instead of a dead preview.
- **P2 onChange on every entity**`Stage.addEntity`/`removeEntity` now fire `_fireChange(en)`,
so tlui's `onChange→_syncRows` builds a row per entity, incl. programmatic `applyState` loads.
Fixed the empty-names bug on both add and remove. Also fixed a latent bug: the gizmo `mouseUp`
fired `_fireChange` with the *id* not the entity, so the inspector never refreshed on drag.
- **P4 cameras/lights/PiP/backdrop/drag**:
- `Stage` PiP: overlay `<canvas class="pip">` in `#view`, rendered each frame from the active
cam (render active→main canvas, blit to pip, then render director last so the main view is
correct). `setActiveCamera` shows/hides it. Verified 16,495 lit pixels (real content).
- `renderActiveCamera(canvas|null)` hardened as the M3 final-render target; added
`pause()/resume()` so Lane C can freeze the director loop and drive frames deterministically.
- dock "add rig" toolbar: **+ camera / + key light / + ambient** (these have no asset file);
inspector gains **◉ set active camera** for cameras; light `castShadow` param plumbed.
- `setParam` rebuilds the backdrop mesh on mode/image/width change (`_rebuildBackdrop`).
- drag-from-dock onto the viewport: cards are `draggable`; `#view` drop places chars/props at
the cursor (`screenToFloor`) and bakes clips onto the character under the cursor (`entityAt`).
VERIFICATION (in-app, :8020, zero console errors throughout):
- Added man + key light + ambient + camera via toolbar → all 4 appear as tlui rows immediately
(P2 proven). Man lit by the scene lights. Screenshot: `logs/shots/m2-cameras-lights-dome.png`.
- Clicked ANIMATIONS→hiphop on the selected man → `scenegod:clipdrop` fired with
`{id:'e1', path:'animations/test/hiphop.fbx', clipIndex:0}` (P1 proven). Lane B DOES consume it
(timeline.js:98) but the block doesn't land — see the integration gap below.
- capturekey button → `scenegod:capturekey {id:'e1'}`. backdrop plane→dome rebuild swapped the
root mesh with no error.
DECISIONS:
- PiP renders the active cam into the *main* WebGL canvas then blits to the 2D pip canvas (one
shared context); director view is rendered last each frame so it always wins the main canvas.
Same primitive is the M3 render path: `pause()` the loop, `renderActiveCamera(null)`, read pixels.
- Local/upload clip files are a no-op-with-note when a timeline is mounted (no server path to place
a block). ponytail: revisit only if John wants drag-in clip files to become timeline blocks.
BLOCKED/REQUESTS — INTEGRATION GAP (orchestrator, please route):
- Live dock/stage-added entities never reach the timeline's model. `Timeline.scene.entities`
starts `[]` (timeline.js:80) and is only filled by `timeline.load(sceneJson)`. Both
`scenegod:clipdrop`→`addClipDrop` and `scenegod:capturekey`→`addKey` call `_tracks(id)`, which
throws `no entity <id>` for anything added through the dock. Verified live at :8020:
`manOnStage=true, inTimelineModel=false, addClipDropErr="no entity e1"`. So on today's build,
keyframing/clip-dropping a dock-added entity is dead — it only works after a scene `load()`.
- This is a cross-lane seam, not a Lane-A-file fix (timeline.js is Lane B's). The natural hook is
already in place on my side: **`stage.onChange(en)` now fires on every add AND remove**
(my P2 fix). Suggested resolution (Lane B, ~4 lines): in the Timeline ctor,
`stage.onChange(en => this._mirror(en))` where `_mirror` upserts/removes a `scene.entities`
entry from `{id,kind,label,source,params, transform: stage.entityTransform(id), tracks:{}}`.
I can instead dispatch a dedicated `scenegod:entityadd/entityremove` event from the dock if the
orchestrator prefers an explicit contract over reusing onChange — say the word and I'll add it.
NEXT: once the entity-mirror seam is decided, the "lady + man + street + 2 cameras + sunset" SYNC-2
demo should play. Holding at the M2 boundary until the orchestrator flips the milestone line.

View File

@ -59,3 +59,47 @@ NEXT: Hold at M1 boundary. At SYNC 1 the stub swaps for the real Stage
(one-line change in the page import) and tldev.html/stagestub.js get
deleted. M2 (locked): clip crossfade polish, params/cut lane UI, snapping
config, undo Ctrl+Z already stubbed, box-select multi-drag.
## 2026-07-18 session 2 (M2)
DONE: M2 per orchestrator update. SYNC findings fixed first, then polish.
- SYNC1 #3a/#3b: added `Timeline.onLoad(cb)`; `load()` fires it →
TimelineUI rebuilds rows + refreshes name/duration fields. Verified with
the exact repro (UI built against empty stage, then `load()` a 2-entity
scene → rows now appear, scene bar updates).
- SYNC1 #4 clipdrop: `scenegod:clipdrop {id,path,clipIndex}``addClipDrop`
drops a block at the playhead, `out = clip.duration` (from preload), auto
`fade:0.25` where it abuts a neighbour. Verified via dispatched event.
- Crossfade fade-IN (was fade-OUT only): a predecessor's fade pre-rolls the
successor's playback start earlier by `pred.fade` so local time is
continuous across the boundary; weights ramp 1→0 / 0→1 and sum to 1
(asserted in test).
- Params lanes (per-key hue), camera-cut lane add via dblclick on Cameras
row (uses active cam / first camera), snap toggle in scene bar (Alt still
= free), box-select + multi-drag + Delete of keys, selection highlight,
fade wedge drawn on clip blocks, panel grows to content height (was fixed
200px → clipped the Cameras row once params rows were added).
- timeline_test.mjs extended: onLoad fires once, addClipDrop out=duration,
crossfade weights sum to 1. All green.
DECISIONS / BUG:
- **tlui reads tracks from the TIMELINE, not the stage entity.** Found mid-
verify: `load()` structuredClones the scene, so timeline.scene.entities and
stage entities are SEPARATE objects. Drawing from stage meant clipdrop/
addKey (which mutate the timeline copy) never appeared. Repointed all draw
methods to `_te(id) = tl.scene.entities.find(id)`. Rows now built from the
timeline list merged with any stage-only (dock-added, not-yet-keyframed)
entities. Per PLAN the timeline owns tracks — this is the correct source.
- Grow-to-content height, no scroll yet (ponytail: add max-height+scroll only
if a scene ever has enough entities to dominate the viewport).
BLOCKED/REQUESTS:
- Gap for the orchestrator/Lane A to note: there's no bridge for a
dock-added stage entity to enter `timeline.scene.entities`, so a freshly
dropped character can't be keyframed until a scene load. tlui shows it as a
row (empty tracks) but mutators need the entity present. If Lane A's dock
should register new entities with the timeline, we need a
`timeline.addEntity(desc)` (or the dock calls `stage.captureState()`
`timeline.load`). Flagging for SYNC 2 — not building it uninvited.
- Verified with a throwaway harness (`_check.html`) served on :8099; deleted,
NOT committed (orchestrator removed the M1 tldev.html deliberately).
NEXT: Hold at M2. Remaining M2 polish if a session 3 is granted: undo already
wired to Ctrl+Z; could add snapping increment config + keyable-param add UI
(currently params keys come from Lane A capture/inspector). Await SYNC 2.

View File

@ -35,3 +35,32 @@ BLOCKED/REQUESTS: none.
NEXT: M2 (when unlocked): scene-validation hardening + asset thumbnails
endpoint. M3 render pipeline (ffmpeg encode + web/render.js) is designed in
C-server.md §C5, endpoints already stubbed — implement at SYNC-2/M3.
## 2026-07-18 session 2
DONE: M2 thumbnails + M3 render pipeline (orchestrator green-lit both). Commit b627a0f.
- `/render/begin` (uuid dir + meta.json, 503 if no ffmpeg), `/frame/{n}`
(PNG->frames/{n:06d}.png, 400 on empty), `/end` (spawns bg ffmpeg thread:
libx264 crf18 yuv420p, 400 if no frames), `/status` (queued|encoding|
done|error+log tail; on-disk out.mp4 fallback after restart), `/out.mp4`.
- Boot-time `reap_renders()` deletes render dirs >48h.
- `/assets/thumb?path=` serves same-stem image sidecar (404 if none).
- `web/render.js`: draftRecord (MediaRecorder webm), finalRender
(timeline.step -> renderActiveCamera -> toBlob -> POST, <=4 in flight,
resize+restore renderer), download() helper. node --check clean.
- `test_server.py`: added render group (ffmpeg lavfi -> 30 PNGs -> begin ->
post -> end -> poll -> ffprobe duration ~1s) + thumbnail asserts.
`OK: 6 test groups passed`.
DECISIONS:
- Render state in-memory (single-process local tool); status() infers from
disk after a restart. ponytail-commented.
- Thumbnails pass-through, no resize — Pillow would be a new dep (stack rule
says log a REQUEST first); not worth it until dock load is measurably slow.
- finalRender total frames = round(duration*fps); relies on timeline.step
being deterministic (orchestrator verified).
BLOCKED/REQUESTS:
- finalRender's per-frame path depends on Lane A `stage.renderActiveCamera`
as a real render target (their M2). Server + ffmpeg path fully tested with
synthetic frames; the browser client path is unexercised until A lands it.
NEXT: M4 is locked. When unlocked: ffmpeg audio mux (scene audio[]),
/mb/submit MODELBEAST proxy, scripts/graft_limb.py. Meanwhile available to
integration-test finalRender once Lane A's renderActiveCamera lands.

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

View File

@ -11,12 +11,14 @@ import json
import os
import re
import shutil
import subprocess
import threading
import time
import uuid
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
ROOT = Path(__file__).resolve().parent.parent
WEB = Path(__file__).resolve().parent / "web" # scenegod/web (PLAN §2)
@ -41,6 +43,19 @@ SCENES.mkdir(parents=True, exist_ok=True)
RENDERS.mkdir(parents=True, exist_ok=True)
HAVE_FFMPEG = shutil.which("ffmpeg") is not None
def reap_renders(max_age_s=48 * 3600):
"""Delete render dirs older than max_age_s. Called at boot (C5)."""
now = time.time()
for d in RENDERS.iterdir() if RENDERS.is_dir() else []:
try:
if d.is_dir() and now - d.stat().st_mtime > max_age_s:
shutil.rmtree(d, ignore_errors=True)
except OSError:
pass
reap_renders()
app = FastAPI(title="SCENEGOD")
@ -108,6 +123,20 @@ def assets_file(path: str):
return FileResponse(full, media_type=MEDIA_TYPES.get(ext, "application/octet-stream"))
@app.get("/assets/thumb")
def assets_thumb(path: str):
"""Sidecar thumbnail for an asset path (same dir+stem, any image ext), 404
if none. Serves the file as-is no resize (would need a Pillow dep).
ponytail: pass-through; add downscaling if dock load is measurably slow."""
full = safe_under(ASSETS, path)
for ext in IMG_EXTS:
thumb = full.with_suffix("." + ext)
if thumb.is_file():
return FileResponse(thumb, media_type=MEDIA_TYPES.get(ext),
headers={"Cache-Control": "max-age=3600"})
raise HTTPException(404, "no thumbnail")
# ---- scenes ----
SLUG = re.compile(r"[^a-z0-9_-]")
@ -190,39 +219,90 @@ async def scene_save(name: str, request: Request):
return {"ok": True, "name": p.stem}
# ---- render (M3 — design landed, encode stubbed) ----
# ---- render (C5) ----
# ponytail: in-memory state, single-process local tool. A server restart
# mid-encode loses live status; status() falls back to on-disk out.mp4.
_renders: dict[str, dict] = {} # rid -> {state: queued|encoding|done|error, log: str}
def _render_dir(rid: str) -> Path:
return safe_under(RENDERS, rid) # rid is a hex uuid; guard anyway
def _encode(rid: str, fps: int):
d = _render_dir(rid)
_renders[rid] = {"state": "encoding", "log": ""}
cmd = ["ffmpeg", "-y", "-framerate", str(fps), "-i", str(d / "frames" / "%06d.png"),
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", str(d / "out.mp4")]
try:
p = subprocess.run(cmd, capture_output=True, text=True)
ok = p.returncode == 0 and (d / "out.mp4").is_file()
_renders[rid] = {"state": "done" if ok else "error", "log": p.stderr[-2000:]}
except Exception as e: # noqa: BLE001 — surface any spawn failure as error state
_renders[rid] = {"state": "error", "log": str(e)}
@app.post("/render/begin")
async def render_begin(request: Request):
if not HAVE_FFMPEG:
raise HTTPException(503, "ffmpeg not found on server")
rid = uuid.uuid4().hex
(RENDERS / rid / "frames").mkdir(parents=True)
(RENDERS / rid / "meta.json").write_text((await request.body()).decode() or "{}")
try:
meta = json.loads(await request.body() or b"{}")
except json.JSONDecodeError:
meta = {}
(RENDERS / rid / "meta.json").write_text(json.dumps(meta))
_renders[rid] = {"state": "queued", "log": ""}
return {"renderId": rid}
@app.post("/render/{rid}/frame/{n}")
async def render_frame(rid: str, n: int, request: Request):
frames = safe_under(RENDERS, rid) / "frames"
frames = _render_dir(rid) / "frames"
if not frames.is_dir():
raise HTTPException(404, "unknown renderId")
(frames / f"{n:06d}.png").write_bytes(await request.body())
body = await request.body()
if not body:
raise HTTPException(400, "empty frame body")
(frames / f"{n:06d}.png").write_bytes(body)
return {"ok": True}
@app.post("/render/{rid}/end")
async def render_end(rid: str):
raise HTTPException(501, "encode not implemented until M3")
d = _render_dir(rid)
if not (d / "frames").is_dir():
raise HTTPException(404, "unknown renderId")
if not any((d / "frames").glob("*.png")):
raise HTTPException(400, "no frames posted")
try:
fps = int(json.loads((d / "meta.json").read_text()).get("fps", 30))
except (OSError, json.JSONDecodeError):
fps = 30
threading.Thread(target=_encode, args=(rid, fps), daemon=True).start()
return {"ok": True}
@app.get("/render/{rid}/status")
def render_status(rid: str):
raise HTTPException(501, "not implemented until M3")
st = _renders.get(rid)
if st:
return st
d = _render_dir(rid) # fell out of memory (restart) — infer from disk
if (d / "out.mp4").is_file():
return {"state": "done", "log": ""}
if d.is_dir():
return {"state": "queued", "log": ""}
raise HTTPException(404, "unknown renderId")
@app.get("/render/{rid}/out.mp4")
def render_out(rid: str):
raise HTTPException(501, "not implemented until M3")
out = _render_dir(rid) / "out.mp4"
if not out.is_file():
raise HTTPException(404, "not ready")
return FileResponse(out, media_type="video/mp4")
# ---- statics (last: catch-all /web is defined after API routes) ----

View File

@ -14,6 +14,7 @@ export class Dock {
stage.onChange(en => { if(en && en.id === this._selId) this.renderInspector(en); });
this._selId = null;
this._buildTabs();
this._viewDrop();
this._dropZone();
this.load();
}
@ -31,6 +32,14 @@ export class Dock {
}
_buildTabs(){
const add = document.createElement('div'); add.className = 'addbar';
for(const [txt, what] of [['+ camera','camera'], ['+ key light','keylight'], ['+ ambient','ambient']]){
const b = document.createElement('button'); b.textContent = txt;
b.onclick = () => this._addRig(what).catch(e => this._toast('failed: ' + String(e).slice(0,80)));
add.appendChild(b);
}
this.dock.appendChild(add);
const bar = document.createElement('div'); bar.className = 'tabs';
for(const t of TABS){
const b = document.createElement('button'); b.textContent = t.toUpperCase();
@ -43,11 +52,35 @@ export class Dock {
this.dock.appendChild(this.list);
}
// tolerate string paths or {path,name,files:[{path,ext}]} until Lane C nails the shape (logged REQUEST)
_path(it){ return typeof it === 'string' ? it
: (it.path || (it.files && it.files[0] && it.files[0].path) || it.file || ''); }
_name(it){ return typeof it === 'string' ? it.split('/').pop()
: (it.name || this._path(it).split('/').pop()); }
// drag a dock card onto the viewport → place at the drop point (chars/props) or bake onto the
// character under the cursor (animations). Library items only; local files use the window drop.
_viewDrop(){
this.view.addEventListener('dragover', e => {
if([...e.dataTransfer.types].includes('application/scenegod')) e.preventDefault(); });
this.view.addEventListener('drop', async e => {
const raw = e.dataTransfer.getData('application/scenegod');
if(!raw) return; // a local file drop — let the window handler take it
e.preventDefault(); e.stopPropagation();
const { tab, path, name } = JSON.parse(raw);
try {
if(tab === 'animations'){
const id = this.stage.entityAt(e.clientX, e.clientY, 'character');
if(!id) return this._toast('drop the clip onto a character');
return this._applyClip(id, path, 0);
}
const kind = tab === 'characters' ? 'character' : tab === 'props' ? 'prop' : 'backdrop';
const desc = { kind, label: name, source:{ type:'assets', path } };
if(kind === 'backdrop') desc.params = { mode:'plane', image:path, width:10 };
else desc.transform = { pos: this.stage.screenToFloor(e.clientX, e.clientY), rot:[0,0,0], scale:1 };
const en = await this.stage.addEntity(desc);
this.stage.select(en.id);
} catch(err){ this._toast('drop failed: ' + String(err).slice(0,90)); }
});
}
// /assets/tree item shape (confirmed by Lane C): {name, path, formats:[...], thumb: relpath|null}
_path(it){ return it.path; }
_name(it){ return it.name || (it.path || '').split('/').pop(); }
renderBrowser(){
this.list.innerHTML = '';
@ -60,9 +93,14 @@ export class Dock {
}
const icon = { characters:'🕺', props:'🪑', backdrops:'🖼', animations:'🏃' }[this.tab];
for(const it of items){
const c = document.createElement('div'); c.className = 'card';
c.innerHTML = `<div class="th">${icon}</div><div class="nm">${this._name(it)}</div>`;
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)}">`
: `<div class="th">${icon}</div>`;
c.innerHTML = `${th}<div class="nm">${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) }));
this.list.appendChild(c);
}
}
@ -73,11 +111,7 @@ export class Dock {
if(this.tab === 'animations'){
const en = this.stage.getEntity(this._selId);
if(!en || en.kind !== 'character') return this._toast('select a character first');
this._toast('baking clip…');
const clip = await this.stage.prepareClip(en.id, path, 0);
this.stage.playClip(en.id, clip);
this._toast('▶ ' + this._name(it));
return;
return this._applyClip(en.id, path, 0);
}
const kind = this.tab === 'characters' ? 'character' : this.tab === 'props' ? 'prop' : 'backdrop';
const desc = { kind, label: this._name(it), source: { type:'assets', path } };
@ -89,6 +123,31 @@ export class Dock {
} catch(e){ this._toast('failed: ' + String(e).slice(0, 100)); }
}
// clips go to the timeline once it's mounted; standalone preview only when it isn't
async _applyClip(id, path, clipIndex){
if(window.timeline){
dispatchEvent(new CustomEvent('scenegod:clipdrop', { detail:{ id, path, clipIndex } }));
this._toast('+ ' + path.split('/').pop() + ' → timeline');
return;
}
this._toast('baking clip…');
const clip = await this.stage.prepareClip(id, path, clipIndex);
this.stage.playClip(id, clip);
this._toast('▶ ' + path.split('/').pop());
}
// cameras and lights have no asset file — spawn them from the toolbar
async _addRig(what){
const D = {
camera: { kind:'camera', label:'camera', params:{ fov:45 }, transform:{ pos:[0,1.6,4], rot:[0,0,0], scale:1 } },
keylight: { kind:'light', label:'key light', params:{ type:'key', color:'#ffffff', intensity:2.2, castShadow:true }, transform:{ pos:[3,5,2], rot:[-0.9,0.5,0], scale:1 } },
ambient: { kind:'light', label:'ambient', params:{ type:'ambient', color:'#ffffff', intensity:1.0 } },
}[what];
const en = await this.stage.addEntity(D);
this.stage.select(en.id);
if(en.kind === 'camera') this.stage.setActiveCamera(en.id); // preview through the new cam
}
// ---- inspector ----
renderInspector(en){
this._selId = en ? en.id : null;
@ -104,15 +163,19 @@ export class Dock {
el.appendChild(this._vec3('rot', t.rot, v => this.stage.setTransform(en.id, { rot:v })));
el.appendChild(this._num('scale', t.scale, v => this.stage.setTransform(en.id, { scale:v })));
if(en.kind === 'camera')
if(en.kind === 'camera'){
el.appendChild(this._num('fov', en.params.fov ?? 45, v => this.stage.setParam(en.id, 'fov', v)));
const act = document.createElement('button'); act.textContent = '◉ set active camera'; act.className = 'wide';
act.onclick = () => this.stage.setActiveCamera(en.id);
el.appendChild(act);
}
if(en.kind === 'light'){
el.appendChild(this._num('intensity', en.params.intensity ?? 1.5, v => this.stage.setParam(en.id, 'intensity', v)));
el.appendChild(this._color('color', en.params.color || '#ffffff', v => this.stage.setParam(en.id, 'color', v)));
}
if(en.kind === 'backdrop')
el.appendChild(this._sel('mode', ['plane','corner','dome'], en.params.mode || 'plane',
v => this.stage.setParam(en.id, 'mode', v))); // rebuild is M2
v => this.stage.setParam(en.id, 'mode', v))); // stage rebuilds the mesh on change
const row = document.createElement('div'); row.className = 'ibtns';
const key = document.createElement('button'); key.textContent = '⏺ key'; key.title = 'capture keyframe';
@ -158,9 +221,13 @@ export class Dock {
const sel = this.stage.getEntity(this._selId);
const probe = await this._probe(buf, f.name);
if(sel && sel.kind === 'character' && probe.anims && !probe.meshes){
this._toast('baking ' + f.name + '…');
const clip = await this.stage.prepareClipUpload(sel.id, buf, f.name, 0);
this.stage.playClip(sel.id, clip); this._toast('▶ ' + f.name);
// uploaded clip has no server path → the timeline can't place a block for it
if(window.timeline){ this._toast('add clips from the dock, not local files, once the timeline is up'); }
else {
this._toast('baking ' + f.name + '…');
const clip = await this.stage.prepareClipUpload(sel.id, buf, f.name, 0);
this.stage.playClip(sel.id, clip); this._toast('▶ ' + f.name);
}
} else {
const en = await this.stage.addEntity({ kind:'character', label:f.name,
source:{ type:'upload', path:f.name }, _buf:buf });

79
scenegod/web/render.js Normal file
View File

@ -0,0 +1,79 @@
// render.js — draft + final video capture (Lane C, PLAN §4.3 / C-server §C5).
// Talks to Stage/Timeline through their public APIs only. No three.js import.
//
// draftRecord(stage, timeline) -> Promise<Blob> quick webm via MediaRecorder
// finalRender(stage, timeline, opts) -> Promise<url> deterministic PNG->ffmpeg mp4
const sleep = ms => new Promise(r => setTimeout(r, ms));
// --- draft: record the live canvas while the timeline plays, once through ----
export function draftRecord(stage, timeline, { fps = 30, mimeType = 'video/webm' } = {}) {
const stream = stage.renderer.domElement.captureStream(fps);
const rec = new MediaRecorder(stream, MediaRecorder.isTypeSupported(mimeType) ? { mimeType } : {});
const chunks = [];
rec.ondataavailable = e => e.data.size && chunks.push(e.data);
return new Promise((resolve, reject) => {
rec.onstop = () => resolve(new Blob(chunks, { type: rec.mimeType || 'video/webm' }));
rec.onerror = reject;
rec.start();
timeline.seek(0);
timeline.play();
const stop = timeline.onTick(t => {
if (t >= timeline.duration) { timeline.pause(); rec.stop(); if (typeof stop === 'function') stop(); }
});
});
}
// --- final: step frame-by-frame (deterministic), upload PNGs, ffmpeg encode ---
export async function finalRender(stage, timeline, opts = {}) {
const { fps = 30, width = 1920, height = 1080, name = 'scene', onProgress } = opts;
const canvas = stage.renderer.domElement;
const prev = { w: canvas.width, h: canvas.height, dpr: stage.renderer.getPixelRatio() };
stage.renderer.setPixelRatio(1);
stage.renderer.setSize(width, height, false); // false = don't touch CSS size
try {
const total = Math.max(1, Math.round(timeline.duration * fps));
const { renderId } = await postJSON('/render/begin', { name, fps, width, height });
const inflight = new Set();
for (let f = 0; f < total; f++) {
timeline.step(f); // seek(f/fps)+evaluate — never wall-clock
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 })
.then(r => { if (!r.ok) throw new Error(`frame ${f}: ${r.status}`); })
.finally(() => inflight.delete(p));
inflight.add(p);
onProgress?.(f + 1, total);
}
await Promise.all(inflight);
await postJSON(`/render/${renderId}/end`, {});
let st;
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`;
} finally {
stage.renderer.setPixelRatio(prev.dpr);
stage.renderer.setSize(prev.w, prev.h, false);
}
}
// trigger a browser download of a blob or url (draft webm / final mp4)
export function download(blobOrUrl, filename) {
const a = document.createElement('a');
a.href = typeof blobOrUrl === 'string' ? blobOrUrl : URL.createObjectURL(blobOrUrl);
a.download = filename;
a.click();
if (typeof blobOrUrl !== 'string') setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
async function postJSON(url, body) {
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body) });
if (!r.ok) throw new Error(`${url}: ${r.status}`);
return r.json();
}

View File

@ -24,6 +24,12 @@ export class Stage {
r.setPixelRatio(devicePixelRatio);
r.shadowMap.enabled = true;
view.appendChild(r.domElement);
this._paused = false; // M3 final render freezes the director loop, then drives renderActiveCamera
// PiP overlay — the active camera's view, shown top-right when a camera is active
const pip = this._pip = document.createElement('canvas');
pip.width = 256; pip.height = 144; pip.className = 'pip'; pip.style.display = 'none';
view.appendChild(pip);
const scene = this.scene = new THREE.Scene();
const cam = this._dirCam = new THREE.PerspectiveCamera(45, 1, 0.01, 3000);
@ -45,7 +51,7 @@ export class Stage {
// gizmo
const tc = this._tc = new TransformControls(cam, r.domElement);
tc.addEventListener('dragging-changed', e => { ctr.enabled = !e.value; });
tc.addEventListener('mouseUp', () => { if(this._selected) this._fireChange(this._selected); });
tc.addEventListener('mouseUp', () => { if(this._selected) this._fireChange(this.getEntity(this._selected)); });
scene.add(tc);
addEventListener('keydown', e => {
if(!this._selected) return;
@ -58,9 +64,12 @@ export class Stage {
this.clock = new THREE.Clock();
this._resize(); addEventListener('resize', () => this._resize());
const loop = () => { requestAnimationFrame(loop);
ctr.update();
const dt = this.clock.getDelta();
if(this._paused) return; // M3: caller drives rendering deterministically
ctr.update();
if(this._mixerAuto) for(const en of this._entities.values()) en.mixer && en.mixer.update(dt);
// PiP first (it scribbles the active cam onto the main canvas), then the director view last
if(this._activeCamId && pip.style.display !== 'none') this.renderActiveCamera(pip);
r.render(scene, this._dirCam);
};
loop();
@ -109,6 +118,7 @@ export class Stage {
this.scene.add(wrapper);
this._entities.set(id, en);
if(desc.transform) this.setTransform(id, desc.transform);
this._fireChange(en); // tlui/dock build a row per entity off onChange (incl. applyState loads)
return en;
}
@ -121,6 +131,7 @@ export class Stage {
this._entities.delete(id);
for(const k of [...this._baked.keys()]) if(k.startsWith(id+'|')) this._baked.delete(k);
if(en.kind === 'light') this._refreshDefaults();
this._fireChange(en); // trigger a row resync so the deleted entity drops out
}
async _loadAsset(desc){
@ -250,8 +261,16 @@ export class Stage {
if(en.light){
if(key === 'intensity') en.light.intensity = value;
if(key === 'color') en.light.color.set(value);
if(key === 'castShadow' && en.light.isDirectionalLight) en.light.castShadow = !!value;
}
// backdrop mode/image/width changes rebuild lazily — M2; ponytail: rebuild-on-change, add when keyable.
if(en.kind === 'backdrop' && (key === 'mode' || key === 'image' || key === 'width'))
this._rebuildBackdrop(en); // geometry depends on these — rebuild the mesh
}
async _rebuildBackdrop(en){
const old = en.root;
en.root = await this._buildBackdrop(en);
en.wrapper.add(en.root);
if(old){ en.wrapper.remove(old); disposeRoot(old); }
}
// ---- clips (bake is expensive — cache per path parse and per (entity,path,index) bake) ----
@ -297,16 +316,43 @@ export class Stage {
setMixerAuto(on){ this._mixerAuto = on; } // Timeline calls setMixerAuto(false) at integration
// ---- cameras ----
setActiveCamera(id){ this._activeCamId = id; }
setActiveCamera(id){
this._activeCamId = id;
this._pip.style.display = (id && this._entities.get(id)?.cam) ? 'block' : 'none';
}
_activeCam(){ const en = this._activeCamId && this._entities.get(this._activeCamId);
return en && en.cam ? en.cam : this._dirCam; }
// PiP: pass the small overlay canvas. Final render (M3): pass null after pause() — the active cam
// lands on the main WebGL canvas at its current size for readPixels/toBlob. Hard contract, keep it.
renderActiveCamera(canvas){
const cam = this._activeCam();
if(cam.isPerspectiveCamera && canvas){ cam.aspect = canvas.width/canvas.height; cam.updateProjectionMatrix(); }
const aspect = canvas ? canvas.width/canvas.height
: this.renderer.domElement.width/this.renderer.domElement.height;
if(cam.isPerspectiveCamera){ cam.aspect = aspect; cam.updateProjectionMatrix(); }
this.renderer.render(this.scene, cam);
if(canvas){ const ctx = canvas.getContext('2d');
ctx.drawImage(this.renderer.domElement, 0, 0, canvas.width, canvas.height); }
}
pause(){ this._paused = true; }
resume(){ this._paused = false; this.clock.getDelta(); } // swallow the paused gap so mixers don't jump
// ---- pointer→world helpers (drag-from-dock placement) ----
screenToFloor(clientX, clientY){
const rect = this.renderer.domElement.getBoundingClientRect();
const ndc = new THREE.Vector2(((clientX-rect.left)/rect.width)*2-1, -((clientY-rect.top)/rect.height)*2+1);
const rc = new THREE.Raycaster(); rc.setFromCamera(ndc, this._dirCam);
const hit = new THREE.Vector3();
return rc.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0,1,0), 0), hit) ? [hit.x, 0, hit.z] : [0,0,0];
}
entityAt(clientX, clientY, kind){
const rect = this.renderer.domElement.getBoundingClientRect();
const ndc = new THREE.Vector2(((clientX-rect.left)/rect.width)*2-1, -((clientY-rect.top)/rect.height)*2+1);
const rc = new THREE.Raycaster(); rc.setFromCamera(ndc, this._dirCam);
const hits = rc.intersectObjects(this.entities().map(e=>e.wrapper), true);
for(const h of hits){ let o=h.object; while(o && o.userData.entityId==null) o=o.parent;
if(o){ const en=this._entities.get(o.userData.entityId); if(en && (!kind || en.kind===kind)) return en.id; } }
return null;
}
// ---- scene JSON round-trip (entities only; Timeline owns tracks/duration/cuts) ----
captureState(){

View File

@ -56,6 +56,9 @@ header .tag{font-family:var(--mono);font-size:10px;color:var(--teal);letter-spac
padding:8px;font-family:var(--mono);font-size:12px;cursor:pointer}
.ibtns button:hover{border-color:var(--teal)}
.ibtns button.danger:hover{border-color:var(--bad);color:var(--bad)}
button.wide{width:100%;margin:8px 0 0;background:var(--panel);border:1px solid var(--line2);color:var(--ink);
border-radius:7px;padding:8px;font-family:var(--mono);font-size:11px;cursor:pointer}
button.wide:hover{border-color:var(--teal)}
/* drop + toast */
body.hot::after{content:'drop to load';position:fixed;inset:0;display:flex;align-items:center;justify-content:center;
@ -63,5 +66,17 @@ body.hot::after{content:'drop to load';position:fixed;inset:0;display:flex;align
#toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--panel);
border:1px solid var(--teal);border-radius:9px;padding:9px 16px;font-size:13px;display:none;z-index:50;max-width:80vw}
/* PiP — active camera preview overlay */
.pip{position:absolute;top:12px;right:12px;width:256px;height:144px;border:1px solid var(--teal);
border-radius:6px;background:#000;box-shadow:0 4px 18px rgba(0,0,0,.55);z-index:5}
.pip::after{content:'CAM'}
/* dock "add rig" toolbar (cameras + lights have no asset file) */
.addbar{display:flex;gap:6px;margin-bottom:10px}
.addbar button{flex:1;background:var(--panel);border:1px solid var(--line2);color:var(--ink);border-radius:7px;
padding:6px 4px;font-family:var(--mono);font-size:10.5px;cursor:pointer}
.addbar button:hover{border-color:var(--teal)}
.card[draggable]{cursor:grab}
/* === LANE B === */
/* Lane B appends timeline styling below this marker */

View File

@ -81,6 +81,7 @@ export class Timeline {
this.time = 0;
this.playing = false;
this._tickCbs = [];
this._loadCbs = [];
this._raf = null;
this._last = 0;
this._qcache = new WeakMap(); // key obj -> cached quat (keeps keys pure for round-trip)
@ -93,6 +94,10 @@ export class Timeline {
const id = e.detail && e.detail.id;
if (id) this.addKey(id, 'transform', { t: this.time, ...this.stage.entityTransform(id), ease: 'inout' });
});
// Lane A dock drops a clip on a character → add a block at the playhead.
window.addEventListener('scenegod:clipdrop', (e) => {
if (e.detail && e.detail.id) this.addClipDrop(e.detail);
});
}
}
@ -113,10 +118,31 @@ export class Timeline {
this._clipActions.clear();
this._activeCam = undefined;
this.seek(0);
for (const cb of this._loadCbs) cb(this.scene); // UI: rebuild rows + refresh scene bar (SYNC1 #3)
return this;
}
onLoad(cb) { this._loadCbs.push(cb); }
toJSON() { return structuredClone(this.scene); }
// Lane A clip-drop: fetch the clip (for its duration), drop a block at the
// playhead, add a default crossfade where it abuts a neighbour, preload.
async addClipDrop({ id, path, clipIndex = 0 }) {
const clip = await this.stage.prepareClip(id, path, clipIndex);
const dur = (clip && clip.duration) || 1;
this.addClipBlock(id, { path, clipIndex, start: this.time, in: 0, out: dur, loop: 1, fade: 0 });
this._setAbutFades(id);
await this.preload();
this.seek(this.time); // re-evaluate + redraw
}
_setAbutFades(id) { // 0.25s fade on any block that abuts its successor
const blocks = (this._tracks(id).clips) || [];
for (let i = 0; i < blocks.length - 1; i++) {
const b = blocks[i], n = blocks[i + 1];
const end = b.start + ((b.out - b.in) || 0) * (b.loop || 1);
if (Math.abs(end - n.start) < 1e-3 && !b.fade) b.fade = 0.25;
}
}
_sortTracks(e) {
const tr = e.tracks;
if (!tr) return;
@ -226,18 +252,22 @@ export class Timeline {
const rec = this._clipActions.get(b);
if (!rec) continue; // not preloaded yet → silent
const span = (b.out - b.in) || 0.0001;
const loops = b.loop || 1;
const blockEnd = b.start + span * loops;
const blockEnd = b.start + span * (b.loop || 1);
// pre-roll: a predecessor's fade pulls THIS block's playback start earlier
// by pred.fade so it advances during the crossfade (continuous local time).
const pred = blocks[i - 1];
const preroll = (pred && pred.fade > 0) ? pred.fade : 0;
const inStart = b.start - preroll;
let w = 0, local = b.in;
if (t >= b.start && t < blockEnd) {
local = b.in + ((t - b.start) % span);
if (t >= inStart && t < blockEnd) {
local = b.in + ((t - inStart) % span); // continuous across the boundary
w = 1;
// ponytail: fade-OUT ramp only for M1; cross-block fade-IN is M2 "crossfade polish".
const succ = blocks[i + 1];
if (succ && b.fade > 0 && t >= blockEnd - b.fade) w = Math.max(0, (blockEnd - t) / b.fade);
if (t < b.start) w = (t - inStart) / preroll; // fade-IN 0→1 over the pred's fade window
const succ = blocks[i + 1]; // fade-OUT 1→0 over own fade window
if (succ && b.fade > 0 && t >= blockEnd - b.fade) w = Math.min(w, (blockEnd - t) / b.fade);
}
rec.action.time = local;
rec.action.weight = w;
rec.action.weight = Math.max(0, w);
rec.action.enabled = w > 0;
}
mixer.update(0); // set-time style — exact when scrubbing

View File

@ -99,4 +99,42 @@ const tl3 = new Timeline(new StageStub());
tl3.load(withExtra);
assert.deepEqual(tl3.toJSON().entities[0].laneAOnly, { gizmo: 'move', foo: [1, 2, 3] }, 'unknown fields must survive');
// ---- M2: onLoad fires ----
let loadFired = 0;
const tlL = new Timeline(new StageStub());
tlL.onLoad(() => loadFired++);
tlL.load(scene);
assert.equal(loadFired, 1, 'onLoad must fire once per load()');
// ---- M2: addClipDrop sets out = clip duration, drops block at playhead ----
const dropStage = new StageStub();
await dropStage.addEntity({ id: 'c1', kind: 'character', label: 'c', tracks: {} });
const tlD = new Timeline(dropStage);
tlD.load({ version: 1, fps: 30, duration: 10, entities: [{ id: 'c1', kind: 'character', tracks: {} }], cameraCuts: [] });
tlD.seek(2);
await tlD.addClipDrop({ id: 'c1', path: 'animations/run.fbx', clipIndex: 0 });
const blk = tlD.scene.entities[0].tracks.clips[0];
assert.ok(blk && near(blk.start, 2) && near(blk.out, 2.4), `clipdrop block start=2 out=2.4, got ${blk && blk.start}/${blk && blk.out}`);
// ---- M2: crossfade — two abutting blocks fade through ~0.5 at the boundary ----
const xStage = new StageStub();
await xStage.addEntity({ id: 'c2', kind: 'character', tracks: {} });
const tlX = new Timeline(xStage);
tlX.load({
version: 1, fps: 30, duration: 10, cameraCuts: [],
entities: [{ id: 'c2', kind: 'character', tracks: { clips: [
{ path: 'a.fbx', clipIndex: 0, start: 0, in: 0, out: 1.0, loop: 1, fade: 0.4 },
{ path: 'b.fbx', clipIndex: 0, start: 1.0, in: 0, out: 1.0, loop: 1, fade: 0 },
] } }],
});
await tlX.preload();
xStage.applied.length = 0;
tlX.step(24); // t=0.8 → mid of block-A's [0.6,1.0] fade
const lastIn = (arr, pred) => { for (let i = arr.length - 1; i >= 0; i--) if (pred(arr[i])) return arr[i]; return null; };
const wa = lastIn(xStage.applied, (a) => a.type === 'clip' && a.clip === 'a.fbx#0');
const wb = lastIn(xStage.applied, (a) => a.type === 'clip' && a.clip === 'b.fbx#0');
assert.ok(wa && near(wa.weight, 0.5, 1e-6), `A fade-out weight ~0.5, got ${wa && wa.weight}`);
assert.ok(wb && near(wb.weight, 0.5, 1e-6), `B fade-in weight ~0.5, got ${wb && wb.weight}`);
assert.ok(near(wa.weight + wb.weight, 1, 1e-6), 'crossfade weights sum to 1');
console.log('OK — timeline_test.mjs: all assertions passed');

View File

@ -3,31 +3,31 @@
//
// Layout: [scene bar] / [names column | ruler+canvas lanes]. One <canvas> for
// all lanes (decision: simpler than canvas-per-lane; hit-testing via a rebuilt
// hitbox list each draw). Fit-to-width time axis; zoom/pan is M2.
// hitbox list each draw). Fit-to-width time axis; zoom/pan is a later problem.
const ROW_H = 28;
const RULER_H = 22;
const KEY_R = 5;
// ponytail: styles injected here, not written into Lane A's web/style.css —
// keeps Lane B inside its own files. Move into a /* === LANE B === */ block at
// SYNC if John wants them centralized.
// keeps Lane B inside its own files (orchestrator confirmed this is permanent).
const CSS = `
#timeline{display:flex;flex-direction:column;background:#161a20;color:#c9d1d9;font:12px/1.4 ui-monospace,Menlo,monospace;border-top:1px solid #2b323c;user-select:none}
#timeline .tl-bar{display:flex;gap:8px;align-items:center;padding:6px 8px;border-bottom:1px solid #2b323c}
#timeline .tl-bar input{background:#0d1117;border:1px solid #2b323c;color:#c9d1d9;padding:2px 6px;border-radius:3px;font:inherit}
#timeline .tl-bar input.name{width:150px}
#timeline .tl-bar input.num{width:56px}
#timeline .tl-bar label{display:flex;align-items:center;gap:3px;color:#8b949e;cursor:pointer}
#timeline .tl-bar button{background:#21262d;border:1px solid #2b323c;color:#c9d1d9;padding:3px 10px;border-radius:3px;cursor:pointer;font:inherit}
#timeline .tl-bar button:hover{background:#2b323c}
#timeline .tl-bar .spring{flex:1}
#timeline .tl-bar .clock{color:#7aa2f7;min-width:64px;text-align:right}
#timeline .tl-body{display:flex;height:200px}
#timeline .tl-body{display:flex}
#timeline .tl-names{width:150px;flex:none;overflow:hidden;border-right:1px solid #2b323c;background:#12161c}
#timeline .tl-names .row{height:${ROW_H}px;display:flex;align-items:center;padding:0 8px;box-sizing:border-box;border-bottom:1px solid #1b2027;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#timeline .tl-names .row.head{margin-top:${RULER_H}px}
#timeline .tl-names .row .k{color:#565f6b;margin-right:6px;font-size:10px}
#timeline .tl-names .row.clip{color:#8b949e;padding-left:20px;font-size:11px}
#timeline .tl-names .row.sub{color:#8b949e;padding-left:20px;font-size:11px}
#timeline .tl-lanes{flex:1;position:relative;overflow:hidden}
#timeline canvas{display:block;width:100%;height:100%}
`;
@ -39,14 +39,22 @@ export class TimelineUI {
this.el = typeof mountEl === 'string' ? document.querySelector(mountEl) : mountEl;
this._hits = [];
this._drag = null;
this._selKeys = []; // box-selected keys: [{id, track, key}]
this._snapOn = true;
this._injectCSS();
this._build();
this.tl.onTick(() => this.draw());
this.tl.onLoad(() => { this._syncRows(); this._refreshBar(); }); // SYNC1 #3a/#3b
if (this.stage.onChange) this.stage.onChange(() => this._syncRows());
this._syncRows();
this.draw();
}
_refreshBar() {
if (this.$name) this.$name.value = this.tl.scene.name || '';
if (this.$dur) this.$dur.value = this.tl.duration;
}
_injectCSS() {
if (document.getElementById('laneB-tl-css')) return;
const s = document.createElement('style');
@ -57,7 +65,6 @@ export class TimelineUI {
_build() {
this.el.innerHTML = '';
// scene bar
const bar = document.createElement('div');
bar.className = 'tl-bar';
bar.innerHTML = `
@ -65,6 +72,7 @@ export class TimelineUI {
<span class="clock">0.00s</span>
<input class="name" placeholder="scene name" value="${this.tl.scene.name || ''}">
<span>dur</span><input class="num dur" type="number" min="0" step="0.5" value="${this.tl.duration}">
<label><input type="checkbox" class="snap" checked> snap</label>
<span class="spring"></span>
<button data-act="save">Save</button>
<button data-act="load">Load</button>`;
@ -73,13 +81,14 @@ export class TimelineUI {
this.$clock = bar.querySelector('.clock');
this.$name = bar.querySelector('.name');
this.$dur = bar.querySelector('.dur');
this.$snap = bar.querySelector('.snap');
this.$play.onclick = () => this._toggle();
bar.querySelector('[data-act="save"]').onclick = () => this.save();
bar.querySelector('[data-act="load"]').onclick = () => this.load(this.$name.value);
this.$dur.onchange = () => { this.tl.scene.duration = Math.max(0.1, +this.$dur.value || 1); this.draw(); };
this.$name.onchange = () => { this.tl.scene.name = this.$name.value; };
this.$snap.onchange = () => { this._snapOn = this.$snap.checked; };
// body
const body = document.createElement('div');
body.className = 'tl-body';
this.$names = document.createElement('div'); this.$names.className = 'tl-names';
@ -90,7 +99,6 @@ export class TimelineUI {
this.el.appendChild(body);
this.lanesEl = lanes;
// canvas events
this.canvas.addEventListener('mousedown', (e) => this._down(e));
window.addEventListener('mousemove', (e) => this._move(e));
window.addEventListener('mouseup', () => this._up());
@ -100,12 +108,18 @@ export class TimelineUI {
window.addEventListener('resize', () => this.draw());
}
// rows: [{type:'transform'|'clip'|'cameras', id?, label}]
// rows: [{type:'transform'|'params'|'clip'|'cameras', id?, label}].
// Built from the timeline's entities (authoritative for tracks) merged with
// any stage entities not yet keyframed (dock-added but not in a loaded scene).
_rows() {
const rows = [];
for (const e of this.stage.entities()) {
const list = [...this.tl.scene.entities];
for (const se of this.stage.entities()) if (!list.find((e) => e.id === se.id)) list.push(se);
for (const e of list) {
rows.push({ type: 'transform', id: e.id, kind: e.kind, label: e.label || e.id });
if (e.kind === 'character') rows.push({ type: 'clip', id: e.id, label: '↳ clips' });
const paramable = (e.tracks && e.tracks.params) || e.kind === 'camera' || e.kind === 'light';
if (paramable) rows.push({ type: 'params', id: e.id, label: '↳ params' });
}
rows.push({ type: 'cameras', label: 'Cameras' });
return rows;
@ -113,15 +127,21 @@ export class TimelineUI {
_syncRows() {
this.rows = this._rows();
// rebuild names column
this.$names.innerHTML = '';
this.rows.forEach((r, i) => {
const d = document.createElement('div');
d.className = 'row' + (i === 0 ? ' head' : '') + (r.type === 'clip' ? ' clip' : '');
const sub = r.type === 'clip' || r.type === 'params';
d.className = 'row' + (i === 0 ? ' head' : '') + (sub ? ' sub' : '');
if (r.type === 'transform') d.innerHTML = `<span class="k">${(r.kind || '?')[0].toUpperCase()}</span>${r.label}`;
else d.textContent = r.label;
this.$names.appendChild(d);
});
// size lanes to content so no row (esp. Cameras) is clipped as rows grow.
// ponytail: grow-to-content; add a max-height+scroll only if a scene ever
// has so many entities the panel dominates the viewport.
const hpx = RULER_H + this.rows.length * ROW_H;
this.$names.style.height = hpx + 'px';
this.lanesEl.style.height = hpx + 'px';
this.draw();
}
@ -149,12 +169,12 @@ export class TimelineUI {
// ruler
c.fillStyle = '#12161c'; c.fillRect(0, 0, w, RULER_H);
c.strokeStyle = '#2b323c'; c.fillStyle = '#565f6b'; c.font = '10px ui-monospace';
c.fillStyle = '#565f6b'; c.font = '10px ui-monospace';
const dur = this.tl.duration || 1;
const stepSec = dur <= 5 ? 0.5 : dur <= 20 ? 1 : 5;
for (let t = 0; t <= dur + 1e-6; t += stepSec) {
const x = this._t2x(t);
c.beginPath(); c.moveTo(x, 0); c.lineTo(x, h); c.strokeStyle = '#1b2027'; c.stroke();
c.strokeStyle = '#1b2027'; c.beginPath(); c.moveTo(x, 0); c.lineTo(x, h); c.stroke();
c.fillText(t.toFixed(stepSec < 1 ? 1 : 0) + 's', x + 2, 12);
}
@ -165,10 +185,20 @@ export class TimelineUI {
c.fillRect(0, y, w, ROW_H);
c.strokeStyle = '#1b2027'; c.beginPath(); c.moveTo(0, y + ROW_H); c.lineTo(w, y + ROW_H); c.stroke();
if (r.type === 'transform') this._drawKeys(c, r, y);
else if (r.type === 'params') this._drawParams(c, r, y);
else if (r.type === 'clip') this._drawClips(c, r, y);
else if (r.type === 'cameras') this._drawCuts(c, y);
});
// box-select overlay
const d = this._drag;
if (d && d.kind === 'box' && d.moved) {
c.fillStyle = 'rgba(122,162,247,0.15)'; c.strokeStyle = '#7aa2f7';
const x = Math.min(d.x0, d.x), y = Math.min(d.y0, d.y);
c.fillRect(x, y, Math.abs(d.x - d.x0), Math.abs(d.y - d.y0));
c.strokeRect(x + 0.5, y + 0.5, Math.abs(d.x - d.x0), Math.abs(d.y - d.y0));
}
// playhead
const px = this._t2x(this.tl.time);
c.strokeStyle = '#f7768e'; c.lineWidth = 1.5;
@ -179,30 +209,48 @@ export class TimelineUI {
this.$play.textContent = this.tl.playing ? '❚❚' : '▶';
}
_entity(id) { return this.stage.getEntity(id); }
_keysOf(id) { const e = this._entity(id); return (e && e.tracks && e.tracks.transform) || []; }
_entity(id) { return this.stage.getEntity(id); } // stage: live transform/existence
_te(id) { return this.tl.scene.entities.find((e) => e.id === id); } // timeline: authoritative tracks
_selected(k) { return this._selKeys.some((s) => s.key === k); }
_diamond(c, x, cy, r, fill, sel) {
c.fillStyle = fill;
c.beginPath(); c.moveTo(x, cy - r); c.lineTo(x + r, cy); c.lineTo(x, cy + r); c.lineTo(x - r, cy); c.closePath(); c.fill();
if (sel) { c.strokeStyle = '#fff'; c.lineWidth = 1.5; c.stroke(); c.lineWidth = 1; }
}
_drawKeys(c, r, y) {
const cy = y + ROW_H / 2;
for (const k of this._keysOf(r.id)) {
const keys = (this._te(r.id)?.tracks?.transform) || [];
for (const k of keys) {
const x = this._t2x(k.t);
c.fillStyle = '#7aa2f7';
c.beginPath(); c.moveTo(x, cy - KEY_R); c.lineTo(x + KEY_R, cy); c.lineTo(x, cy + KEY_R); c.lineTo(x - KEY_R, cy); c.fill();
this._diamond(c, x, cy, KEY_R, '#7aa2f7', this._selected(k));
this._hits.push({ kind: 'key', id: r.id, track: 'transform', key: k, x, y: cy });
}
}
_paramColor(name) { let h = 0; for (const ch of name) h = (h * 31 + ch.charCodeAt(0)) % 360; return `hsl(${h},55%,62%)`; }
_drawParams(c, r, y) {
const cy = y + ROW_H / 2;
const keys = (this._te(r.id)?.tracks?.params) || [];
for (const k of keys) {
const x = this._t2x(k.t);
this._diamond(c, x, cy, 4, this._paramColor(k.key), this._selected(k));
this._hits.push({ kind: 'key', id: r.id, track: 'params', key: k, x, y: cy });
}
}
_drawClips(c, r, y) {
const e = this._entity(r.id);
const blocks = (e && e.tracks && e.tracks.clips) || [];
const blocks = (this._te(r.id)?.tracks?.clips) || [];
for (const b of blocks) {
const span = (b.out - b.in) || 0.0001, end = b.start + span * (b.loop || 1);
const x0 = this._t2x(b.start), x1 = this._t2x(end);
c.fillStyle = '#3d5a80'; c.fillRect(x0, y + 5, Math.max(2, x1 - x0), ROW_H - 10);
c.strokeStyle = '#98c1d9'; c.strokeRect(x0 + 0.5, y + 5.5, Math.max(2, x1 - x0) - 1, ROW_H - 11);
const x0 = this._t2x(b.start), x1 = this._t2x(end), bw = Math.max(2, x1 - x0);
c.fillStyle = '#3d5a80'; c.fillRect(x0, y + 5, bw, ROW_H - 10);
c.strokeStyle = '#98c1d9'; c.strokeRect(x0 + 0.5, y + 5.5, bw - 1, ROW_H - 11);
if (b.fade > 0) { // fade-out wedge at the tail
const fx = this._t2x(end - b.fade);
c.fillStyle = 'rgba(231,175,104,0.35)'; c.fillRect(fx, y + 5, x1 - fx, ROW_H - 10);
}
c.fillStyle = '#e0e6ed'; c.font = '10px ui-monospace';
const name = (b.path || '').split('/').pop();
c.save(); c.beginPath(); c.rect(x0, y, x1 - x0, ROW_H); c.clip();
c.fillText(name, x0 + 4, y + ROW_H / 2 + 3); c.restore();
c.fillText((b.path || '').split('/').pop(), x0 + 4, y + ROW_H / 2 + 3); c.restore();
this._hits.push({ kind: 'block', id: r.id, block: b, x0, x1, y });
this._hits.push({ kind: 'edge', id: r.id, block: b, side: 'out', x: x1, y });
}
@ -211,8 +259,7 @@ export class TimelineUI {
const cy = y + ROW_H / 2;
for (const cut of this.tl.cameraCuts) {
const x = this._t2x(cut.t);
c.fillStyle = '#e0af68';
c.fillRect(x - 1, y + 4, 2, ROW_H - 8);
c.fillStyle = '#e0af68'; c.fillRect(x - 1, y + 4, 2, ROW_H - 8);
c.beginPath(); c.arc(x, cy, 4, 0, Math.PI * 2); c.fill();
c.fillStyle = '#161a20'; c.font = '9px ui-monospace'; c.fillText(cut.camera || '?', x + 6, cy + 3);
this._hits.push({ kind: 'cut', cut, x, y: cy });
@ -221,9 +268,8 @@ export class TimelineUI {
// ---- interaction ----
_local(e) { const r = this.canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }
_snap(t, e) { return e && e.altKey ? t : Math.round(t * this.tl.fps) / this.tl.fps; }
_snap(t, e) { return (e && e.altKey) || !this._snapOn ? t : Math.round(t * this.tl.fps) / this.tl.fps; }
_hitAt(x, y) {
// prefer key/edge handles (small), then blocks
for (const h of this._hits) {
if (h.kind === 'key' || h.kind === 'cut') { if (Math.abs(h.x - x) < 7 && Math.abs(h.y - y) < 9) return h; }
else if (h.kind === 'edge') { if (Math.abs(h.x - x) < 5 && y > h.y && y < h.y + ROW_H) return h; }
@ -231,44 +277,67 @@ export class TimelineUI {
for (const h of this._hits) if (h.kind === 'block' && x >= h.x0 && x <= h.x1 && y > h.y && y < h.y + ROW_H) return h;
return null;
}
_keysInRect(x0, y0, x1, y1) {
const ax = Math.min(x0, x1), bx = Math.max(x0, x1), ay = Math.min(y0, y1), by = Math.max(y0, y1);
const out = [];
for (const h of this._hits) if (h.kind === 'key' && h.x >= ax && h.x <= bx && h.y >= ay && h.y <= by) out.push({ id: h.id, track: h.track, key: h.key });
return out;
}
_down(e) {
const { x, y } = this._local(e);
if (y < RULER_H) { this._drag = { kind: 'seek' }; this.tl.seek(this._x2t(x)); return; }
const h = this._hitAt(x, y);
if (!h) { this._drag = { kind: 'seek' }; this.tl.seek(this._x2t(x)); return; }
if (h.kind === 'key') this._drag = { kind: 'key', h };
else if (h.kind === 'edge') this._drag = { kind: 'trim', h };
if (!h) { this._drag = { kind: 'box', x0: x, y0: y, x, y, moved: false }; return; } // empty → box/seek
if (h.kind === 'key') {
if (this._selected(h.key) && this._selKeys.length > 1) {
this._drag = { kind: 'multikey', grabT: this._x2t(x), orig: this._selKeys.map((s) => ({ ...s, t0: s.key.t })) };
} else { this._selKeys = []; this._drag = { kind: 'key', h }; }
} else if (h.kind === 'edge') this._drag = { kind: 'trim', h };
else if (h.kind === 'block') this._drag = { kind: 'block', h, grabT: this._x2t(x) - h.block.start };
else if (h.kind === 'cut') this._drag = { kind: 'cut', h };
}
_move(e) {
if (!this._drag) return;
const { x } = this._local(e);
const { x, y } = this._local(e);
const t = this._x2t(x), d = this._drag;
if (d.kind === 'seek') this.tl.seek(t);
else if (d.kind === 'key') { this.tl.moveKey(d.h.id, 'transform', d.h.key, this._snap(t, e)); this.draw(); }
else if (d.kind === 'key') { this.tl.moveKey(d.h.id, d.h.track, d.h.key, this._snap(t, e)); this.draw(); }
else if (d.kind === 'multikey') { for (const s of d.orig) this.tl.moveKey(s.id, s.track, s.key, this._snap(s.t0 + (t - d.grabT), e)); this.draw(); }
else if (d.kind === 'cut') { d.h.cut.t = this._snap(t, e); this.tl.cameraCuts.sort((a, b) => a.t - b.t); this.tl._activeCam = undefined; this.draw(); }
else if (d.kind === 'block') { this.tl.moveClipBlock(d.h.id, d.h.block, Math.max(0, this._snap(t - d.grabT, e))); this.draw(); }
else if (d.kind === 'trim') { const b = d.h.block; this.tl.trimClipBlock(d.h.id, b, { out: Math.max(b.in + 0.05, b.in + (t - b.start)) }); this.draw(); }
else if (d.kind === 'box') { d.x = x; d.y = y; d.moved = Math.abs(x - d.x0) > 4 || Math.abs(y - d.y0) > 4; this.draw(); }
}
_up() {
const d = this._drag; this._drag = null;
if (!d) return;
if (d.kind === 'box') {
if (!d.moved) this.tl.seek(this._x2t(d.x0)); // click on empty = seek
else { this._selKeys = this._keysInRect(d.x0, d.y0, d.x, d.y); this.draw(); }
}
}
_up() { this._drag = null; }
_dbl(e) {
const { x, y } = this._local(e);
const i = this._rowAtY(y); if (i < 0) return;
const r = this.rows[i];
if (r.type !== 'transform') return;
const t = this._snap(this._x2t(x), e);
const cur = this.stage.entityTransform(r.id);
this.tl.addKey(r.id, 'transform', { t, pos: cur.pos, rot: cur.rot, scale: cur.scale, ease: 'inout' });
this.draw();
if (r.type === 'transform') {
const cur = this.stage.entityTransform(r.id);
this.tl.addKey(r.id, 'transform', { t, pos: cur.pos, rot: cur.rot, scale: cur.scale, ease: 'inout' });
this.draw();
} else if (r.type === 'cameras') {
const cams = this.stage.entities().filter((en) => en.kind === 'camera');
const cam = this.tl._activeCam || (cams[0] && cams[0].id);
if (cam) { this.tl.addCut(t, cam); this.tl.seek(this.tl.time); this.draw(); }
}
}
_ctx(e) {
e.preventDefault();
const { x, y } = this._local(e);
const h = this._hitAt(x, y);
if (h && h.kind === 'key') { this.tl.deleteKey(h.id, 'transform', h.key); this.draw(); }
if (h && h.kind === 'key') { this.tl.deleteKey(h.id, h.track, h.key); this._selKeys = this._selKeys.filter((s) => s.key !== h.key); this.draw(); }
else if (h && h.kind === 'cut') { const i = this.tl.cameraCuts.indexOf(h.cut); if (i >= 0) this.tl.cameraCuts.splice(i, 1); this.tl._activeCam = undefined; this.draw(); }
}
_key(e) {
@ -277,6 +346,11 @@ export class TimelineUI {
else if (e.key === 'Home') this.tl.seek(0);
else if (e.key === 'End') this.tl.seek(this.tl.duration);
else if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); this.tl.undo(); this.draw(); }
else if ((e.key === 'Delete' || e.key === 'Backspace') && this._selKeys.length) {
e.preventDefault();
for (const s of this._selKeys) this.tl.deleteKey(s.id, s.track, s.key);
this._selKeys = []; this.draw();
}
}
_toggle() { this.tl.playing ? this.tl.pause() : this.tl.play(); this.draw(); }
@ -291,7 +365,7 @@ export class TimelineUI {
});
if (!res.ok) throw new Error(res.status);
} catch {
localStorage.setItem('scenegod:scene:' + name, JSON.stringify(json)); // fallback until Lane C lands
localStorage.setItem('scenegod:scene:' + name, JSON.stringify(json)); // fallback until Lane C reachable
}
}
async load(name) {
@ -301,11 +375,8 @@ export class TimelineUI {
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);
this.tl.load(json);
this.tl.load(json); // fires onLoad → _syncRows + _refreshBar
await this.tl.preload();
this.$name.value = json.name || name;
this.$dur.value = this.tl.duration;
this._syncRows();
this.draw();
}
}

View File

@ -9,6 +9,7 @@ validation failures.
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
@ -51,8 +52,11 @@ def main():
(assets / "backdrops" / "street.jpg").write_bytes(b"img")
for d in ("animations", "props", "audio"):
(assets / d).mkdir()
(assets / "props" / "box.glb").write_bytes(b"b") # no image sidecar
env = {**os.environ, "SCENEGOD_ASSETS": str(assets), "SCENEGOD_SCENES": str(scenes)}
renders = tmp / "renders"
env = {**os.environ, "SCENEGOD_ASSETS": str(assets), "SCENEGOD_SCENES": str(scenes),
"SCENEGOD_RENDERS": str(renders)}
proc = subprocess.Popen(
[str(PY), "-m", "uvicorn", "scenegod.server:app", "--port", str(PORT)],
cwd=ROOT, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
@ -77,9 +81,12 @@ def main():
assert tree["backdrops"][0]["path"] == "backdrops/street.jpg", tree["backdrops"]
n += 1
# --- file streaming ---
# --- file streaming + thumbnail sidecar ---
st, b = req("GET", "/assets/file?path=backdrops/street.jpg")
assert st == 200 and b == b"img", (st, b)
st, b = req("GET", "/assets/thumb?path=" + urllib.parse.quote("characters/pack01/lady.glb"))
assert st == 200 and b == b"x", (st, b) # resolves lady.jpg sidecar
assert req("GET", "/assets/thumb?path=props/box.glb")[0] == 404 # no sidecar
n += 1
# --- path traversal rejected ---
@ -123,6 +130,36 @@ def main():
assert st == 200, st
n += 1
# --- render pipeline: begin -> post 30 PNGs -> end -> poll -> mp4 ---
if shutil.which("ffmpeg") and shutil.which("ffprobe"):
fr = tmp / "genframes"
fr.mkdir()
subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", "color=c=red:s=64x64",
"-frames:v", "30", str(fr / "%06d.png")],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
rid = json.loads(req("POST", "/render/begin",
json.dumps({"name": "t", "fps": 30}))[1])["renderId"]
for i, png in enumerate(sorted(fr.glob("*.png"))):
st, _ = req("POST", f"/render/{rid}/frame/{i}", png.read_bytes())
assert st == 200, st
assert req("POST", f"/render/{rid}/end", "{}")[0] == 200
for _ in range(100): # poll up to ~20s
state = json.loads(req("GET", f"/render/{rid}/status")[1])["state"]
if state in ("done", "error"):
break
time.sleep(0.2)
assert state == "done", state
st, mp4 = req("GET", f"/render/{rid}/out.mp4")
assert st == 200 and mp4[:4] != b"", st
dur = subprocess.check_output(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nw=1:nokey=1", str(renders / rid / "out.mp4")],
text=True).strip()
assert abs(float(dur) - 1.0) < 0.2, f"mp4 duration {dur}s, expected ~1s"
n += 1
else:
print("(render test skipped — ffmpeg/ffprobe not found)")
print(f"OK: {n} test groups passed")
finally:
proc.terminate()