synabun 1.3.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 (187) hide show
  1. package/CHANGELOG.md +665 -0
  2. package/LICENSE +191 -0
  3. package/README.md +853 -0
  4. package/hooks/claude-code/post-plan.mjs +391 -0
  5. package/hooks/claude-code/post-remember.mjs +239 -0
  6. package/hooks/claude-code/pre-compact.mjs +241 -0
  7. package/hooks/claude-code/pre-websearch.mjs +90 -0
  8. package/hooks/claude-code/prompt-submit.mjs +861 -0
  9. package/hooks/claude-code/session-start.mjs +494 -0
  10. package/hooks/claude-code/shared.mjs +368 -0
  11. package/hooks/claude-code/stop.mjs +646 -0
  12. package/hooks/claude-code/test-user-learning.mjs +531 -0
  13. package/mcp-server/package.json +38 -0
  14. package/mcp-server/run.mjs +10 -0
  15. package/mcp-server/src/config.ts +70 -0
  16. package/mcp-server/src/http.ts +93 -0
  17. package/mcp-server/src/index.ts +203 -0
  18. package/mcp-server/src/preload-http.ts +25 -0
  19. package/mcp-server/src/preload.ts +29 -0
  20. package/mcp-server/src/scripts/migrate-qdrant-to-sqlite.ts +506 -0
  21. package/mcp-server/src/services/categories.ts +429 -0
  22. package/mcp-server/src/services/discord.ts +702 -0
  23. package/mcp-server/src/services/file-checksums.ts +33 -0
  24. package/mcp-server/src/services/local-embeddings.ts +77 -0
  25. package/mcp-server/src/services/neural-interface.ts +344 -0
  26. package/mcp-server/src/services/sqlite.ts +823 -0
  27. package/mcp-server/src/tools/browser-advanced.ts +123 -0
  28. package/mcp-server/src/tools/browser-interact.ts +255 -0
  29. package/mcp-server/src/tools/browser-navigate.ts +77 -0
  30. package/mcp-server/src/tools/browser-observe.ts +1330 -0
  31. package/mcp-server/src/tools/browser.ts +107 -0
  32. package/mcp-server/src/tools/card-tools.ts +193 -0
  33. package/mcp-server/src/tools/card.ts +22 -0
  34. package/mcp-server/src/tools/category.ts +379 -0
  35. package/mcp-server/src/tools/discord-channel.ts +274 -0
  36. package/mcp-server/src/tools/discord-guild.ts +188 -0
  37. package/mcp-server/src/tools/discord-member.ts +158 -0
  38. package/mcp-server/src/tools/discord-message.ts +231 -0
  39. package/mcp-server/src/tools/discord-onboarding.ts +228 -0
  40. package/mcp-server/src/tools/discord-role.ts +209 -0
  41. package/mcp-server/src/tools/discord-thread.ts +110 -0
  42. package/mcp-server/src/tools/discord-webhook.ts +176 -0
  43. package/mcp-server/src/tools/discord.ts +40 -0
  44. package/mcp-server/src/tools/forget.ts +38 -0
  45. package/mcp-server/src/tools/git-tools.ts +141 -0
  46. package/mcp-server/src/tools/git.ts +13 -0
  47. package/mcp-server/src/tools/image-tools.ts +98 -0
  48. package/mcp-server/src/tools/image.ts +17 -0
  49. package/mcp-server/src/tools/leonardo-browser-tools.ts +281 -0
  50. package/mcp-server/src/tools/leonardo.ts +28 -0
  51. package/mcp-server/src/tools/loop.ts +348 -0
  52. package/mcp-server/src/tools/memories.ts +118 -0
  53. package/mcp-server/src/tools/recall.ts +323 -0
  54. package/mcp-server/src/tools/reflect.ts +145 -0
  55. package/mcp-server/src/tools/remember.ts +115 -0
  56. package/mcp-server/src/tools/response.ts +26 -0
  57. package/mcp-server/src/tools/restore.ts +34 -0
  58. package/mcp-server/src/tools/sync.ts +114 -0
  59. package/mcp-server/src/tools/tictactoe-tools.ts +98 -0
  60. package/mcp-server/src/tools/tictactoe.ts +13 -0
  61. package/mcp-server/src/tools/utils.ts +25 -0
  62. package/mcp-server/src/tools/whiteboard-tools.ts +260 -0
  63. package/mcp-server/src/tools/whiteboard.ts +22 -0
  64. package/mcp-server/src/tui.ts +940 -0
  65. package/mcp-server/src/types.ts +85 -0
  66. package/mcp-server/tsconfig.json +19 -0
  67. package/neural-interface/package.json +27 -0
  68. package/neural-interface/public/apple-touch-icon.png +0 -0
  69. package/neural-interface/public/category-logos/claude.svg +1 -0
  70. package/neural-interface/public/category-logos/gemini-color.svg +1 -0
  71. package/neural-interface/public/category-logos/openai.svg +1 -0
  72. package/neural-interface/public/category-logos/synabun.png +0 -0
  73. package/neural-interface/public/claude-chat.css +878 -0
  74. package/neural-interface/public/claude-chat.html +92 -0
  75. package/neural-interface/public/claude-chat.js +1168 -0
  76. package/neural-interface/public/favicon-16x16.png +0 -0
  77. package/neural-interface/public/favicon-32x32.png +0 -0
  78. package/neural-interface/public/gallery.svg +6 -0
  79. package/neural-interface/public/icon-192.png +0 -0
  80. package/neural-interface/public/icon-512.png +0 -0
  81. package/neural-interface/public/index-legacy.html +14677 -0
  82. package/neural-interface/public/index.html +212 -0
  83. package/neural-interface/public/index2d-legacy.html +8193 -0
  84. package/neural-interface/public/index2d.html +127 -0
  85. package/neural-interface/public/invite.html +351 -0
  86. package/neural-interface/public/leonardoai.svg +1 -0
  87. package/neural-interface/public/logo-synabun.png +0 -0
  88. package/neural-interface/public/logoHD.png +0 -0
  89. package/neural-interface/public/manifest.json +14 -0
  90. package/neural-interface/public/offline.html +299 -0
  91. package/neural-interface/public/onboarding.html +2335 -0
  92. package/neural-interface/public/openclaw-logo-text.png +0 -0
  93. package/neural-interface/public/pin-svgrepo-com.svg +25 -0
  94. package/neural-interface/public/sessionicon.svg +8 -0
  95. package/neural-interface/public/shared/ansi-parser.js +305 -0
  96. package/neural-interface/public/shared/api.js +1043 -0
  97. package/neural-interface/public/shared/colors.js +153 -0
  98. package/neural-interface/public/shared/constants.js +141 -0
  99. package/neural-interface/public/shared/html-shell.js +777 -0
  100. package/neural-interface/public/shared/html-term-renderer.js +1022 -0
  101. package/neural-interface/public/shared/i18n.js +147 -0
  102. package/neural-interface/public/shared/registry.js +82 -0
  103. package/neural-interface/public/shared/state.js +86 -0
  104. package/neural-interface/public/shared/storage.js +150 -0
  105. package/neural-interface/public/shared/styles.css +21212 -0
  106. package/neural-interface/public/shared/ui-automation-studio.js +3573 -0
  107. package/neural-interface/public/shared/ui-bookmarks.js +231 -0
  108. package/neural-interface/public/shared/ui-claude-panel.js +6333 -0
  109. package/neural-interface/public/shared/ui-command-runner.js +534 -0
  110. package/neural-interface/public/shared/ui-cost-widget.js +602 -0
  111. package/neural-interface/public/shared/ui-detail.js +1454 -0
  112. package/neural-interface/public/shared/ui-explorer.js +1356 -0
  113. package/neural-interface/public/shared/ui-file-explorer.js +2750 -0
  114. package/neural-interface/public/shared/ui-games.js +411 -0
  115. package/neural-interface/public/shared/ui-help.js +107 -0
  116. package/neural-interface/public/shared/ui-image-gallery.js +461 -0
  117. package/neural-interface/public/shared/ui-invite.js +519 -0
  118. package/neural-interface/public/shared/ui-keybinds.js +452 -0
  119. package/neural-interface/public/shared/ui-layouts.js +297 -0
  120. package/neural-interface/public/shared/ui-link.js +731 -0
  121. package/neural-interface/public/shared/ui-loading.js +255 -0
  122. package/neural-interface/public/shared/ui-menubar.js +499 -0
  123. package/neural-interface/public/shared/ui-multiselect.js +93 -0
  124. package/neural-interface/public/shared/ui-navbar.js +222 -0
  125. package/neural-interface/public/shared/ui-notifications.js +287 -0
  126. package/neural-interface/public/shared/ui-panels.js +309 -0
  127. package/neural-interface/public/shared/ui-resume.js +653 -0
  128. package/neural-interface/public/shared/ui-search.js +145 -0
  129. package/neural-interface/public/shared/ui-sessions.js +531 -0
  130. package/neural-interface/public/shared/ui-settings.js +5987 -0
  131. package/neural-interface/public/shared/ui-sidebar.js +1905 -0
  132. package/neural-interface/public/shared/ui-skills.js +2805 -0
  133. package/neural-interface/public/shared/ui-stats.js +38 -0
  134. package/neural-interface/public/shared/ui-sync.js +201 -0
  135. package/neural-interface/public/shared/ui-terminal.js +6385 -0
  136. package/neural-interface/public/shared/ui-tooltip.js +123 -0
  137. package/neural-interface/public/shared/ui-trash.js +439 -0
  138. package/neural-interface/public/shared/ui-tutorial-draw.js +875 -0
  139. package/neural-interface/public/shared/ui-tutorial-steps.js +2276 -0
  140. package/neural-interface/public/shared/ui-tutorial.js +1117 -0
  141. package/neural-interface/public/shared/ui-update.js +244 -0
  142. package/neural-interface/public/shared/ui-whiteboard.js +3262 -0
  143. package/neural-interface/public/shared/ui-workspaces.js +722 -0
  144. package/neural-interface/public/shared/utils.js +360 -0
  145. package/neural-interface/public/shared/vterm-buffer.js +1102 -0
  146. package/neural-interface/public/sw.js +52 -0
  147. package/neural-interface/public/synabun.png +0 -0
  148. package/neural-interface/public/toolbar-album.svg +8 -0
  149. package/neural-interface/public/toolbar-question.svg +6 -0
  150. package/neural-interface/public/toolbar-sessions.svg +6 -0
  151. package/neural-interface/public/variant/2d/background.js +113 -0
  152. package/neural-interface/public/variant/2d/context-menu.js +133 -0
  153. package/neural-interface/public/variant/2d/gfx.js +45 -0
  154. package/neural-interface/public/variant/2d/graph.js +1635 -0
  155. package/neural-interface/public/variant/2d/hulls.js +207 -0
  156. package/neural-interface/public/variant/2d/lasso.js +135 -0
  157. package/neural-interface/public/variant/2d/layout.js +594 -0
  158. package/neural-interface/public/variant/2d/main.js +521 -0
  159. package/neural-interface/public/variant/2d/minimap.js +312 -0
  160. package/neural-interface/public/variant/2d/settings-gfx.js +98 -0
  161. package/neural-interface/public/variant/3d/background.js +884 -0
  162. package/neural-interface/public/variant/3d/camera.js +661 -0
  163. package/neural-interface/public/variant/3d/gfx.js +116 -0
  164. package/neural-interface/public/variant/3d/graph.js +2399 -0
  165. package/neural-interface/public/variant/3d/main.js +500 -0
  166. package/neural-interface/public/variant/3d/settings-gfx.js +228 -0
  167. package/neural-interface/scripts/rebuild-pty.js +100 -0
  168. package/neural-interface/server.js +15000 -0
  169. package/neural-interface/templates/CLAUDE-template.md +211 -0
  170. package/package.json +53 -0
  171. package/postinstall.js +135 -0
  172. package/setup.js +260 -0
  173. package/skills/leonardo/SKILL.md +159 -0
  174. package/skills/leonardo/icon.svg +1 -0
  175. package/skills/leonardo/modules/browser-guide.md +378 -0
  176. package/skills/leonardo/modules/image-prompter.md +277 -0
  177. package/skills/leonardo/modules/model-advisor.md +100 -0
  178. package/skills/leonardo/modules/prompt-library.md +110 -0
  179. package/skills/leonardo/modules/style-guide.md +186 -0
  180. package/skills/leonardo/modules/video-prompter.md +311 -0
  181. package/skills/synabun/SKILL.md +185 -0
  182. package/skills/synabun/icon.png +0 -0
  183. package/skills/synabun/modules/audit.md +374 -0
  184. package/skills/synabun/modules/changelog.md +194 -0
  185. package/skills/synabun/modules/idea.md +144 -0
  186. package/skills/synabun/modules/memorize.md +188 -0
  187. package/skills/synabun/synabunicon.png +0 -0
@@ -0,0 +1,360 @@
1
+ // ═══════════════════════════════════════════
2
+ // SynaBun Neural Interface — Utilities
3
+ // ═══════════════════════════════════════════
4
+ // Shared helper functions used across UI components.
5
+
6
+ import { t, getLocale } from './i18n.js';
7
+
8
+ // ─── Data Normalization ─────────────────
9
+
10
+ /**
11
+ * Ensure every node has a valid payload.category.
12
+ * Nodes with null/undefined/empty category get 'uncategorized'.
13
+ * Mutates in place and returns the array for chaining.
14
+ * @param {Array} nodes
15
+ * @returns {Array}
16
+ */
17
+ export function normalizeNodes(nodes) {
18
+ for (const n of nodes) {
19
+ if (n.payload && !n.payload.category) {
20
+ n.payload.category = 'uncategorized';
21
+ }
22
+ }
23
+ return nodes;
24
+ }
25
+
26
+ // ─── String Utilities ────────────────────
27
+
28
+ /**
29
+ * Truncate a string to a maximum length, adding ellipsis if truncated.
30
+ * @param {string} str
31
+ * @param {number} len
32
+ * @returns {string}
33
+ */
34
+ export function truncate(str, len) {
35
+ if (!str) return '';
36
+ return str.length > len ? str.slice(0, len) + '...' : str;
37
+ }
38
+
39
+ /**
40
+ * Escape HTML entities using a DOM element for safety.
41
+ * @param {string} str
42
+ * @returns {string}
43
+ */
44
+ export function escapeHtml(str) {
45
+ const div = document.createElement('div');
46
+ div.textContent = str;
47
+ return div.innerHTML;
48
+ }
49
+
50
+ // ─── Date / Time Formatting ──────────────
51
+
52
+ /**
53
+ * Format a relative time string from an ISO date (e.g. "5m ago", "3 days ago").
54
+ * Used by the trash panel to show when items were trashed.
55
+ * @param {string} isoDate
56
+ * @returns {string}
57
+ */
58
+ export function formatTrashAge(isoDate) {
59
+ const diffMs = Date.now() - new Date(isoDate).getTime();
60
+ const mins = Math.floor(diffMs / 60000);
61
+ if (mins < 1) return t('time.justNow');
62
+ if (mins < 60) return t('time.minutesAgo', { n: mins });
63
+ const hours = Math.floor(mins / 60);
64
+ if (hours < 24) return t('time.hoursAgo', { n: hours });
65
+ const days = Math.floor(hours / 24);
66
+ if (days === 1) return t('time.dayAgo');
67
+ if (days < 30) return t('time.daysAgo', { n: days });
68
+ const months = Math.floor(days / 30);
69
+ return months === 1 ? t('time.monthAgo') : t('time.monthsAgo', { n: months });
70
+ }
71
+
72
+ // ─── Content Formatting ─────────────────
73
+
74
+ /**
75
+ * Convert markdown-like memory content into styled HTML.
76
+ *
77
+ * Supports:
78
+ * - Headings (## / ### / ####)
79
+ * - Code blocks (```)
80
+ * - Lists (- / * / numbered)
81
+ * - Bold (**text**), italic (*text*), inline code (`code`)
82
+ * - Em-dash description splits ( — description)
83
+ * - Key: Value patterns (label followed by 2+ spaces then value)
84
+ * - Heading-like lines ending with colon
85
+ * - Separator lines (---, ===, ___)
86
+ *
87
+ * @param {string} text — raw memory content
88
+ * @returns {string} — formatted HTML string
89
+ */
90
+ export function formatMemoryContent(text) {
91
+ if (!text) return '';
92
+
93
+ const esc = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
94
+
95
+ // Inline formatting: `code`, **bold**, *italic*, em-dash descriptions
96
+ function inlineFmt(s) {
97
+ let out = esc(s);
98
+ // Backtick code spans
99
+ out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
100
+ // Bold
101
+ out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
102
+ // Italic
103
+ out = out.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
104
+ // Em-dash description split (` — ` becomes dimmed after)
105
+ out = out.replace(/ — (.+)$/, ' <span class="kv-desc">\u2014 $1</span>');
106
+ return out;
107
+ }
108
+
109
+ const lines = text.split('\n');
110
+ let html = '';
111
+ let inList = false;
112
+ let inCode = false;
113
+
114
+ function closeList() { if (inList) { html += '</ul>'; inList = false; } }
115
+
116
+ for (let i = 0; i < lines.length; i++) {
117
+ const raw = lines[i];
118
+ const trimmed = raw.trim();
119
+
120
+ // Empty line
121
+ if (!trimmed) {
122
+ if (inCode) { html += '\n'; continue; }
123
+ closeList();
124
+ continue;
125
+ }
126
+
127
+ // Code block fences
128
+ if (trimmed.startsWith('```')) {
129
+ if (inCode) { html += '</div>'; inCode = false; }
130
+ else { closeList(); inCode = true; html += '<div class="mc-codeblock">'; }
131
+ continue;
132
+ }
133
+ if (inCode) { html += esc(raw) + '\n'; continue; }
134
+
135
+ // Markdown headings ## / ### / ####
136
+ const headingMatch = trimmed.match(/^(#{1,4})\s+(.+)$/);
137
+ if (headingMatch) {
138
+ closeList();
139
+ const level = headingMatch[1].length;
140
+ const cls = level <= 2 ? 'mc-h2' : level === 3 ? 'mc-h3' : 'mc-h4';
141
+ html += `<div class="${cls}">${inlineFmt(headingMatch[2])}</div>`;
142
+ continue;
143
+ }
144
+
145
+ // Separator lines
146
+ if (/^[-=_]{3,}$/.test(trimmed)) {
147
+ closeList();
148
+ html += '<div class="mc-divider"></div>';
149
+ continue;
150
+ }
151
+
152
+ // Standalone backtick line (full line is a code key pattern)
153
+ if (trimmed.startsWith('`') && trimmed.endsWith('`') && !trimmed.includes(' ')) {
154
+ closeList();
155
+ html += `<p>${inlineFmt(trimmed)}</p>`;
156
+ continue;
157
+ }
158
+
159
+ // Backtick line followed by em-dash description
160
+ if (/^`[^`]+`\s+—\s+/.test(trimmed)) {
161
+ closeList();
162
+ html += `<p>${inlineFmt(trimmed)}</p>`;
163
+ continue;
164
+ }
165
+
166
+ // List items (- or * or numbered)
167
+ if (/^[-*\u2022]\s/.test(trimmed) || /^\d+[.)]\s/.test(trimmed)) {
168
+ if (!inList) { html += '<ul>'; inList = true; }
169
+ const content = trimmed.replace(/^[-*\u2022]\s+/, '').replace(/^\d+[.)]\s+/, '');
170
+ html += `<li>${inlineFmt(content)}</li>`;
171
+ continue;
172
+ }
173
+
174
+ // Heading-like: short line ending with colon (not containing backticks — those are code patterns)
175
+ if (trimmed.endsWith(':') && trimmed.length < 50 && !trimmed.includes('`') && !/\s{2,}/.test(trimmed)) {
176
+ closeList();
177
+ html += `<div class="mc-h3">${inlineFmt(trimmed.slice(0, -1))}</div>`;
178
+ continue;
179
+ }
180
+
181
+ // Key: Value (label < 25 chars, then colon + space + value)
182
+ const kvMatch = trimmed.match(/^([A-Za-z][\w\s/().,-]{0,24}?)\s{2,}(.+)$/);
183
+ if (kvMatch) {
184
+ closeList();
185
+ html += `<div class="mc-kv"><span class="mc-kv-key">${esc(kvMatch[1])}</span> <span class="mc-kv-val">${inlineFmt(kvMatch[2])}</span></div>`;
186
+ continue;
187
+ }
188
+
189
+ // Regular paragraph
190
+ closeList();
191
+ html += `<p>${inlineFmt(trimmed)}</p>`;
192
+ }
193
+
194
+ closeList();
195
+ if (inCode) html += '</div>';
196
+ return html;
197
+ }
198
+
199
+ // ─── Export ──────────────────────────────
200
+
201
+ /**
202
+ * Convert a memory node to markdown and trigger a file download.
203
+ * @param {{ id: string, payload: Object }} node — graph node with payload
204
+ */
205
+ export function exportMemoryAsMarkdown(node) {
206
+ const p = node.payload;
207
+ const lines = [];
208
+
209
+ // Title — first meaningful line of content, or category
210
+ const firstLine = (p.content || '').split('\n').find(l => l.trim()) || p.category || 'Memory';
211
+ const title = firstLine.length > 80 ? firstLine.slice(0, 80) + '...' : firstLine;
212
+ lines.push(`# ${title}`);
213
+ lines.push('');
214
+
215
+ // Metadata table
216
+ const dateFmt = { month: 'short', day: 'numeric', year: 'numeric' };
217
+ const locale = getLocale();
218
+ lines.push(`| ${t('export.fieldLabel')} | ${t('export.valueLabel')} |`);
219
+ lines.push('|-------|-------|');
220
+ lines.push(`| ${t('export.categoryLabel')} | ${p.category}${p.subcategory ? ' / ' + p.subcategory : ''} |`);
221
+ lines.push(`| ${t('export.importanceLabel')} | ${p.importance || 5}/10 |`);
222
+ if (p.project) lines.push(`| ${t('export.projectLabel')} | ${p.project} |`);
223
+ if (p.source) lines.push(`| ${t('export.sourceLabel')} | ${p.source} |`);
224
+ if (p.created_at) lines.push(`| ${t('export.createdLabel')} | ${new Date(p.created_at).toLocaleDateString(locale, dateFmt)} |`);
225
+ if (p.updated_at) lines.push(`| ${t('export.updatedLabel')} | ${new Date(p.updated_at).toLocaleDateString(locale, dateFmt)} |`);
226
+ lines.push(`| ${t('export.idLabel')} | \`${node.id}\` |`);
227
+ lines.push('');
228
+
229
+ // Tags
230
+ const tags = (p.tags && Array.isArray(p.tags) && p.tags.length) ? p.tags : null;
231
+ if (tags) {
232
+ lines.push(`**${t('export.tagsLabel')}** ${tags.map(tg => '`' + tg + '`').join(' ')}`);
233
+ lines.push('');
234
+ }
235
+
236
+ // Content
237
+ lines.push(`## ${t('export.contentHeading')}`);
238
+ lines.push('');
239
+ lines.push(p.content || t('export.emptyContent'));
240
+ lines.push('');
241
+
242
+ // Related files
243
+ if (p.related_files && p.related_files.length) {
244
+ lines.push(`## ${t('export.relatedFilesHeading')}`);
245
+ lines.push('');
246
+ p.related_files.forEach(f => lines.push(`- \`${f}\``));
247
+ lines.push('');
248
+ }
249
+
250
+ const md = lines.join('\n');
251
+
252
+ // Build filename from category + truncated content
253
+ const slug = (p.category + (p.subcategory ? '-' + p.subcategory : '')).replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase();
254
+ const contentSlug = (p.content || 'memory').split('\n')[0].trim().slice(0, 40).replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/\s+/g, '-').toLowerCase();
255
+ const filename = `${slug}--${contentSlug || 'memory'}.md`;
256
+
257
+ // Trigger download
258
+ const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
259
+ const url = URL.createObjectURL(blob);
260
+ const a = document.createElement('a');
261
+ a.href = url;
262
+ a.download = filename;
263
+ document.body.appendChild(a);
264
+ a.click();
265
+ document.body.removeChild(a);
266
+ URL.revokeObjectURL(url);
267
+ }
268
+
269
+ // ─── Browser Frame Renderer ─────────────
270
+
271
+ /**
272
+ * Creates a serialized frame renderer that prevents decode backlog.
273
+ * Accepts raw Blob/ArrayBuffer (binary WS) or base64 string (legacy fallback).
274
+ * Only one decode runs at a time; newer frames replace queued ones.
275
+ * Draws are gated to requestAnimationFrame for vsync-aligned rendering.
276
+ *
277
+ * @param {HTMLCanvasElement} canvas
278
+ * @param {CanvasRenderingContext2D} ctx
279
+ * @returns {{ render(data: Blob|ArrayBuffer|string): void, destroy(): void }}
280
+ */
281
+ export function createFrameRenderer(canvas, ctx) {
282
+ let pending = null;
283
+ let rendering = false;
284
+ let destroyed = false;
285
+ const useBitmap = typeof createImageBitmap === 'function';
286
+ const FRAME_INTERVAL = 1000 / 30; // 30fps cap
287
+ let lastFrameTime = 0;
288
+
289
+ function flush() {
290
+ if (destroyed || rendering || !pending) return;
291
+ const now = performance.now();
292
+ if (now - lastFrameTime < FRAME_INTERVAL) {
293
+ // Drop frame — too soon since last render
294
+ return;
295
+ }
296
+ lastFrameTime = now;
297
+ rendering = true;
298
+ const data = pending;
299
+ pending = null;
300
+
301
+ // Convert input to a Blob for createImageBitmap
302
+ let blob;
303
+ if (data instanceof Blob) {
304
+ blob = data;
305
+ } else if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
306
+ blob = new Blob([data], { type: 'image/jpeg' });
307
+ } else {
308
+ // Legacy base64 string fallback
309
+ const bin = atob(data);
310
+ const bytes = new Uint8Array(bin.length);
311
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
312
+ blob = new Blob([bytes], { type: 'image/jpeg' });
313
+ }
314
+
315
+ if (useBitmap) {
316
+ createImageBitmap(blob).then(bmp => {
317
+ if (destroyed) { bmp.close(); return; }
318
+ if (canvas.width !== bmp.width || canvas.height !== bmp.height) {
319
+ canvas.width = bmp.width;
320
+ canvas.height = bmp.height;
321
+ }
322
+ ctx.drawImage(bmp, 0, 0);
323
+ bmp.close();
324
+ rendering = false;
325
+ if (pending) flush();
326
+ }).catch(() => {
327
+ if (destroyed) return;
328
+ rendering = false;
329
+ if (pending) flush();
330
+ });
331
+ } else {
332
+ // Fallback for browsers without createImageBitmap
333
+ const url = URL.createObjectURL(blob);
334
+ const img = new Image();
335
+ img.onload = () => {
336
+ if (destroyed) { URL.revokeObjectURL(url); return; }
337
+ if (canvas.width !== img.naturalWidth || canvas.height !== img.naturalHeight) {
338
+ canvas.width = img.naturalWidth;
339
+ canvas.height = img.naturalHeight;
340
+ }
341
+ ctx.drawImage(img, 0, 0);
342
+ URL.revokeObjectURL(url);
343
+ rendering = false;
344
+ if (pending) flush();
345
+ };
346
+ img.onerror = () => {
347
+ URL.revokeObjectURL(url);
348
+ if (destroyed) return;
349
+ rendering = false;
350
+ if (pending) flush();
351
+ };
352
+ img.src = url;
353
+ }
354
+ }
355
+
356
+ return {
357
+ render(data) { pending = data; flush(); },
358
+ destroy() { destroyed = true; pending = null; },
359
+ };
360
+ }