/** * 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 ); }