Download src/video-engine/physics/production/deriveVisualState.ts from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 21.2 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/video-engine/physics/production/deriveVisualState.ts
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/video-engine/physics/production/deriveVisualState.ts
-
curl -L -o deriveVisualState.ts https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/video-engine/physics/production/deriveVisualState.ts
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 | |
| ); | |
| } | |