import type { AnimatableProps, Easing, Keyframe, Layer } from '@/types/scene' import { applyWiggle } from './wiggle' const easings: Record number> = { linear: (t) => t, easeIn: (t) => t * t, easeOut: (t) => 1 - (1 - t) ** 2, easeInOut: (t) => (t < 0.5 ? 2 * t * t : 1 - ((-2 * t + 2) ** 2) / 2), } function merge(base: AnimatableProps, partial: Partial): AnimatableProps { return { ...base, ...partial } } function lerp(a: number, b: number, t: number) { return a + (b - a) * t } function parseHex(hex: string): [number, number, number] | null { const h = hex.trim().replace('#', '') const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h if (full.length < 6) return null const n = parseInt(full.slice(0, 6), 16) if (Number.isNaN(n)) return null return [(n >> 16) & 255, (n >> 8) & 255, n & 255] } function formatHex(r: number, g: number, b: number): string { const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v))) const h = (v: number) => clamp(v).toString(16).padStart(2, '0') return `#${h(r)}${h(g)}${h(b)}` } export function lerpColor(a: string, b: string, t: number): string { const pa = parseHex(a) const pb = parseHex(b) if (!pa || !pb) return t < 0.5 ? a : b return formatHex(lerp(pa[0], pb[0], t), lerp(pa[1], pb[1], t), lerp(pa[2], pb[2], t)) } export function lerpProps(a: AnimatableProps, b: AnimatableProps, t: number): AnimatableProps { const out: AnimatableProps = { x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t), scaleX: lerp(a.scaleX, b.scaleX, t), scaleY: lerp(a.scaleY, b.scaleY, t), rotation: lerp(a.rotation, b.rotation, t), opacity: lerp(a.opacity, b.opacity, t), } if (a.fill && b.fill) out.fill = lerpColor(a.fill, b.fill, t) else if (b.fill) out.fill = b.fill else if (a.fill) out.fill = a.fill if (a.blur !== undefined && b.blur !== undefined) out.blur = lerp(a.blur, b.blur, t) else if (b.blur !== undefined) out.blur = b.blur else if (a.blur !== undefined) out.blur = a.blur for (const key of ['shadowBlur', 'shadowOpacity', 'shadowAngle', 'shadowDistance'] as const) { const av = a[key] const bv = b[key] if (av !== undefined && bv !== undefined) out[key] = lerp(av, bv, t) else if (bv !== undefined) out[key] = bv else if (av !== undefined) out[key] = av } if (a.shadowColor && b.shadowColor) out.shadowColor = lerpColor(a.shadowColor, b.shadowColor, t) else if (b.shadowColor) out.shadowColor = b.shadowColor else if (a.shadowColor) out.shadowColor = a.shadowColor if (a.stroke && b.stroke) out.stroke = lerpColor(a.stroke, b.stroke, t) else if (b.stroke) out.stroke = b.stroke else if (a.stroke) out.stroke = a.stroke if (a.strokeWidth !== undefined && b.strokeWidth !== undefined) out.strokeWidth = lerp(a.strokeWidth, b.strokeWidth, t) else if (b.strokeWidth !== undefined) out.strokeWidth = b.strokeWidth else if (a.strokeWidth !== undefined) out.strokeWidth = a.strokeWidth if (a.points && b.points && a.points.length === b.points.length) { out.points = a.points.map((pa, i) => { const pb = b.points![i] const merged: typeof pa = { x: lerp(pa.x, pb.x, t), y: lerp(pa.y, pb.y, t) } for (const key of ['inX', 'inY', 'outX', 'outY'] as const) { const av = pa[key] const bv = pb[key] if (av !== undefined && bv !== undefined) merged[key] = lerp(av, bv, t) else if (bv !== undefined) merged[key] = bv else if (av !== undefined) merged[key] = av } return merged }) } else if (b.points) { out.points = b.points.map((p) => ({ ...p })) } else if (a.points) { out.points = a.points.map((p) => ({ ...p })) } return out } export function sortedKeyframes(layer: Layer): Keyframe[] { return [...layer.keyframes].sort((a, b) => a.time - b.time) } function sampleKeyframeSet( kfs: Keyframe[] | undefined, base: AnimatableProps, time: number, ): AnimatableProps { if (!kfs || !kfs.length) return { ...base } // End-kfs are visibility markers only — they carry no props and // must not influence interpolation. Filter them out before sorting. const sorted = kfs.filter((k) => k.kind !== 'end').sort((a, b) => a.time - b.time) if (!sorted.length) return { ...base } if (time <= sorted[0].time) return merge(base, sorted[0].props) const last = sorted[sorted.length - 1] if (time >= last.time) return merge(base, last.props) for (let i = 0; i < sorted.length - 1; i++) { const a = sorted[i] const b = sorted[i + 1] if (time >= a.time && time <= b.time) { if (a.hold) { if (time >= b.time) return merge(base, b.props) return merge(base, a.props) } const raw = (time - a.time) / (b.time - a.time) const t = easings[b.easing](raw) const pa = merge(base, a.props) const pb = merge(base, b.props) const out = lerpProps(pa, pb, t) // Bezier-curved position: if b defines a quadratic control, override x/y if (b.ctrlX !== undefined && b.ctrlY !== undefined) { const oneMinus = 1 - t out.x = oneMinus * oneMinus * pa.x + 2 * oneMinus * t * b.ctrlX + t * t * pb.x out.y = oneMinus * oneMinus * pa.y + 2 * oneMinus * t * b.ctrlY + t * t * pb.y } return out } } return { ...base } } /** Earliest end-kf time across the layer's main track, or null when * no end kf exists (→ layer never auto-disappears). End kfs only * live on `keyframes`, never on the separated position/rotation * tracks — there's only one end-of-life per layer. */ export function layerEndTime(layer: Layer): number | null { let end: number | null = null for (const k of layer.keyframes ?? []) { if (k.kind !== 'end') continue if (end === null || k.time < end) end = k.time } return end } /** * Union range of all NORMAL keyframe times on a layer (main + position * + rotation tracks). The layer is "alive" from `min` onward; the end * is determined by `layerEndTime` or, if there is no end kf, runs out * to Infinity. * * Returns null when the layer has zero normal keyframes — a static * layer with no animation range, always visible. */ export function layerActiveRange( layer: Layer, ): { min: number; max: number } | null { const main = layer.keyframes || [] const pos = layer.positionKeyframes || [] const rot = layer.rotationKeyframes || [] let min = Infinity let foundNormal = false for (const arr of [main, pos, rot]) { for (const k of arr) { if (k.kind === 'end') continue foundNormal = true if (k.time < min) min = k.time } } if (!foundNormal) return null const end = layerEndTime(layer) return { min, max: end ?? Infinity } } export function isLayerActiveAt(layer: Layer, time: number): boolean { const range = layerActiveRange(layer) if (!range) return true const EPSILON = 1e-6 return time >= range.min - EPSILON && time < range.max - EPSILON } /** * Visibility gate for layers INSIDE a button asset. The 4 discrete * state times (normal / hover / pressed / disabled) are treated as * exact frames — a still-frame (hold) keyframe renders only on its own * state and is invisible on neighbours, not "held forward until the * next keyframe" as on a regular timeline. * * Rules: * - No keyframes → static from base, always visible. * - `time` matches a keyframe's time → visible. * - `time` sits strictly between two adjacent keyframes → visible * only when the left bracket is NOT a hold (i.e. an interpolation * region, not a held-into-silence region). * - Before the first keyframe or after the last → hidden. */ export function isButtonLayerActiveAt(layer: Layer, time: number): boolean { const EPSILON = 1e-6 const kfs: Keyframe[] = [] // End kfs aren't meaningful in button-asset edit (button states // are discrete frame slots, not a timeline) — skip them. for (const k of layer.keyframes) if (k.kind !== 'end') kfs.push(k) if (layer.positionKeyframes) for (const k of layer.positionKeyframes) kfs.push(k) if (layer.rotationKeyframes) for (const k of layer.rotationKeyframes) kfs.push(k) if (kfs.length === 0) return true kfs.sort((a, b) => a.time - b.time) for (const kf of kfs) { if (Math.abs(kf.time - time) < EPSILON) return true } for (let i = 0; i < kfs.length - 1; i++) { const a = kfs[i] const b = kfs[i + 1] if (time > a.time + EPSILON && time < b.time - EPSILON) { return !a.hold } } return false } export function sampleLayer(layer: Layer, time: number): AnimatableProps { const main = sampleKeyframeSet(layer.keyframes, layer.base, time) let result: AnimatableProps if (!layer.separated) { result = main } else { const posSource = layer.positionKeyframes const rotSource = layer.rotationKeyframes const pos = posSource ? sampleKeyframeSet(posSource, layer.base, time) : main const rot = rotSource ? sampleKeyframeSet(rotSource, layer.base, time) : main result = { ...main, x: pos.x, y: pos.y, rotation: rot.rotation, } } // Wiggle stacks on top of the keyframe sample — every renderer goes // through this function so the modifier lights up everywhere // (canvas2d, Konva stage, button-state, clip-internal). The export // engine mirrors the same math in JS. if (layer.wiggle) return applyWiggle(result, layer, time) return result }