import type { ImageSegment, ImagePose } from '../types/cutscene'; import { applyEasing } from '../constants/easings'; function clamp(v: number, min: number, max: number) { return Math.max(min, Math.min(max, v)); } function lerp(a: number, b: number, t: number) { return a + (b - a) * t; } export interface SegmentRenderState { alpha: number; pose: ImagePose; } export function computeSegmentState(seg: ImageSegment, currentMs: number): SegmentRenderState | null { if (currentMs < seg.startMs || currentMs > seg.endMs) return null; const duration = seg.endMs - seg.startMs; const localMs = currentMs - seg.startMs; const t = duration > 0 ? clamp(localMs / duration, 0, 1) : 1; const tEased = applyEasing(t, seg.easing); const pose: ImagePose = { centerX: lerp(seg.from.centerX, seg.to.centerX, tEased), centerY: lerp(seg.from.centerY, seg.to.centerY, tEased), scale: lerp(seg.from.scale, seg.to.scale, tEased), }; let alpha = 1; if (seg.fadeInMs > 0 && localMs < seg.fadeInMs) { alpha = localMs / seg.fadeInMs; } else if (seg.fadeOutMs > 0 && localMs > duration - seg.fadeOutMs) { alpha = (duration - localMs) / seg.fadeOutMs; } return { alpha: clamp(alpha, 0, 1), pose }; } /** * Returns CSS for an absolutely-positioned inside the viewport div. * * The image is stretched (object-fit: fill) to exactly logicalW × logicalH * logical pixels on screen — matching how the game engine maps the full * texture quad to those dimensions, regardless of the file's natural size. * * At scale=1 the logical area fills the viewport (aspect-ratio corrected). * The viewport's own overflow:hidden clips anything that extends beyond. */ export function poseToStyle( pose: ImagePose, logicalW: number, // segment.width (e.g. 1280) logicalH: number, // segment.height (e.g. 720) containerW: number, containerH: number, ): React.CSSProperties { const baseScale = Math.max(containerW / logicalW, containerH / logicalH); const renderW = logicalW * baseScale * pose.scale; const renderH = logicalH * baseScale * pose.scale; const maxOffsetX = Math.max(0, (renderW - containerW) / 2); const maxOffsetY = Math.max(0, (renderH - containerH) / 2); const offsetX = clamp((0.5 - pose.centerX) * renderW, -maxOffsetX, maxOffsetX); // Y axis is inverted: centerY=0 → bottom of image, centerY=1 → top (Y-up convention) const offsetY = clamp((pose.centerY - 0.5) * renderH, -maxOffsetY, maxOffsetY); return { position: 'absolute', top: '50%', left: '50%', width: renderW, height: renderH, // Default object-fit (fill) stretches the full texture to these logical // dimensions, exactly as the game engine renders the quad. objectFit: 'fill', transform: `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY}px))`, }; }