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:
1 parent
7d81a50e74
commit
9e25cf155a
11 files changed
+546
-32
No files matched your search
+150
-2
@@ -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'")
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user