1.2.10: wiggle — deterministic procedural shake modifier per layer

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>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-26 20:24:23 +02:00
1 parent 7d81a50e74
commit 9e25cf155a
11 files changed
+546 -32

No files matched your search

+150 -2
View File
@@ -15548,8 +15548,8 @@ export function registerAllTests(): void {
expect(src).toContain('function drawClipInstance(')
expect(src).toContain('clipKaleidoPivot')
expect(src).toContain('clipArmLocalT')
// Runtime version got bumped through this feature's iterations.
expect(src).toContain("'31'")
// Runtime version got bumped during the kaleido + wiggle work.
expect(src).toContain("'32'")
})
test('exported runtime layerActiveAt is unaffected by kaleido fields', async () => {
@@ -15767,4 +15767,152 @@ export function registerAllTests(): void {
}
})
})
suite('wiggle — procedural shake modifier', () => {
test('wiggleNoise is deterministic and bounded in [-1, 1]', async () => {
const { wiggleNoise } = await import('@/lib/wiggle')
// Same seed + time always yields the exact same number.
const a = wiggleNoise(12345, 0.42)
const b = wiggleNoise(12345, 0.42)
expect(a).toBe(b)
// Bounds: noise ∈ [-1, 1] for any seed/time.
const seeds = [1, 9999, 0xdeadbeef, 0x12345678]
for (const s of seeds) {
for (let t = 0; t < 5; t += 0.05) {
const n = wiggleNoise(s, t)
expect(n >= -1 && n <= 1).toBe(true)
}
}
})
test('layerSeeds returns three distinct, stable seeds per layer id', async () => {
const { layerSeeds } = await import('@/lib/wiggle')
const a = layerSeeds('layer-A')
const b = layerSeeds('layer-A')
// Stable across calls.
expect(a[0]).toBe(b[0])
expect(a[1]).toBe(b[1])
expect(a[2]).toBe(b[2])
// The three seeds are independent (would be a bug if any two
// collide and produce identical noise streams).
expect(a[0] === a[1]).toBe(false)
expect(a[1] === a[2]).toBe(false)
expect(a[0] === a[2]).toBe(false)
// Different ids → different seed triples.
const c = layerSeeds('layer-B')
expect(a[0] === c[0]).toBe(false)
})
test('applyWiggle leaves props unchanged when the layer has no wiggle', async () => {
const { applyWiggle } = await import('@/lib/wiggle')
const layer = { id: 'l1', base: {}, keyframes: [] } as unknown as import('@/types/scene').Layer
const props = { x: 100, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 } as import('@/types/scene').AnimatableProps
const out = applyWiggle(props, layer, 1)
expect(out).toBe(props)
})
test('sampleLayer with wiggle drifts around the base props', async () => {
const { sampleLayer } = await import('@/lib/interpolate')
const layer = {
id: 'l-wiggle',
name: 'L',
assetId: 'a',
visible: true,
base: { x: 100, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [],
wiggle: { amplitude: 10, rotation: 4, frequency: 1 },
} as unknown as import('@/types/scene').Layer
// Sample over a second of playback. The result should never
// sit exactly on the base (noise hits the boundary) — and it
// should stay within the configured amplitude bands.
let sawDrift = false
for (let t = 0; t <= 1; t += 0.05) {
const p = sampleLayer(layer, t)
const dx = p.x - 100
const dy = p.y - 50
if (Math.abs(dx) > 0.01 || Math.abs(dy) > 0.01) sawDrift = true
expect(Math.abs(dx) <= 10 + 1e-6).toBe(true)
expect(Math.abs(dy) <= 10 + 1e-6).toBe(true)
expect(Math.abs(p.rotation) <= 4 + 1e-6).toBe(true)
}
expect(sawDrift).toBe(true)
})
test('sampleLayer with wiggle is deterministic — same time, same drift', async () => {
const { sampleLayer } = await import('@/lib/interpolate')
const layer = {
id: 'l-stable',
name: 'L',
assetId: 'a',
visible: true,
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [],
wiggle: { amplitude: 5, rotation: 2, frequency: 1.5 },
} as unknown as import('@/types/scene').Layer
const a = sampleLayer(layer, 0.42)
const b = sampleLayer(layer, 0.42)
expect(a.x).toBe(b.x)
expect(a.y).toBe(b.y)
expect(a.rotation).toBe(b.rotation)
})
test('store.setLayerWiggle: roundtrip, on→off, magnitude clamp', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const layer = s.addLayer(a.id)!
// Off by default.
expect(layer.wiggle === undefined).toBe(true)
// Patch on.
s.setLayerWiggle(layer.id, { amplitude: 6, rotation: 3, frequency: 2 })
expect(layer.wiggle?.amplitude).toBe(6)
expect(layer.wiggle?.rotation).toBe(3)
expect(layer.wiggle?.frequency).toBe(2)
// Negative inputs clamp to 0.
s.setLayerWiggle(layer.id, { amplitude: -5 })
expect(layer.wiggle?.amplitude).toBe(0)
// Pass null → delete the whole field.
s.setLayerWiggle(layer.id, null)
expect(layer.wiggle === undefined).toBe(true)
})
test('PropertiesPanel: Wiggle section toggles the wiggle field', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const layer = s.addLayer(a.id)!
s.selectedLayerId = layer.id
s.selectedLayerIds = [layer.id]
const handle = mountComponent(PropertiesPanel, {})
try {
await nextTick()
// Open the Wiggle section.
const headers = Array.from(handle.host.querySelectorAll<HTMLButtonElement>('.group-header'))
const wHeader = headers.find((b) => (b.textContent ?? '').toLowerCase().includes('wiggle'))!
expect(!!wHeader).toBe(true)
wHeader.click()
await nextTick()
// Toggle the modifier on. The toggle button is the first
// .icon-toggle inside this section's body.
const sectionBody = wHeader.parentElement!.querySelector('.group-body') as HTMLElement
const toggle = sectionBody.querySelector('.icon-toggle') as HTMLButtonElement
toggle.click()
await nextTick()
const stored = s.scene.layers.find((l) => l.id === layer.id)!
expect(!!stored.wiggle).toBe(true)
expect(stored.wiggle?.amplitude).toBe(8)
expect(stored.wiggle?.rotation).toBe(2)
expect(stored.wiggle?.frequency).toBe(2)
} finally {
handle.unmount()
}
})
test('exported runtime engine carries wiggle helpers', () => {
const src = generateRuntimeScript()
expect(src).toContain('function wiggleNoise(')
expect(src).toContain('function applyWiggle(')
expect(src).toContain('function wiggleSeeds(')
// Runtime version got bumped when wiggle landed.
expect(src).toContain("'32'")
})
})
}