mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-02 22:01:01 +02:00
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:
parent
8094931ea9
commit
5e8a22a427
2 changed files with 216 additions and 32 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue