|
| 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