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,397 @@
1
+ /**
2
+ * Pixel format conversion utilities for libretro cores
3
+ * Converts from libretro pixel formats to RGB24
4
+ */
5
+
6
+ import { RETRO_PIXEL_FORMAT, FRAMEBUFFER_HEADROOM, RGB24_BYTES_PER_PIXEL } from "..";
7
+
8
+ import { logger } from "@/utils/logger";
9
+
10
+ // Pixel-format specific constants
11
+ import {
12
+ XRGB8888_BYTES_PER_PIXEL,
13
+ RGB16_BYTES_PER_PIXEL,
14
+ MASK_5BIT,
15
+ MASK_6BIT,
16
+ RGB565_RED_SHIFT,
17
+ RGB565_GREEN_SHIFT,
18
+ XRGB1555_RED_SHIFT,
19
+ XRGB1555_GREEN_SHIFT,
20
+ SCALE_5BIT_TO_8BIT_SHIFT,
21
+ SCALE_6BIT_TO_8BIT_SHIFT,
22
+ REPLICATE_5BIT_SHIFT,
23
+ REPLICATE_6BIT_SHIFT,
24
+ } from "./consts";
25
+
26
+ export * from './consts';
27
+
28
+ // Reusable output buffer to avoid allocations per frame
29
+ let outputBuffer: Uint8Array | null = null;
30
+ let outputBufferCapacity = 0;
31
+
32
+ /** Region bounds for cropped conversion */
33
+ interface ConvertBounds {
34
+ top: number;
35
+ left: number;
36
+ width: number;
37
+ height: number;
38
+ }
39
+
40
+ /**
41
+ * Convert a libretro framebuffer to RGB24 format
42
+ *
43
+ * @param data Raw framebuffer data from the core
44
+ * @param width Frame width in pixels (full source frame)
45
+ * @param height Frame height in pixels (full source frame)
46
+ * @param pitch Row stride in bytes (may be larger than width * bytesPerPixel)
47
+ * @param format Pixel format (XRGB1555, RGB565, or XRGB8888)
48
+ * @param bounds Optional region to convert (if omitted, converts full frame)
49
+ * @returns RGB24 framebuffer (3 bytes per pixel: R, G, B)
50
+ */
51
+ export const convertFramebuffer = (
52
+ data: Uint8Array,
53
+ width: number,
54
+ height: number,
55
+ pitch: number,
56
+ format: number,
57
+ bounds?: ConvertBounds
58
+ ): Uint8Array => {
59
+ // Use bounds if provided, otherwise convert full frame
60
+ const outWidth = bounds?.width ?? width;
61
+ const outHeight = bounds?.height ?? height;
62
+ const outputSize = outWidth * outHeight * RGB24_BYTES_PER_PIXEL;
63
+
64
+ // Reuse buffer if possible
65
+ if (!outputBuffer || outputBufferCapacity < outputSize) {
66
+ outputBufferCapacity = outputSize + FRAMEBUFFER_HEADROOM;
67
+ outputBuffer = new Uint8Array(outputBufferCapacity);
68
+ }
69
+
70
+ const output = outputBuffer;
71
+
72
+ switch (format) {
73
+ case RETRO_PIXEL_FORMAT.XRGB8888:
74
+ convertXRGB8888(data, width, pitch, output, bounds);
75
+ break;
76
+
77
+ case RETRO_PIXEL_FORMAT.RGB565:
78
+ convertRGB565(data, width, pitch, output, bounds);
79
+ break;
80
+
81
+ case RETRO_PIXEL_FORMAT.XRGB1555:
82
+ default:
83
+ convertXRGB1555(data, width, pitch, output, bounds);
84
+ break;
85
+ }
86
+
87
+ // Return a view of just the used portion
88
+ return output.subarray(0, outputSize);
89
+ };
90
+
91
+ /**
92
+ * Convert XRGB8888 (32-bit) to RGB24
93
+ * Format: XXXXXXXX RRRRRRRR GGGGGGGG BBBBBBBB (little-endian: B G R X)
94
+ */
95
+ const convertXRGB8888 = (
96
+ data: Uint8Array,
97
+ sourceWidth: number,
98
+ pitch: number,
99
+ output: Uint8Array,
100
+ bounds?: ConvertBounds
101
+ ): void => {
102
+ const startX = bounds?.left ?? 0;
103
+ const startY = bounds?.top ?? 0;
104
+ const outWidth = bounds?.width ?? sourceWidth;
105
+ const outHeight = bounds?.height ?? Math.floor(data.length / pitch);
106
+ let outIdx = 0;
107
+
108
+ for (let y = 0; y < outHeight; y++) {
109
+ const srcY = startY + y;
110
+ const rowOffset = srcY * pitch;
111
+
112
+ for (let x = 0; x < outWidth; x++) {
113
+ const srcX = startX + x;
114
+ const idx = rowOffset + srcX * XRGB8888_BYTES_PER_PIXEL;
115
+ // Little-endian: B at idx+0, G at idx+1, R at idx+2, X at idx+3
116
+ output[outIdx++] = data[idx + 2]; // R
117
+ output[outIdx++] = data[idx + 1]; // G
118
+ output[outIdx++] = data[idx]; // B
119
+ }
120
+ }
121
+ };
122
+
123
+ /**
124
+ * Convert RGB565 (16-bit) to RGB24
125
+ * Format: RRRRRGGG GGGBBBBB (little-endian)
126
+ * Uses DataView for efficient 16-bit reads.
127
+ */
128
+ const convertRGB565 = (
129
+ data: Uint8Array,
130
+ sourceWidth: number,
131
+ pitch: number,
132
+ output: Uint8Array,
133
+ bounds?: ConvertBounds
134
+ ): void => {
135
+ const startX = bounds?.left ?? 0;
136
+ const startY = bounds?.top ?? 0;
137
+ const outWidth = bounds?.width ?? sourceWidth;
138
+ const outHeight = bounds?.height ?? Math.floor(data.length / pitch);
139
+ let outIdx = 0;
140
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
141
+
142
+ for (let y = 0; y < outHeight; y++) {
143
+ const srcY = startY + y;
144
+ const rowOffset = srcY * pitch;
145
+
146
+ for (let x = 0; x < outWidth; x++) {
147
+ const srcX = startX + x;
148
+ const idx = rowOffset + srcX * RGB16_BYTES_PER_PIXEL;
149
+ const pixel = view.getUint16(idx, true); // true = little-endian
150
+
151
+ // Extract 5-bit R, 6-bit G, 5-bit B
152
+ const r5 = (pixel >> RGB565_RED_SHIFT) & MASK_5BIT;
153
+ const g6 = (pixel >> RGB565_GREEN_SHIFT) & MASK_6BIT;
154
+ const b5 = pixel & MASK_5BIT;
155
+
156
+ // Scale to 8-bit (replicate upper bits into lower bits for accuracy)
157
+ output[outIdx++] = (r5 << SCALE_5BIT_TO_8BIT_SHIFT) | (r5 >> REPLICATE_5BIT_SHIFT);
158
+ output[outIdx++] = (g6 << SCALE_6BIT_TO_8BIT_SHIFT) | (g6 >> REPLICATE_6BIT_SHIFT);
159
+ output[outIdx++] = (b5 << SCALE_5BIT_TO_8BIT_SHIFT) | (b5 >> REPLICATE_5BIT_SHIFT);
160
+ }
161
+ }
162
+ };
163
+
164
+ /**
165
+ * Convert XRGB1555 (15-bit) to RGB24
166
+ * Format: XRRRRRGG GGGBBBBB (little-endian, X bit is ignored)
167
+ * Uses DataView for efficient 16-bit reads.
168
+ */
169
+ const convertXRGB1555 = (
170
+ data: Uint8Array,
171
+ sourceWidth: number,
172
+ pitch: number,
173
+ output: Uint8Array,
174
+ bounds?: ConvertBounds
175
+ ): void => {
176
+ const startX = bounds?.left ?? 0;
177
+ const startY = bounds?.top ?? 0;
178
+ const outWidth = bounds?.width ?? sourceWidth;
179
+ const outHeight = bounds?.height ?? Math.floor(data.length / pitch);
180
+ let outIdx = 0;
181
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
182
+
183
+ for (let y = 0; y < outHeight; y++) {
184
+ const srcY = startY + y;
185
+ const rowOffset = srcY * pitch;
186
+
187
+ for (let x = 0; x < outWidth; x++) {
188
+ const srcX = startX + x;
189
+ const idx = rowOffset + srcX * RGB16_BYTES_PER_PIXEL;
190
+ const pixel = view.getUint16(idx, true); // true = little-endian
191
+
192
+ // Extract 5-bit R, G, B (bit 15 is X, ignored)
193
+ const r5 = (pixel >> XRGB1555_RED_SHIFT) & MASK_5BIT;
194
+ const g5 = (pixel >> XRGB1555_GREEN_SHIFT) & MASK_5BIT;
195
+ const b5 = pixel & MASK_5BIT;
196
+
197
+ // Scale to 8-bit (replicate upper bits into lower bits for accuracy)
198
+ output[outIdx++] = (r5 << SCALE_5BIT_TO_8BIT_SHIFT) | (r5 >> REPLICATE_5BIT_SHIFT);
199
+ output[outIdx++] = (g5 << SCALE_5BIT_TO_8BIT_SHIFT) | (g5 >> REPLICATE_5BIT_SHIFT);
200
+ output[outIdx++] = (b5 << SCALE_5BIT_TO_8BIT_SHIFT) | (b5 >> REPLICATE_5BIT_SHIFT);
201
+ }
202
+ }
203
+ };
204
+
205
+ /**
206
+ * Get bytes per pixel for a given format
207
+ */
208
+ export const getBytesPerPixel = (format: number): number => {
209
+ switch (format) {
210
+ case RETRO_PIXEL_FORMAT.XRGB8888:
211
+ return XRGB8888_BYTES_PER_PIXEL;
212
+ case RETRO_PIXEL_FORMAT.RGB565:
213
+ case RETRO_PIXEL_FORMAT.XRGB1555:
214
+ default:
215
+ return RGB16_BYTES_PER_PIXEL;
216
+ }
217
+ };
218
+
219
+ /**
220
+ * Get the format name for debugging
221
+ */
222
+ export const getFormatName = (format: number): string => {
223
+ switch (format) {
224
+ case RETRO_PIXEL_FORMAT.XRGB8888:
225
+ return "XRGB8888";
226
+ case RETRO_PIXEL_FORMAT.RGB565:
227
+ return "RGB565";
228
+ case RETRO_PIXEL_FORMAT.XRGB1555:
229
+ return "XRGB1555";
230
+ default:
231
+ return `Unknown(${format})`;
232
+ }
233
+ };
234
+
235
+ /** Content bounds detected in framebuffer */
236
+ export interface ContentBounds {
237
+ top: number;
238
+ bottom: number;
239
+ left: number;
240
+ right: number;
241
+ width: number;
242
+ height: number;
243
+ }
244
+
245
+ /** Threshold for considering a row/column as blank (0-255) */
246
+ const BLANK_VARIANCE_THRESHOLD = 10;
247
+
248
+ /** Minimum samples to check per row for blank detection */
249
+ const SAMPLES_PER_ROW = 16;
250
+
251
+ /**
252
+ * Detect actual content bounds in an RGB24 framebuffer.
253
+ * Finds the region of the frame that contains non-uniform content,
254
+ * excluding blank/solid color borders that some systems output.
255
+ *
256
+ * @param data RGB24 framebuffer (3 bytes per pixel: R, G, B)
257
+ * @param width Frame width in pixels
258
+ * @param height Frame height in pixels
259
+ * @returns Content bounds or null if entire frame appears uniform
260
+ */
261
+ export const detectContentBounds = (data: Uint8Array, width: number, height: number): ContentBounds | null => {
262
+ // Find top bound (first non-blank row from top)
263
+ let top = 0;
264
+ for (let y = 0; y < height; y++) {
265
+ if (!isRowBlank(data, width, y)) {
266
+ top = y;
267
+ break;
268
+ }
269
+ }
270
+
271
+ // Find bottom bound (first non-blank row from bottom)
272
+ let bottom = height - 1;
273
+ for (let y = height - 1; y >= top; y--) {
274
+ if (!isRowBlank(data, width, y)) {
275
+ bottom = y;
276
+ break;
277
+ }
278
+ }
279
+
280
+ // Find left bound (first non-blank column from left)
281
+ let left = 0;
282
+ for (let x = 0; x < width; x++) {
283
+ if (!isColumnBlank(data, width, height, x, top, bottom)) {
284
+ left = x;
285
+ break;
286
+ }
287
+ }
288
+
289
+ // Find right bound (first non-blank column from right)
290
+ let right = width - 1;
291
+ for (let x = width - 1; x >= left; x--) {
292
+ if (!isColumnBlank(data, width, height, x, top, bottom)) {
293
+ right = x;
294
+ break;
295
+ }
296
+ }
297
+
298
+ const contentWidth = right - left + 1;
299
+ const contentHeight = bottom - top + 1;
300
+
301
+ // Debug: log detected bounds
302
+ logger.debug(
303
+ `detectContentBounds: top=${top}, bottom=${bottom}, left=${left}, right=${right} (content: ${contentWidth}x${contentHeight} of ${width}x${height})`,
304
+ 'Core'
305
+ );
306
+
307
+ // If detected bounds are the same as original, no cropping needed
308
+ if (contentWidth === width && contentHeight === height) {
309
+ return null;
310
+ }
311
+
312
+ // Only return bounds if we found meaningful content area
313
+ // (at least 25% of original dimensions to avoid false positives)
314
+ const MIN_CONTENT_RATIO = 0.25;
315
+ if (contentWidth < width * MIN_CONTENT_RATIO || contentHeight < height * MIN_CONTENT_RATIO) {
316
+ logger.debug('detectContentBounds: content too small, rejecting', 'Core');
317
+ return null;
318
+ }
319
+
320
+ return { top, bottom, left, right, width: contentWidth, height: contentHeight };
321
+ };
322
+
323
+ /**
324
+ * Check if a row is "blank" (uniform color or very low variance)
325
+ */
326
+ const isRowBlank = (data: Uint8Array, width: number, y: number): boolean => {
327
+ const rowStart = y * width * RGB24_BYTES_PER_PIXEL;
328
+
329
+ // Sample pixels across the row
330
+ const step = Math.max(1, Math.floor(width / SAMPLES_PER_ROW));
331
+ const firstR = data[rowStart];
332
+ const firstG = data[rowStart + 1];
333
+ const firstB = data[rowStart + 2];
334
+
335
+ for (let x = step; x < width; x += step) {
336
+ const idx = rowStart + x * RGB24_BYTES_PER_PIXEL;
337
+ const dr = Math.abs(data[idx] - firstR);
338
+ const dg = Math.abs(data[idx + 1] - firstG);
339
+ const db = Math.abs(data[idx + 2] - firstB);
340
+
341
+ if (dr > BLANK_VARIANCE_THRESHOLD || dg > BLANK_VARIANCE_THRESHOLD || db > BLANK_VARIANCE_THRESHOLD) {
342
+ return false; // Row has varied content
343
+ }
344
+ }
345
+
346
+ return true; // Row is uniform
347
+ };
348
+
349
+ /**
350
+ * Check if a column is "blank" (uniform color) within the content area
351
+ */
352
+ const isColumnBlank = (data: Uint8Array, width: number, _height: number, x: number, top: number, bottom: number): boolean => {
353
+ const firstIdx = (top * width + x) * RGB24_BYTES_PER_PIXEL;
354
+ const firstR = data[firstIdx];
355
+ const firstG = data[firstIdx + 1];
356
+ const firstB = data[firstIdx + 2];
357
+
358
+ // Sample pixels down the column
359
+ const colHeight = bottom - top + 1;
360
+ const step = Math.max(1, Math.floor(colHeight / SAMPLES_PER_ROW));
361
+
362
+ for (let y = top + step; y <= bottom; y += step) {
363
+ const idx = (y * width + x) * RGB24_BYTES_PER_PIXEL;
364
+ const dr = Math.abs(data[idx] - firstR);
365
+ const dg = Math.abs(data[idx + 1] - firstG);
366
+ const db = Math.abs(data[idx + 2] - firstB);
367
+
368
+ if (dr > BLANK_VARIANCE_THRESHOLD || dg > BLANK_VARIANCE_THRESHOLD || db > BLANK_VARIANCE_THRESHOLD) {
369
+ return false;
370
+ }
371
+ }
372
+
373
+ return true;
374
+ };
375
+
376
+ /**
377
+ * Check if an RGB24 framebuffer has any actual content (non-uniform pixels).
378
+ * Used to determine if a frame is worth analyzing for content bounds,
379
+ * or if it's still a blank/loading frame that should be skipped.
380
+ *
381
+ * @param data RGB24 framebuffer (3 bytes per pixel: R, G, B)
382
+ * @param width Frame width in pixels
383
+ * @param height Frame height in pixels
384
+ * @returns true if frame has varied content, false if entirely uniform
385
+ */
386
+ export const hasFrameContent = (data: Uint8Array, width: number, height: number): boolean => {
387
+ // Sample rows across the frame to check for any non-uniform content
388
+ const rowStep = Math.max(1, Math.floor(height / SAMPLES_PER_ROW));
389
+
390
+ for (let y = 0; y < height; y += rowStep) {
391
+ if (!isRowBlank(data, width, y)) {
392
+ return true; // Found a row with varied content
393
+ }
394
+ }
395
+
396
+ return false; // All sampled rows are uniform (blank frame)
397
+ };