Compare commits
5 Commits
1f8ad13ed6
...
dfd4cebd2b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfd4cebd2b | ||
|
|
bbb3f99016 | ||
|
|
5d9950211c | ||
|
|
24b7b4d4b7 | ||
|
|
b627a0f2ff |
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
BIN
logs/shots/m2-cameras-lights-dome.png
Normal file
BIN
logs/shots/m2-cameras-lights-dome.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
@ -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) ----
|
||||
|
||||
@ -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
79
scenegod/web/render.js
Normal 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();
|
||||
}
|
||||
@ -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(){
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user