Per-layer modifier that adds noise to the sampled position and
rotation each frame. Pairs thematically with kaleido: both stack
on top of the keyframe sample, both per-layer, both deterministic
so editor and exported runtime render identically.
Math (src/lib/wiggle.ts):
- hash32 + layerSeeds(layerId): three uncorrelated 32-bit seeds per
layer, derived from the id so the same layer always wobbles the
same way.
- wiggleNoise(seed, t): sum-of-three-sines in [-1, 1]. Smooth (C∞),
cheap, no noise-lib dependency. Phase constants are irrational
ratios picked so the streams don't collapse into a low-period
pattern.
- applyWiggle(props, layer, time): adds amp * noise(seed, t*freq)
to x / y / rotation. Defaults: 8px position, 2° rotation, 2Hz.
Wire-in:
- src/lib/interpolate.ts sampleLayer: post-processes the keyframe
sample with applyWiggle. Every renderer goes through sampleLayer
(canvas2d, Konva, button-state, clip-internal), so the modifier
lights up everywhere from a single hook.
- src/lib/exportPlayer.ts engine: byte-for-byte JS mirror of
hash32 / layerSeeds / wiggleNoise / applyWiggle, plus the same
post-process branch in the engine's sample(). RUNTIME_VERSION
31 → 32.
Data + store:
- Layer.wiggle?: { amplitude?, rotation?, frequency? }. All sub-
fields optional with the documented defaults.
- store.setLayerWiggle(id, patch | null): patch object updates
individual fields with magnitude clamping (≥ 0); null deletes
the whole wiggle field.
UI (PropertiesPanel + i18n):
- New "Wiggle" section between Mask and Kaleidoscope. Toggle
(PhWaveSine icon) seeds the pleasant defaults; three RangeSliders
for position amplitude (0-64 px), rotation amplitude (0-45°),
and frequency (0-10 Hz). Hidden for button-asset layers
(consistent with Kaleido's hit-area constraint).
Tests (8 new under "wiggle — procedural shake modifier"):
- wiggleNoise determinism + [-1, 1] bounds across 4 seeds × ~100
time points.
- layerSeeds stability + the three seeds are pairwise distinct;
different ids yield different triples.
- applyWiggle on a layer without `wiggle` is a no-op (returns the
input reference unchanged).
- sampleLayer with wiggle: drift visible across a 1s window and
always within the configured amplitude bands.
- sampleLayer with wiggle is deterministic — same time, same drift.
- setLayerWiggle roundtrip + magnitude clamp + null-delete.
- PropertiesPanel Wiggle toggle writes the wiggle field with the
documented defaults.
- Exported runtime carries wiggleNoise / applyWiggle / wiggleSeeds
and bumps RUNTIME_VERSION to '32'.
Plan for the next two backlog items (motion-path overlay + canvas
eyedropper) is in claude.txt for later.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
246 lines
9.2 KiB
TypeScript
246 lines
9.2 KiB
TypeScript
import type { AnimatableProps, Easing, Keyframe, Layer } from '@/types/scene'
|
|
import { applyWiggle } from './wiggle'
|
|
|
|
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 }
|
|
// 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
|
|
}
|