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
+536 -22

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
FUTURE FUN: THREE FEATURES TO LAND
==================================
End-of-day backlog of ideas Claude pitched and the user liked. Each
sized for ~one patch bump. Wiggle goes first; the others can wait.
1. WIGGLE — procedural shake modifier (per layer)
-------------------------------------------------
Add a "Wiggle" property to Layer. When on, every sampled frame
adds deterministic noise to position and/or rotation, giving any
layer that classic animator wobble. Pairs thematically with Kaleido:
both are per-layer procedural modifiers stacked on top of the base
keyframe sample.
Data model (src/types/scene.ts):
wiggle?: {
amplitude?: number // pixels for x/y
rotation?: number // degrees for rotation
frequency?: number // Hz (cycles per second)
}
// Defaults when `wiggle` is set but a sub-field is missing:
// amplitude=8px, rotation=2°, frequency=2Hz. Toggle off ⇒ delete.
Math (src/lib/wiggle.ts) — shared truth:
function layerSeed(id: string): [number, number, number]
// 3 stable 32-bit ints derived from the layer id, used as
// independent seeds for x / y / rotation noise streams.
function wiggleNoise(seed: number, t: number): number
// Deterministic [-1, 1] output. Sum of three sin waves with
// different irrational phase offsets — cheap, smooth, no
// dependency on a noise lib.
return (
sin(seed * 12.9898 + t * 78.233) +
sin(seed * 4.31 + t * 11.71 ) +
sin(seed * 7.71 + t * 23.5 )
) / 3
function applyWiggle(props, layer, time): props'
// Adds amp * noise(seedX, t * freq) to props.x, etc.
Wire-in:
- src/lib/interpolate.ts sampleLayer: post-process the result of
the keyframe sample with applyWiggle. ALL renderers go through
sampleLayer (canvas2d, Konva, button-state, clip-internal), so a
single hook here lights up everywhere except the export engine.
- src/lib/exportPlayer.ts: mirror the math in JS inside the engine
source so exports look identical. RUNTIME_VERSION bump.
UI (src/components/PropertiesPanel.vue + i18n):
- New "Wiggle" collapsible section, between Mask and Kaleido feels
natural. Toggle (PhWaveSine icon), then three RangeSliders:
amplitude (0–64 px), rotation (0–45°), frequency (0–10 Hz).
Hidden for button-asset layers (consistent with Kaleido).
Store (src/stores/scene.ts):
- setLayerWiggle(id, patch) with same delete-on-default semantics
as Kaleido (rotation=0 ⇒ delete the field, etc.).
Tests:
- wiggleNoise determinism: noise(seed, t) === noise(seed, t).
- wiggleNoise smoothness: |n(t) - n(t+1/60)| stays bounded.
- sampleLayer with wiggle: x/y/rotation drift around base, return
to base when wiggle is off.
- Engine source carries wiggleNoise; same sample at the same time
matches between editor and engine.
- PropertiesPanel renders the new section + writes wiggle fields
back to the store.
2. MOTION-PATH OVERLAY (selected layer's position keyframes)
------------------------------------------------------------
Visualize a layer's keyframed path on the canvas as a faint dashed
line connecting consecutive position keyframes, with each keyframe
rendered as a draggable dot. The position-keyframe drag-handle
infrastructure (PathFrame, activeSegments in CanvasStage.vue ~2562)
is already 70% there — currently only used when a position-kf is
explicitly being dragged. Promote that overlay to "always on while
the layer is selected".
Scope:
- Toggle in the View menu (or a slash-key shortcut) called "Motion
paths" — global flag persisted in localStorage like
`useShowLocked`.
- For the selected layer (and only the selected layer), draw the
overlay even when nothing is being dragged. Reuse the existing
segment / curve-sampling helpers.
- Color follows layer.color so two layers' paths don't collide
visually.
Bigger lift than Wiggle but mostly leveraging existing code.
3. EYEDROPPER (color sampling from anywhere on the page)
--------------------------------------------------------
Tiny feature using the browser's EyeDropper API. Adds a small
pipette icon next to every color swatch in the PropertiesPanel
(fill, stroke, shadow color). Click → fires `new EyeDropper().open()`
→ user picks any pixel on screen → result sets the swatch.
Caveats:
- Chromium-only API. Hide the button when `window.EyeDropper` is
undefined (Safari + Firefox) — a clean progressive enhancement.
- Permission prompt is browser-handled, no extra plumbing.
Smallest of the three. Good "polish day" snack.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
"version": "1.2.9",
"version": "1.2.10",
"type": "module",
"scripts": {
"dev": "vite",
+89 -1
View File
@@ -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" />
+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'")
})
})
}
+8
View File
@@ -202,6 +202,7 @@ export const en = {
appearance: 'Appearance',
button: 'On Click',
kaleido: 'Kaleidoscope',
wiggle: 'Wiggle',
},
text: {
text: 'Text',
@@ -221,6 +222,13 @@ export const en = {
clipInstance: {
banner: 'Clip instance — only kaleidoscope is editable here. Use the canvas to move/resize.',
},
wiggle: {
toggle: 'Wiggle',
amplitude: 'Position amplitude',
rotation: 'Rotation amplitude',
frequency: 'Frequency',
hint: 'Adds procedural shake on top of the keyframe sample. Deterministic per layer — same time always gives the same wobble.',
},
polygonPoints: 'Points',
polygonPointsHint: 'Changing the point count rewrites all keyframes; new points are inserted at the midpoint of the last edge.',
polygonClosed: 'Closed path (fill)',
+51 -12
View File
@@ -11,7 +11,7 @@ function isProject(p: ExportPayload): p is Project {
return Array.isArray((p as Partial<Project>).scenes)
}
const RUNTIME_VERSION = '31'
const RUNTIME_VERSION = '32'
/** Materialize the engine source. The font map is rebuilt on every
* call so user-uploaded custom fonts (which live in localStorage
@@ -140,21 +140,60 @@ const ENGINE_TEMPLATE = `(function(){
}
return merge(base);
}
// Mirror of src/lib/wiggle.ts — keep in lockstep so editor and
// exported runtime render an identical wobble at the same time.
function wiggleHash32(s){
var h=5381;
for(var i=0;i<s.length;i++){ h=((h<<5)+h+s.charCodeAt(i))|0; }
return h>>>0;
}
function wiggleSeeds(id){
var h=wiggleHash32(id);
return [(h^0x9e3779b9)>>>0, (h^0x85ebca6b)>>>0, (h^0xc2b2ae35)>>>0];
}
function wiggleNoise(seed, t){
var a=Math.sin(seed*12.9898+t*78.233);
var b=Math.sin(seed*4.31+t*11.71);
var c=Math.sin(seed*7.71+t*23.5);
return (a+b+c)/3;
}
function applyWiggle(props, layer, time){
var w=layer.wiggle;
if(!w) return props;
var amp=(w.amplitude==null?8:w.amplitude);
var rot=(w.rotation==null?2:w.rotation);
var freq=(w.frequency==null?2:w.frequency);
if(amp<=0 && rot<=0) return props;
if(freq<=0) return props;
var seeds=wiggleSeeds(layer.id);
var tt=time*freq;
var out={};
for(var k in props) out[k]=props[k];
out.x=props.x+amp*wiggleNoise(seeds[0],tt);
out.y=props.y+amp*wiggleNoise(seeds[1],tt);
out.rotation=props.rotation+rot*wiggleNoise(seeds[2],tt);
return out;
}
function sample(layer,time){
var main=sampleKeyframeSet(layer.keyframes, layer.base, time);
if(!layer.separated) return main;
// Separated mode: position and rotation come from their own tracks
// if present, merged back over the main-track values. This matches
// the editor's sampleLayer() so animations on either side of the
// Separate-toggle play identically.
var result;
if(!layer.separated){
result=main;
} else {
// Separated mode: position and rotation come from their own
// tracks if present, merged back over the main-track values.
// Matches the editor's sampleLayer().
var pos = layer.positionKeyframes ? sampleKeyframeSet(layer.positionKeyframes, layer.base, time) : main;
var rot = layer.rotationKeyframes ? sampleKeyframeSet(layer.rotationKeyframes, layer.base, time) : main;
var outSep={};
for(var k in main) outSep[k]=main[k];
outSep.x=pos.x;
outSep.y=pos.y;
outSep.rotation=rot.rotation;
return outSep;
result={};
for(var k in main) result[k]=main[k];
result.x=pos.x;
result.y=pos.y;
result.rotation=rot.rotation;
}
if(layer.wiggle) return applyWiggle(result, layer, time);
return result;
}
function loadImages(assets){
+13 -5
View File
@@ -1,4 +1,5 @@
import type { AnimatableProps, Easing, Keyframe, Layer } from '@/types/scene'
import { applyWiggle } from './wiggle'
const easings: Record<Easing, (t: number) => number> = {
linear: (t) => t,
@@ -220,18 +221,25 @@ export function isButtonLayerActiveAt(layer: Layer, time: number): boolean {
export function sampleLayer(layer: Layer, time: number): AnimatableProps {
const main = sampleKeyframeSet(layer.keyframes, layer.base, time)
if (!layer.separated) return main
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
return {
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
}
+83
View File
@@ -0,0 +1,83 @@
// Procedural shake modifier — adds deterministic noise to a layer's
// sampled props so it visibly "wobbles" over time. The same time
// always yields the same value (so the editor stage, render.ts
// renderer and exported runtime stay in lockstep), and three
// independent noise streams (x / y / rotation) keep the wobble from
// looking like a single 1D oscillation.
//
// Mirrored as plain JS inside exportPlayer.ts's engine source —
// keep the formulas in `wiggleNoise` and `applyWiggle` byte-for-byte
// equivalent on both sides.
import type { AnimatableProps, Layer } from '@/types/scene'
const DEFAULT_AMPLITUDE = 8
const DEFAULT_ROTATION = 2
const DEFAULT_FREQUENCY = 2
/** Stable 32-bit hash of a string. djb2-ish — good enough for
* picking three uncorrelated seeds per layer. */
function hash32(s: string): number {
let h = 5381
for (let i = 0; i < s.length; i++) {
h = ((h << 5) + h + s.charCodeAt(i)) | 0
}
return h >>> 0
}
/** Three independent seeds for the layer's x, y, rotation noise
* streams. Derived deterministically from the layer id so the same
* layer always wobbles the same way. */
export function layerSeeds(layerId: string): [number, number, number] {
const h = hash32(layerId)
return [
(h ^ 0x9e3779b9) >>> 0,
(h ^ 0x85ebca6b) >>> 0,
(h ^ 0xc2b2ae35) >>> 0,
]
}
/** Sum-of-sines noise in [-1, 1]. Smooth (each input maps to a
* C∞ output) and cheap. The phase constants are irrational ratios
* picked so the three components don't collapse into a low-period
* pattern. */
export function wiggleNoise(seed: number, t: number): number {
const a = Math.sin(seed * 12.9898 + t * 78.233)
const b = Math.sin(seed * 4.31 + t * 11.71)
const c = Math.sin(seed * 7.71 + t * 23.5)
return (a + b + c) / 3
}
/** Returns true when the layer has wiggle on AND at least one of
* the magnitudes is non-zero. */
export function hasWiggle(layer: Layer): boolean {
const w = layer.wiggle
if (!w) return false
const amp = w.amplitude ?? DEFAULT_AMPLITUDE
const rot = w.rotation ?? DEFAULT_ROTATION
return amp > 0 || rot > 0
}
/** Adds wiggle to a layer's sampled props in place — caller passes
* in a mutable copy. Does nothing when the layer has no wiggle. */
export function applyWiggle(
props: AnimatableProps,
layer: Layer,
time: number,
): AnimatableProps {
const w = layer.wiggle
if (!w) return props
const amp = w.amplitude ?? DEFAULT_AMPLITUDE
const rot = w.rotation ?? DEFAULT_ROTATION
const freq = w.frequency ?? DEFAULT_FREQUENCY
if (amp <= 0 && rot <= 0) return props
if (freq <= 0) return props
const [sx, sy, sr] = layerSeeds(layer.id)
const tt = time * freq
return {
...props,
x: props.x + amp * wiggleNoise(sx, tt),
y: props.y + amp * wiggleNoise(sy, tt),
rotation: props.rotation + rot * wiggleNoise(sr, tt),
}
}
+21
View File
@@ -2180,6 +2180,27 @@ export const useSceneStore = defineStore('scene', {
const n = Math.max(2, Math.min(64, Math.floor(count)))
layer.kaleidoCount = n
},
setLayerWiggle(
id: string,
patch: Partial<{ amplitude: number; rotation: number; frequency: number }> | null,
) {
const layer = this.activeLayers.find(l => l.id === id)
if (!layer) return
if (patch === null) {
delete layer.wiggle
return
}
const w = (layer.wiggle ||= {})
if (patch.amplitude !== undefined) {
w.amplitude = Math.max(0, patch.amplitude)
}
if (patch.rotation !== undefined) {
w.rotation = Math.max(0, patch.rotation)
}
if (patch.frequency !== undefined) {
w.frequency = Math.max(0, patch.frequency)
}
},
setClipTrackName(id: string, name: string) {
const track = this.scene.clipTracks?.find((t) => t.id === id)
if (track) track.name = name.trim() || undefined
+11
View File
@@ -237,6 +237,17 @@ export interface Layer {
/** ≥ 2, integer. Total visible copies = kaleidoCount. Disabled
* when `kaleido` is false. */
kaleidoCount?: number
/** Procedural shake. Three noise streams (x, y, rotation) add to
* the sampled props at render time. Deterministic per layer id
* so renders are stable across editor / export. */
wiggle?: {
/** Position amplitude in pixels. Default 8. */
amplitude?: number
/** Rotation amplitude in degrees. Default 2. */
rotation?: number
/** Cycles per second. Default 2. */
frequency?: number
}
}
export interface Clip {
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '1.2.9'
export const APP_VERSION = '1.2.10'
export const APP_NAME = 'RUWIO'