Skip to content

Commit f2afa09

Browse files
committed
feat: add aba cadence and flicker filters
1 parent 3f7540c commit f2afa09

7 files changed

Lines changed: 704 additions & 0 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# 030 - ABA temporal filters
2+
3+
## Goal
4+
5+
Add a small set of temporal filters inspired by ABA frame-order cadence and repeated-frame playback artifacts.
6+
7+
## Scope
8+
9+
- Add new main-thread temporal filters that use recent frame history to emulate ABA-style bounce, ghosting, and cadence
10+
- Register the new filters in the main filter registry so they appear in the browser and worker-visible metadata
11+
- Add targeted tests for the new temporal behavior
12+
13+
## Notes
14+
15+
- These filters should stay lightweight and reuse the existing temporal pipeline conventions
16+
- Favor perceptual "ABA-like" motion artifacts over trying to force literal frame insertion in realtime playback

src/filters/abaBounce.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { ACTION, RANGE } from "constants/controlTypes";
2+
import { cloneCanvas } from "utils";
3+
4+
let heldABuf: Uint8ClampedArray | null = null;
5+
let heldBBuf: Uint8ClampedArray | null = null;
6+
let heldWidth = 0;
7+
let heldHeight = 0;
8+
let lastFrameIndex = -1;
9+
10+
const resetTriplet = (source: Uint8ClampedArray, width: number, height: number) => {
11+
heldABuf = new Uint8ClampedArray(source);
12+
heldBBuf = null;
13+
heldWidth = width;
14+
heldHeight = height;
15+
};
16+
17+
export const optionTypes = {
18+
strength: {
19+
type: RANGE,
20+
range: [0, 2],
21+
step: 0.05,
22+
default: 1.1,
23+
desc: "How hard the third beat reflects backward from B toward and past A",
24+
},
25+
cadenceDrift: {
26+
type: RANGE,
27+
range: [0, 1],
28+
step: 0.05,
29+
default: 0.45,
30+
desc: "How much the emphasized bounce beat wanders between strict ABA timing and a looser variable-frame cadence",
31+
},
32+
animSpeed: {
33+
type: RANGE,
34+
range: [1, 30],
35+
step: 1,
36+
default: 15,
37+
desc: "Playback speed when using the built-in animation toggle",
38+
},
39+
animate: {
40+
type: ACTION,
41+
label: "Play / Stop",
42+
action: (actions, inputCanvas, _filterFunc, options) => {
43+
if (actions.isAnimating()) actions.stopAnimLoop();
44+
else actions.startAnimLoop(inputCanvas, options.animSpeed || 15);
45+
},
46+
},
47+
};
48+
49+
export const defaults = {
50+
strength: optionTypes.strength.default,
51+
cadenceDrift: optionTypes.cadenceDrift.default,
52+
animSpeed: optionTypes.animSpeed.default,
53+
};
54+
55+
const abaBounce = (input, options: any = defaults) => {
56+
const strength = Math.max(0, Number(options.strength ?? defaults.strength));
57+
const cadenceDrift = Math.max(0, Math.min(1, Number(options.cadenceDrift ?? defaults.cadenceDrift)));
58+
const frameIndex = Number(options._frameIndex ?? 0);
59+
const output = cloneCanvas(input, false);
60+
const inputCtx = input.getContext("2d");
61+
const outputCtx = output.getContext("2d");
62+
if (!inputCtx || !outputCtx) return input;
63+
64+
const width = input.width;
65+
const height = input.height;
66+
const source = inputCtx.getImageData(0, 0, width, height).data;
67+
const restartedAnimation = frameIndex === 0 && lastFrameIndex > 0;
68+
const phase = ((frameIndex % 3) + 3) % 3;
69+
const cadenceOffset = cadenceDrift <= 0
70+
? 0
71+
: ((Math.sin(frameIndex * 0.91) + Math.sin(frameIndex * 0.37 + 1.7)) * 0.25 + 0.5) < cadenceDrift
72+
? 1
73+
: 0;
74+
const effectivePhase = (phase + cadenceOffset) % 3;
75+
76+
if (!heldABuf || heldWidth !== width || heldHeight !== height || restartedAnimation) {
77+
resetTriplet(source, width, height);
78+
}
79+
lastFrameIndex = frameIndex;
80+
81+
if (effectivePhase === 0) {
82+
resetTriplet(source, width, height);
83+
outputCtx.putImageData(new ImageData(new Uint8ClampedArray(source), width, height), 0, 0);
84+
return output;
85+
}
86+
87+
if (effectivePhase === 1) {
88+
heldBBuf = new Uint8ClampedArray(source);
89+
outputCtx.putImageData(new ImageData(new Uint8ClampedArray(source), width, height), 0, 0);
90+
return output;
91+
}
92+
93+
if (!heldABuf) {
94+
outputCtx.putImageData(new ImageData(new Uint8ClampedArray(source), width, height), 0, 0);
95+
return output;
96+
}
97+
98+
const bBuf = heldBBuf || source;
99+
const outBuf = new Uint8ClampedArray(source.length);
100+
for (let i = 0; i < source.length; i += 4) {
101+
const dr = bBuf[i] - heldABuf[i];
102+
const dg = bBuf[i + 1] - heldABuf[i + 1];
103+
const db = bBuf[i + 2] - heldABuf[i + 2];
104+
outBuf[i] = Math.max(0, Math.min(255, Math.round(heldABuf[i] - dr * strength)));
105+
outBuf[i + 1] = Math.max(0, Math.min(255, Math.round(heldABuf[i + 1] - dg * strength)));
106+
outBuf[i + 2] = Math.max(0, Math.min(255, Math.round(heldABuf[i + 2] - db * strength)));
107+
outBuf[i + 3] = source[i + 3];
108+
}
109+
110+
outputCtx.putImageData(new ImageData(outBuf, width, height), 0, 0);
111+
return output;
112+
};
113+
114+
export default {
115+
name: "ABA Bounce",
116+
func: abaBounce,
117+
optionTypes,
118+
options: defaults,
119+
defaults,
120+
mainThread: true,
121+
description: "Store A and B, then turn the emphasized beat into a reflected reverse frame whose cadence can drift off the strict ABA grid",
122+
};

src/filters/abaGhost.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { ACTION, RANGE } from "constants/controlTypes";
2+
import { cloneCanvas } from "utils";
3+
4+
let heldABuf: Uint8ClampedArray | null = null;
5+
let heldBBuf: Uint8ClampedArray | null = null;
6+
let ghostBuf: Float32Array | null = null;
7+
let heldWidth = 0;
8+
let heldHeight = 0;
9+
let lastFrameIndex = -1;
10+
11+
const resetTriplet = (source: Uint8ClampedArray, width: number, height: number) => {
12+
heldABuf = new Uint8ClampedArray(source);
13+
heldBBuf = null;
14+
ghostBuf = new Float32Array(source.length);
15+
heldWidth = width;
16+
heldHeight = height;
17+
};
18+
19+
export const optionTypes = {
20+
ghostMix: {
21+
type: RANGE,
22+
range: [0, 1],
23+
step: 0.05,
24+
default: 0.95,
25+
desc: "How strongly the stored B frame dominates the returned A beat so the ghost image stays obvious instead of reading like pure stutter",
26+
},
27+
persistence: {
28+
type: RANGE,
29+
range: [0, 0.99],
30+
step: 0.01,
31+
default: 0.85,
32+
desc: "How long the ghost image lingers across later beats and triplets",
33+
},
34+
flash: {
35+
type: RANGE,
36+
range: [0, 1.8],
37+
step: 0.05,
38+
default: 0.15,
39+
desc: "Brightness lift applied to the ghosted beat; lower values keep the persistent double exposure dominant",
40+
},
41+
cadenceDrift: {
42+
type: RANGE,
43+
range: [0, 1],
44+
step: 0.05,
45+
default: 0.45,
46+
desc: "How much the emphasized ghost beat wanders between strict ABA timing and a looser variable-frame cadence",
47+
},
48+
animSpeed: {
49+
type: RANGE,
50+
range: [1, 30],
51+
step: 1,
52+
default: 15,
53+
desc: "Playback speed when using the built-in animation toggle",
54+
},
55+
animate: {
56+
type: ACTION,
57+
label: "Play / Stop",
58+
action: (actions, inputCanvas, _filterFunc, options) => {
59+
if (actions.isAnimating()) actions.stopAnimLoop();
60+
else actions.startAnimLoop(inputCanvas, options.animSpeed || 15);
61+
},
62+
},
63+
};
64+
65+
export const defaults = {
66+
ghostMix: optionTypes.ghostMix.default,
67+
persistence: optionTypes.persistence.default,
68+
flash: optionTypes.flash.default,
69+
cadenceDrift: optionTypes.cadenceDrift.default,
70+
animSpeed: optionTypes.animSpeed.default,
71+
};
72+
73+
const abaGhost = (input, options: any = defaults) => {
74+
const ghostMix = Math.max(0, Math.min(1, Number(options.ghostMix ?? defaults.ghostMix)));
75+
const persistence = Math.max(0, Math.min(0.999, Number(options.persistence ?? defaults.persistence)));
76+
const flash = Math.max(0, Number(options.flash ?? defaults.flash));
77+
const cadenceDrift = Math.max(0, Math.min(1, Number(options.cadenceDrift ?? defaults.cadenceDrift)));
78+
const frameIndex = Number(options._frameIndex ?? 0);
79+
const output = cloneCanvas(input, false);
80+
const inputCtx = input.getContext("2d");
81+
const outputCtx = output.getContext("2d");
82+
if (!inputCtx || !outputCtx) return input;
83+
84+
const width = input.width;
85+
const height = input.height;
86+
const source = inputCtx.getImageData(0, 0, width, height).data;
87+
const restartedAnimation = frameIndex === 0 && lastFrameIndex > 0;
88+
const phase = ((frameIndex % 3) + 3) % 3;
89+
const cadenceOffset = cadenceDrift <= 0
90+
? 0
91+
: ((Math.sin(frameIndex * 0.91) + Math.sin(frameIndex * 0.37 + 1.7)) * 0.25 + 0.5) < cadenceDrift
92+
? 1
93+
: 0;
94+
const effectivePhase = (phase + cadenceOffset) % 3;
95+
96+
if (!heldABuf || !ghostBuf || heldWidth !== width || heldHeight !== height || restartedAnimation) {
97+
resetTriplet(source, width, height);
98+
}
99+
lastFrameIndex = frameIndex;
100+
101+
if (effectivePhase === 0) {
102+
heldABuf = new Uint8ClampedArray(source);
103+
heldBBuf = null;
104+
} else if (effectivePhase === 1) {
105+
heldBBuf = new Uint8ClampedArray(source);
106+
}
107+
108+
const bBuf = heldBBuf || source;
109+
const outBuf = new Uint8ClampedArray(source.length);
110+
111+
for (let i = 0; i < source.length; i += 4) {
112+
const injectedGhostR = (heldABuf![i] * (1 - ghostMix) + bBuf[i] * ghostMix) * flash;
113+
const injectedGhostG = (heldABuf![i + 1] * (1 - ghostMix) + bBuf[i + 1] * ghostMix) * flash;
114+
const injectedGhostB = (heldABuf![i + 2] * (1 - ghostMix) + bBuf[i + 2] * ghostMix) * flash;
115+
116+
const ghostWrite = effectivePhase === 2 ? 1 : 0.18;
117+
ghostBuf![i] = ghostBuf![i] * persistence + injectedGhostR * ghostWrite;
118+
ghostBuf![i + 1] = ghostBuf![i + 1] * persistence + injectedGhostG * ghostWrite;
119+
ghostBuf![i + 2] = ghostBuf![i + 2] * persistence + injectedGhostB * ghostWrite;
120+
121+
const carry = effectivePhase === 2 ? 1 : Math.min(0.65, persistence * 0.55);
122+
outBuf[i] = Math.max(0, Math.min(255, Math.round(source[i] * (1 - carry) + ghostBuf![i] * carry)));
123+
outBuf[i + 1] = Math.max(0, Math.min(255, Math.round(source[i + 1] * (1 - carry) + ghostBuf![i + 1] * carry)));
124+
outBuf[i + 2] = Math.max(0, Math.min(255, Math.round(source[i + 2] * (1 - carry) + ghostBuf![i + 2] * carry)));
125+
outBuf[i + 3] = source[i + 3];
126+
}
127+
128+
outputCtx.putImageData(new ImageData(outBuf, width, height), 0, 0);
129+
return output;
130+
};
131+
132+
export default {
133+
name: "ABA Ghost",
134+
func: abaGhost,
135+
optionTypes,
136+
options: defaults,
137+
defaults,
138+
mainThread: true,
139+
description: "Store A and B, then replay a persistent mostly-B double exposure whose emphasized beat can drift off the strict ABA grid for a looser variable-frame ghost trail",
140+
};

0 commit comments

Comments
 (0)