emoemu 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/.claude/settings.local.json +77 -0
  2. package/.node-version +1 -0
  3. package/CLAUDE.md +435 -0
  4. package/README.md +404 -0
  5. package/TODO.md +655 -0
  6. package/dist/index.cjs +25108 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +25085 -0
  9. package/docs/building-libretro-cores-arm-mac.md +237 -0
  10. package/docs/config-file-format.md +488 -0
  11. package/docs/cores-trd.md +425 -0
  12. package/docs/headless-hardware-rendering-trd.md +676 -0
  13. package/docs/libretro-cores-trd.md +997 -0
  14. package/docs/mupen64-software-rendering-trd.md +751 -0
  15. package/docs/n64-support-trd.md +306 -0
  16. package/docs/native-rendering-trd.md +540 -0
  17. package/docs/native-ui-rendering-trd.md +1195 -0
  18. package/docs/netplay-trd.md +665 -0
  19. package/docs/retroarch-netplay-docs.md +277 -0
  20. package/docs/save-state-format.md +666 -0
  21. package/eslint.config.js +111 -0
  22. package/icon/icon.png +0 -0
  23. package/icon/icon.pxd +0 -0
  24. package/package.json +63 -0
  25. package/pnpm-workspace.yaml +10 -0
  26. package/src/Emulator/consts.ts +14 -0
  27. package/src/Emulator/index.ts +2496 -0
  28. package/src/Emulator/saveState/index.ts +155 -0
  29. package/src/Emulator/screenshot/index.ts +160 -0
  30. package/src/Emulator/terminalDimensions/index.ts +79 -0
  31. package/src/Emulator/types.ts +83 -0
  32. package/src/cli/commands/consts.ts +10 -0
  33. package/src/cli/commands/index.ts +462 -0
  34. package/src/cli/parseArgs/consts.ts +17 -0
  35. package/src/cli/parseArgs/index.ts +457 -0
  36. package/src/cli/parseArgs/types.ts +61 -0
  37. package/src/cli/runEmulator/index.ts +406 -0
  38. package/src/cli/runEmulator/types.ts +7 -0
  39. package/src/consts.ts +19 -0
  40. package/src/core/button/consts.ts +35 -0
  41. package/src/core/button/index.ts +123 -0
  42. package/src/core/core.ts +300 -0
  43. package/src/core/index.ts +19 -0
  44. package/src/cores/libretro/api/index.ts +334 -0
  45. package/src/cores/libretro/api/types.ts +148 -0
  46. package/src/cores/libretro/callbacks/consts.ts +41 -0
  47. package/src/cores/libretro/callbacks/index.ts +456 -0
  48. package/src/cores/libretro/consts.ts +45 -0
  49. package/src/cores/libretro/coreOptions/consts.ts +36 -0
  50. package/src/cores/libretro/coreOptions/index.ts +222 -0
  51. package/src/cores/libretro/environment/consts.ts +118 -0
  52. package/src/cores/libretro/environment/index.ts +1095 -0
  53. package/src/cores/libretro/index.ts +937 -0
  54. package/src/cores/libretro/loader/index.ts +496 -0
  55. package/src/cores/libretro/pixelFormat/consts.ts +43 -0
  56. package/src/cores/libretro/pixelFormat/index.ts +397 -0
  57. package/src/cores/libretro/types.ts +339 -0
  58. package/src/frontend/AudioManager/index.ts +420 -0
  59. package/src/frontend/SettingsManager/index.ts +250 -0
  60. package/src/frontend/config/index.ts +608 -0
  61. package/src/frontend/config/tests.ts +354 -0
  62. package/src/frontend/config/types.ts +36 -0
  63. package/src/frontend/consts.ts +114 -0
  64. package/src/frontend/coreBuilder/index.ts +644 -0
  65. package/src/frontend/coreBuilder/types.ts +15 -0
  66. package/src/frontend/coreDownloader/index.ts +620 -0
  67. package/src/frontend/coreDownloader/types.ts +17 -0
  68. package/src/frontend/corePreferences/index.ts +69 -0
  69. package/src/frontend/coreRegistry/index.ts +276 -0
  70. package/src/frontend/directoryCache/index.ts +75 -0
  71. package/src/frontend/index.ts +79 -0
  72. package/src/frontend/notifications/consts.ts +14 -0
  73. package/src/frontend/notifications/index.ts +250 -0
  74. package/src/frontend/playlist/consts.ts +168 -0
  75. package/src/frontend/playlist/index.ts +899 -0
  76. package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
  77. package/src/frontend/playlist/labelFormatter/index.ts +155 -0
  78. package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
  79. package/src/frontend/playlist/reader/index.ts +559 -0
  80. package/src/frontend/playlist/sync/index.ts +511 -0
  81. package/src/frontend/playlist/systemLookup/index.ts +233 -0
  82. package/src/frontend/playlist/utils/index.ts +50 -0
  83. package/src/frontend/romScanner/consts.ts +348 -0
  84. package/src/frontend/romScanner/index.ts +1957 -0
  85. package/src/frontend/saveServices/consts.ts +2 -0
  86. package/src/frontend/saveServices/index.ts +313 -0
  87. package/src/frontend/serviceProvider/index.ts +108 -0
  88. package/src/frontend/serviceProvider/types.ts +13 -0
  89. package/src/index.ts +428 -0
  90. package/src/input/Controller/consts.ts +50 -0
  91. package/src/input/Controller/index.ts +81 -0
  92. package/src/input/GamepadManager/consts.ts +22 -0
  93. package/src/input/GamepadManager/index.ts +418 -0
  94. package/src/input/InputManager/consts.ts +86 -0
  95. package/src/input/InputManager/index.ts +593 -0
  96. package/src/input/InputMapper/consts.ts +33 -0
  97. package/src/input/InputMapper/index.ts +436 -0
  98. package/src/input/consts.ts +410 -0
  99. package/src/input/gamepadProfiles/index.ts +1100 -0
  100. package/src/input/index.ts +38 -0
  101. package/src/input/inputUtils/index.ts +31 -0
  102. package/src/netplay/FrameBuffer/consts.ts +2 -0
  103. package/src/netplay/FrameBuffer/index.ts +364 -0
  104. package/src/netplay/FrameBuffer/tests.ts +286 -0
  105. package/src/netplay/InputBuffer/consts.ts +7 -0
  106. package/src/netplay/InputBuffer/index.ts +347 -0
  107. package/src/netplay/InputBuffer/tests.ts +166 -0
  108. package/src/netplay/NetplayClient/index.ts +976 -0
  109. package/src/netplay/NetplayConnection/index.ts +536 -0
  110. package/src/netplay/NetplayDiscovery/consts.ts +41 -0
  111. package/src/netplay/NetplayDiscovery/index.ts +525 -0
  112. package/src/netplay/NetplayServer/index.ts +1407 -0
  113. package/src/netplay/SyncManager/index.ts +984 -0
  114. package/src/netplay/SyncManager/tests.ts +419 -0
  115. package/src/netplay/consts.ts +371 -0
  116. package/src/netplay/crc32/consts.ts +14 -0
  117. package/src/netplay/crc32/index.ts +97 -0
  118. package/src/netplay/crc32/tests.ts +40 -0
  119. package/src/netplay/index.ts +41 -0
  120. package/src/netplay/netplayLogger/consts.ts +30 -0
  121. package/src/netplay/netplayLogger/index.ts +345 -0
  122. package/src/netplay/protocol/consts.ts +86 -0
  123. package/src/netplay/protocol/index.ts +1280 -0
  124. package/src/netplay/protocol/tests.ts +606 -0
  125. package/src/netplay/protocol/types.ts +20 -0
  126. package/src/netplay/types.ts +395 -0
  127. package/src/rendering/KittyRenderer/index.ts +616 -0
  128. package/src/rendering/NativeRenderer/index.ts +279 -0
  129. package/src/rendering/NativeRenderer/tests.ts +133 -0
  130. package/src/rendering/TerminalRenderer/index.ts +770 -0
  131. package/src/rendering/consts.ts +401 -0
  132. package/src/rendering/fonts/CozetteVector.ttf +0 -0
  133. package/src/rendering/index.ts +26 -0
  134. package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
  135. package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
  136. package/src/rendering/nativeUi/consts.ts +6 -0
  137. package/src/rendering/nativeUi/index.ts +20 -0
  138. package/src/rendering/postProcessing/consts.ts +38 -0
  139. package/src/rendering/postProcessing/index.ts +923 -0
  140. package/src/rendering/shared/ansi/consts.ts +12 -0
  141. package/src/rendering/shared/ansi/index.ts +104 -0
  142. package/src/rendering/shared/consts.ts +2 -0
  143. package/src/rendering/shared/fitToTerminal/index.ts +67 -0
  144. package/src/ui/AddRomsPrompt/consts.ts +13 -0
  145. package/src/ui/AddRomsPrompt/index.tsx +781 -0
  146. package/src/ui/App/consts.ts +2 -0
  147. package/src/ui/App/index.tsx +456 -0
  148. package/src/ui/AppCapabilities/index.tsx +67 -0
  149. package/src/ui/ConfigContext/index.tsx +56 -0
  150. package/src/ui/CoreManager/consts.ts +11 -0
  151. package/src/ui/CoreManager/index.tsx +779 -0
  152. package/src/ui/CoreSelector/consts.ts +2 -0
  153. package/src/ui/CoreSelector/index.tsx +251 -0
  154. package/src/ui/DialogContainer/index.tsx +42 -0
  155. package/src/ui/DialogOptionsList/index.tsx +61 -0
  156. package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
  157. package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
  158. package/src/ui/GamepadContext/consts.ts +15 -0
  159. package/src/ui/GamepadContext/index.tsx +295 -0
  160. package/src/ui/NativeDialog/index.tsx +120 -0
  161. package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
  162. package/src/ui/NetplayPauseMenu/consts.ts +2 -0
  163. package/src/ui/NetplayPauseMenu/index.tsx +97 -0
  164. package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
  165. package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
  166. package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
  167. package/src/ui/RomBrowser/consts.ts +61 -0
  168. package/src/ui/RomBrowser/index.tsx +1164 -0
  169. package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
  170. package/src/ui/RomBrowser/types.ts +67 -0
  171. package/src/ui/SaveStateDialog/consts.ts +2 -0
  172. package/src/ui/SaveStateDialog/index.tsx +225 -0
  173. package/src/ui/WarningDialog/index.tsx +113 -0
  174. package/src/ui/consts.ts +27 -0
  175. package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
  176. package/src/ui/hooks/useClearTerminal/index.ts +37 -0
  177. package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
  178. package/src/ui/hooks/useGamepad/consts.ts +21 -0
  179. package/src/ui/hooks/useGamepad/index.ts +194 -0
  180. package/src/ui/index.ts +27 -0
  181. package/src/utils/buffer/consts.ts +17 -0
  182. package/src/utils/buffer/index.ts +129 -0
  183. package/src/utils/color/consts.ts +58 -0
  184. package/src/utils/color/index.ts +183 -0
  185. package/src/utils/compression/consts.ts +50 -0
  186. package/src/utils/compression/index.ts +101 -0
  187. package/src/utils/consts.ts +2 -0
  188. package/src/utils/crc32/consts.ts +22 -0
  189. package/src/utils/crc32/index.ts +83 -0
  190. package/src/utils/ensureDirectory/index.ts +10 -0
  191. package/src/utils/format/consts.ts +8 -0
  192. package/src/utils/format/index.ts +53 -0
  193. package/src/utils/getErrorMessage/index.ts +10 -0
  194. package/src/utils/index.ts +113 -0
  195. package/src/utils/ini/index.ts +200 -0
  196. package/src/utils/kitty/consts.ts +13 -0
  197. package/src/utils/kitty/index.ts +181 -0
  198. package/src/utils/logger/consts.ts +35 -0
  199. package/src/utils/logger/index.ts +217 -0
  200. package/src/utils/paths/consts.ts +18 -0
  201. package/src/utils/paths/index.ts +151 -0
  202. package/src/utils/png/consts.ts +34 -0
  203. package/src/utils/png/index.ts +131 -0
  204. package/src/utils/readJsonFile/index.ts +16 -0
  205. package/src/utils/rotateLogFile/index.ts +44 -0
  206. package/src/utils/safeClose/index.ts +10 -0
  207. package/src/utils/terminal/consts.ts +8 -0
  208. package/src/utils/terminal/index.ts +102 -0
  209. package/src/utils/thumbnailRenderer/consts.ts +2 -0
  210. package/src/utils/thumbnailRenderer/index.ts +147 -0
  211. package/src/utils/typedError/index.ts +26 -0
  212. package/tsconfig.json +31 -0
  213. package/vitest.config.ts +13 -0
@@ -0,0 +1,923 @@
1
+ /**
2
+ * Post-processing effects pipeline for CRT simulation.
3
+ *
4
+ * Effects are applied in this order:
5
+ * 1. Color adjustments (brightness, contrast, saturation)
6
+ * 2. NTSC artifacts (chroma blur)
7
+ * 3. Chromatic aberration (RGB color fringing)
8
+ * 4. Curvature (barrel distortion) - optionally combines scanlines + vignette
9
+ * 5. Scanlines (darken odd rows)
10
+ * 6. Bloom (glow from bright areas) - optionally combines vignette
11
+ * 7. Vignette (darken edges)
12
+ *
13
+ * NOTE: This file disables no-magic-numbers because the shader-like image
14
+ * processing code contains many mathematical coefficients (color science values,
15
+ * fixed-point arithmetic, bit shifts) that are standard constants in image
16
+ * processing. Extracting each would significantly harm readability of the
17
+ * pixel processing loops.
18
+ *
19
+ * Common values used:
20
+ * - 3: RGB bytes per pixel
21
+ * - 8: bit shift for fixed-point division (>> 8 = divide by 256)
22
+ * - 256: fixed-point scale factor (2^8)
23
+ * - 255: maximum 8-bit color channel value
24
+ * - 128: midpoint for contrast calculations
25
+ * - 0.299, 0.587, 0.114: ITU-R BT.601 luminance coefficients
26
+ * - 77, 150, 29: fast integer luminance coefficients (≈ 0.299*256, etc.)
27
+ * - YIQ conversion: NTSC color space coefficients
28
+ */
29
+ /* eslint-disable @typescript-eslint/no-magic-numbers */
30
+
31
+ import { clamp } from 'remeda';
32
+ import { buildGammaLUT } from '../../utils/color';
33
+ import { DEFAULT_BLOOM_THRESHOLD } from '..';
34
+ import {
35
+ LUMA_R_INT, LUMA_G_INT, LUMA_B_INT,
36
+ CURVATURE_INTENSITY_SCALE,
37
+ NTSC_CHROMA_BLUR_RADIUS,
38
+ BLOOM_BLUR_RADIUS,
39
+ CHROMATIC_ABERRATION_OFFSET_SCALE,
40
+ YIQ_I_R, YIQ_I_G, YIQ_I_B,
41
+ YIQ_Q_R, YIQ_Q_G, YIQ_Q_B,
42
+ YIQ_INV_R_I, YIQ_INV_R_Q,
43
+ YIQ_INV_G_I, YIQ_INV_G_Q,
44
+ YIQ_INV_B_I, YIQ_INV_B_Q,
45
+ } from './consts';
46
+
47
+ export * from './consts';
48
+
49
+ export interface EffectOptions {
50
+ gamma?: number; // Gamma correction (default: 1.0, CRT-like: 1.1-1.4)
51
+ scanlines?: number; // Scanline intensity 0.0-1.0 (default: 0.0)
52
+ saturation?: number; // Color saturation multiplier (default: 1.0)
53
+ brightness?: number; // Brightness multiplier (default: 1.0)
54
+ contrast?: number; // Contrast multiplier (default: 1.0)
55
+ vignette?: number; // Vignette intensity (default: 0.0)
56
+ bloom?: number; // Bloom/glow intensity (default: 0.0)
57
+ bloomThreshold?: number; // Bloom brightness threshold (default: 0.6)
58
+ ntsc?: number; // NTSC artifact intensity (default: 0.0)
59
+ curvature?: number; // CRT curvature intensity (default: 0.0)
60
+ chromaticAberration?: number; // Chromatic aberration intensity (default: 0.0)
61
+ }
62
+
63
+ export class PostProcessingPipeline {
64
+ // Options
65
+ private gamma: number;
66
+ private scanlineIntensity: number;
67
+ private saturation: number;
68
+ private brightness: number;
69
+ private contrast: number;
70
+ private vignette: number;
71
+ private bloom: number;
72
+ private bloomThreshold: number;
73
+ private ntsc: number;
74
+ private curvature: number;
75
+ private chromaticAberration: number;
76
+
77
+ // Precomputed lookup tables
78
+ private gammaLUT: Uint8Array;
79
+
80
+ // Vignette map
81
+ private vignetteMap: Uint16Array | null = null;
82
+ private vignetteMapWidth: number = 0;
83
+ private vignetteMapHeight: number = 0;
84
+ private vignetteMapIntensity: number = 0;
85
+
86
+ // Curvature map
87
+ private curvatureMap: Int32Array | null = null;
88
+ private curvatureMapWidth: number = 0;
89
+ private curvatureMapHeight: number = 0;
90
+ private curvatureMapIntensity: number = 0;
91
+
92
+ // Reusable buffers
93
+ private curvatureSrcBuffer: Uint8Array | null = null;
94
+ private bloomBuffer: Uint8Array | null = null;
95
+ private bloomTempRow: Uint8Array | null = null;
96
+ private bloomTempCol: Uint8Array | null = null;
97
+ private ntscChromaBuffer: Int32Array | null = null;
98
+ private ntscTempRow: Int32Array | null = null;
99
+ private chromaticAberrationSrcBuffer: Uint8Array | null = null;
100
+
101
+ // Chromatic aberration map (precomputed for performance)
102
+ // Stores source pixel indices: [redSrcIdx, blueSrcIdx] pairs for each destination pixel
103
+ private chromaticAberrationMap: Int32Array | null = null;
104
+ private chromaticAberrationMapWidth: number = 0;
105
+ private chromaticAberrationMapHeight: number = 0;
106
+ private chromaticAberrationMapIntensity: number = 0;
107
+
108
+ // Flags to track which effects have already been applied (to avoid duplicate passes)
109
+ private scanlinesApplied: boolean = false;
110
+ private vignetteApplied: boolean = false;
111
+
112
+ constructor(options: EffectOptions = {}) {
113
+ this.gamma = options.gamma ?? 1.0;
114
+ this.scanlineIntensity = clamp(options.scanlines ?? 0, { min: 0, max: 1 });
115
+ this.saturation = options.saturation ?? 1.0;
116
+ this.brightness = options.brightness ?? 1.0;
117
+ this.contrast = options.contrast ?? 1.0;
118
+ this.vignette = Math.max(0, options.vignette ?? 0);
119
+ this.bloom = Math.max(0, options.bloom ?? 0);
120
+ this.bloomThreshold = clamp(options.bloomThreshold ?? DEFAULT_BLOOM_THRESHOLD, { min: 0, max: 1 });
121
+ this.ntsc = Math.max(0, options.ntsc ?? 0);
122
+ this.curvature = Math.max(0, options.curvature ?? 0);
123
+ this.chromaticAberration = Math.max(0, options.chromaticAberration ?? 0);
124
+ this.gammaLUT = buildGammaLUT(this.gamma);
125
+ }
126
+
127
+ /**
128
+ * Get the gamma lookup table for use during frame conversion.
129
+ */
130
+ getGammaLUT(): Uint8Array {
131
+ return this.gammaLUT;
132
+ }
133
+
134
+ /**
135
+ * Check if any effects are enabled.
136
+ */
137
+ hasEffects(): boolean {
138
+ return this.bloom > 0 ||
139
+ this.vignette > 0 ||
140
+ this.scanlineIntensity > 0 ||
141
+ this.ntsc > 0 ||
142
+ this.curvature > 0 ||
143
+ this.chromaticAberration > 0 ||
144
+ this.brightness !== 1.0 ||
145
+ this.contrast !== 1.0 ||
146
+ this.saturation !== 1.0;
147
+ }
148
+
149
+ /**
150
+ * Apply all post-processing effects to the RGB buffer.
151
+ *
152
+ * Effect combination strategy (to minimize full-image iterations):
153
+ * - Vignette is combined with: bloom (if enabled) > curvature > scanlines > standalone
154
+ * - Scanlines are combined with: curvature (if enabled) > standalone
155
+ *
156
+ * This means:
157
+ * - If bloom is enabled: vignette is applied in bloom's final pass
158
+ * - Else if curvature is enabled: scanlines + vignette are applied in curvature pass
159
+ * - Else: scanlines + vignette can be combined in a single pass
160
+ */
161
+ apply(buffer: Uint8Array, width: number, height: number): void {
162
+ // Early bailout when no effects are enabled
163
+ if (!this.hasEffects()) {
164
+ return;
165
+ }
166
+
167
+ // Reset flags - these track whether effects were combined into an earlier pass
168
+ this.scanlinesApplied = false;
169
+ this.vignetteApplied = false;
170
+
171
+ // 1. Color adjustments (brightness, contrast, saturation)
172
+ this.applyColorAdjustments(buffer);
173
+
174
+ // 2. NTSC artifacts (chroma blur for color bleeding effect)
175
+ this.applyNtscArtifacts(buffer, width, height);
176
+
177
+ // 3. Chromatic aberration (RGB fringing toward edges)
178
+ this.applyChromaticAberration(buffer, width, height);
179
+
180
+ // 4. Spatial effects: curvature, scanlines, vignette
181
+ // These are combined where possible to reduce iterations
182
+ if (this.curvature > 0) {
183
+ // Curvature pass combines: curvature + scanlines + vignette (if no bloom)
184
+ this.applyCurvature(buffer, width, height);
185
+ } else if (this.scanlineIntensity > 0 || (this.vignette > 0 && this.bloom <= 0)) {
186
+ // No curvature: combine scanlines + vignette in single pass (if no bloom)
187
+ this.applyScanlinesAndVignette(buffer, width, height);
188
+ }
189
+
190
+ // 5. Bloom (glow from bright areas) - combines vignette if enabled
191
+ this.applyBloom(buffer, width, height);
192
+
193
+ // 6. Apply any effects that weren't combined into earlier passes
194
+ // (these methods check the flags internally and return early if already applied)
195
+ this.applyScanlines(buffer, width, height);
196
+ this.applyVignette(buffer, width, height);
197
+ }
198
+
199
+ /**
200
+ * Combined brightness, contrast, and saturation adjustment.
201
+ */
202
+ private applyColorAdjustments(buffer: Uint8Array): void {
203
+ const needsBrightness = this.brightness !== 1.0;
204
+ const needsContrast = this.contrast !== 1.0;
205
+ const needsSaturation = this.saturation !== 1.0;
206
+
207
+ if (!needsBrightness && !needsContrast && !needsSaturation) {return;}
208
+
209
+ const len = buffer.length;
210
+ const bright = this.brightness;
211
+ const con = this.contrast;
212
+ const sat = this.saturation;
213
+
214
+ for (let i = 0; i < len; i += 3) {
215
+ let r = buffer[i];
216
+ let g = buffer[i + 1];
217
+ let b = buffer[i + 2];
218
+
219
+ if (needsBrightness) {
220
+ r = r * bright;
221
+ g = g * bright;
222
+ b = b * bright;
223
+ }
224
+
225
+ if (needsContrast) {
226
+ r = (r - 128) * con + 128;
227
+ g = (g - 128) * con + 128;
228
+ b = (b - 128) * con + 128;
229
+ }
230
+
231
+ if (needsSaturation) {
232
+ const gray = 0.299 * r + 0.587 * g + 0.114 * b;
233
+ r = gray + (r - gray) * sat;
234
+ g = gray + (g - gray) * sat;
235
+ b = gray + (b - gray) * sat;
236
+ }
237
+
238
+ buffer[i] = r < 0 ? 0 : r > 255 ? 255 : r | 0;
239
+ buffer[i + 1] = g < 0 ? 0 : g > 255 ? 255 : g | 0;
240
+ buffer[i + 2] = b < 0 ? 0 : b > 255 ? 255 : b | 0;
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Build vignette lookup map.
246
+ */
247
+ private buildVignetteMap(width: number, height: number): void {
248
+ this.vignetteMap = new Uint16Array(width * height);
249
+ this.vignetteMapWidth = width;
250
+ this.vignetteMapHeight = height;
251
+ this.vignetteMapIntensity = this.vignette;
252
+
253
+ const halfW = width / 2;
254
+ const halfH = height / 2;
255
+ const invMaxDistSq = 1 / (halfW * halfW + halfH * halfH);
256
+
257
+ let idx = 0;
258
+ for (let y = 0; y < height; y++) {
259
+ const dy = y - halfH;
260
+ const dySq = dy * dy;
261
+
262
+ for (let x = 0; x < width; x++) {
263
+ const dx = x - halfW;
264
+ const normDistSq = (dx * dx + dySq) * invMaxDistSq;
265
+ let factor = 1 - this.vignette * normDistSq;
266
+ if (factor < 0) {factor = 0;}
267
+ this.vignetteMap[idx++] = (factor * 256) | 0;
268
+ }
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Ensure vignette map exists and is up to date.
274
+ * Call this before any method that needs to use the vignette map.
275
+ */
276
+ private ensureVignetteMap(width: number, height: number): void {
277
+ if (!this.vignetteMap ||
278
+ this.vignetteMapWidth !== width ||
279
+ this.vignetteMapHeight !== height ||
280
+ this.vignetteMapIntensity !== this.vignette) {
281
+ this.buildVignetteMap(width, height);
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Ensure curvature map exists and is up to date.
287
+ */
288
+ private ensureCurvatureMap(width: number, height: number): void {
289
+ if (!this.curvatureMap ||
290
+ this.curvatureMapWidth !== width ||
291
+ this.curvatureMapHeight !== height ||
292
+ this.curvatureMapIntensity !== this.curvature) {
293
+ this.buildCurvatureMap(width, height);
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Ensure chromatic aberration map exists and is up to date.
299
+ */
300
+ private ensureChromaticAberrationMap(width: number, height: number): void {
301
+ if (!this.chromaticAberrationMap ||
302
+ this.chromaticAberrationMapWidth !== width ||
303
+ this.chromaticAberrationMapHeight !== height ||
304
+ this.chromaticAberrationMapIntensity !== this.chromaticAberration) {
305
+ this.buildChromaticAberrationMap(width, height);
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Ensure a Uint8Array buffer exists with the required size.
311
+ * Returns existing buffer if size matches, otherwise allocates new one.
312
+ */
313
+ private ensureUint8Buffer(current: Uint8Array | null, size: number): Uint8Array {
314
+ if (!current || current.length !== size) {
315
+ return new Uint8Array(size);
316
+ }
317
+ return current;
318
+ }
319
+
320
+ /**
321
+ * Ensure an Int32Array buffer exists with the required size.
322
+ * Returns existing buffer if size matches, otherwise allocates new one.
323
+ */
324
+ private ensureInt32Buffer(current: Int32Array | null, size: number): Int32Array {
325
+ if (!current || current.length !== size) {
326
+ return new Int32Array(size);
327
+ }
328
+ return current;
329
+ }
330
+
331
+ /**
332
+ * Apply vignette effect (standalone pass).
333
+ * Only runs if vignette wasn't already combined into an earlier pass.
334
+ */
335
+ private applyVignette(buffer: Uint8Array, width: number, height: number): void {
336
+ if (this.vignette <= 0 || this.vignetteApplied) {return;}
337
+
338
+ this.ensureVignetteMap(width, height);
339
+ const map = this.vignetteMap!;
340
+ const len = width * height;
341
+
342
+ for (let i = 0; i < len; i++) {
343
+ const factor = map[i];
344
+ const idx = i * 3;
345
+ buffer[idx] = (buffer[idx] * factor) >> 8;
346
+ buffer[idx + 1] = (buffer[idx + 1] * factor) >> 8;
347
+ buffer[idx + 2] = (buffer[idx + 2] * factor) >> 8;
348
+ }
349
+ this.vignetteApplied = true;
350
+ }
351
+
352
+ /**
353
+ * Apply scanline effect (standalone pass).
354
+ * Only runs if scanlines weren't already combined into an earlier pass.
355
+ */
356
+ private applyScanlines(buffer: Uint8Array, width: number, height: number): void {
357
+ if (this.scanlineIntensity <= 0 || this.scanlinesApplied) {return;}
358
+
359
+ const rowBytes = width * 3;
360
+ const mult256 = ((1 - this.scanlineIntensity) * 256) | 0;
361
+
362
+ for (let y = 1; y < height; y += 2) {
363
+ const rowStart = y * rowBytes;
364
+ for (let x = 0; x < rowBytes; x++) {
365
+ buffer[rowStart + x] = (buffer[rowStart + x] * mult256) >> 8;
366
+ }
367
+ }
368
+ this.scanlinesApplied = true;
369
+ }
370
+
371
+ /**
372
+ * Combined scanlines + vignette pass (when curvature is disabled).
373
+ * Combines these effects into a single iteration when both are needed.
374
+ * Vignette is only applied here if bloom is disabled (otherwise bloom handles it).
375
+ */
376
+ private applyScanlinesAndVignette(buffer: Uint8Array, width: number, height: number): void {
377
+ const needsScanlines = this.scanlineIntensity > 0;
378
+ const needsVignette = this.vignette > 0 && this.bloom <= 0; // Vignette goes in bloom if bloom is enabled
379
+
380
+ if (!needsScanlines && !needsVignette) {return;}
381
+
382
+ if (needsVignette) {
383
+ this.ensureVignetteMap(width, height);
384
+ }
385
+
386
+ const scanlineMult256 = needsScanlines ? ((1 - this.scanlineIntensity) * 256) | 0 : 256;
387
+ const vmap = needsVignette ? this.vignetteMap! : null;
388
+
389
+ if (needsScanlines && needsVignette) {
390
+ // Combined pass: both scanlines and vignette
391
+ for (let y = 0; y < height; y++) {
392
+ const rowStart = y * width;
393
+ const isOddRow = y & 1;
394
+ const rowMult = isOddRow ? scanlineMult256 : 256;
395
+
396
+ for (let x = 0; x < width; x++) {
397
+ const i = rowStart + x;
398
+ const idx = i * 3;
399
+ const vfactor = vmap![i];
400
+ const combinedMult = (rowMult * vfactor) >> 8;
401
+ buffer[idx] = (buffer[idx] * combinedMult) >> 8;
402
+ buffer[idx + 1] = (buffer[idx + 1] * combinedMult) >> 8;
403
+ buffer[idx + 2] = (buffer[idx + 2] * combinedMult) >> 8;
404
+ }
405
+ }
406
+ this.scanlinesApplied = true;
407
+ this.vignetteApplied = true;
408
+ } else if (needsScanlines) {
409
+ // Scanlines only (vignette will be in bloom or not needed)
410
+ const rowBytes = width * 3;
411
+ for (let y = 1; y < height; y += 2) {
412
+ const rowStart = y * rowBytes;
413
+ for (let x = 0; x < rowBytes; x++) {
414
+ buffer[rowStart + x] = (buffer[rowStart + x] * scanlineMult256) >> 8;
415
+ }
416
+ }
417
+ this.scanlinesApplied = true;
418
+ } else if (needsVignette) {
419
+ // Vignette only (no scanlines)
420
+ const pixelCount = width * height;
421
+ for (let i = 0; i < pixelCount; i++) {
422
+ const factor = vmap![i];
423
+ const idx = i * 3;
424
+ buffer[idx] = (buffer[idx] * factor) >> 8;
425
+ buffer[idx + 1] = (buffer[idx + 1] * factor) >> 8;
426
+ buffer[idx + 2] = (buffer[idx + 2] * factor) >> 8;
427
+ }
428
+ this.vignetteApplied = true;
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Build curvature distortion map.
434
+ */
435
+ private buildCurvatureMap(width: number, height: number): void {
436
+ const k = this.curvature * CURVATURE_INTENSITY_SCALE;
437
+
438
+ this.curvatureMap = new Int32Array(width * height);
439
+ this.curvatureMapWidth = width;
440
+ this.curvatureMapHeight = height;
441
+ this.curvatureMapIntensity = this.curvature;
442
+
443
+ const halfW = width / 2;
444
+ const halfH = height / 2;
445
+ const maxDim = Math.max(halfW, halfH);
446
+
447
+ let idx = 0;
448
+ for (let y = 0; y < height; y++) {
449
+ const ny = (y - halfH) / maxDim;
450
+
451
+ for (let x = 0; x < width; x++) {
452
+ const nx = (x - halfW) / maxDim;
453
+ const r2 = nx * nx + ny * ny;
454
+ const factor = 1 + k * r2;
455
+ const srcX = nx * factor * maxDim + halfW;
456
+ const srcY = ny * factor * maxDim + halfH;
457
+ const srcXi = Math.round(srcX);
458
+ const srcYi = Math.round(srcY);
459
+
460
+ if (srcXi >= 0 && srcXi < width && srcYi >= 0 && srcYi < height) {
461
+ this.curvatureMap[idx] = srcYi * width + srcXi;
462
+ } else {
463
+ this.curvatureMap[idx] = -1;
464
+ }
465
+ idx++;
466
+ }
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Apply CRT curvature with optional scanlines and vignette.
472
+ * Combines these effects into a single iteration to minimize passes.
473
+ * Vignette is only combined here if bloom is disabled.
474
+ */
475
+ private applyCurvature(buffer: Uint8Array, width: number, height: number): void {
476
+ if (this.curvature <= 0) {return;}
477
+
478
+ const pixelCount = width * height;
479
+ const bufferSize = pixelCount * 3;
480
+
481
+ this.ensureCurvatureMap(width, height);
482
+ this.curvatureSrcBuffer = this.ensureUint8Buffer(this.curvatureSrcBuffer, bufferSize);
483
+
484
+ const src = this.curvatureSrcBuffer;
485
+ const map = this.curvatureMap!;
486
+ src.set(buffer);
487
+
488
+ // Determine which effects to combine into this pass
489
+ const combineScanlines = this.scanlineIntensity > 0;
490
+ const combineVignette = this.vignette > 0 && this.bloom <= 0; // Vignette goes in bloom if enabled
491
+ const scanlineMult256 = combineScanlines ? ((1 - this.scanlineIntensity) * 256) | 0 : 256;
492
+
493
+ if (combineVignette) {
494
+ this.ensureVignetteMap(width, height);
495
+ }
496
+ const vmap = this.vignetteMap;
497
+
498
+ // Unified loop: multiplying by 256 then >> 8 is a no-op, so disabled effects have no cost
499
+ for (let y = 0; y < height; y++) {
500
+ const rowStart = y * width;
501
+ const rowMult = combineScanlines && (y & 1) ? scanlineMult256 : 256;
502
+
503
+ for (let x = 0; x < width; x++) {
504
+ const i = rowStart + x;
505
+ const srcIdx = map[i];
506
+ const dstIdx = i * 3;
507
+
508
+ if (srcIdx >= 0) {
509
+ const srcOffset = srcIdx * 3;
510
+ const vfactor = combineVignette ? vmap![i] : 256;
511
+ const finalMult = (rowMult * vfactor) >> 8;
512
+ buffer[dstIdx] = (src[srcOffset] * finalMult) >> 8;
513
+ buffer[dstIdx + 1] = (src[srcOffset + 1] * finalMult) >> 8;
514
+ buffer[dstIdx + 2] = (src[srcOffset + 2] * finalMult) >> 8;
515
+ } else {
516
+ buffer[dstIdx] = 0;
517
+ buffer[dstIdx + 1] = 0;
518
+ buffer[dstIdx + 2] = 0;
519
+ }
520
+ }
521
+ }
522
+
523
+ if (combineScanlines) {this.scanlinesApplied = true;}
524
+ if (combineVignette) {this.vignetteApplied = true;}
525
+ }
526
+
527
+ /**
528
+ * Apply bloom/glow effect.
529
+ */
530
+ private applyBloom(buffer: Uint8Array, width: number, height: number): void {
531
+ if (this.bloom <= 0) {return;}
532
+
533
+ const intensity256 = (this.bloom * 256) | 0;
534
+ const threshold = (this.bloomThreshold * 255) | 0;
535
+ const rowBytes = width * 3;
536
+
537
+ const halfW = (width + 1) >> 1;
538
+ const halfH = (height + 1) >> 1;
539
+ const halfRowBytes = halfW * 3;
540
+ const halfBufferSize = halfW * halfH * 3;
541
+
542
+ this.bloomBuffer = this.ensureUint8Buffer(this.bloomBuffer, halfBufferSize);
543
+ this.bloomTempRow = this.ensureUint8Buffer(this.bloomTempRow, halfRowBytes);
544
+ this.bloomTempCol = this.ensureUint8Buffer(this.bloomTempCol, halfH * 3);
545
+
546
+ const bloom = this.bloomBuffer;
547
+ const tempRow = this.bloomTempRow;
548
+ const tempCol = this.bloomTempCol;
549
+
550
+ // Downsample and extract bright pixels
551
+ const thresholdRange = 255 - threshold || 1;
552
+ for (let hy = 0; hy < halfH; hy++) {
553
+ const sy = hy * 2;
554
+ const sy1 = Math.min(sy + 1, height - 1);
555
+ for (let hx = 0; hx < halfW; hx++) {
556
+ const sx = hx * 2;
557
+ const sx1 = Math.min(sx + 1, width - 1);
558
+
559
+ const i00 = (sy * width + sx) * 3;
560
+ const i10 = (sy * width + sx1) * 3;
561
+ const i01 = (sy1 * width + sx) * 3;
562
+ const i11 = (sy1 * width + sx1) * 3;
563
+
564
+ const r = (buffer[i00] + buffer[i10] + buffer[i01] + buffer[i11]) >> 2;
565
+ const g = (buffer[i00 + 1] + buffer[i10 + 1] + buffer[i01 + 1] + buffer[i11 + 1]) >> 2;
566
+ const b = (buffer[i00 + 2] + buffer[i10 + 2] + buffer[i01 + 2] + buffer[i11 + 2]) >> 2;
567
+
568
+ const lum = (LUMA_R_INT * r + LUMA_G_INT * g + LUMA_B_INT * b) >> 8;
569
+ const outIdx = (hy * halfW + hx) * 3;
570
+
571
+ if (lum > threshold) {
572
+ const excess256 = ((lum - threshold) << 8) / thresholdRange;
573
+ bloom[outIdx] = (r * excess256) >> 8;
574
+ bloom[outIdx + 1] = (g * excess256) >> 8;
575
+ bloom[outIdx + 2] = (b * excess256) >> 8;
576
+ } else {
577
+ bloom[outIdx] = 0;
578
+ bloom[outIdx + 1] = 0;
579
+ bloom[outIdx + 2] = 0;
580
+ }
581
+ }
582
+ }
583
+
584
+ // Horizontal blur
585
+ const radius = BLOOM_BLUR_RADIUS;
586
+ for (let y = 0; y < halfH; y++) {
587
+ const rowStart = y * halfRowBytes;
588
+ let sumR = 0, sumG = 0, sumB = 0;
589
+ for (let dx = 0; dx <= radius && dx < halfW; dx++) {
590
+ const idx = rowStart + dx * 3;
591
+ sumR += bloom[idx];
592
+ sumG += bloom[idx + 1];
593
+ sumB += bloom[idx + 2];
594
+ }
595
+ let windowSize = Math.min(radius + 1, halfW);
596
+
597
+ for (let x = 0; x < halfW; x++) {
598
+ const outIdx = x * 3;
599
+ tempRow[outIdx] = (sumR / windowSize) | 0;
600
+ tempRow[outIdx + 1] = (sumG / windowSize) | 0;
601
+ tempRow[outIdx + 2] = (sumB / windowSize) | 0;
602
+
603
+ const leftX = x - radius;
604
+ const rightX = x + radius + 1;
605
+ if (leftX >= 0) {
606
+ const leftIdx = rowStart + leftX * 3;
607
+ sumR -= bloom[leftIdx];
608
+ sumG -= bloom[leftIdx + 1];
609
+ sumB -= bloom[leftIdx + 2];
610
+ windowSize--;
611
+ }
612
+ if (rightX < halfW) {
613
+ const rightIdx = rowStart + rightX * 3;
614
+ sumR += bloom[rightIdx];
615
+ sumG += bloom[rightIdx + 1];
616
+ sumB += bloom[rightIdx + 2];
617
+ windowSize++;
618
+ }
619
+ }
620
+ bloom.set(tempRow.subarray(0, halfRowBytes), rowStart);
621
+ }
622
+
623
+ // Vertical blur
624
+ for (let x = 0; x < halfW; x++) {
625
+ const xOffset = x * 3;
626
+ let sumR = 0, sumG = 0, sumB = 0;
627
+ for (let dy = 0; dy <= radius && dy < halfH; dy++) {
628
+ const idx = dy * halfRowBytes + xOffset;
629
+ sumR += bloom[idx];
630
+ sumG += bloom[idx + 1];
631
+ sumB += bloom[idx + 2];
632
+ }
633
+ let windowSize = Math.min(radius + 1, halfH);
634
+
635
+ for (let y = 0; y < halfH; y++) {
636
+ const outIdx = y * 3;
637
+ tempCol[outIdx] = (sumR / windowSize) | 0;
638
+ tempCol[outIdx + 1] = (sumG / windowSize) | 0;
639
+ tempCol[outIdx + 2] = (sumB / windowSize) | 0;
640
+
641
+ const topY = y - radius;
642
+ const bottomY = y + radius + 1;
643
+ if (topY >= 0) {
644
+ const topIdx = topY * halfRowBytes + xOffset;
645
+ sumR -= bloom[topIdx];
646
+ sumG -= bloom[topIdx + 1];
647
+ sumB -= bloom[topIdx + 2];
648
+ windowSize--;
649
+ }
650
+ if (bottomY < halfH) {
651
+ const bottomIdx = bottomY * halfRowBytes + xOffset;
652
+ sumR += bloom[bottomIdx];
653
+ sumG += bloom[bottomIdx + 1];
654
+ sumB += bloom[bottomIdx + 2];
655
+ windowSize++;
656
+ }
657
+ }
658
+
659
+ for (let y = 0; y < halfH; y++) {
660
+ const srcIdx = y * 3;
661
+ const dstIdx = y * halfRowBytes + xOffset;
662
+ bloom[dstIdx] = tempCol[srcIdx];
663
+ bloom[dstIdx + 1] = tempCol[srcIdx + 1];
664
+ bloom[dstIdx + 2] = tempCol[srcIdx + 2];
665
+ }
666
+ }
667
+
668
+ // Upsample and blend, combining with vignette if enabled
669
+ const combineVignette = this.vignette > 0;
670
+
671
+ if (combineVignette) {
672
+ this.ensureVignetteMap(width, height);
673
+ const vmap = this.vignetteMap!;
674
+
675
+ for (let y = 0; y < height; y++) {
676
+ const hy = y >> 1;
677
+ const srcRowStart = hy * halfRowBytes;
678
+ const dstRowStart = y * rowBytes;
679
+ const vignetteRowStart = y * width;
680
+
681
+ for (let x = 0; x < width; x++) {
682
+ const hx = x >> 1;
683
+ const bloomIdx = srcRowStart + hx * 3;
684
+ const dstIdx = dstRowStart + x * 3;
685
+ const vfactor = vmap[vignetteRowStart + x];
686
+
687
+ const blendedR = buffer[dstIdx] + ((bloom[bloomIdx] * intensity256) >> 8);
688
+ const blendedG = buffer[dstIdx + 1] + ((bloom[bloomIdx + 1] * intensity256) >> 8);
689
+ const blendedB = buffer[dstIdx + 2] + ((bloom[bloomIdx + 2] * intensity256) >> 8);
690
+
691
+ buffer[dstIdx] = ((blendedR > 255 ? 255 : blendedR) * vfactor) >> 8;
692
+ buffer[dstIdx + 1] = ((blendedG > 255 ? 255 : blendedG) * vfactor) >> 8;
693
+ buffer[dstIdx + 2] = ((blendedB > 255 ? 255 : blendedB) * vfactor) >> 8;
694
+ }
695
+ }
696
+ this.vignetteApplied = true;
697
+ } else {
698
+ for (let y = 0; y < height; y++) {
699
+ const hy = y >> 1;
700
+ const srcRowStart = hy * halfRowBytes;
701
+ const dstRowStart = y * rowBytes;
702
+
703
+ for (let x = 0; x < width; x++) {
704
+ const hx = x >> 1;
705
+ const bloomIdx = srcRowStart + hx * 3;
706
+ const dstIdx = dstRowStart + x * 3;
707
+
708
+ const blendedR = buffer[dstIdx] + ((bloom[bloomIdx] * intensity256) >> 8);
709
+ const blendedG = buffer[dstIdx + 1] + ((bloom[bloomIdx + 1] * intensity256) >> 8);
710
+ const blendedB = buffer[dstIdx + 2] + ((bloom[bloomIdx + 2] * intensity256) >> 8);
711
+
712
+ buffer[dstIdx] = blendedR > 255 ? 255 : blendedR;
713
+ buffer[dstIdx + 1] = blendedG > 255 ? 255 : blendedG;
714
+ buffer[dstIdx + 2] = blendedB > 255 ? 255 : blendedB;
715
+ }
716
+ }
717
+ }
718
+ }
719
+
720
+ /**
721
+ * Apply NTSC color artifact effect using fixed-point integer math.
722
+ *
723
+ * YIQ color space coefficients (scaled by 256 for fixed-point):
724
+ * - RGB to I: 0.596, -0.274, -0.322 → 153, -70, -82
725
+ * - RGB to Q: 0.211, -0.523, 0.312 → 54, -134, 80
726
+ * - RGB to Y: 0.299, 0.587, 0.114 → 77, 150, 29
727
+ * - I/Q to R: 0.956, 0.621 → 245, 159
728
+ * - I/Q to G: -0.272, -0.647 → -70, -166
729
+ * - I/Q to B: -1.106, 1.703 → -283, 436
730
+ */
731
+ private applyNtscArtifacts(buffer: Uint8Array, width: number, height: number): void {
732
+ if (this.ntsc <= 0) {return;}
733
+
734
+ const rowBytes = width * 3;
735
+ const halfW = (width + 1) >> 1;
736
+ const rowChromaSize = halfW * 2;
737
+
738
+ // Fixed-point intensity (scaled by 256)
739
+ const int256 = (this.ntsc * 256) | 0;
740
+ const oneMinusInt256 = 256 - int256;
741
+
742
+ this.ntscChromaBuffer = this.ensureInt32Buffer(this.ntscChromaBuffer, rowChromaSize);
743
+ this.ntscTempRow = this.ensureInt32Buffer(this.ntscTempRow, rowChromaSize);
744
+
745
+ const chromaRow = this.ntscChromaBuffer;
746
+ const blurredRow = this.ntscTempRow;
747
+ const radius = NTSC_CHROMA_BLUR_RADIUS;
748
+ const rowStep = this.scanlineIntensity > 0 ? 2 : 1;
749
+
750
+ for (let y = 0; y < height; y += rowStep) {
751
+ const rowStart = y * rowBytes;
752
+
753
+ // Extract I and Q chroma components (scaled by 256)
754
+ for (let hx = 0; hx < halfW; hx++) {
755
+ const sx = hx * 2;
756
+ const idx = rowStart + sx * 3;
757
+ const r = buffer[idx];
758
+ const g = buffer[idx + 1];
759
+ const b = buffer[idx + 2];
760
+
761
+ const chromaIdx = hx * 2;
762
+ chromaRow[chromaIdx] = YIQ_I_R * r - YIQ_I_G * g - YIQ_I_B * b; // I * 256
763
+ chromaRow[chromaIdx + 1] = YIQ_Q_R * r - YIQ_Q_G * g + YIQ_Q_B * b; // Q * 256
764
+ }
765
+
766
+ // Horizontal blur using sliding window
767
+ let sumI = 0, sumQ = 0;
768
+ for (let dx = 0; dx <= radius && dx < halfW; dx++) {
769
+ const idx = dx * 2;
770
+ sumI += chromaRow[idx];
771
+ sumQ += chromaRow[idx + 1];
772
+ }
773
+ let windowSize = Math.min(radius + 1, halfW);
774
+
775
+ for (let hx = 0; hx < halfW; hx++) {
776
+ const outIdx = hx * 2;
777
+ blurredRow[outIdx] = (sumI / windowSize) | 0;
778
+ blurredRow[outIdx + 1] = (sumQ / windowSize) | 0;
779
+
780
+ const leftX = hx - radius;
781
+ const rightX = hx + radius + 1;
782
+
783
+ if (leftX >= 0) {
784
+ const leftIdx = leftX * 2;
785
+ sumI -= chromaRow[leftIdx];
786
+ sumQ -= chromaRow[leftIdx + 1];
787
+ windowSize--;
788
+ }
789
+ if (rightX < halfW) {
790
+ const rightIdx = rightX * 2;
791
+ sumI += chromaRow[rightIdx];
792
+ sumQ += chromaRow[rightIdx + 1];
793
+ windowSize++;
794
+ }
795
+ }
796
+
797
+ // Apply chroma blur and convert back to RGB
798
+ for (let x = 0; x < width; x++) {
799
+ const idx = rowStart + x * 3;
800
+ const r = buffer[idx];
801
+ const g = buffer[idx + 1];
802
+ const b = buffer[idx + 2];
803
+
804
+ // Luma (Y) - unscaled, 0-255 range
805
+ const luma = (LUMA_R_INT * r + LUMA_G_INT * g + LUMA_B_INT * b) >> 8;
806
+
807
+ // Original I and Q (scaled by 256)
808
+ const iOrig = YIQ_I_R * r - YIQ_I_G * g - YIQ_I_B * b;
809
+ const qOrig = YIQ_Q_R * r - YIQ_Q_G * g + YIQ_Q_B * b;
810
+
811
+ // Blurred I and Q (already scaled by 256)
812
+ const hx = x >> 1;
813
+ const chromaIdx = hx * 2;
814
+ const iBlur = blurredRow[chromaIdx];
815
+ const qBlur = blurredRow[chromaIdx + 1];
816
+
817
+ // Blend original and blurred chroma (result still scaled by 256)
818
+ const iFinal = (iOrig * oneMinusInt256 + iBlur * int256) >> 8;
819
+ const qFinal = (qOrig * oneMinusInt256 + qBlur * int256) >> 8;
820
+
821
+ // Convert YIQ back to RGB
822
+ // Coefficients scaled by 256, I/Q scaled by 256, so >> 16 total
823
+ const newR = luma + ((YIQ_INV_R_I * iFinal + YIQ_INV_R_Q * qFinal) >> 16);
824
+ const newG = luma + ((-YIQ_INV_G_I * iFinal - YIQ_INV_G_Q * qFinal) >> 16);
825
+ const newB = luma + ((-YIQ_INV_B_I * iFinal + YIQ_INV_B_Q * qFinal) >> 16);
826
+
827
+ buffer[idx] = newR < 0 ? 0 : newR > 255 ? 255 : newR;
828
+ buffer[idx + 1] = newG < 0 ? 0 : newG > 255 ? 255 : newG;
829
+ buffer[idx + 2] = newB < 0 ? 0 : newB > 255 ? 255 : newB;
830
+ }
831
+ }
832
+ }
833
+
834
+ /**
835
+ * Build chromatic aberration lookup map.
836
+ * Precomputes source pixel indices for red and blue channel sampling.
837
+ */
838
+ private buildChromaticAberrationMap(width: number, height: number): void {
839
+ const intensity = this.chromaticAberration;
840
+ const maxOffset = intensity * CHROMATIC_ABERRATION_OFFSET_SCALE;
841
+
842
+ const centerX = width / 2;
843
+ const centerY = height / 2;
844
+ const maxDist = Math.sqrt(centerX * centerX + centerY * centerY);
845
+
846
+ // Store 2 indices per pixel: [redSrcIdx, blueSrcIdx]
847
+ this.chromaticAberrationMap = new Int32Array(width * height * 2);
848
+ this.chromaticAberrationMapWidth = width;
849
+ this.chromaticAberrationMapHeight = height;
850
+ this.chromaticAberrationMapIntensity = intensity;
851
+
852
+ let mapIdx = 0;
853
+ for (let y = 0; y < height; y++) {
854
+ const dy = y - centerY;
855
+
856
+ for (let x = 0; x < width; x++) {
857
+ const dx = x - centerX;
858
+ const dist = Math.sqrt(dx * dx + dy * dy) / maxDist;
859
+ const distSq = dist * dist; // Quadratic falloff
860
+
861
+ // Calculate direction from center (normalized)
862
+ const dirX = dist > 0 ? dx / (dist * maxDist) : 0;
863
+ const dirY = dist > 0 ? dy / (dist * maxDist) : 0;
864
+
865
+ const offset = maxOffset * distSq;
866
+
867
+ // Red channel: sample from position shifted outward
868
+ let redX = Math.round(x + dirX * offset);
869
+ let redY = Math.round(y + dirY * offset);
870
+ // Clamp to bounds
871
+ if (redX < 0) {redX = 0;} else if (redX >= width) {redX = width - 1;}
872
+ if (redY < 0) {redY = 0;} else if (redY >= height) {redY = height - 1;}
873
+
874
+ // Blue channel: sample from position shifted inward
875
+ let blueX = Math.round(x - dirX * offset);
876
+ let blueY = Math.round(y - dirY * offset);
877
+ // Clamp to bounds
878
+ if (blueX < 0) {blueX = 0;} else if (blueX >= width) {blueX = width - 1;}
879
+ if (blueY < 0) {blueY = 0;} else if (blueY >= height) {blueY = height - 1;}
880
+
881
+ // Store source pixel indices (byte offset / 3)
882
+ this.chromaticAberrationMap[mapIdx++] = redY * width + redX;
883
+ this.chromaticAberrationMap[mapIdx++] = blueY * width + blueX;
884
+ }
885
+ }
886
+ }
887
+
888
+ /**
889
+ * Apply chromatic aberration effect (RGB color fringing).
890
+ * Simulates CRT electron beam convergence errors and lens distortion
891
+ * where different color channels separate toward screen edges.
892
+ */
893
+ private applyChromaticAberration(buffer: Uint8Array, width: number, height: number): void {
894
+ if (this.chromaticAberration <= 0) {return;}
895
+
896
+ this.ensureChromaticAberrationMap(width, height);
897
+
898
+ const bufferSize = width * height * 3;
899
+ this.chromaticAberrationSrcBuffer = this.ensureUint8Buffer(this.chromaticAberrationSrcBuffer, bufferSize);
900
+
901
+ const source = this.chromaticAberrationSrcBuffer;
902
+ source.set(buffer);
903
+
904
+ const map = this.chromaticAberrationMap!;
905
+ const pixelCount = width * height;
906
+
907
+ for (let i = 0; i < pixelCount; i++) {
908
+ const mapIdx = i * 2;
909
+ const redSrcPixel = map[mapIdx];
910
+ const blueSrcPixel = map[mapIdx + 1];
911
+ const dstIdx = i * 3;
912
+
913
+ // Sample red from shifted position
914
+ buffer[dstIdx] = source[redSrcPixel * 3];
915
+
916
+ // Green stays at original position
917
+ buffer[dstIdx + 1] = source[dstIdx + 1];
918
+
919
+ // Sample blue from shifted position
920
+ buffer[dstIdx + 2] = source[blueSrcPixel * 3 + 2];
921
+ }
922
+ }
923
+ }