Files
Portfolio/src/scene/create-scene-controller.ts

635 lines
21 KiB
TypeScript

import type { JourneySnapshot } from '@/journey/types';
import type { QualityTier } from '@/journey/motion-policy';
import { SceneResourceBag } from '@/scene/resources';
import type {
LiveQualityTier,
PageLifecycleOverlay,
SceneBuildContext,
SceneController,
SceneControllerOptions,
SceneControllerSnapshot,
SceneGeneration,
SceneLifecycleMachine,
SceneLifecycleState,
ScenePageEnvironment,
SceneScheduler,
} from '@/scene/types';
const RESTORE_CUTOFF_MS = 5_000;
const FIRST_FRAME_CUTOFF_MS = 5_000;
const legalTransitions: Readonly<Record<SceneLifecycleState['kind'], readonly SceneLifecycleState['kind'][]>> = {
idle: ['loading', 'paused', 'static', 'disposed'],
loading: ['running', 'paused', 'lost', 'static', 'disposed'],
running: ['paused', 'lost', 'static', 'disposed'],
paused: ['running', 'loading', 'lost', 'static', 'disposed'],
lost: ['restoring', 'paused', 'static', 'disposed'],
restoring: ['running', 'paused', 'lost', 'static', 'disposed'],
static: ['loading', 'disposed'],
disposed: [],
};
export function createSceneLifecycleMachine(initial: SceneLifecycleState = { kind: 'idle' }): SceneLifecycleMachine {
let current = initial;
return {
transition(next) {
if (!legalTransitions[current.kind].includes(next.kind)) {
throw new Error(`Illegal transition: ${current.kind} -> ${next.kind}`);
}
if (
current.kind === 'paused'
&& next.kind === 'lost'
&& (current.generation === null || current.resumeNeedsLoad)
) {
throw new Error('Illegal transition: paused -> lost requires a retained context');
}
current = next;
return current;
},
replace(next) {
if (next.kind !== current.kind) throw new Error('Lifecycle metadata replacement must preserve state kind');
current = next;
return current;
},
snapshot: () => current,
};
}
export function createSceneController(options: SceneControllerOptions): SceneController {
const scheduler = options.scheduler ?? browserScheduler();
const page = options.page ?? browserPageEnvironment();
const machine = createSceneLifecycleMachine();
const rootResources = new SceneResourceBag();
let overlay: PageLifecycleOverlay = 'active';
let quality: LiveQualityTier = options.quality;
let generationCounter = 0;
let current: ActiveGeneration | null = null;
let loading: LoadingGeneration | null = null;
let activeOperation: Promise<void> | null = null;
let latestJourney = initialJourneySnapshot();
let pauseReason: 'visitor' | 'visibility' | 'freeze' | null = null;
let contextLossCount = 0;
let restorationSucceeded = false;
let lostAtForRestore: number | null = null;
let firstFrameTimer: number | null = null;
let firstFrameGeneration: number | null = null;
const transition = (next: SceneLifecycleState): void => {
machine.transition(next);
options.onStateChange?.(next);
};
const stopRaf = (): void => rootResources.cancelAnimationFrame();
const clearFirstFrameWatchdog = (generation?: number): void => {
if (
firstFrameTimer === null
|| (generation !== undefined && firstFrameGeneration !== generation)
) return;
scheduler.clearTimeout(firstFrameTimer);
rootResources.releaseTimer(firstFrameTimer);
firstFrameTimer = null;
firstFrameGeneration = null;
};
const armFirstFrameWatchdog = (generation: number): void => {
clearFirstFrameWatchdog();
firstFrameGeneration = generation;
const timer = scheduler.setTimeout(() => {
rootResources.releaseTimer(timer);
if (firstFrameTimer !== timer || firstFrameGeneration !== generation) return;
firstFrameTimer = null;
firstFrameGeneration = null;
const lifecycle = machine.snapshot();
const generationIsCurrent = (
(lifecycle.kind === 'loading' && lifecycle.generation === generation)
|| (lifecycle.kind === 'running' && lifecycle.generation === generation)
|| (
lifecycle.kind === 'paused'
&& lifecycle.generation === generation
&& !lifecycle.resumeNeedsLoad
)
);
if (generationIsCurrent) enterStatic('first-frame-timeout');
}, FIRST_FRAME_CUTOFF_MS);
firstFrameTimer = rootResources.timer(timer, scheduler.clearTimeout);
};
const confirmFirstFrame = (generation: number): void => {
clearFirstFrameWatchdog(generation);
};
const disposeGeneration = (): void => {
stopRaf();
if (current) {
clearFirstFrameWatchdog(current.generation);
current.resources.dispose();
current = null;
}
};
const invalidateLoading = (): void => {
if (!loading) return;
const invalidated = loading;
loading = null;
activeOperation = null;
clearFirstFrameWatchdog(invalidated.generation);
invalidated.abort.abort();
invalidated.resources.dispose();
};
const invalidateAll = (): void => {
invalidateLoading();
disposeGeneration();
};
const enterStatic = (reason: string): void => {
if (machine.snapshot().kind === 'disposed') return;
invalidateAll();
clearRestoreWatchdog();
pauseReason = null;
lostAtForRestore = null;
transition({ kind: 'static', reason });
};
const scheduleFrame = (): void => {
if (
rootResources.snapshot().rafCount > 0
|| overlay !== 'active'
|| page.visibilityState !== 'visible'
|| machine.snapshot().kind !== 'running'
|| !current
) return;
let frameId = 0;
frameId = scheduler.requestAnimationFrame((now) => {
rootResources.releaseAnimationFrame(frameId);
if (overlay !== 'active' || machine.snapshot().kind !== 'running' || !current) return;
try {
const frameStartedAt = scheduler.now();
const active = current;
const complete = active.runtime.render(latestJourney, now) !== false;
const frameEndedAt = scheduler.now();
if (complete && current === active) {
confirmFirstFrame(active.generation);
options.onFrameRendered?.(frameStartedAt, frameEndedAt);
}
} catch {
enterStatic('render-failure');
return;
}
scheduleFrame();
});
rootResources.animationFrame(frameId, scheduler.cancelAnimationFrame);
};
const beginLoad = (mode: 'loading' | 'restoring', restoreLostAt?: number): Promise<void> => {
if (overlay !== 'active' || machine.snapshot().kind === 'disposed') return Promise.resolve();
invalidateLoading();
disposeGeneration();
const generation = ++generationCounter;
const abort = new AbortController();
const resources = new SceneResourceBag();
loading = restoreLostAt === undefined
? { generation, abort, resources, mode }
: { generation, abort, resources, mode, restoreLostAt };
transition(mode === 'restoring'
? { kind: 'restoring', generation, lostAt: restoreLostAt ?? scheduler.now() }
: { kind: 'loading', generation });
if (mode === 'loading') armFirstFrameWatchdog(generation);
const context: SceneBuildContext = {
canvas: options.canvas,
quality,
generation,
signal: abort.signal,
resources,
};
let buildResult: Promise<SceneGeneration>;
try {
buildResult = Promise.resolve(options.build(context));
} catch (error) {
buildResult = Promise.reject(error);
}
const operation = buildResult
.then((runtime) => finishLoad(generation, runtime))
.catch(() => failLoad(generation));
activeOperation = operation;
void operation.finally(() => {
if (activeOperation === operation) activeOperation = null;
});
return operation;
};
const finishLoad = (generation: number, runtime: SceneGeneration): void => {
const candidate = loading;
if (!candidate || candidate.generation !== generation) {
runtime.dispose();
return;
}
candidate.resources.own('other', runtime);
const lifecycle = machine.snapshot();
const expectedKind = candidate.mode === 'restoring' ? 'restoring' : 'loading';
const pastCutoff = candidate.mode === 'restoring'
&& candidate.restoreLostAt !== undefined
&& scheduler.now() > candidate.restoreLostAt + RESTORE_CUTOFF_MS;
if (
candidate.abort.signal.aborted
|| overlay !== 'active'
|| lifecycle.kind !== expectedKind
|| lifecycle.generation !== generation
|| pastCutoff
) {
loading = null;
candidate.resources.dispose();
if (pastCutoff && machine.snapshot().kind === 'restoring') enterStatic('context-restore-timeout');
return;
}
loading = null;
current = { generation, resources: candidate.resources, runtime };
if (candidate.mode === 'restoring') {
restorationSucceeded = true;
lostAtForRestore = null;
clearRestoreWatchdog();
}
if (page.visibilityState === 'visible') {
transition({ kind: 'running', generation });
pauseReason = null;
scheduleFrame();
} else {
transition({ kind: 'paused', generation, resumeNeedsLoad: false });
pauseReason = 'visibility';
}
};
const failLoad = (generation: number): void => {
if (!loading || loading.generation !== generation) return;
const failed = loading;
loading = null;
failed.resources.dispose();
if (failed.abort.signal.aborted || machine.snapshot().kind === 'disposed') return;
enterStatic(failed.mode === 'restoring' ? 'context-restore-failure' : 'scene-load-failure');
};
let restoreTimer: number | null = null;
const clearRestoreWatchdog = (): void => {
if (restoreTimer === null) return;
scheduler.clearTimeout(restoreTimer);
rootResources.releaseTimer(restoreTimer);
restoreTimer = null;
};
const armRestoreWatchdog = (lostAt: number): void => {
clearRestoreWatchdog();
const delay = Math.max(0, lostAt + RESTORE_CUTOFF_MS - scheduler.now());
const timer = scheduler.setTimeout(() => {
rootResources.releaseTimer(timer);
if (restoreTimer === timer) restoreTimer = null;
evaluatePendingRestore();
}, delay);
restoreTimer = rootResources.timer(timer, scheduler.clearTimeout);
};
const evaluatePendingRestore = (): void => {
const lifecycle = machine.snapshot();
if (lifecycle.kind !== 'lost' || !lifecycle.restorationPending) return;
if (overlay !== 'active' || page.visibilityState !== 'visible') return;
if (scheduler.now() > lifecycle.lostAt + RESTORE_CUTOFF_MS) {
enterStatic('context-restore-timeout');
return;
}
void beginLoad('restoring', lifecycle.lostAt);
};
const onContextLost = (rawEvent: Event): void => {
const lifecycle = machine.snapshot();
const legal = lifecycle.kind === 'loading'
|| lifecycle.kind === 'running'
|| (lifecycle.kind === 'paused' && lifecycle.generation !== null && !lifecycle.resumeNeedsLoad);
if (!legal || overlay === 'disposed') return;
rawEvent.preventDefault();
contextLossCount += 1;
if (restorationSucceeded || contextLossCount > 1) {
enterStatic('repeated-context-loss');
return;
}
const lostAt = scheduler.now();
invalidateAll();
pauseReason = null;
lostAtForRestore = lostAt;
transition({ kind: 'lost', lostAt, restorationPending: false });
if (page.visibilityState === 'visible' && overlay === 'active') armRestoreWatchdog(lostAt);
};
const onContextRestored = (): void => {
const lifecycle = machine.snapshot();
if (lifecycle.kind !== 'lost' || overlay === 'disposed') return;
machine.replace({ ...lifecycle, restorationPending: true });
if (overlay === 'active' && page.visibilityState === 'visible') evaluatePendingRestore();
};
const onVisibilityChange = (): void => {
if (overlay !== 'active' || machine.snapshot().kind === 'disposed') return;
const lifecycle = machine.snapshot();
if (page.visibilityState === 'hidden') {
stopRaf();
if (lifecycle.kind === 'running' && current) {
transition({ kind: 'paused', generation: current.generation, resumeNeedsLoad: false });
pauseReason = 'visibility';
} else if (lifecycle.kind === 'restoring') {
const lostAt = lifecycle.lostAt;
invalidateLoading();
transition({ kind: 'lost', lostAt, restorationPending: true });
lostAtForRestore = lostAt;
}
return;
}
if (lifecycle.kind === 'paused' && pauseReason === 'visibility') {
if (current && !lifecycle.resumeNeedsLoad) {
transition({ kind: 'running', generation: current.generation });
pauseReason = null;
scheduleFrame();
} else {
pauseReason = null;
void beginLoad('loading');
}
} else if (lifecycle.kind === 'lost') {
evaluatePendingRestore();
}
};
const onPageHide = (event: Event): void => {
const persisted = Boolean((event as Event & { persisted?: boolean }).persisted);
if (!persisted) {
dispose();
return;
}
if (overlay === 'disposed') return;
overlay = 'frozen';
const lifecycle = machine.snapshot();
switch (lifecycle.kind) {
case 'idle':
case 'static':
case 'disposed':
break;
case 'loading':
invalidateLoading();
transition({ kind: 'paused', generation: null, resumeNeedsLoad: true });
pauseReason = 'freeze';
break;
case 'running':
stopRaf();
transition({ kind: 'paused', generation: lifecycle.generation, resumeNeedsLoad: false });
pauseReason = 'freeze';
break;
case 'paused':
if (pauseReason !== 'visitor') pauseReason = 'freeze';
break;
case 'lost':
clearRestoreWatchdog();
break;
case 'restoring': {
const lostAt = lifecycle.lostAt;
invalidateLoading();
transition({ kind: 'lost', lostAt, restorationPending: true });
lostAtForRestore = lostAt;
break;
}
}
};
const onPageShow = (event: Event): void => {
const persisted = Boolean((event as Event & { persisted?: boolean }).persisted);
if (!persisted || overlay !== 'frozen') return;
overlay = 'active';
const lifecycle = machine.snapshot();
if (!(options.motionAllowed?.() ?? true)) {
if (
lifecycle.kind === 'paused'
&& current !== null
&& !lifecycle.resumeNeedsLoad
&& pauseReason === 'visitor'
) return;
enterStatic('motion-disallowed');
return;
}
if (page.visibilityState !== 'visible') return;
if (lifecycle.kind === 'paused' && pauseReason === 'freeze') {
pauseReason = null;
if (current && !lifecycle.resumeNeedsLoad) {
transition({ kind: 'running', generation: current.generation });
scheduleFrame();
} else {
void beginLoad('loading');
}
} else if (lifecycle.kind === 'lost') {
evaluatePendingRestore();
}
};
rootResources.listen(options.canvas, 'webglcontextlost', onContextLost);
rootResources.listen(options.canvas, 'webglcontextrestored', onContextRestored);
rootResources.listen(page.document, 'visibilitychange', onVisibilityChange);
rootResources.listen(page.window, 'pagehide', onPageHide);
rootResources.listen(page.window, 'pageshow', onPageShow);
if (options.signal) {
if (options.signal.aborted) dispose();
else rootResources.listen(options.signal, 'abort', dispose, { once: true });
}
function start(): Promise<void> {
const lifecycle = machine.snapshot();
if (overlay !== 'active' || lifecycle.kind === 'disposed') return Promise.resolve();
if (lifecycle.kind === 'running' || lifecycle.kind === 'loading' || lifecycle.kind === 'restoring') {
return activeOperation ?? Promise.resolve();
}
if (lifecycle.kind !== 'idle') return Promise.resolve();
return beginLoad('loading');
}
function pause(): void {
const lifecycle = machine.snapshot();
if (lifecycle.kind === 'disposed' || lifecycle.kind === 'static' || lifecycle.kind === 'paused') return;
pauseReason = 'visitor';
if (lifecycle.kind === 'running' && current) {
stopRaf();
transition({ kind: 'paused', generation: current.generation, resumeNeedsLoad: false });
return;
}
invalidateAll();
clearRestoreWatchdog();
lostAtForRestore = null;
transition({ kind: 'paused', generation: null, resumeNeedsLoad: true });
}
function resume(): Promise<void> {
const lifecycle = machine.snapshot();
if (overlay !== 'active' || lifecycle.kind !== 'paused') return Promise.resolve();
pauseReason = null;
if (current && !lifecycle.resumeNeedsLoad) {
transition({ kind: 'running', generation: current.generation });
scheduleFrame();
return Promise.resolve();
}
return beginLoad('loading');
}
function setStatic(reason: string): void {
if (machine.snapshot().kind === 'static' || machine.snapshot().kind === 'disposed') return;
enterStatic(reason);
}
function enableMotion(): Promise<void> {
if (machine.snapshot().kind !== 'static' || !(options.motionAllowed?.() ?? true)) return Promise.resolve();
return beginLoad('loading');
}
async function setQuality(tier: QualityTier): Promise<void> {
if (tier === 'static') {
setStatic('quality-static');
return;
}
if (quality === tier) return;
quality = tier;
const lifecycle = machine.snapshot();
if (lifecycle.kind === 'running') {
stopRaf();
transition({ kind: 'paused', generation: lifecycle.generation, resumeNeedsLoad: false });
disposeGeneration();
await beginLoad('loading');
} else if (lifecycle.kind === 'loading' || lifecycle.kind === 'restoring') {
pause();
await resume();
}
}
function update(snapshot: JourneySnapshot): void {
latestJourney = copyJourneySnapshot(snapshot);
}
function snapshot(): SceneControllerSnapshot {
return {
state: machine.snapshot(),
overlay,
quality,
canvasCount: overlay === 'disposed' ? 0 : 1,
rafCount: rootResources.snapshot().rafCount,
currentGeneration: current?.generation ?? null,
};
}
function whenSettled(): Promise<void> {
return activeOperation ?? Promise.resolve();
}
function dispose(): void {
if (machine.snapshot().kind === 'disposed') return;
overlay = 'disposed';
invalidateAll();
clearRestoreWatchdog();
transition({ kind: 'disposed' });
rootResources.dispose();
}
return {
start,
pause,
resume,
setStatic,
enableMotion,
setQuality,
update,
snapshot,
whenSettled,
dispose,
};
}
interface ActiveGeneration {
generation: number;
resources: SceneResourceBag;
runtime: SceneGeneration;
}
interface LoadingGeneration {
generation: number;
abort: AbortController;
resources: SceneResourceBag;
mode: 'loading' | 'restoring';
restoreLostAt?: number;
}
function copyJourneySnapshot(snapshot: JourneySnapshot): JourneySnapshot {
return {
scrollY: finite(snapshot.scrollY),
routeProgress: finite(snapshot.routeProgress),
direction: snapshot.direction,
acceleration: finite(snapshot.acceleration),
driveSpeed: finite(snapshot.driveSpeed),
activeSectionId: snapshot.activeSectionId,
chapter: snapshot.chapter,
sign: snapshot.sign ? {
targetId: snapshot.sign.targetId,
phase: snapshot.sign.phase,
progress: finite(snapshot.sign.progress),
opacity: finite(snapshot.sign.opacity),
...(snapshot.sign.viewportProfile === undefined
? {}
: { viewportProfile: snapshot.sign.viewportProfile }),
...(snapshot.sign.navigationArrival === true
? { navigationArrival: true as const }
: {}),
} : null,
arrived: Boolean(snapshot.arrived),
sectionActivation: {
layoutRevision: snapshot.sectionActivation.layoutRevision,
activeHeadingOffsetVh: finite(snapshot.sectionActivation.activeHeadingOffsetVh),
nextHeadingOffsetVh: snapshot.sectionActivation.nextHeadingOffsetVh === null
? null
: finite(snapshot.sectionActivation.nextHeadingOffsetVh),
},
};
}
function finite(value: number): number {
return Number.isFinite(value) ? value : 0;
}
function initialJourneySnapshot(): JourneySnapshot {
return {
scrollY: 0,
routeProgress: 0,
direction: 0,
acceleration: 0,
driveSpeed: 0.18,
activeSectionId: 'start',
chapter: 'deep-night',
sign: null,
arrived: false,
sectionActivation: {
layoutRevision: 0,
activeHeadingOffsetVh: 0,
nextHeadingOffsetVh: null,
},
};
}
function browserScheduler(): SceneScheduler {
return {
now: () => performance.now(),
requestAnimationFrame: (callback) => window.requestAnimationFrame(callback),
cancelAnimationFrame: (id) => window.cancelAnimationFrame(id),
setTimeout: (callback, delay) => window.setTimeout(callback, delay),
clearTimeout: (id) => window.clearTimeout(id),
};
}
function browserPageEnvironment(): ScenePageEnvironment {
return {
document,
window,
get visibilityState() { return document.visibilityState; },
};
}