polats Claude Opus 4.8 (1M context) commited on
Commit
db6b273
·
1 Parent(s): 69e8c08

Portraits: in-browser WebGPU via vendored Bonsai (FLUX.2-Klein 4B, on-device)

Browse files

Replaces the broken Janus engine with Bonsai Image 4B (FLUX.2-Klein, 1-bit/ternary,
Apache-2.0) — the only in-browser WebGPU image model that actually works (verified
SOTA-quality portraits on an RTX 3090).

Bonsai's engine is a bespoke WGSL app with a custom 1-bit weight packing and no JS
API, so we can't load its weights into a generic pipeline. Instead we VENDOR its
self-contained static bundle (index.html + one 912 KB JS, Apache-2.0) under
web/bonsai/ and run it in a same-origin hidden iframe, driving its UI directly
(load → prompt → GENERATE → read the output <img> into a Blob via canvas).

Same-origin is the key: it avoids the cross-origin background-throttling that hung
earlier iframe attempts, and lets us drive the app's DOM directly (no postMessage
bridge). Desktop-only (~4-5 GB model); shown disabled on mobile, where cloud FLUX
serves instead. Verified end-to-end in the persona panel: load → paint → 512²
portrait cached per hero.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

web/bonsai/assets/index-Bf-HmMxp.js ADDED
The diff for this file is too large to render. See raw diff
 
web/bonsai/index.html ADDED
@@ -0,0 +1,1380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Bonsai · image generation</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@300;400;500;600&family=Geist+Mono:wght@400;500&display=swap">
10
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌳</text></svg>">
11
+ <style>
12
+ :root {
13
+ --bg: #0c0a08;
14
+ --surface: #14110f;
15
+ --border: #231f1c;
16
+ --border2: #1a1714;
17
+ --faint: #5a5048;
18
+ --dim: #7a6f63;
19
+ --muted: #9a8f81;
20
+ --cream: #f4ecde;
21
+ --amber: #e8a55e;
22
+ }
23
+
24
+ * { box-sizing: border-box; }
25
+ html, body { margin: 0; padding: 0; }
26
+
27
+ body {
28
+ background: var(--bg);
29
+ color: var(--cream);
30
+ font-family: 'Geist', system-ui, sans-serif;
31
+ -webkit-font-smoothing: antialiased;
32
+ min-height: 100vh;
33
+ opacity: 0;
34
+ transition: opacity 0.35s ease;
35
+ }
36
+ body.loaded { opacity: 1; }
37
+
38
+ [hidden] { display: none !important; }
39
+
40
+ .section {
41
+ min-height: 100vh;
42
+ display: flex;
43
+ flex-direction: column;
44
+ align-items: center;
45
+ justify-content: center;
46
+ padding: 4rem 1.5rem;
47
+ }
48
+
49
+ .gate-inner {
50
+ width: 100%;
51
+ max-width: 540px;
52
+ display: flex;
53
+ flex-direction: column;
54
+ gap: 2rem;
55
+ opacity: 0;
56
+ transform: translateY(8px);
57
+ transition: opacity 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) 0.1s,
58
+ transform 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) 0.1s;
59
+ }
60
+ body.loaded .gate-inner { opacity: 1; transform: translateY(0); }
61
+
62
+ .brand {
63
+ font-family: 'Instrument Serif', serif;
64
+ font-size: 34px;
65
+ letter-spacing: -0.5px;
66
+ margin: 0 0 0.25rem 0;
67
+ color: var(--cream);
68
+ }
69
+ .brand em {
70
+ font-style: italic;
71
+ color: var(--amber);
72
+ }
73
+ .brand-sub {
74
+ margin: 0 0 1rem 0;
75
+ font-family: 'Geist Mono', monospace;
76
+ font-size: 11px;
77
+ text-transform: uppercase;
78
+ letter-spacing: 0.22em;
79
+ color: var(--dim);
80
+ }
81
+
82
+ .disclaimer {
83
+ display: flex;
84
+ gap: 0.875rem;
85
+ padding: 1rem 1.125rem;
86
+ background: rgba(232, 165, 94, 0.04);
87
+ border: 1px solid rgba(232, 165, 94, 0.18);
88
+ border-radius: 6px;
89
+ }
90
+ .disclaimer .icon { color: var(--amber); flex-shrink: 0; margin-top: 2px; }
91
+ .disclaimer-content { flex: 1; min-width: 0; }
92
+ .disclaimer-title {
93
+ margin: 0 0 0.375rem 0;
94
+ font-family: 'Geist Mono', monospace;
95
+ font-size: 11px;
96
+ text-transform: uppercase;
97
+ letter-spacing: 0.18em;
98
+ color: var(--amber);
99
+ }
100
+ .disclaimer-body {
101
+ margin: 0;
102
+ font-size: 14px;
103
+ color: var(--muted);
104
+ line-height: 1.55;
105
+ }
106
+ .flag-name {
107
+ font-family: 'Geist Mono', monospace;
108
+ font-size: 12.5px;
109
+ color: var(--amber);
110
+ }
111
+ .flag-copy {
112
+ display: flex;
113
+ align-items: center;
114
+ gap: 0.5rem;
115
+ margin-top: 0.5rem;
116
+ padding: 6px 6px 6px 10px;
117
+ background: rgba(232, 165, 94, 0.06);
118
+ border: 1px solid rgba(232, 165, 94, 0.18);
119
+ border-radius: 4px;
120
+ }
121
+ .flag-url {
122
+ flex: 1;
123
+ font-family: 'Geist Mono', monospace;
124
+ font-size: 12px;
125
+ color: var(--cream);
126
+ user-select: all;
127
+ overflow: hidden;
128
+ text-overflow: ellipsis;
129
+ white-space: nowrap;
130
+ }
131
+ .flag-copy-btn {
132
+ background: transparent;
133
+ border: none;
134
+ cursor: pointer;
135
+ color: var(--dim);
136
+ padding: 4px 6px;
137
+ display: inline-flex;
138
+ align-items: center;
139
+ justify-content: center;
140
+ border-radius: 3px;
141
+ transition: color 0.15s ease, background 0.15s ease;
142
+ flex-shrink: 0;
143
+ }
144
+ .flag-copy-btn:hover { color: var(--cream); background: rgba(232, 165, 94, 0.08); }
145
+
146
+ .token-group {
147
+ display: flex;
148
+ flex-direction: column;
149
+ gap: 0.625rem;
150
+ }
151
+ .token-label {
152
+ font-family: 'Geist Mono', monospace;
153
+ font-size: 12px;
154
+ text-transform: uppercase;
155
+ letter-spacing: 0.1em;
156
+ color: var(--dim);
157
+ }
158
+ .token-input-wrap {
159
+ display: flex;
160
+ align-items: center;
161
+ background: var(--surface);
162
+ border: 1px solid var(--border);
163
+ border-radius: 6px;
164
+ padding-right: 4px;
165
+ transition: border-color 0.15s ease;
166
+ }
167
+ .token-input-wrap:focus-within { border-color: var(--faint); }
168
+ .token-input-wrap.error { border-color: rgba(232, 165, 94, 0.5); }
169
+ .token-input {
170
+ flex: 1;
171
+ background: transparent;
172
+ border: none;
173
+ outline: none;
174
+ color: var(--cream);
175
+ font-family: 'Geist Mono', monospace;
176
+ font-size: 14px;
177
+ padding: 12px 16px;
178
+ min-width: 0;
179
+ }
180
+ .token-input::placeholder { color: var(--faint); }
181
+ .token-toggle {
182
+ background: transparent;
183
+ border: none;
184
+ cursor: pointer;
185
+ color: var(--dim);
186
+ font-family: 'Geist Mono', monospace;
187
+ font-size: 11px;
188
+ text-transform: uppercase;
189
+ letter-spacing: 0.1em;
190
+ padding: 8px 12px;
191
+ transition: color 0.15s ease;
192
+ flex-shrink: 0;
193
+ }
194
+ .token-toggle:hover { color: var(--cream); }
195
+
196
+ .token-help {
197
+ margin: 0;
198
+ font-family: 'Geist Mono', monospace;
199
+ font-size: 11px;
200
+ line-height: 1.55;
201
+ color: var(--dim);
202
+ }
203
+ .token-help .model-id {
204
+ color: var(--muted);
205
+ text-decoration: none;
206
+ transition: color 0.15s ease;
207
+ }
208
+ .token-help .model-id:hover { color: var(--cream); text-decoration: underline; }
209
+ .token-help.error { color: var(--amber); }
210
+
211
+ .loading-inner {
212
+ width: 100%;
213
+ max-width: 420px;
214
+ display: flex;
215
+ flex-direction: column;
216
+ align-items: center;
217
+ gap: 1.25rem;
218
+ }
219
+ .loading-title {
220
+ font-family: 'Geist Mono', monospace;
221
+ font-size: 11px;
222
+ text-transform: uppercase;
223
+ letter-spacing: 0.18em;
224
+ color: var(--amber);
225
+ margin: 0;
226
+ }
227
+ .loading-status {
228
+ font-size: 14px;
229
+ color: var(--muted);
230
+ margin: 0;
231
+ text-align: center;
232
+ min-height: 1.2em;
233
+ }
234
+ .loading-bar {
235
+ width: 100%;
236
+ height: 4px;
237
+ background: var(--border);
238
+ border-radius: 2px;
239
+ overflow: hidden;
240
+ }
241
+ .loading-bar-fill {
242
+ height: 100%;
243
+ width: 0%;
244
+ background: var(--amber);
245
+ transition: width 0.2s ease;
246
+ }
247
+ .loading-spinner { color: var(--amber); }
248
+
249
+ .landing {
250
+ position: relative;
251
+ overflow: hidden;
252
+ padding: 0;
253
+ align-items: flex-start;
254
+ justify-content: center;
255
+ }
256
+ .hero-scene {
257
+ position: absolute;
258
+ inset: 0;
259
+ z-index: 0;
260
+ background:
261
+ radial-gradient(ellipse 55% 60% at 62% 50%,
262
+ rgba(232, 165, 94, 0.18) 0%,
263
+ rgba(232, 120, 60, 0.08) 28%,
264
+ rgba(12, 10, 8, 0) 60%),
265
+ radial-gradient(ellipse 90% 80% at 62% 50%,
266
+ rgba(40, 25, 15, 0.55) 0%,
267
+ rgba(12, 10, 8, 0) 70%);
268
+ }
269
+ .hero-scene #root { width: 100%; height: 100%; }
270
+
271
+ .landing-inner {
272
+ position: relative;
273
+ z-index: 10;
274
+ width: 100%;
275
+ max-width: 620px;
276
+ padding: 2rem clamp(2rem, 6vw, 5rem) 2rem clamp(3rem, 11vw, 9rem);
277
+ display: flex;
278
+ flex-direction: column;
279
+ align-items: flex-start;
280
+ gap: 1.25rem;
281
+ opacity: 0;
282
+ transform: translateY(8px);
283
+ transition: opacity 0.8s cubic-bezier(0.2, 0.7, 0.2, 1) 0.15s,
284
+ transform 0.8s cubic-bezier(0.2, 0.7, 0.2, 1) 0.15s;
285
+ }
286
+ body.loaded .landing-inner { opacity: 1; transform: translateY(0); }
287
+
288
+ .landing.leaving { transition: opacity 0.5s ease; opacity: 0; pointer-events: none; }
289
+
290
+ .landing-eyebrow {
291
+ font-family: 'Geist Mono', monospace;
292
+ font-size: 11px;
293
+ font-weight: 500;
294
+ text-transform: uppercase;
295
+ letter-spacing: 0.22em;
296
+ color: var(--amber);
297
+ margin: 0;
298
+ display: flex;
299
+ align-items: center;
300
+ gap: 12px;
301
+ }
302
+ .landing-eyebrow::before {
303
+ content: '';
304
+ width: 24px;
305
+ height: 1px;
306
+ background: var(--amber);
307
+ }
308
+ .landing-title {
309
+ font-family: 'Instrument Serif', serif;
310
+ font-size: clamp(40px, 5.5vw, 64px);
311
+ font-weight: 400;
312
+ line-height: 1.05;
313
+ letter-spacing: -1.5px;
314
+ color: var(--cream);
315
+ margin: 0;
316
+ }
317
+ .landing-title em {
318
+ font-style: italic;
319
+ color: var(--amber);
320
+ }
321
+ .landing-tagline {
322
+ font-size: 15px;
323
+ line-height: 1.6;
324
+ color: var(--muted);
325
+ max-width: 420px;
326
+ margin: 0;
327
+ }
328
+ .cta-group {
329
+ position: relative;
330
+ margin-top: 0.75rem;
331
+ display: inline-flex;
332
+ align-items: stretch;
333
+ gap: 2px;
334
+ }
335
+ .landing-cta {
336
+ background: var(--cream);
337
+ color: var(--bg);
338
+ border: none;
339
+ font-family: 'Geist Mono', monospace;
340
+ font-size: 13px;
341
+ text-transform: uppercase;
342
+ letter-spacing: 0.18em;
343
+ cursor: pointer;
344
+ display: inline-flex;
345
+ align-items: center;
346
+ gap: 10px;
347
+ transition: transform 0.2s ease, background 0.2s ease;
348
+ }
349
+ .landing-cta:hover { background: #fff5e3; }
350
+ .landing-cta-main {
351
+ border-radius: 9999px 0 0 9999px;
352
+ padding: 12px 24px 12px 28px;
353
+ }
354
+ .landing-cta-main:hover { transform: translateY(-1px); }
355
+ .landing-cta-main svg { transition: transform 0.2s ease; }
356
+ .landing-cta-main:hover svg { transform: translateX(3px); }
357
+ .landing-cta-toggle {
358
+ border-radius: 0 9999px 9999px 0;
359
+ padding: 12px 16px;
360
+ }
361
+ .landing-cta-toggle svg { transition: transform 0.2s ease; }
362
+ .landing-cta-toggle[aria-expanded="true"] svg { transform: rotate(-180deg); }
363
+
364
+ .model-menu {
365
+ position: absolute;
366
+ bottom: calc(100% + 10px);
367
+ left: 0;
368
+ z-index: 20;
369
+ width: clamp(320px, 32rem, 480px);
370
+ background: var(--surface);
371
+ border: 1px solid var(--border);
372
+ border-radius: 10px;
373
+ padding: 1rem;
374
+ box-shadow: 0 24px 60px -16px rgba(0, 0, 0, 0.6);
375
+ display: flex;
376
+ flex-direction: column;
377
+ gap: 0.75rem;
378
+ }
379
+ .model-menu-title {
380
+ margin: 0 0 0.25rem 0;
381
+ font-family: 'Geist Mono', monospace;
382
+ font-size: 11px;
383
+ text-transform: uppercase;
384
+ letter-spacing: 0.18em;
385
+ color: var(--dim);
386
+ }
387
+ .model-menu-item {
388
+ all: unset;
389
+ cursor: pointer;
390
+ display: flex;
391
+ gap: 0.75rem;
392
+ padding: 0.875rem;
393
+ border-radius: 8px;
394
+ border: 1px solid var(--border);
395
+ background: transparent;
396
+ transition: border-color 0.15s ease, background 0.15s ease;
397
+ }
398
+ .model-menu-item:hover {
399
+ border-color: var(--faint);
400
+ background: rgba(232, 165, 94, 0.04);
401
+ }
402
+ .model-menu-item.selected {
403
+ border-color: rgba(232, 165, 94, 0.5);
404
+ background: rgba(232, 165, 94, 0.06);
405
+ }
406
+ .model-menu-radio {
407
+ flex-shrink: 0;
408
+ width: 14px;
409
+ height: 14px;
410
+ margin-top: 4px;
411
+ border-radius: 50%;
412
+ border: 1.5px solid var(--faint);
413
+ background: transparent;
414
+ transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
415
+ }
416
+ .model-menu-item.selected .model-menu-radio {
417
+ border-color: var(--amber);
418
+ background: var(--amber);
419
+ box-shadow: inset 0 0 0 3px var(--surface);
420
+ }
421
+ .model-menu-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
422
+ .model-menu-name {
423
+ font-family: 'Geist Mono', monospace;
424
+ font-size: 12.5px;
425
+ color: var(--cream);
426
+ letter-spacing: 0.04em;
427
+ display: inline-flex;
428
+ align-items: center;
429
+ gap: 0.5rem;
430
+ }
431
+ .model-menu-tag {
432
+ font-size: 9.5px;
433
+ text-transform: uppercase;
434
+ letter-spacing: 0.15em;
435
+ color: var(--amber);
436
+ background: rgba(232, 165, 94, 0.1);
437
+ border: 1px solid rgba(232, 165, 94, 0.25);
438
+ padding: 2px 6px;
439
+ border-radius: 9999px;
440
+ }
441
+ .model-menu-desc {
442
+ font-family: 'Geist', system-ui, sans-serif;
443
+ font-size: 12.5px;
444
+ line-height: 1.5;
445
+ color: var(--muted);
446
+ }
447
+ .model-menu-meta {
448
+ font-family: 'Geist Mono', monospace;
449
+ font-size: 11px;
450
+ color: var(--dim);
451
+ letter-spacing: 0.08em;
452
+ }
453
+ .model-menu-footnote {
454
+ margin: 0;
455
+ padding-top: 0.625rem;
456
+ border-top: 1px solid var(--border2);
457
+ font-family: 'Geist Mono', monospace;
458
+ font-size: 11px;
459
+ line-height: 1.55;
460
+ color: var(--dim);
461
+ }
462
+
463
+ .hero-inner {
464
+ position: relative;
465
+ z-index: 10;
466
+ width: 100%;
467
+ max-width: 560px;
468
+ display: flex;
469
+ flex-direction: column;
470
+ gap: 2rem;
471
+ opacity: 0;
472
+ transform: translateY(8px);
473
+ transition: opacity 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) 0.1s,
474
+ transform 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) 0.1s;
475
+ }
476
+ body.loaded .hero-inner { opacity: 1; transform: translateY(0); }
477
+
478
+ .hero-eyebrow-row {
479
+ display: flex;
480
+ align-items: center;
481
+ justify-content: space-between;
482
+ gap: 1rem;
483
+ flex-wrap: wrap;
484
+ }
485
+ .hero-eyebrow {
486
+ font-family: 'Geist Mono', monospace;
487
+ font-size: 11px;
488
+ font-weight: 500;
489
+ text-transform: uppercase;
490
+ letter-spacing: 0.22em;
491
+ color: var(--amber);
492
+ margin: 0;
493
+ display: flex;
494
+ align-items: center;
495
+ gap: 12px;
496
+ }
497
+ .hero-eyebrow::before {
498
+ content: '';
499
+ width: 24px;
500
+ height: 1px;
501
+ background: var(--amber);
502
+ }
503
+
504
+ /* "Running locally" badge: a pulsing dot + status text + (optional)
505
+ detected hardware. Sits on the right side of the prompt eyebrow row.
506
+ Hover tooltip explains what "locally" means. */
507
+ .local-badge {
508
+ display: inline-flex;
509
+ align-items: center;
510
+ gap: 8px;
511
+ padding: 5px 12px 5px 10px;
512
+ background: rgba(74, 158, 96, 0.08);
513
+ border: 1px solid rgba(74, 158, 96, 0.25);
514
+ border-radius: 9999px;
515
+ font-family: 'Geist Mono', monospace;
516
+ font-size: 10.5px;
517
+ letter-spacing: 0.08em;
518
+ color: #b4d8a6;
519
+ cursor: help;
520
+ user-select: none;
521
+ white-space: nowrap;
522
+ }
523
+ .local-badge-dot {
524
+ width: 7px;
525
+ height: 7px;
526
+ border-radius: 50%;
527
+ background: #6ec47a;
528
+ box-shadow: 0 0 0 0 rgba(110, 196, 122, 0.6);
529
+ animation: local-badge-pulse 2.4s cubic-bezier(0.4, 0, 0.6, 1) infinite;
530
+ }
531
+ .local-badge-text { font-weight: 500; }
532
+ .local-badge-meta {
533
+ color: rgba(180, 216, 166, 0.7);
534
+ font-weight: 400;
535
+ padding-left: 6px;
536
+ border-left: 1px solid rgba(74, 158, 96, 0.25);
537
+ /* Long GPU strings can blow the layout out; clamp + ellipsize. */
538
+ max-width: 22ch;
539
+ overflow: hidden;
540
+ text-overflow: ellipsis;
541
+ white-space: nowrap;
542
+ }
543
+ @keyframes local-badge-pulse {
544
+ 0% { box-shadow: 0 0 0 0 rgba(110, 196, 122, 0.55); }
545
+ 70% { box-shadow: 0 0 0 8px rgba(110, 196, 122, 0); }
546
+ 100% { box-shadow: 0 0 0 0 rgba(110, 196, 122, 0); }
547
+ }
548
+
549
+ .prompt {
550
+ width: 100%;
551
+ background: transparent;
552
+ border: none;
553
+ outline: none;
554
+ resize: none;
555
+ color: var(--cream);
556
+ font-family: 'Instrument Serif', serif;
557
+ font-style: italic;
558
+ font-size: 2.25rem;
559
+ line-height: 1.25;
560
+ padding: 0;
561
+ }
562
+ .prompt::placeholder { color: var(--faint); }
563
+ @media (max-width: 540px) { .prompt { font-size: 1.875rem; } }
564
+
565
+ .example-link {
566
+ align-self: flex-end;
567
+ display: inline-flex;
568
+ align-items: center;
569
+ gap: 6px;
570
+ background: transparent;
571
+ border: none;
572
+ cursor: pointer;
573
+ color: var(--dim);
574
+ font-family: 'Geist Mono', monospace;
575
+ font-size: 12px;
576
+ text-transform: uppercase;
577
+ letter-spacing: 0.1em;
578
+ padding: 4px 0;
579
+ transition: color 0.15s ease;
580
+ }
581
+ .example-link:hover { color: var(--cream); }
582
+
583
+ .controls {
584
+ display: flex;
585
+ flex-direction: column;
586
+ gap: 1.5rem;
587
+ padding-top: 1.5rem;
588
+ border-top: 1px solid var(--border2);
589
+ }
590
+ .size-group {
591
+ display: flex;
592
+ flex-direction: column;
593
+ gap: 0.875rem;
594
+ }
595
+ .size-label {
596
+ color: var(--dim);
597
+ text-transform: uppercase;
598
+ letter-spacing: 0.1em;
599
+ font-size: 12px;
600
+ font-family: 'Geist Mono', monospace;
601
+ }
602
+ .presets {
603
+ display: flex;
604
+ flex-wrap: wrap;
605
+ gap: 0.4rem;
606
+ }
607
+ .preset {
608
+ background: transparent;
609
+ border: 1px solid var(--border);
610
+ color: var(--dim);
611
+ font-family: 'Geist Mono', monospace;
612
+ font-size: 12px;
613
+ letter-spacing: 0.05em;
614
+ padding: 7px 14px;
615
+ border-radius: 9999px;
616
+ cursor: pointer;
617
+ transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
618
+ }
619
+ .preset:hover { color: var(--cream); border-color: var(--faint); }
620
+ .preset.active {
621
+ background: var(--cream);
622
+ color: var(--bg);
623
+ border-color: var(--cream);
624
+ }
625
+ .slider-row {
626
+ display: grid;
627
+ grid-template-columns: 22px 1fr 4ch;
628
+ align-items: center;
629
+ gap: 0.875rem;
630
+ font-family: 'Geist Mono', ui-monospace, monospace;
631
+ font-size: 13px;
632
+ }
633
+ .slider-row .sub-label {
634
+ color: var(--dim);
635
+ text-transform: uppercase;
636
+ letter-spacing: 0.1em;
637
+ font-size: 12px;
638
+ }
639
+ .slider-row .sub-value {
640
+ color: var(--cream);
641
+ font-variant-numeric: tabular-nums;
642
+ text-align: right;
643
+ }
644
+ .seed-row {
645
+ display: grid;
646
+ grid-template-columns: 60px 1fr auto;
647
+ align-items: center;
648
+ gap: 1rem;
649
+ font-family: 'Geist Mono', ui-monospace, monospace;
650
+ font-size: 13px;
651
+ }
652
+ .seed-row .seed-label {
653
+ color: var(--dim);
654
+ text-transform: uppercase;
655
+ letter-spacing: 0.1em;
656
+ font-size: 12px;
657
+ }
658
+ .seed-input {
659
+ width: 100%;
660
+ background: transparent;
661
+ border: none;
662
+ outline: none;
663
+ color: var(--cream);
664
+ font-family: inherit;
665
+ font-size: 14px;
666
+ padding: 4px 0;
667
+ font-variant-numeric: tabular-nums;
668
+ }
669
+ .seed-input::placeholder { color: var(--faint); }
670
+
671
+ input[type="range"] {
672
+ -webkit-appearance: none;
673
+ appearance: none;
674
+ width: 100%;
675
+ background: transparent;
676
+ cursor: pointer;
677
+ margin: 0;
678
+ }
679
+ input[type="range"]::-webkit-slider-runnable-track { height: 1px; background: var(--border); }
680
+ input[type="range"]::-moz-range-track { height: 1px; background: var(--border); }
681
+ input[type="range"]::-webkit-slider-thumb {
682
+ -webkit-appearance: none;
683
+ appearance: none;
684
+ width: 14px;
685
+ height: 14px;
686
+ border-radius: 50%;
687
+ background: var(--cream);
688
+ border: none;
689
+ margin-top: -6.5px;
690
+ cursor: grab;
691
+ transition: transform 0.15s ease;
692
+ }
693
+ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.15); }
694
+ input[type="range"]::-webkit-slider-thumb:active { cursor: grabbing; transform: scale(1.1); }
695
+ input[type="range"]::-moz-range-thumb {
696
+ width: 14px;
697
+ height: 14px;
698
+ border-radius: 50%;
699
+ background: var(--cream);
700
+ border: none;
701
+ cursor: grab;
702
+ }
703
+
704
+ .icon-btn-sm {
705
+ width: 28px;
706
+ height: 28px;
707
+ display: inline-flex;
708
+ align-items: center;
709
+ justify-content: center;
710
+ background: transparent;
711
+ border: 1px solid var(--border);
712
+ border-radius: 6px;
713
+ color: var(--dim);
714
+ cursor: pointer;
715
+ transition: color 0.15s ease, border-color 0.15s ease;
716
+ flex-shrink: 0;
717
+ padding: 0;
718
+ }
719
+ .icon-btn-sm:hover { color: var(--cream); border-color: var(--faint); }
720
+
721
+ .generate {
722
+ width: 100%;
723
+ background: var(--cream);
724
+ color: var(--bg);
725
+ border: none;
726
+ border-radius: 9999px;
727
+ padding: 14px 24px;
728
+ font-family: 'Geist Mono', monospace;
729
+ font-size: 14px;
730
+ text-transform: uppercase;
731
+ letter-spacing: 0.15em;
732
+ cursor: pointer;
733
+ display: inline-flex;
734
+ align-items: center;
735
+ justify-content: center;
736
+ gap: 10px;
737
+ transition: opacity 0.15s ease;
738
+ margin-top: 0.5rem;
739
+ }
740
+ .generate:disabled {
741
+ background: var(--surface);
742
+ color: var(--faint);
743
+ cursor: not-allowed;
744
+ }
745
+
746
+ .gallery-link {
747
+ position: absolute;
748
+ bottom: 1.5rem;
749
+ left: 50%;
750
+ transform: translateX(-50%);
751
+ background: transparent;
752
+ border: none;
753
+ cursor: pointer;
754
+ color: var(--faint);
755
+ font-family: 'Geist Mono', monospace;
756
+ font-size: 12px;
757
+ text-transform: uppercase;
758
+ letter-spacing: 0.15em;
759
+ transition: color 0.15s ease;
760
+ z-index: 10;
761
+ }
762
+ .gallery-link:hover { color: var(--cream); }
763
+
764
+ .gallery-header {
765
+ position: sticky;
766
+ top: 0;
767
+ z-index: 20;
768
+ background: rgba(12, 10, 8, 0.78);
769
+ backdrop-filter: blur(20px);
770
+ -webkit-backdrop-filter: blur(20px);
771
+ border-bottom: 1px solid var(--border2);
772
+ padding: 0.875rem 1.5rem;
773
+ display: flex;
774
+ align-items: center;
775
+ justify-content: space-between;
776
+ }
777
+ .image-count {
778
+ font-family: 'Geist Mono', monospace;
779
+ font-size: 12px;
780
+ text-transform: uppercase;
781
+ letter-spacing: 0.15em;
782
+ color: var(--faint);
783
+ }
784
+ .header-actions { display: flex; align-items: center; gap: 0.5rem; }
785
+
786
+ .pill-btn {
787
+ background: transparent;
788
+ border: 1px solid var(--border);
789
+ border-radius: 9999px;
790
+ color: var(--muted);
791
+ font-family: 'Geist Mono', monospace;
792
+ font-size: 11px;
793
+ text-transform: uppercase;
794
+ letter-spacing: 0.15em;
795
+ padding: 7px 14px;
796
+ cursor: pointer;
797
+ display: inline-flex;
798
+ align-items: center;
799
+ gap: 6px;
800
+ transition: color 0.15s ease, border-color 0.15s ease;
801
+ }
802
+ .pill-btn:hover { color: var(--cream); border-color: var(--faint); }
803
+ .text-btn {
804
+ background: transparent;
805
+ border: none;
806
+ cursor: pointer;
807
+ color: var(--faint);
808
+ font-family: 'Geist Mono', monospace;
809
+ font-size: 11px;
810
+ text-transform: uppercase;
811
+ letter-spacing: 0.15em;
812
+ padding: 6px 10px;
813
+ transition: color 0.15s ease;
814
+ }
815
+ .text-btn:hover { color: var(--cream); }
816
+
817
+ .grid {
818
+ display: grid;
819
+ grid-template-columns: 1fr;
820
+ gap: 2px;
821
+ padding: 2px;
822
+ }
823
+ @media (min-width: 640px) { .grid { grid-template-columns: repeat(2, 1fr); } }
824
+ @media (min-width: 1024px) { .grid { grid-template-columns: repeat(3, 1fr); } }
825
+
826
+ .grid-item {
827
+ aspect-ratio: 1 / 1;
828
+ position: relative;
829
+ overflow: hidden;
830
+ background: var(--surface);
831
+ border: none;
832
+ padding: 0;
833
+ cursor: pointer;
834
+ }
835
+ .grid-img {
836
+ width: 100%;
837
+ height: 100%;
838
+ object-fit: cover;
839
+ transition: opacity 0.7s ease, transform 1s ease;
840
+ }
841
+ .grid-item:hover .grid-img { transform: scale(1.04); }
842
+ .grid-overlay {
843
+ position: absolute;
844
+ inset: 0;
845
+ background: linear-gradient(180deg, transparent 40%, rgba(12, 10, 8, 0.92) 100%);
846
+ display: flex;
847
+ align-items: flex-end;
848
+ padding: 1rem;
849
+ opacity: 0;
850
+ transition: opacity 0.3s ease;
851
+ pointer-events: none;
852
+ }
853
+ .grid-item:hover .grid-overlay { opacity: 1; }
854
+ .grid-prompt {
855
+ margin: 0;
856
+ font-family: 'Instrument Serif', serif;
857
+ font-style: italic;
858
+ color: var(--cream);
859
+ font-size: 1.1rem;
860
+ line-height: 1.25;
861
+ display: -webkit-box;
862
+ -webkit-line-clamp: 3;
863
+ line-clamp: 3;
864
+ -webkit-box-orient: vertical;
865
+ overflow: hidden;
866
+ }
867
+ .grid-share {
868
+ position: absolute;
869
+ top: 0.75rem;
870
+ right: 0.75rem;
871
+ width: 32px;
872
+ height: 32px;
873
+ display: inline-flex;
874
+ align-items: center;
875
+ justify-content: center;
876
+ border-radius: 9999px;
877
+ background: rgba(12, 10, 8, 0.6);
878
+ border: 1px solid rgba(244, 236, 222, 0.15);
879
+ color: var(--cream);
880
+ cursor: pointer;
881
+ padding: 0;
882
+ opacity: 0;
883
+ transition: opacity 0.2s ease, background 0.15s ease, color 0.15s ease;
884
+ pointer-events: auto;
885
+ backdrop-filter: blur(4px);
886
+ -webkit-backdrop-filter: blur(4px);
887
+ }
888
+ .grid-item:hover .grid-share { opacity: 1; }
889
+ .grid-share:hover { background: rgba(232, 165, 94, 0.16); color: var(--amber); }
890
+ .grid-fail {
891
+ position: absolute;
892
+ inset: 0;
893
+ display: flex;
894
+ align-items: center;
895
+ justify-content: center;
896
+ color: var(--faint);
897
+ font-family: 'Geist Mono', monospace;
898
+ font-size: 11px;
899
+ text-transform: uppercase;
900
+ letter-spacing: 0.15em;
901
+ }
902
+
903
+ .load-more {
904
+ height: 6rem;
905
+ display: flex;
906
+ align-items: center;
907
+ justify-content: center;
908
+ color: var(--faint);
909
+ }
910
+
911
+ @keyframes shimmer {
912
+ 0% { background-position: -200% 0; }
913
+ 100% { background-position: 200% 0; }
914
+ }
915
+ .shimmer {
916
+ position: absolute;
917
+ inset: 0;
918
+ background: linear-gradient(90deg, var(--surface) 0%, #211d19 50%, var(--surface) 100%);
919
+ background-size: 200% 100%;
920
+ animation: shimmer 1.4s linear infinite;
921
+ }
922
+
923
+ @keyframes spin {
924
+ from { transform: rotate(0deg); }
925
+ to { transform: rotate(360deg); }
926
+ }
927
+ .spin { animation: spin 1s linear infinite; }
928
+
929
+ @keyframes fadeUp {
930
+ from { opacity: 0; transform: translateY(8px); }
931
+ to { opacity: 1; transform: translateY(0); }
932
+ }
933
+ .fade-up { animation: fadeUp 0.6s cubic-bezier(0.2, 0.7, 0.2, 1) backwards; }
934
+
935
+ .modal {
936
+ position: fixed;
937
+ inset: 0;
938
+ z-index: 50;
939
+ background: rgba(8, 7, 6, 0.96);
940
+ backdrop-filter: blur(20px);
941
+ -webkit-backdrop-filter: blur(20px);
942
+ display: flex;
943
+ align-items: center;
944
+ justify-content: center;
945
+ padding: 1.5rem;
946
+ }
947
+ .icon-btn {
948
+ width: 40px;
949
+ height: 40px;
950
+ border-radius: 9999px;
951
+ background: var(--surface);
952
+ border: 1px solid var(--border);
953
+ color: var(--cream);
954
+ cursor: pointer;
955
+ display: inline-flex;
956
+ align-items: center;
957
+ justify-content: center;
958
+ padding: 0;
959
+ transition: background 0.15s ease;
960
+ }
961
+ .icon-btn:hover { background: #1c1816; }
962
+ .modal-close { position: absolute; top: 1.5rem; right: 1.5rem; z-index: 10; }
963
+ .modal-prev { position: absolute; left: 1.5rem; top: 50%; transform: translateY(-50%); z-index: 10; }
964
+ .modal-next { position: absolute; right: 1.5rem; top: 50%; transform: translateY(-50%); z-index: 10; }
965
+
966
+ .modal-content {
967
+ width: 100%;
968
+ max-width: 1280px;
969
+ max-height: 100%;
970
+ display: flex;
971
+ gap: 2.5rem;
972
+ align-items: center;
973
+ }
974
+ @media (max-width: 900px) {
975
+ .modal-content { flex-direction: column; gap: 1.5rem; }
976
+ }
977
+ .modal-img-wrap {
978
+ flex: 1;
979
+ display: flex;
980
+ align-items: center;
981
+ justify-content: center;
982
+ position: relative;
983
+ min-width: 0;
984
+ }
985
+ .modal-loader {
986
+ position: absolute;
987
+ inset: 0;
988
+ display: flex;
989
+ align-items: center;
990
+ justify-content: center;
991
+ color: var(--faint);
992
+ }
993
+ .modal-img {
994
+ max-width: 100%;
995
+ max-height: 85vh;
996
+ object-fit: contain;
997
+ box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.8);
998
+ transition: opacity 0.5s ease;
999
+ }
1000
+ .modal-meta {
1001
+ width: 18rem;
1002
+ max-width: 100%;
1003
+ max-height: 85vh;
1004
+ overflow-y: auto;
1005
+ flex-shrink: 0;
1006
+ }
1007
+
1008
+ .prompt-block { position: relative; margin-bottom: 2.5rem; }
1009
+ .prompt-header {
1010
+ display: flex;
1011
+ align-items: center;
1012
+ justify-content: space-between;
1013
+ margin-bottom: 0.75rem;
1014
+ min-height: 28px;
1015
+ }
1016
+ .prompt-header .meta-label { margin: 0; }
1017
+ .copy-btn {
1018
+ opacity: 0;
1019
+ transition: opacity 0.15s ease, color 0.15s ease, border-color 0.15s ease;
1020
+ }
1021
+ .prompt-block:hover .copy-btn,
1022
+ .copy-btn:focus-visible { opacity: 1; }
1023
+ .copy-btn.copied {
1024
+ opacity: 1;
1025
+ color: var(--cream);
1026
+ border-color: var(--faint);
1027
+ }
1028
+
1029
+ .meta-label {
1030
+ margin: 0 0 0.75rem 0;
1031
+ font-family: 'Geist Mono', monospace;
1032
+ font-size: 11px;
1033
+ text-transform: uppercase;
1034
+ letter-spacing: 0.25em;
1035
+ color: var(--dim);
1036
+ }
1037
+ .meta-prompt {
1038
+ margin: 0;
1039
+ font-family: 'Instrument Serif', serif;
1040
+ font-style: italic;
1041
+ color: var(--cream);
1042
+ font-size: 1.875rem;
1043
+ line-height: 1.25;
1044
+ }
1045
+ #metaParams { margin: 0 0 2.5rem 0; }
1046
+ .meta-row {
1047
+ display: flex;
1048
+ justify-content: space-between;
1049
+ align-items: baseline;
1050
+ gap: 1rem;
1051
+ padding: 0.625rem 0;
1052
+ border-bottom: 1px solid var(--border2);
1053
+ font-family: 'Geist Mono', monospace;
1054
+ font-size: 13px;
1055
+ }
1056
+ .meta-row dt {
1057
+ color: var(--dim);
1058
+ text-transform: uppercase;
1059
+ letter-spacing: 0.1em;
1060
+ font-size: 11px;
1061
+ }
1062
+ .meta-row dd { color: var(--cream); margin: 0; }
1063
+
1064
+ .meta-actions {
1065
+ display: flex;
1066
+ align-items: center;
1067
+ gap: 1.25rem;
1068
+ }
1069
+ .meta-share,
1070
+ .meta-delete {
1071
+ background: transparent;
1072
+ border: none;
1073
+ cursor: pointer;
1074
+ color: var(--dim);
1075
+ font-family: 'Geist Mono', monospace;
1076
+ font-size: 11px;
1077
+ text-transform: uppercase;
1078
+ letter-spacing: 0.15em;
1079
+ display: inline-flex;
1080
+ align-items: center;
1081
+ gap: 6px;
1082
+ padding: 0;
1083
+ transition: color 0.15s ease;
1084
+ }
1085
+ .meta-share:hover { color: var(--amber); }
1086
+ .meta-delete:hover { color: var(--cream); }
1087
+ .meta-share[disabled] { opacity: 0.4; cursor: default; }
1088
+
1089
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
1090
+ ::-webkit-scrollbar-track { background: transparent; }
1091
+ ::-webkit-scrollbar-thumb { background: #2a2522; border-radius: 4px; }
1092
+ ::-webkit-scrollbar-thumb:hover { background: #3a3530; }
1093
+ </style>
1094
+ <script type="module" crossorigin src="assets/index-Bf-HmMxp.js"></script>
1095
+ </head>
1096
+ <body>
1097
+
1098
+ <section class="section landing" id="landingSection">
1099
+ <div class="hero-scene">
1100
+ <div id="root"></div>
1101
+ </div>
1102
+
1103
+ <div class="landing-inner">
1104
+ <p class="landing-eyebrow">Bonsai Image · 4B</p>
1105
+ <h1 class="landing-title">State-of-the-art image generation,<br><em>in your browser.</em></h1>
1106
+ <p class="landing-tagline">
1107
+ A family of compressed image-generation models for high-quality diffusion on local hardware.
1108
+ </p>
1109
+
1110
+ <div class="cta-group" id="ctaGroup">
1111
+ <button class="landing-cta landing-cta-main" id="tryDemoBtn" type="button">
1112
+ <span>Load <span id="ctaModelLabel">Ternary</span> model</span>
1113
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1114
+ <path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>
1115
+ </svg>
1116
+ </button>
1117
+ <button class="landing-cta landing-cta-toggle" id="modelMenuToggle" type="button" aria-haspopup="menu" aria-expanded="false" aria-label="Choose model">
1118
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1119
+ <path d="m6 9 6 6 6-6"/>
1120
+ </svg>
1121
+ </button>
1122
+
1123
+ <div class="model-menu" id="modelMenu" role="menu" hidden>
1124
+ <p class="model-menu-title">Choose a variant</p>
1125
+ <button class="model-menu-item" role="menuitemradio" data-model-id="prism-ml/bonsai-image-ternary-4B-mlx-2bit" type="button">
1126
+ <span class="model-menu-radio" aria-hidden="true"></span>
1127
+ <span class="model-menu-text">
1128
+ <span class="model-menu-name">Ternary Bonsai Image 4B<span class="model-menu-tag">recommended</span></span>
1129
+ <span class="model-menu-desc">
1130
+ {-1, 0, +1} weights with FP16 group-wise scale — ~1.7 bits/weight. The extra zero state improves visual quality and prompt fidelity while staying extremely compact.
1131
+ </span>
1132
+ <span class="model-menu-meta">3.3 GB</span>
1133
+ </span>
1134
+ </button>
1135
+ <button class="model-menu-item" role="menuitemradio" data-model-id="prism-ml/bonsai-image-binary-4B-mlx-1bit" type="button">
1136
+ <span class="model-menu-radio" aria-hidden="true"></span>
1137
+ <span class="model-menu-text">
1138
+ <span class="model-menu-name">1-bit Bonsai Image 4B</span>
1139
+ <span class="model-menu-desc">
1140
+ Binary {-1, +1} weights with FP16 group-wise scale — ~1.1 bits/weight. Targets maximum compression when memory pressure and deployment footprint are the priority.
1141
+ </span>
1142
+ <span class="model-menu-meta">2.86 GB</span>
1143
+ </span>
1144
+ </button>
1145
+ <p class="model-menu-footnote">
1146
+ Compressed from the original FLUX2-Klein 4B (15.97 GB).
1147
+ </p>
1148
+ </div>
1149
+ </div>
1150
+ </div>
1151
+
1152
+ </section>
1153
+
1154
+ <section class="section" id="gateSection" hidden>
1155
+ <div class="gate-inner">
1156
+ <div>
1157
+ <h1 class="brand">Bonsai Image · <em>4B</em></h1>
1158
+ <p class="brand-sub">Flux2-Klein · WebGPU</p>
1159
+ </div>
1160
+
1161
+ <div class="disclaimer">
1162
+ <svg class="icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1163
+ <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/>
1164
+ <path d="M12 9v4"/>
1165
+ <path d="M12 17h.01"/>
1166
+ </svg>
1167
+ <div class="disclaimer-content">
1168
+ <p class="disclaimer-title">experimental</p>
1169
+ <p class="disclaimer-body">
1170
+ Research software, primarily tested on Apple M4 Max and M5 Max.
1171
+ It may not be optimized for your hardware.
1172
+ </p>
1173
+ <p class="disclaimer-body" style="margin-top: 0.625rem;">
1174
+ On Chrome/Edge, enable the
1175
+ <code class="flag-name">#enable-unsafe-webgpu</code>
1176
+ flag for best performance:
1177
+ </p>
1178
+ <div class="flag-copy">
1179
+ <code class="flag-url" id="flagUrl">chrome://flags/#enable-unsafe-webgpu</code>
1180
+ <button class="flag-copy-btn" id="flagCopyBtn" type="button" title="Copy URL">
1181
+ <span id="flagCopyIcon"></span>
1182
+ </button>
1183
+ </div>
1184
+ </div>
1185
+ </div>
1186
+
1187
+ <div class="token-group">
1188
+ <label class="token-label" for="tokenInput">HuggingFace Access Token</label>
1189
+ <div class="token-input-wrap" id="tokenInputWrap">
1190
+ <input
1191
+ type="password"
1192
+ id="tokenInput"
1193
+ class="token-input"
1194
+ placeholder="hf_..."
1195
+ autocomplete="off"
1196
+ spellcheck="false"
1197
+ >
1198
+ <button class="token-toggle" id="tokenToggleBtn" type="button">show</button>
1199
+ </div>
1200
+ <p class="token-help" id="tokenHelp"></p>
1201
+ </div>
1202
+
1203
+ <button class="generate" id="continueBtn" disabled>continue</button>
1204
+ </div>
1205
+ </section>
1206
+
1207
+ <section class="section" id="loadingSection" hidden>
1208
+ <div class="loading-inner">
1209
+ <svg class="spin loading-spinner" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1210
+ <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
1211
+ </svg>
1212
+ <p class="loading-title">loading model</p>
1213
+ <p class="loading-status" id="loadingStatus">preparing…</p>
1214
+ <div class="loading-bar"><div class="loading-bar-fill" id="loadingBarFill"></div></div>
1215
+ </div>
1216
+ </section>
1217
+
1218
+ <section class="section" id="hero" hidden>
1219
+ <div class="hero-inner">
1220
+ <div class="hero-eyebrow-row">
1221
+ <p class="hero-eyebrow">enter a prompt</p>
1222
+ <div class="local-badge" id="localBadge" title="All inference happens in your browser via WebGPU. No server calls, no data leaving your machine.">
1223
+ <span class="local-badge-dot" aria-hidden="true"></span>
1224
+ <span class="local-badge-text">Running locally</span>
1225
+ <span class="local-badge-meta" id="localBadgeMeta" hidden></span>
1226
+ </div>
1227
+ </div>
1228
+
1229
+ <textarea
1230
+ class="prompt"
1231
+ id="prompt"
1232
+ placeholder="Describe your image..."
1233
+ rows="3"
1234
+ ></textarea>
1235
+
1236
+ <button class="example-link" id="exampleBtn" type="button" title="Try an example prompt">
1237
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1238
+ <rect width="12" height="12" x="2" y="10" rx="2" ry="2"/>
1239
+ <path d="m17.92 14 3.5-3.5a2.24 2.24 0 0 0 0-3l-5-4.92a2.24 2.24 0 0 0-3 0L10 6"/>
1240
+ <path d="M6 18h.01"/><path d="M10 14h.01"/><path d="M15 6h.01"/><path d="M18 9h.01"/>
1241
+ </svg>
1242
+ try an example
1243
+ </button>
1244
+
1245
+ <div class="controls">
1246
+ <div class="size-group">
1247
+ <span class="size-label">Size</span>
1248
+ <div class="presets" id="presets">
1249
+ <button class="preset" data-ratio="1:1" data-rw="1" data-rh="1" type="button">1:1</button>
1250
+ <button class="preset" data-ratio="4:3" data-rw="4" data-rh="3" type="button">4:3</button>
1251
+ <button class="preset" data-ratio="3:4" data-rw="3" data-rh="4" type="button">3:4</button>
1252
+ <button class="preset" data-ratio="16:9" data-rw="16" data-rh="9" type="button">16:9</button>
1253
+ <button class="preset" data-ratio="9:16" data-rw="9" data-rh="16" type="button">9:16</button>
1254
+ </div>
1255
+ <div class="slider-row">
1256
+ <span class="sub-label">W</span>
1257
+ <input type="range" id="widthSlider" min="256" max="1024" step="16" value="512">
1258
+ <span class="sub-value" id="widthValue">512</span>
1259
+ </div>
1260
+ <div class="slider-row">
1261
+ <span class="sub-label">H</span>
1262
+ <input type="range" id="heightSlider" min="256" max="1024" step="16" value="512">
1263
+ <span class="sub-value" id="heightValue">512</span>
1264
+ </div>
1265
+ </div>
1266
+
1267
+ <div class="seed-row">
1268
+ <span class="seed-label">Steps</span>
1269
+ <input type="range" id="stepsSlider" min="1" max="50" step="1" value="4">
1270
+ <span class="sub-value" id="stepsValue">4</span>
1271
+ </div>
1272
+
1273
+ <div class="seed-row">
1274
+ <span class="seed-label">Seed</span>
1275
+ <input
1276
+ type="text"
1277
+ inputmode="numeric"
1278
+ id="seedInput"
1279
+ class="seed-input"
1280
+ placeholder="random"
1281
+ >
1282
+ <button class="icon-btn-sm" id="randomSeedBtn" type="button" title="Randomize seed">
1283
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1284
+ <rect width="12" height="12" x="2" y="10" rx="2" ry="2"/>
1285
+ <path d="m17.92 14 3.5-3.5a2.24 2.24 0 0 0 0-3l-5-4.92a2.24 2.24 0 0 0-3 0L10 6"/>
1286
+ <path d="M6 18h.01"/><path d="M10 14h.01"/><path d="M15 6h.01"/><path d="M18 9h.01"/>
1287
+ </svg>
1288
+ </button>
1289
+ </div>
1290
+ </div>
1291
+
1292
+ <button class="generate" id="generateBtn" disabled>generate</button>
1293
+ </div>
1294
+
1295
+ <button class="gallery-link" id="galleryLink" hidden></button>
1296
+ </section>
1297
+
1298
+ <section id="gallerySection" hidden>
1299
+ <div class="gallery-header">
1300
+ <span class="image-count" id="imageCountTop"></span>
1301
+ <div class="header-actions">
1302
+ <button class="text-btn" id="clearAllBtn">clear all</button>
1303
+ <button class="pill-btn" id="newBtn">
1304
+ <svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1305
+ <path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>
1306
+ </svg>
1307
+ new
1308
+ </button>
1309
+ </div>
1310
+ </div>
1311
+ <div class="grid" id="grid"></div>
1312
+ <div class="load-more" id="loadMoreSentinel"></div>
1313
+ </section>
1314
+
1315
+ <div class="modal" id="modal" hidden>
1316
+ <button class="icon-btn modal-close" id="modalClose" aria-label="Close">
1317
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1318
+ <path d="M18 6 6 18"/><path d="m6 6 12 12"/>
1319
+ </svg>
1320
+ </button>
1321
+ <button class="icon-btn modal-prev" id="modalPrev" aria-label="Previous">
1322
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1323
+ <path d="m15 18-6-6 6-6"/>
1324
+ </svg>
1325
+ </button>
1326
+ <button class="icon-btn modal-next" id="modalNext" aria-label="Next">
1327
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1328
+ <path d="m9 18 6-6-6-6"/>
1329
+ </svg>
1330
+ </button>
1331
+
1332
+ <div class="modal-content">
1333
+ <div class="modal-img-wrap">
1334
+ <div class="modal-loader" id="modalLoader">
1335
+ <svg class="spin" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1336
+ <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
1337
+ </svg>
1338
+ </div>
1339
+ <img class="modal-img" id="modalImg" alt="">
1340
+ </div>
1341
+
1342
+ <aside class="modal-meta">
1343
+ <div class="prompt-block">
1344
+ <div class="prompt-header">
1345
+ <span class="meta-label">prompt</span>
1346
+ <button class="copy-btn icon-btn-sm" id="copyPromptBtn" type="button" title="Copy prompt">
1347
+ <span id="copyIcon"></span>
1348
+ </button>
1349
+ </div>
1350
+ <p class="meta-prompt" id="metaPrompt"></p>
1351
+ </div>
1352
+
1353
+ <p class="meta-label">parameters</p>
1354
+ <dl id="metaParams"></dl>
1355
+
1356
+ <div class="meta-actions">
1357
+ <button class="meta-share" id="modalShare" type="button" hidden>
1358
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1359
+ <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/>
1360
+ <polyline points="16 6 12 2 8 6"/>
1361
+ <line x1="12" x2="12" y1="2" y2="15"/>
1362
+ </svg>
1363
+ share
1364
+ </button>
1365
+ <button class="meta-delete" id="modalDelete" type="button">
1366
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1367
+ <path d="M3 6h18"/>
1368
+ <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
1369
+ <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
1370
+ <line x1="10" x2="10" y1="11" y2="17"/>
1371
+ <line x1="14" x2="14" y1="11" y2="17"/>
1372
+ </svg>
1373
+ delete
1374
+ </button>
1375
+ </div>
1376
+ </aside>
1377
+ </div>
1378
+ </div>
1379
+ </body>
1380
+ </html>
web/imagen.js CHANGED
@@ -2,9 +2,9 @@
2
  // GPU, or cloud FLUX; in-browser SD-Turbo / Janus get added here later) and exposes one
3
  // generatePortrait(). The persona panel + the Settings image bar import only from here.
4
  import { engineLocal as zimagelocal, engineCloud as flux, engineCloudDev as fluxdev, isLocalhost } from '/web/imagenServer.js'
5
- import { engine as janus } from '/web/imagenJanus.js'
6
 
7
- const ENGINES = [zimagelocal, janus, flux, fluxdev]
8
  // Default: local Z-Image on localhost (your GPU), cloud FLUX in prod. Persisted across
9
  // refreshes; a saved choice wins if it's still available.
10
  const KEY = 'tinyarmy.imageEngine'
 
2
  // GPU, or cloud FLUX; in-browser SD-Turbo / Janus get added here later) and exposes one
3
  // generatePortrait(). The persona panel + the Settings image bar import only from here.
4
  import { engineLocal as zimagelocal, engineCloud as flux, engineCloudDev as fluxdev, isLocalhost } from '/web/imagenServer.js'
5
+ import { engine as bonsai } from '/web/imagenBonsai.js'
6
 
7
+ const ENGINES = [zimagelocal, bonsai, flux, fluxdev]
8
  // Default: local Z-Image on localhost (your GPU), cloud FLUX in prod. Persisted across
9
  // refreshes; a saved choice wins if it's still available.
10
  const KEY = 'tinyarmy.imageEngine'
web/imagenBonsai.js ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // In-browser image engine: Bonsai Image 4B (FLUX.2-Klein, 1-bit/ternary, Apache-2.0) on
2
+ // WebGPU. Bonsai's engine is a bespoke WGSL app with no JS API and a custom 1-bit weight
3
+ // packing only its own shaders can run — so we can't load the weights into a generic
4
+ // pipeline. Instead we VENDOR its self-contained static bundle under /web/bonsai/ and run
5
+ // it in a same-origin hidden iframe, driving its UI directly (load → prompt → generate →
6
+ // read the output image). Same-origin = no postMessage bridge and no cross-origin iframe
7
+ // throttling. Desktop-only: the model is ~4-5 GB. generate() → an image Blob.
8
+ const APP = '/web/bonsai/index.html'
9
+
10
+ const isMobile = () => { try { return /Mobi|Android|iPhone|iPad|iPod|IEMobile/i.test(navigator.userAgent) } catch { return false } }
11
+
12
+ let _iframe = null, _readyP = null, _loaded = false, _loadP = null
13
+
14
+ const doc = () => _iframe.contentDocument
15
+ const win = () => _iframe.contentWindow
16
+ const btns = () => Array.from(doc().querySelectorAll('button'))
17
+ const findBtn = (re, enabledOnly) => btns().find((b) => re.test((b.textContent || '').trim()) && (!enabledOnly || !b.disabled))
18
+ const promptEl = () => doc().querySelector('textarea[placeholder*="Describe" i], input[placeholder*="Describe" i]')
19
+ const bodyText = () => (doc().body && doc().body.innerText) || ''
20
+ const isLoading = () => /downloading|loading model|compiling|preparing|fetching/i.test(bodyText())
21
+ const loadBtn = () => findBtn(/load .*model/i, false)
22
+ // "Load … model" persists in the picker post-load, so detect readiness via the prompt UI.
23
+ const isLoaded = () => /enter a prompt|running locally/i.test(bodyText()) && !isLoading()
24
+
25
+ function wait(test, { timeout = 600000, interval = 400, onTick } = {}) {
26
+ return new Promise((res, rej) => {
27
+ const t0 = Date.now()
28
+ ;(function tick() {
29
+ let v
30
+ try { v = test() } catch (e) { return rej(e) }
31
+ if (v) return res(v)
32
+ if (onTick) { try { onTick() } catch { /* ignore */ } }
33
+ if (Date.now() - t0 > timeout) return rej(new Error('bonsai timeout'))
34
+ setTimeout(tick, interval)
35
+ })()
36
+ })
37
+ }
38
+
39
+ function setValue(el, val) {
40
+ const W = win() // use the iframe realm's prototypes/constructors
41
+ const proto = el.tagName === 'TEXTAREA' ? W.HTMLTextAreaElement.prototype : W.HTMLInputElement.prototype
42
+ Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, val)
43
+ el.dispatchEvent(new W.Event('input', { bubbles: true }))
44
+ el.dispatchEvent(new W.Event('change', { bubbles: true }))
45
+ }
46
+
47
+ function progFrac() {
48
+ const m = bodyText().match(/([\d.]+)\s*(MB|GB)\s*\/\s*([\d.]+)\s*(MB|GB)/i)
49
+ if (!m) return null
50
+ const toMB = (v, u) => parseFloat(v) * (/GB/i.test(u) ? 1024 : 1)
51
+ const f = toMB(m[1], m[2]) / toMB(m[3], m[4])
52
+ return isFinite(f) ? Math.max(0, Math.min(0.99, f)) : null
53
+ }
54
+
55
+ // Read the iframe's output <img> into a Blob in OUR realm (same-origin → no canvas taint).
56
+ function imgToBlob(img) {
57
+ const c = document.createElement('canvas')
58
+ c.width = img.naturalWidth; c.height = img.naturalHeight
59
+ c.getContext('2d').drawImage(img, 0, 0)
60
+ return new Promise((res) => c.toBlob(res, 'image/png'))
61
+ }
62
+
63
+ function ensureFrame() {
64
+ if (_readyP) return _readyP
65
+ _iframe = document.createElement('iframe')
66
+ _iframe.setAttribute('aria-hidden', 'true')
67
+ // Fill the viewport (so the app renders its DESKTOP controls) but invisible + click-through.
68
+ // Same-origin avoids the cross-origin background throttling; in-viewport keeps it painted.
69
+ _iframe.style.cssText = 'position:fixed;inset:0;width:100vw;height:100vh;opacity:.004;pointer-events:none;border:0;z-index:2147483647'
70
+ _readyP = new Promise((res, rej) => {
71
+ _iframe.addEventListener('load', () => {
72
+ wait(() => promptEl() || loadBtn(), { timeout: 60000 }).then(() => res(), rej)
73
+ })
74
+ _iframe.addEventListener('error', () => rej(new Error('bonsai app failed to load')))
75
+ document.body.appendChild(_iframe)
76
+ _iframe.src = APP
77
+ })
78
+ return _readyP
79
+ }
80
+
81
+ async function ensure(onProgress) {
82
+ await ensureFrame()
83
+ if (_loaded) return
84
+ if (_loadP) return _loadP
85
+ _loadP = (async () => {
86
+ if (!isLoaded()) {
87
+ const lb = loadBtn(); if (lb) lb.click()
88
+ await wait(isLoaded, { onTick: () => { const f = progFrac(); if (onProgress && f != null) onProgress(f) } })
89
+ }
90
+ _loaded = true
91
+ })()
92
+ return _loadP
93
+ }
94
+
95
+ async function generate(prompt) {
96
+ await ensure()
97
+ const p = promptEl(); if (!p) throw new Error('bonsai prompt field not found')
98
+ setValue(p, prompt)
99
+ const before = new Set(Array.from(doc().querySelectorAll('img')).map((i) => i.src).filter((s) => s.startsWith('blob:')))
100
+ const gen = await wait(() => findBtn(/generate/i, true), { timeout: 15000 })
101
+ gen.click()
102
+ const img = await wait(() => {
103
+ const fresh = Array.from(doc().querySelectorAll('img')).filter((i) => (i.src || '').startsWith('blob:') && !before.has(i.src) && i.complete && i.naturalWidth > 0)
104
+ return fresh.length ? fresh[fresh.length - 1] : null
105
+ }, { timeout: 300000 })
106
+ return imgToBlob(img)
107
+ }
108
+
109
+ export const engine = {
110
+ id: 'bonsai',
111
+ label: 'Bonsai · in-browser (WebGPU, desktop)',
112
+ available: () => { try { return !!navigator.gpu && !isMobile() } catch { return false } },
113
+ note: 'desktop WebGPU · ~4-5 GB first use',
114
+ needsDownload: true,
115
+ networked: false,
116
+ ensure,
117
+ generate: (prompt, _opts = {}) => generate(prompt),
118
+ backendLabel: () => '⚡ WebGPU · Bonsai (on-device)',
119
+ }
web/imagenJanus.js DELETED
@@ -1,54 +0,0 @@
1
- // In-browser image engine: Janus-Pro-1B (DeepSeek, MIT) via Transformers.js + WebGPU —
2
- // the SAME runtime our LLM transformers engine + Kokoro already use. Runs on the device,
3
- // no cloud. WebGPU-required (a small WASM stage prepares inputs). generate() → PNG Blob.
4
- const MODEL_ID = 'onnx-community/Janus-Pro-1B-ONNX'
5
-
6
- let _lib = null, _proc = null, _model = null, _loadP = null
7
- async function lib() { if (!_lib) _lib = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@3'); return _lib }
8
-
9
- // shader-f16 lets us use the smaller/faster fp16 weights; else fall back to fp32.
10
- async function fp16Supported() {
11
- try { const a = navigator.gpu && await navigator.gpu.requestAdapter(); return !!(a && a.features && a.features.has('shader-f16')) } catch { return false }
12
- }
13
-
14
- async function ensure(onProgress) {
15
- if (_proc && _model) return
16
- if (_loadP) return _loadP
17
- _loadP = (async () => {
18
- const { AutoProcessor, MultiModalityCausalLM } = await lib()
19
- const prog = (p) => { if (onProgress && p.status === 'progress' && p.total) onProgress(p.loaded / p.total) }
20
- const proc = await AutoProcessor.from_pretrained(MODEL_ID, { progress_callback: prog })
21
- const fp16 = await fp16Supported()
22
- // Per-component placement: the input-embed prep runs on WASM, the rest on WebGPU.
23
- const dtype = fp16
24
- ? { prepare_inputs_embeds: 'q4', language_model: 'q4f16', lm_head: 'fp16', gen_head: 'fp16', gen_img_embeds: 'fp16', image_decode: 'fp32' }
25
- : { prepare_inputs_embeds: 'fp32', language_model: 'q4', lm_head: 'fp32', gen_head: 'fp32', gen_img_embeds: 'fp32', image_decode: 'fp32' }
26
- const device = { prepare_inputs_embeds: 'wasm', language_model: 'webgpu', lm_head: 'webgpu', gen_head: 'webgpu', gen_img_embeds: 'webgpu', image_decode: 'webgpu' }
27
- const model = await MultiModalityCausalLM.from_pretrained(MODEL_ID, { dtype, device, progress_callback: prog })
28
- _proc = proc; _model = model
29
- })().catch((e) => { _loadP = null; throw e })
30
- return _loadP
31
- }
32
-
33
- async function generate(prompt) {
34
- await ensure()
35
- const conversation = [{ role: '<|User|>', content: prompt }]
36
- const inputs = await _proc(conversation, { chat_template: 'text_to_image' })
37
- const n = _proc.num_image_tokens
38
- const outputs = await _model.generate_images({ ...inputs, min_new_tokens: n, max_new_tokens: n, do_sample: true })
39
- return outputs[0].toBlob() // RawImage → PNG Blob
40
- }
41
-
42
- export const engine = {
43
- id: 'janus',
44
- label: 'Janus-Pro-1B · in-browser (WebGPU)',
45
- // WebGPU-only; mirror the WebLLM check (gpu object present). ensure() fails gracefully
46
- // and the picker disables it where there's genuinely no WebGPU.
47
- available: () => { try { return !!navigator.gpu } catch { return false } },
48
- note: 'needs WebGPU',
49
- needsDownload: true,
50
- networked: false,
51
- ensure,
52
- generate: (prompt, _opts = {}) => generate(prompt), // Janus is prompt-only (no seed/size)
53
- backendLabel: () => '⚡ WebGPU · Janus-Pro',
54
- }