Files
RUWIO/src/lib/interpolate.ts
T
thandClaude Opus 4.7 68ea8576b6 macro-clips slice 7: button state transitions
- ButtonClip transitions are now editable in ButtonInstanceDialog
  (from/to/duration/easing) and persisted on the clip via the new
  store.setButtonClipTransitions action; transitions are shared by
  every instance of the button (clip-scoped, not instance-scoped).
- lerpProps exported from interpolate.ts so callers outside the
  sampler can blend two poses.
- useButtonRuntime tracks an active transition per instance plus a
  reactive transitionTick that pokes Vue computeds each frame.
  A self-driving RAF loop runs only while some transition is active
  (idles when none), so animations work paused or playing.
- applyButtonStateChange(id, to, transitions, persistedDisabled)
  is the one entry point for state changes — it looks up a matching
  from→to transition and starts it, or flips state instantly. Pointer
  handlers in CanvasStage and store.actionRuntime.setButtonState both
  go through it.
- CanvasStage blends the two state stacks via sampleStateLayers +
  blendStates: layers matched by name interpolate their sampled props
  with lerpProps; unmatched layers fade in/out. Renderer uses the
  blended layer list in place of the pure state stack during transit.
- Export runtime mirrors the same flow: applyButtonChange starts
  transitions, sampleButtonTransition drives per-frame progress, and
  drawButtonStatesBlended composites via drawLayer(layer, 0, override).
  drawLayer gained an optional propsOverride param for this.
  RUNTIME_VERSION bumped to 6.
- Tests cover applyButtonStateChange matching logic and
  sampleTransition's time → u mapping plus done flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:48:50 +02:00

148 lines
5.5 KiB
TypeScript

import type { AnimatableProps, Easing, Keyframe, Layer } from '@/types/scene'
const easings: Record<Easing, (t: number) => 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>): 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 }
const sorted = [...kfs].sort((a, b) => a.time - b.time)
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 }
}
export function sampleLayer(layer: Layer, time: number): AnimatableProps {
const main = sampleKeyframeSet(layer.keyframes, layer.base, time)
if (!layer.separated) return main
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
return {
...main,
x: pos.x,
y: pos.y,
rotation: rot.rotation,
}
}