DocDoeAI / src /video-engine /physics /production /deriveVisualState.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
21.2 kB
/**
* DETERMINISTIC VISUAL STATE DERIVATION
*
* For every frame: visualState = deriveVisualState(initialState, allActions, absoluteFrame)
*
* The same frame must always produce the exact same result regardless of:
* - render order
* - concurrency
* - seeking
* - preview refresh
* - segmented rendering
*/
import type {
VisualAction,
VisualState,
VisualObject,
CameraState,
NumericalPhase,
CreateAction,
AnimateAction,
MoveAction,
TransformAction,
AttachLabelAction,
ShowAction,
HideAction,
MeasureAction,
BuildEquationAction,
SubstituteAction,
CalculateAction,
ReframeCameraAction,
ZoomCameraAction,
SetNumericalPhaseAction,
} from "./types";
// ============================================================================
// DEFAULT STATES
// ============================================================================
const DEFAULT_CAMERA: CameraState = {
x: 520,
y: 280,
zoom: 1,
rotation: 0,
};
const DEFAULT_NUMERICAL_PHASE: NumericalPhase = 'hidden';
// ============================================================================
// INITIAL STATE FACTORY
// ============================================================================
export function createInitialState(segmentStartFrame: number = 0): VisualState {
return {
absoluteFrame: segmentStartFrame,
sceneIndex: 0,
objects: new Map(),
camera: { ...DEFAULT_CAMERA },
numericalPhase: DEFAULT_NUMERICAL_PHASE,
numericalProgress: 0,
segmentStartFrame,
initialStateSnapshot: undefined,
};
}
// ============================================================================
// PURE CURVE FUNCTIONS
// ============================================================================
function applyCurve(progress: number, curve: string = 'linear'): number {
switch (curve) {
case 'linear':
return progress;
case 'easeIn':
return progress * progress;
case 'easeOut':
return 1 - (1 - progress) * (1 - progress);
case 'easeInOut':
return progress < 0.5
? 2 * progress * progress
: 1 - Math.pow(-2 * progress + 2, 2) / 2;
case 'sine':
return 0.5 * (1 - Math.cos(progress * Math.PI));
default:
return progress;
}
}
// ============================================================================
// INTERPOLATION FUNCTIONS
// ============================================================================
function interpolateNumber(
from: number,
to: number,
progress: number,
curve: string = 'linear'
): number {
const clampedProgress = Math.max(0, Math.min(1, progress));
const curved = applyCurve(clampedProgress, curve);
return from + (to - from) * curved;
}
// ============================================================================
// ACTION APPLIERS (Pure functions, no side effects)
// ============================================================================
function applyCreateAction(
state: VisualState,
action: CreateAction,
actionProgress: number
): VisualState {
if (actionProgress < 0 || actionProgress > 1) {
return state;
}
const newObject = action.object;
// For create actions, the object appears based on action progress
// We can use progress to control opacity or other properties
const visible = actionProgress > 0;
const opacity = Math.min(1, actionProgress * 2); // Quick fade-in
const objectWithState: VisualObject = {
...newObject,
visible: visible && (newObject.visible ?? true),
opacity: newObject.opacity !== undefined ? newObject.opacity : opacity,
createdAtFrame: action.startFrame,
};
const newObjects = new Map(state.objects);
newObjects.set(newObject.id, objectWithState);
return {
...state,
objects: newObjects,
};
}
function applyAnimateAction(
state: VisualState,
action: AnimateAction,
actionProgress: number
): VisualState {
if (actionProgress < 0 || actionProgress > 1) {
return state;
}
const targets = action.targetIds || [];
if (targets.length === 0) {
return state;
}
const newObjects = new Map(state.objects);
const curvedProgress = applyCurve(actionProgress, action.curve);
for (const targetId of targets) {
const obj = newObjects.get(targetId);
if (!obj) continue;
const newObj: VisualObject = { ...obj };
// Animate the specified property
if (action.property in obj.properties) {
const from = obj.properties[action.property];
const to = action.to;
if (typeof from === 'number' && typeof to === 'number') {
newObj.properties = {
...obj.properties,
[action.property]: interpolateNumber(from, to, curvedProgress, action.curve),
};
}
} else if (action.property === 'x' || action.property === 'y') {
const from = obj[action.property as 'x' | 'y'];
const to = action.to;
if (typeof from === 'number' && typeof to === 'number') {
newObj[action.property as 'x' | 'y'] = interpolateNumber(from, to, curvedProgress, action.curve);
}
} else if (action.property === 'opacity') {
const from = obj.opacity ?? 1;
const to = action.to;
if (typeof to === 'number') {
newObj.opacity = interpolateNumber(from, to, curvedProgress, action.curve);
}
}
newObjects.set(targetId, newObj);
}
return {
...state,
objects: newObjects,
};
}
function applyMoveAction(
state: VisualState,
action: MoveAction,
actionProgress: number
): VisualState {
if (actionProgress < 0 || actionProgress > 1) {
return state;
}
const targets = action.targetIds || [];
if (targets.length === 0) {
return state;
}
const curvedProgress = applyCurve(actionProgress, 'linear');
const newObjects = new Map(state.objects);
for (const targetId of targets) {
const obj = newObjects.get(targetId);
if (!obj) continue;
const newObj: VisualObject = { ...obj };
newObj.x = interpolateNumber(action.fromX, action.toX, curvedProgress);
newObj.y = interpolateNumber(action.fromY, action.toY, curvedProgress);
newObjects.set(targetId, newObj);
}
return {
...state,
objects: newObjects,
};
}
function applyTransformAction(
state: VisualState,
action: TransformAction,
actionProgress: number
): VisualState {
if (actionProgress < 0.5) {
// First half: keep original objects
return state;
}
// Second half: create transformed object
const targetIds = action.targetIds || [];
if (targetIds.length === 0) {
return state;
}
const newObjects = new Map(state.objects);
const mergedObjects: VisualObject[] = [];
// Collect all objects to merge
for (const targetId of targetIds) {
const obj = newObjects.get(targetId);
if (obj) {
mergedObjects.push(obj);
}
}
// Create new transformed object
const newId = action.newId || `${action.newType}_${action.startFrame}`;
const firstObj = mergedObjects[0];
const transformedObject: VisualObject = {
id: newId,
type: action.newType,
x: firstObj.x,
y: firstObj.y,
properties: {
...firstObj.properties,
mergedFrom: targetIds,
transformProgress: actionProgress,
},
visible: true,
createdAtFrame: action.startFrame,
};
// Hide original objects
for (const targetId of targetIds) {
const obj = newObjects.get(targetId);
if (obj) {
newObjects.set(targetId, { ...obj, visible: false });
}
}
// Add transformed object
newObjects.set(newId, transformedObject);
return {
...state,
objects: newObjects,
};
}
function applyAttachLabelAction(
state: VisualState,
action: AttachLabelAction,
actionProgress: number
): VisualState {
if (actionProgress < 0 || actionProgress > 1) {
return state;
}
const attachTo = state.objects.get(action.attachToId);
if (!attachTo) {
return state;
}
const newObjects = new Map(state.objects);
const opacity = actionProgress;
const label: VisualObject = {
...action.label,
id: action.label.id || `label_${action.attachToId}_${action.label.text}`,
x: attachTo.x + (action.offset?.x || 0),
y: attachTo.y + (action.offset?.y || 0),
visible: actionProgress > 0,
opacity: opacity,
createdAtFrame: action.startFrame,
attachedTo: action.attachToId,
attachmentPoint: action.position,
};
newObjects.set(label.id, label);
return {
...state,
objects: newObjects,
};
}
function applyShowAction(
state: VisualState,
action: ShowAction,
actionProgress: number
): VisualState {
const targets = action.targetIds || [];
if (targets.length === 0) {
return state;
}
const newObjects = new Map(state.objects);
const opacity = Math.min(1, actionProgress * 2);
for (const targetId of targets) {
const obj = newObjects.get(targetId);
if (obj) {
newObjects.set(targetId, {
...obj,
visible: true,
opacity: obj.opacity !== undefined ? obj.opacity : opacity,
});
}
}
return {
...state,
objects: newObjects,
};
}
function applyHideAction(
state: VisualState,
action: HideAction,
actionProgress: number
): VisualState {
const targets = action.targetIds || [];
if (targets.length === 0) {
return state;
}
const newObjects = new Map(state.objects);
const opacity = 1 - Math.min(1, actionProgress * 2);
for (const targetId of targets) {
const obj = newObjects.get(targetId);
if (obj) {
newObjects.set(targetId, {
...obj,
visible: actionProgress < 1,
opacity: obj.opacity !== undefined ? obj.opacity * opacity : opacity,
});
}
}
return {
...state,
objects: newObjects,
};
}
function applyMeasureAction(
state: VisualState,
action: MeasureAction,
actionProgress: number
): VisualState {
if (actionProgress < 0 || actionProgress > 1) {
return state;
}
const fromObj = state.objects.get(action.fromId);
const toObj = state.objects.get(action.toId);
if (!fromObj || !toObj) {
return state;
}
const newObjects = new Map(state.objects);
const opacity = actionProgress;
// Calculate arrow positions
const arrowId = `arrow_${action.fromId}_to_${action.toId}`;
const labelId = `label_${action.fromId}_to_${action.toId}`;
// Create arrow (line from fromObj to toObj)
const arrow: VisualObject = {
id: arrowId,
type: 'arrow',
x: fromObj.x,
y: fromObj.y,
width: toObj.x - fromObj.x,
height: toObj.y - fromObj.y,
properties: {
fromX: fromObj.x,
fromY: fromObj.y,
toX: toObj.x,
toY: toObj.y,
style: action.arrowStyle || 'dashed',
color: action.arrowColor || '#efd78f',
},
visible: actionProgress > 0,
opacity,
createdAtFrame: action.startFrame,
};
// Create label at midpoint
const label: VisualObject = {
id: labelId,
type: 'label',
x: (fromObj.x + toObj.x) / 2,
y: (fromObj.y + toObj.y) / 2 - 40,
text: action.label,
fontSize: 32,
properties: {},
visible: actionProgress > 0.3,
opacity: opacity > 0.3 ? (opacity - 0.3) / 0.7 : 0,
createdAtFrame: action.startFrame,
};
newObjects.set(arrowId, arrow);
newObjects.set(labelId, label);
return {
...state,
objects: newObjects,
};
}
function applyBuildEquationAction(
state: VisualState,
action: BuildEquationAction,
actionProgress: number
): VisualState {
if (actionProgress < 0 || actionProgress > 1) {
return state;
}
const newObjects = new Map(state.objects);
const stepCount = action.steps.length;
const currentStep = Math.min(
Math.floor(actionProgress * stepCount),
stepCount - 1
);
// Build equation step by step
const equationId = action.equationId;
let equationText = '';
const symbols: Array<{ symbol: string; x: number; y: number }> = [];
for (let i = 0; i <= currentStep; i++) {
const step = action.steps[i];
symbols.push({ symbol: step.symbol, x: step.position.x, y: step.position.y });
equationText += step.symbol;
}
const equation: VisualObject = {
id: equationId,
type: 'equation',
x: 520,
y: 480,
text: equationText,
fontSize: 54,
properties: {
symbols,
currentStep,
totalSteps: stepCount,
},
visible: actionProgress > 0,
opacity: actionProgress,
createdAtFrame: action.startFrame,
};
newObjects.set(equationId, equation);
return {
...state,
objects: newObjects,
};
}
function applySubstituteAction(
state: VisualState,
action: SubstituteAction,
actionProgress: number
): VisualState {
if (actionProgress < 0 || actionProgress > 1) {
return state;
}
const newObjects = new Map(state.objects);
const equation = newObjects.get(action.equationId);
if (!equation) {
return state;
}
const substitutionCount = action.substitutions.length;
const currentSub = Math.min(
Math.floor(actionProgress * substitutionCount),
substitutionCount - 1
);
// Build substituted equation
let displayText = equation.text || '';
const substitutedSymbols: Array<{ symbol: string; value: string | number; sourceObjectId?: string }> = [];
for (let i = 0; i <= currentSub; i++) {
const sub = action.substitutions[i];
substitutedSymbols.push({ symbol: sub.symbol, value: sub.value, sourceObjectId: sub.sourceObjectId });
displayText = displayText.replace(sub.symbol, String(sub.value));
}
const updatedEquation: VisualObject = {
...equation,
text: displayText,
properties: {
...equation.properties,
substitutedSymbols,
currentSubstitution: currentSub,
totalSubstitutions: substitutionCount,
},
};
newObjects.set(action.equationId, updatedEquation);
return {
...state,
objects: newObjects,
};
}
function applyCalculateAction(
state: VisualState,
action: CalculateAction,
actionProgress: number
): VisualState {
if (actionProgress < 0 || actionProgress > 1) {
return state;
}
const newObjects = new Map(state.objects);
const stepCount = action.steps.length;
const currentStep = Math.min(
Math.floor(actionProgress * stepCount),
stepCount - 1
);
const calculation: VisualObject = {
id: `calculation_${action.startFrame}`,
type: 'equation',
x: 520,
y: 540,
text: currentStep >= 0 ? action.steps[currentStep].result : '',
fontSize: 48,
properties: {
expression: action.expression,
steps: action.steps,
currentStep,
totalSteps: stepCount,
},
visible: actionProgress > 0,
opacity: actionProgress,
createdAtFrame: action.startFrame,
};
newObjects.set(calculation.id, calculation);
return {
...state,
objects: newObjects,
};
}
function applyReframeCameraAction(
state: VisualState,
action: ReframeCameraAction,
actionProgress: number
): VisualState {
if (actionProgress < 0 || actionProgress > 1) {
return state;
}
const curvedProgress = applyCurve(actionProgress, 'easeOut');
const newCamera: CameraState = {
...state.camera,
x: interpolateNumber(state.camera.x, action.x, curvedProgress),
y: interpolateNumber(state.camera.y, action.y, curvedProgress),
zoom: interpolateNumber(state.camera.zoom, 1, curvedProgress),
};
return {
...state,
camera: newCamera,
};
}
function applyZoomCameraAction(
state: VisualState,
action: ZoomCameraAction,
actionProgress: number
): VisualState {
if (actionProgress < 0 || actionProgress > 1) {
return state;
}
const newCamera: CameraState = {
...state.camera,
zoom: interpolateNumber(state.camera.zoom, action.zoomLevel, actionProgress),
};
return {
...state,
camera: newCamera,
};
}
function applySetNumericalPhaseAction(
state: VisualState,
action: SetNumericalPhaseAction,
actionProgress: number
): VisualState {
// Numerical phases are discrete steps, not smooth transitions
// But we can use progress for sub-phases
return {
...state,
numericalPhase: action.phase,
numericalProgress: actionProgress,
};
}
// ============================================================================
// ACTION DISPATCHER
// ============================================================================
function applyAction(
state: VisualState,
action: VisualAction,
absoluteFrame: number
): VisualState {
// Handle frames before action starts
if (absoluteFrame < action.startFrame) {
return state;
}
// Calculate action progress (0 to 1)
const actionDuration = action.endFrame - action.startFrame;
const frameIntoAction = absoluteFrame - action.startFrame;
let actionProgress = actionDuration > 0
? Math.max(0, Math.min(1, frameIntoAction / actionDuration))
: 1;
// For frames at or after the end, clamp to progress = 1
if (absoluteFrame >= action.endFrame) {
actionProgress = 1;
}
// Apply action based on type
switch (action.type) {
case 'create':
return applyCreateAction(state, action, actionProgress);
case 'animate':
return applyAnimateAction(state, action, actionProgress);
case 'move':
return applyMoveAction(state, action, actionProgress);
case 'transform':
return applyTransformAction(state, action, actionProgress);
case 'attachLabel':
return applyAttachLabelAction(state, action, actionProgress);
case 'show':
return applyShowAction(state, action, actionProgress);
case 'hide':
return applyHideAction(state, action, actionProgress);
case 'measure':
return applyMeasureAction(state, action, actionProgress);
case 'buildEquation':
return applyBuildEquationAction(state, action, actionProgress);
case 'substitute':
return applySubstituteAction(state, action, actionProgress);
case 'calculate':
return applyCalculateAction(state, action, actionProgress);
case 'reframeCamera':
return applyReframeCameraAction(state, action, actionProgress);
case 'zoomCamera':
return applyZoomCameraAction(state, action, actionProgress);
case 'setNumericalPhase':
return applySetNumericalPhaseAction(state, action, actionProgress);
default:
return state;
}
}
// ============================================================================
// MAIN DERIVATION FUNCTION
// ============================================================================
/**
* Derive the visual state at a specific absolute frame.
*
* This is a PURE FUNCTION with NO SIDE EFFECTS.
* The same inputs always produce the same output.
*
* @param initialState - The initial state at frame 0
* @param allActions - All visual actions in the timeline (sorted by startFrame)
* @param absoluteFrame - The frame number to derive state for
* @returns The complete visual state at that frame
*/
export function deriveVisualState(
initialState: VisualState,
allActions: VisualAction[],
absoluteFrame: number
): VisualState {
// Start from initial state
let state: VisualState = {
...initialState,
absoluteFrame,
};
// Apply all actions that are active at or before this frame
// Actions must be sorted by startFrame for deterministic ordering
const sortedActions = [...allActions].sort((a, b) => a.startFrame - b.startFrame);
for (const action of sortedActions) {
// Skip actions that start after our frame
if (action.startFrame > absoluteFrame) {
break;
}
// Apply the action
state = applyAction(state, action, absoluteFrame);
}
return state;
}
// ============================================================================
// SEGMENT CONTINUITY HELPERS
// ============================================================================
/**
* Create a segment-start state snapshot for continuity.
* This ensures the first frame of segment N matches the last frame of segment N-1.
*/
export function createSegmentStartState(
previousSegmentEndState: VisualState,
segmentStartFrame: number
): VisualState {
return {
...previousSegmentEndState,
absoluteFrame: segmentStartFrame,
segmentStartFrame,
initialStateSnapshot: previousSegmentEndState,
};
}
/**
* Derive state for a specific segment, using its initial state snapshot.
*/
export function deriveSegmentState(
segmentInitialState: VisualState,
segmentActions: VisualAction[],
frameWithinSegment: number
): VisualState {
const absoluteFrame = segmentInitialState.segmentStartFrame + frameWithinSegment;
return deriveVisualState(segmentInitialState, segmentActions, absoluteFrame);
}
// ============================================================================
// UTILITY: Filter actions for a specific frame range
// ============================================================================
export function getActionsInRange(
allActions: VisualAction[],
startFrame: number,
endFrame: number
): VisualAction[] {
return allActions.filter(
(action) =>
action.startFrame <= endFrame &&
action.endFrame >= startFrame
);
}