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
@@ -8,6 +8,7 @@ import {
|
||||
PhEyeSlash,
|
||||
PhFlower,
|
||||
PhMaskHappy,
|
||||
PhWaveSine,
|
||||
PhMinus,
|
||||
PhPolygon,
|
||||
PhPlus,
|
||||
@@ -27,7 +28,7 @@ const { formatTime, timeToInputValue, inputValueToSeconds, inputStep, unitLabel
|
||||
|
||||
const SECTION_STORAGE_KEY = 'animatr:propSections'
|
||||
|
||||
type SectionKey = 'transform' | 'text' | 'polygon' | 'appearance' | 'shadow' | 'mask' | 'button' | 'kaleido'
|
||||
type SectionKey = 'transform' | 'text' | 'polygon' | 'appearance' | 'shadow' | 'mask' | 'button' | 'kaleido' | 'wiggle'
|
||||
|
||||
const defaultOpen: Record<SectionKey, boolean> = {
|
||||
transform: true,
|
||||
@@ -38,6 +39,7 @@ const defaultOpen: Record<SectionKey, boolean> = {
|
||||
mask: false,
|
||||
button: true,
|
||||
kaleido: false,
|
||||
wiggle: false,
|
||||
}
|
||||
|
||||
function readStoredOpen(): Record<SectionKey, boolean> {
|
||||
@@ -213,6 +215,40 @@ function setKaleidoOn(on: boolean) {
|
||||
store.setLayerKaleido(selected.value.id, on)
|
||||
}
|
||||
|
||||
const wiggleOn = computed(() => !!selected.value?.wiggle)
|
||||
const wiggleAmplitude = computed<number>({
|
||||
get: () => selected.value?.wiggle?.amplitude ?? 8,
|
||||
set: (v: number) => {
|
||||
if (!selected.value) return
|
||||
store.setLayerWiggle(selected.value.id, { amplitude: v })
|
||||
},
|
||||
})
|
||||
const wiggleRotation = computed<number>({
|
||||
get: () => selected.value?.wiggle?.rotation ?? 2,
|
||||
set: (v: number) => {
|
||||
if (!selected.value) return
|
||||
store.setLayerWiggle(selected.value.id, { rotation: v })
|
||||
},
|
||||
})
|
||||
const wiggleFrequency = computed<number>({
|
||||
get: () => selected.value?.wiggle?.frequency ?? 2,
|
||||
set: (v: number) => {
|
||||
if (!selected.value) return
|
||||
store.setLayerWiggle(selected.value.id, { frequency: v })
|
||||
},
|
||||
})
|
||||
|
||||
function setWiggleOn(on: boolean) {
|
||||
if (!selected.value) return
|
||||
if (on) {
|
||||
// Seed with the pleasant defaults animators tend to start with;
|
||||
// they can dial it down from there.
|
||||
store.setLayerWiggle(selected.value.id, { amplitude: 8, rotation: 2, frequency: 2 })
|
||||
} else {
|
||||
store.setLayerWiggle(selected.value.id, null)
|
||||
}
|
||||
}
|
||||
|
||||
function onKaleidoCountInput(e: Event) {
|
||||
if (!selected.value) return
|
||||
const v = Number((e.target as HTMLInputElement).value)
|
||||
@@ -775,6 +811,58 @@ function deleteSelection() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="canKaleido && !multiSelected" class="prop-group">
|
||||
<button class="group-header" @click="toggleSection('wiggle')">
|
||||
<component :is="sectionOpen.wiggle ? PhCaretDown : PhCaretRight" :size="11" />
|
||||
<span>{{ t('properties.sections.wiggle') }}</span>
|
||||
</button>
|
||||
<div v-if="sectionOpen.wiggle" class="group-body">
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label">{{ t('properties.wiggle.toggle') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-toggle"
|
||||
:class="{ active: wiggleOn }"
|
||||
v-tooltip="t('properties.wiggle.toggle')"
|
||||
@click="setWiggleOn(!wiggleOn)"
|
||||
>
|
||||
<PhWaveSine :size="14" :weight="wiggleOn ? 'fill' : 'regular'" />
|
||||
</button>
|
||||
</div>
|
||||
<label v-if="wiggleOn" class="slider-label">
|
||||
{{ t('properties.wiggle.amplitude') }}
|
||||
<RangeSlider
|
||||
v-model="wiggleAmplitude"
|
||||
:min="0"
|
||||
:max="64"
|
||||
:step="1"
|
||||
:format="(v: number) => `${Math.round(v)}px`"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="wiggleOn" class="slider-label">
|
||||
{{ t('properties.wiggle.rotation') }}
|
||||
<RangeSlider
|
||||
v-model="wiggleRotation"
|
||||
:min="0"
|
||||
:max="45"
|
||||
:step="0.5"
|
||||
:format="(v: number) => `${Math.round(v * 2) / 2}°`"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="wiggleOn" class="slider-label">
|
||||
{{ t('properties.wiggle.frequency') }}
|
||||
<RangeSlider
|
||||
v-model="wiggleFrequency"
|
||||
:min="0"
|
||||
:max="10"
|
||||
:step="0.1"
|
||||
:format="(v: number) => `${Math.round(v * 10) / 10} Hz`"
|
||||
/>
|
||||
</label>
|
||||
<p v-if="wiggleOn" class="hint">{{ t('properties.wiggle.hint') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="canKaleido && !multiSelected" class="prop-group">
|
||||
<button class="group-header" @click="toggleSection('kaleido')">
|
||||
<component :is="sectionOpen.kaleido ? PhCaretDown : PhCaretRight" :size="11" />
|
||||
|
||||
Reference in new issue
Block a user