SCENEGOD/scenegod/web/index.html
type-two 22c8a9a324 M12: look, render button, save integrity — 3 workflow rounds, 22 agents
FEATURES (from a 5-lens audit: UX friction, feature gaps, bug hunt, visual
quality, discoverability):
- ACES filmic tone mapping + per-preset exposure. grammar.js runs physical sun
  intensities of 1.2-3.4; with NoToneMapping every lit face clipped to white and
  a warm key rendered neutral. Single biggest look win in the app.
- Real shadows: PCFSoft, a shadow box fitted to the scene's bounds, and plates
  that no longer double as the shadow catcher (dedicated ShadowMaterial).
- A Render button in the scene bar. finalRender had ZERO callers - the app's
  deliverable was console-only.
- Save actually saves the stage you built (gizmo moves, inspector edits), and
  Save/Load stop lying about what happened.
- 2x supersample + lanczos downscale; correct bt709 tagging and faststart.
- Frame guide, camera-from-view, dock counts, delete confirmations.

DEFECTS FOUND BY ADVERSARIAL REVIEW AND FIXED (all reproduced first):
- BLOCKER: save wrote the playhead pose over the authored rest transform of
  keyed entities, so every save produced a different file.
- renders were converted with the bt601 matrix while tagged bt709.
- deleting an unkeyed camera stranded its cut -> scene 422s forever.
- corner plates showed one photo at two exposures across the fold.
- exposure was advertised as keyable but nothing keyed it, so a second lighting
  preset permanently poisoned the first.
- the audio pre-flight could never fire (FastAPI does not route HEAD -> 405).
- the two render buttons were not mutually exclusive.
- frame guide letterboxed a narrow viewport that the render does not crop.

ORCHESTRATOR (round 3): dangling camera cuts are pruned in Timeline.toJSON and
skipped by cutCameraAt, rather than trying to keep every undo history clean -
deleting a camera spans Stage (no undo) and Timeline (undo), so any older entry
can resurrect a cut for a dead camera. Verified against the real validator.
Also replaced a VACUOUS test that drained a stack whose only entry was a seeded
no-op; timeline_test.mjs now replays both real histories and discriminates.

Verified: 4 JS suites + 23 server groups + render client green; blocker repro
now preserves the authored rest at every playhead position; save round-trips
200 with zero dangling cuts; zero console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:41:17 +10:00

76 lines
3.0 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SCENEGOD — machinima stage</title>
<link rel="stylesheet" href="web/style.css">
</head>
<body>
<header>
<h1>🎬 SCENEGOD</h1><span class="tag">machinima stage</span>
<span id="selftest"></span>
</header>
<div id="direct"></div>
<div id="app">
<aside id="dock"></aside>
<div id="view"></div>
<aside id="inspector"><div class="empty">nothing selected</div></aside>
<div id="timeline"><div class="empty">timeline — Lane B mounts here</div></div>
</div>
<script type="importmap">
{ "imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}}
</script>
<script type="module">
import { Stage } from './web/stage.js';
import { Dock } from './web/dock.js';
// retarget self-check (?selftest=1) — proves the crown-jewel bake before anything else
if(new URLSearchParams(location.search).has('selftest')){
const { _selftest, canon } = await import('./web/room3d.js');
const r = _selftest();
// M7 bone aliasing: non-mixamo rigs must fold onto the canonical names (node: room3d_test.mjs)
const alias = canon('CC_Base_Hip_02') === 'hips' && canon('CC_Base_L_Upperarm_050') === 'leftarm'
&& canon('upperarm_l') === 'leftarm' && canon('mixamorig4:LeftHand') === 'lefthand';
const el = document.getElementById('selftest');
el.textContent = `selftest: ${r.ok && alias ? 'PASS' : 'FAIL'} (maxErr=${r.maxErr.toExponential(2)}, alias ${alias ? 'ok' : 'BAD'})`;
el.className = r.ok && alias ? 'ok' : 'bad';
console.log('[room3d selftest]', r, 'alias', alias);
}
const stage = new Stage(document.getElementById('view'));
const dock = new Dock({
stage,
dockEl: document.getElementById('dock'),
inspectorEl: document.getElementById('inspector'),
viewEl: document.getElementById('view'),
});
// DIRECT panel (M5 director mode) — guarded like the timeline: page survives its absence
let direct = null;
try {
const { mountDirectPanel } = await import('./web/presets.js');
direct = mountDirectPanel(stage, document.getElementById('direct'));
} catch(e){ console.warn('direct panel not mounted:', e); }
// Timeline is optional — the page must never break when Lane B's file is absent
try {
const { Timeline } = await import('./web/timeline.js');
const { TimelineUI } = await import('./web/tlui.js');
window.timeline = new Timeline(stage);
window.tlui = new TimelineUI(window.timeline, document.getElementById('timeline'));
stage.setMixerAuto(false); // timeline owns mixer stepping from here on
// tlui's render-size <select> exists only now, two awaited imports after the DIRECT panel
// mounted — this is the earliest moment the ⬚ frame guide can read the delivery aspect it
// must mask to. (Its `change` listener keeps it in step from here on.)
direct?.syncFrameAspect?.();
} catch(e){ console.warn('timeline not mounted:', e); }
window.stage = stage; window.dock = dock;
</script>
</body>
</html>