feat(auteur): Phase 2 — director executes the screenplay (shippable hero)

director.ts: optional shots:ResolvedShot[] in DirectorOptions; per-beat
  flight/dwell timing; framePosition now reads move (push_in/pull_back/crane
  scale standoff) + angle (low=look-up, high=look-down) + standoff; orbit shots
  revolve the camera during dwell; Dutch roll via camera.up; hard/match cuts
  snap (editorial cut). With NO shots the camera is byte-identical to before
  (all values fall back to the existing constants + easeInOutCubic lerp).
MemoryCinema.svelte: build computeSignals + planShotsDeterministic + resolveShots
  on launch, pass shots to the director; onBeat drives storm mode + director's
  note + Act + tension from the shot. New UI: pre-roll DIRECTOR'S PLAN card
  (logline naming real memories), per-beat 'why this shot' note, Act I/II/III
  badge, tension-tinted progress bar, Auteur source badge.

The deterministic auteur ships the hero film with zero LLM. 937 tests + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-06-22 00:58:35 -05:00
parent 8094931ea9
commit 5e8a22a427
2 changed files with 216 additions and 32 deletions

View file

@ -22,6 +22,14 @@
type CinemaNarration,
type BeatNarration,
} from '$lib/graph/cinema/narrator';
import { computeSignals } from '$lib/graph/cinema/topology';
import {
planShotsDeterministic,
resolveShots,
type DirectorPlan,
type ResolvedShot,
type StormMode,
} from '$lib/graph/cinema/auteur';
import type { SemanticRole } from '$lib/graph/cinema/storm';
import type { CinemaSandbox } from '$lib/graph/cinema/sandbox';
@ -46,6 +54,12 @@
let voiceOn = $state(false);
let localAiOn = $state(false);
let statusLine = $state('');
// Auteur (director) state surfaced in the overlay.
let directorNote = $state(''); // the current shot's "why" (cites a real metric)
let act = $state<'I' | 'II' | 'III'>('I');
let tension = $state(0); // 0..1 for the tension sparkline
let logline = $state('');
let plan = $state<DirectorPlan | null>(null);
let canvasHost = $state<HTMLDivElement | undefined>(undefined);
let sandbox: CinemaSandbox | null = null;
@ -82,11 +96,6 @@
return pos;
}
function roleFor(beat: CinemaBeat): SemanticRole {
if (beat.kind === 'origin') return 'anchor';
if (beat.kind === 'contradiction') return 'contradiction';
return 'connection';
}
function speak(text: string) {
if (!voiceOn || typeof speechSynthesis === 'undefined') return;
@ -119,15 +128,30 @@
}, 18);
}
function onBeat(beat: CinemaBeat, index: number) {
// Map the director's StormMode to the storm runtime's SemanticRole. 'surprise'
// is a Phase-3 storm mode; until then it reads as 'connection'.
function stormRole(mode: StormMode): SemanticRole {
return mode === 'surprise' ? 'connection' : mode;
}
function onBeat(beat: CinemaBeat, index: number, shot: ResolvedShot | null) {
beatIndex = index + 1;
const text = narration?.beats[index]?.text ?? beat.node.label ?? '';
chip = narration?.beats[index]?.chip ?? '';
streamCaption(text);
speak(text);
// Surface the director's intent for this shot — the "why", act, tension.
if (shot) {
directorNote = shot.why;
act = shot.act;
tension = shot.tension;
}
if (sandbox && webgpuActive) {
const wp = currentPositions?.get(beat.nodeId);
if (wp) sandbox.transitionTo(roleFor(beat), wp);
if (wp) {
const mode: StormMode = shot?.stormMode ?? 'connection';
sandbox.transitionTo(stormRole(mode), wp);
}
}
}
@ -143,6 +167,11 @@
director = null;
narration = null;
renderFailures = 0;
directorNote = '';
logline = '';
plan = null;
act = 'I';
tension = 0;
open = true;
stage = 'planning';
@ -162,6 +191,15 @@
}
currentPositions = layoutPositions(path);
// THE AUTEUR: read the graph's dramatic structure and direct the film.
// Tier 2 (deterministic) ships the hero; Tier 1 (LLM) lands in Phase 4.
const signals = computeSignals(nodes, edges);
plan = planShotsDeterministic(path, signals);
logline = plan.logline;
const shots = resolveShots(plan, path);
act = shots[0]?.act ?? 'I';
tension = shots[0]?.tension ?? 0;
// Tiers 1/2: resolve narration (backend LLM → local captions).
narration = await resolveNarration(path, localAiOn ? localAiFetcher() : fetchBackendNarration);
narrationSource = narration.source;
@ -197,7 +235,7 @@
stage = 'done';
statusLine = 'End of tour.';
},
}, { reducedMotion });
}, { reducedMotion, shots });
stage = 'playing';
statusLine = webgpuActive
@ -340,10 +378,16 @@
<div class="flex items-center gap-2 text-xs text-dim">
<span class="cinema-dot" class:active={stage === 'playing'}></span>
<span>{statusLine}</span>
{#if plan}
<span class="cinema-badge" title="Who directed this film">
{plan.source === 'deterministic' ? 'Auteur (local)' : 'Auteur (AI)'}
</span>
{/if}
{#if narrationSource}
<span class="cinema-badge">{narrationSource === 'backend-llm' ? 'AI narration' : 'Live captions'}</span>
{/if}
{#if webgpuActive}<span class="cinema-badge cinema-badge-gpu">WebGPU</span>{/if}
{#if stage === 'playing'}<span class="cinema-act">Act {act}</span>{/if}
</div>
<div class="flex items-center gap-2">
<label class="cinema-toggle" title="Speak narration aloud">
@ -356,12 +400,26 @@
</div>
</div>
<!-- Bottom: narration captions + progress -->
<!-- Pre-roll DIRECTOR'S PLAN card: the AI states its film before rolling. -->
{#if stage === 'planning' && logline}
<div class="cinema-plan-card glass-panel">
<div class="cinema-plan-kicker">Director's plan</div>
<p class="cinema-plan-logline">{logline}</p>
</div>
{/if}
<!-- Bottom: narration captions + director's note + progress -->
<div class="cinema-caption-wrap">
{#if chip}<div class="cinema-chip">{chip}</div>{/if}
<p class="cinema-caption">{caption}</p>
{#if directorNote && stage === 'playing'}
<p class="cinema-note" title="Why the director chose this shot">{directorNote}</p>
{/if}
<div class="cinema-progress" aria-hidden="true">
<div class="cinema-progress-fill" style="width:{progress * 100}%"></div>
<div
class="cinema-progress-fill"
style="width:{progress * 100}%; --tension:{tension}"
></div>
</div>
<div class="cinema-beatcount text-dim text-xs">
{#if totalBeats > 0}Beat {beatIndex} / {totalBeats}{/if}
@ -406,6 +464,50 @@
border-color: rgba(20, 232, 198, 0.5);
color: #14e8c6;
}
.cinema-act {
font-size: 0.6rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-dream-glow);
opacity: 0.85;
}
/* Pre-roll director's plan card — centered, the AI's statement of intent. */
.cinema-plan-card {
position: absolute;
z-index: 3;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 520px;
padding: 1.5rem 1.75rem;
border-radius: 16px;
text-align: center;
animation: cinema-plan-in 0.5s ease both;
}
@keyframes cinema-plan-in {
from { opacity: 0; transform: translate(-50%, -46%); }
to { opacity: 1; transform: translate(-50%, -50%); }
}
.cinema-plan-kicker {
font-size: 0.65rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-synapse-glow);
margin-bottom: 0.5rem;
}
.cinema-plan-logline {
font-size: clamp(1.05rem, 2.2vw, 1.4rem);
line-height: 1.5;
color: var(--color-bright);
margin: 0;
}
.cinema-note {
font-size: 0.78rem;
color: var(--color-synapse-glow);
opacity: 0.85;
margin: 0 0 0.6rem;
font-style: italic;
}
.cinema-dot {
width: 8px;
height: 8px;
@ -464,8 +566,13 @@
}
.cinema-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--color-synapse), var(--color-dream));
transition: width 0.2s linear;
/* Tint shifts toward crimson as the shot's tension rises (--tension 0..1). */
background: linear-gradient(
90deg,
var(--color-synapse),
color-mix(in oklch, var(--color-dream), #ff2d55 calc(var(--tension, 0) * 100%))
);
transition: width 0.2s linear, background 0.4s ease;
}
.cinema-beatcount {
margin-top: 0.4rem;

View file

@ -11,10 +11,12 @@
import * as THREE from 'three';
import type { CinemaPath, CinemaBeat } from './pathfinder';
import type { ResolvedShot } from './auteur';
export interface DirectorCallbacks {
/** Fired once when the camera arrives at (or cuts to) a beat. */
onBeat?: (beat: CinemaBeat, index: number) => void;
/** Fired once when the camera arrives at (or cuts to) a beat. The resolved
* shot for the beat is passed so consumers can drive storm/score/captions. */
onBeat?: (beat: CinemaBeat, index: number, shot: ResolvedShot | null) => void;
/** Fired when the whole tour finishes. */
onComplete?: () => void;
/** Fired every frame with overall progress 0..1 (for a scrubber/progress bar). */
@ -30,6 +32,11 @@ export interface DirectorOptions {
standoff?: number;
/** Instant cuts instead of flights (prefers-reduced-motion). */
reducedMotion?: boolean;
/** Optional per-beat director's plan (one ResolvedShot per beat, aligned by
* index). When ABSENT the camera behaves byte-identically to the pre-Auteur
* director every value falls back to the constants above. When present,
* each shot's move/angle/dutch/standoff/flight/dwell/cut directs that beat. */
shots?: ResolvedShot[];
}
type Phase = 'idle' | 'flying' | 'dwelling' | 'done';
@ -73,9 +80,25 @@ export class CinemaDirector {
dwellSeconds: opts.dwellSeconds ?? 3.2,
standoff: opts.standoff ?? 26,
reducedMotion: opts.reducedMotion ?? false,
shots: opts.shots ?? [],
};
}
/** The resolved shot directing a beat, or null when no plan was supplied
* ( the camera uses the constant defaults = pre-Auteur behavior). */
private shotAt(index: number): ResolvedShot | null {
return this.opts.shots[index] ?? null;
}
/** Per-beat flight duration: the shot's value, else the global default. A
* hard/match cut has zero flight (handled in beginFlightTo). */
private flightSecondsAt(index: number): number {
return this.shotAt(index)?.flightSeconds ?? this.opts.flightSeconds;
}
private dwellSecondsAt(index: number): number {
return this.shotAt(index)?.dwellSeconds ?? this.opts.dwellSeconds;
}
get totalBeats(): number {
return this.path.beats.length;
}
@ -99,39 +122,61 @@ export class CinemaDirector {
this.phase = 'done';
}
/** Compute the camera stand-off position for a beat's node. */
private framePosition(beat: CinemaBeat, out: THREE.Vector3): THREE.Vector3 {
/** Compute the camera stand-off position for a beat's node, directed by its
* shot (move / angle / standoff). With no shot, reproduces the original
* framing exactly: standoff = opts.standoff, +0.35 up-bias (filmic tilt). */
private framePosition(beat: CinemaBeat, index: number, out: THREE.Vector3): THREE.Vector3 {
const nodePos = this.positions.get(beat.nodeId);
if (!nodePos) {
// Node has no resolved position yet — keep current framing.
return out.copy(this.camera.position);
}
// Offset back + up from the node along the current view direction so the
// node sits centered with a cinematic slightly-above angle.
const shot = this.shotAt(index);
_tmpDir.copy(this.camera.position).sub(nodePos);
if (_tmpDir.lengthSq() < 1e-4) _tmpDir.set(0, 0.4, 1);
_tmpDir.normalize();
// Bias the approach vector upward a touch for a filmic tilt.
_tmpDir.addScaledVector(_tmpUp, 0.35).normalize();
return out.copy(nodePos).addScaledVector(_tmpDir, this.opts.standoff);
// Vertical bias = the camera angle. Default +0.35 (slightly above, the
// original filmic tilt). low = look UP at the node (power), high = look
// DOWN (decay/fading).
let upBias = 0.35;
if (shot) {
if (shot.angle === 'low') upBias = -0.45;
else if (shot.angle === 'high') upBias = 0.7;
}
_tmpDir.addScaledVector(_tmpUp, upBias).normalize();
// Stand-off = how close: push_in tightens, pull_back/crane widen.
let standoff = shot?.standoff ?? this.opts.standoff;
if (shot) {
if (shot.move === 'push_in') standoff *= 0.7;
else if (shot.move === 'pull_back') standoff *= 1.5;
else if (shot.move === 'crane') standoff *= 1.8;
}
return out.copy(nodePos).addScaledVector(_tmpDir, standoff);
}
private beginFlightTo(index: number): void {
const beat = this.path.beats[index];
const nodePos = this.positions.get(beat.nodeId);
const shot = this.shotAt(index);
this.fromPos.copy(this.camera.position);
this.fromTarget.copy(this.target);
this.framePosition(beat, this.toPos);
this.framePosition(beat, index, this.toPos);
this.toTarget.copy(nodePos ?? this.target);
this.phaseElapsed = 0;
if (this.opts.reducedMotion) {
// Jump-cut: snap, fire the beat, go straight to dwelling.
// A directed hard/match cut snaps instantly (like reduced-motion), so the
// editorial "cut" reads as an edit, not a fly. reduced-motion forces this
// for every beat regardless of shot.
const snap = this.opts.reducedMotion || shot?.cut === 'hard_cut' || shot?.cut === 'match_cut';
if (snap) {
this.camera.position.copy(this.toPos);
this.target.copy(this.toTarget);
this.phase = 'dwelling';
this.cb.onBeat?.(beat, index);
this.cb.onBeat?.(beat, index, shot);
} else {
this.phase = 'flying';
}
@ -144,23 +189,33 @@ export class CinemaDirector {
const dt = Math.max(0, Math.min(deltaSeconds, 0.05));
this.phaseElapsed += dt;
const flightSecs = this.flightSecondsAt(this.beatIndex);
const dwellSecs = this.dwellSecondsAt(this.beatIndex);
if (this.phase === 'flying') {
const t = Math.min(1, this.phaseElapsed / this.opts.flightSeconds);
const t = Math.min(1, this.phaseElapsed / flightSecs);
const e = easeInOutCubic(t);
this.camera.position.lerpVectors(this.fromPos, this.toPos, e);
this.target.lerpVectors(this.fromTarget, this.toTarget, e);
this.applyDutch(this.beatIndex, e);
if (t >= 1) {
this.phase = 'dwelling';
this.phaseElapsed = 0;
this.cb.onBeat?.(this.path.beats[this.beatIndex], this.beatIndex);
this.cb.onBeat?.(this.path.beats[this.beatIndex], this.beatIndex, this.shotAt(this.beatIndex));
}
} else if (this.phase === 'dwelling') {
// Gentle drift during the dwell keeps the shot alive (skipped if reduced).
if (!this.opts.reducedMotion) {
const nodePos = this.positions.get(this.path.beats[this.beatIndex].nodeId);
if (nodePos) this.target.lerp(nodePos, 0.02);
if (nodePos) {
this.target.lerp(nodePos, 0.02); // gentle settle keeps the shot alive
// An orbit shot slowly revolves the camera around the node
// during the dwell — the signature "reverent" move for keystones.
if (this.shotAt(this.beatIndex)?.move === 'orbit') {
this.orbitAround(nodePos, dt * 0.35);
}
}
}
if (this.phaseElapsed >= this.opts.dwellSeconds) {
if (this.phaseElapsed >= dwellSecs) {
const nextIndex = this.beatIndex + 1;
if (nextIndex >= this.path.beats.length) {
this.phase = 'done';
@ -178,10 +233,32 @@ export class CinemaDirector {
const per = this.path.beats.length > 0 ? 1 / this.path.beats.length : 0;
const intra =
this.phase === 'flying'
? Math.min(1, this.phaseElapsed / this.opts.flightSeconds) * 0.5
: 0.5 + Math.min(1, this.phaseElapsed / this.opts.dwellSeconds) * 0.5;
? Math.min(1, this.phaseElapsed / flightSecs) * 0.5
: 0.5 + Math.min(1, this.phaseElapsed / dwellSecs) * 0.5;
this.cb.onProgress?.(Math.min(1, this.beatIndex * per + intra * per));
}
/** Revolve the camera around a node by `angle` radians (orbit shots). */
private orbitAround(center: THREE.Vector3, angle: number): void {
_tmpDir.copy(this.camera.position).sub(center);
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const x = _tmpDir.x * cos - _tmpDir.z * sin;
const z = _tmpDir.x * sin + _tmpDir.z * cos;
_tmpDir.x = x;
_tmpDir.z = z;
this.camera.position.copy(center).add(_tmpDir);
}
/** Roll the camera (Dutch angle) toward the shot's target roll over the
* flight, easing back to upright for non-Dutch shots. */
private applyDutch(index: number, t: number): void {
const targetRoll = this.shotAt(index)?.dutch ?? 0;
const roll = targetRoll * t;
// camera.up = rotate world-up around the camera's forward axis by `roll`.
_tmpDir.set(0, 0, -1).applyQuaternion(this.camera.quaternion); // forward
this.camera.up.set(0, 1, 0).applyAxisAngle(_tmpDir, roll);
}
}
function easeInOutCubic(t: number): number {