ima2-gen 1.0.2 → 1.0.4

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.
package/public/index.html DELETED
@@ -1,1075 +0,0 @@
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.0">
6
- <title>Image Gen</title>
7
- <link rel="preconnect" href="https://fonts.googleapis.com">
8
- <link href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
9
- <style>
10
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
11
-
12
- :root {
13
- --bg: #0a0a0a;
14
- --surface: #141414;
15
- --surface-2: #1c1c1c;
16
- --border: #2a2a2a;
17
- --text: #e8e8e8;
18
- --text-dim: #888;
19
- --accent: #f0f0f0;
20
- --accent-bright: #fff;
21
- --green: #22c55e;
22
- --amber: #f59e0b;
23
- --red: #ef4444;
24
- --radius: 10px;
25
- --font: 'Outfit', sans-serif;
26
- --mono: 'Geist Mono', monospace;
27
- }
28
-
29
- body {
30
- font-family: var(--font);
31
- background: var(--bg);
32
- color: var(--text);
33
- min-height: 100dvh;
34
- -webkit-font-smoothing: antialiased;
35
- }
36
-
37
- .app {
38
- display: grid;
39
- grid-template-columns: 380px 1fr;
40
- min-height: 100dvh;
41
- }
42
-
43
- /* ── Sidebar ── */
44
- .sidebar {
45
- background: var(--surface);
46
- border-right: 1px solid var(--border);
47
- padding: 28px 24px;
48
- display: flex;
49
- flex-direction: column;
50
- gap: 20px;
51
- overflow-y: auto;
52
- }
53
-
54
- .logo {
55
- font-size: 20px;
56
- font-weight: 600;
57
- letter-spacing: -0.5px;
58
- display: flex;
59
- align-items: center;
60
- gap: 10px;
61
- }
62
-
63
- .logo-dot {
64
- width: 8px; height: 8px;
65
- background: var(--green);
66
- border-radius: 50%;
67
- animation: pulse 2s ease-in-out infinite;
68
- }
69
-
70
- @keyframes pulse {
71
- 0%, 100% { opacity: 1; }
72
- 50% { opacity: 0.4; }
73
- }
74
-
75
- .billing-bar {
76
- background: var(--surface-2);
77
- border: 1px solid var(--border);
78
- border-radius: var(--radius);
79
- padding: 14px 16px;
80
- font-family: var(--mono);
81
- font-size: 12px;
82
- display: flex;
83
- flex-direction: column;
84
- gap: 6px;
85
- }
86
-
87
- .billing-bar .label { color: var(--text-dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
88
- .billing-bar .value { font-size: 16px; font-weight: 600; }
89
-
90
- .section-title {
91
- font-size: 11px;
92
- font-weight: 500;
93
- text-transform: uppercase;
94
- letter-spacing: 1px;
95
- color: var(--text-dim);
96
- margin-top: 4px;
97
- }
98
-
99
- /* ── Form controls ── */
100
- .prompt-area {
101
- width: 100%;
102
- min-height: 100px;
103
- max-height: 200px;
104
- resize: vertical;
105
- background: var(--bg);
106
- border: 1px solid var(--border);
107
- border-radius: var(--radius);
108
- color: var(--text);
109
- font-family: var(--font);
110
- font-size: 14px;
111
- padding: 14px;
112
- outline: none;
113
- transition: border-color 0.2s;
114
- }
115
-
116
- .prompt-area:focus { border-color: #444; }
117
- .prompt-area::placeholder { color: #555; }
118
-
119
- .option-group {
120
- display: flex;
121
- flex-direction: column;
122
- gap: 8px;
123
- }
124
-
125
- .option-row {
126
- display: flex;
127
- gap: 6px;
128
- }
129
-
130
- .option-btn {
131
- flex: 1;
132
- padding: 9px 6px;
133
- background: var(--bg);
134
- border: 1px solid var(--border);
135
- border-radius: 8px;
136
- color: var(--text-dim);
137
- font-family: var(--mono);
138
- font-size: 12px;
139
- cursor: pointer;
140
- transition: all 0.15s;
141
- text-align: center;
142
- }
143
-
144
- .option-btn:hover { border-color: #444; color: var(--text); }
145
- .option-btn.active {
146
- border-color: var(--accent);
147
- color: var(--accent-bright);
148
- background: var(--surface-2);
149
- }
150
-
151
- .cost-estimate {
152
- font-family: var(--mono);
153
- font-size: 12px;
154
- color: var(--text-dim);
155
- padding: 8px 0;
156
- display: flex;
157
- justify-content: space-between;
158
- }
159
-
160
- .cost-estimate .price { color: var(--green); font-weight: 500; }
161
-
162
- .generate-btn {
163
- width: 100%;
164
- padding: 14px;
165
- background: var(--accent-bright);
166
- color: var(--bg);
167
- border: none;
168
- border-radius: var(--radius);
169
- font-family: var(--font);
170
- font-size: 15px;
171
- font-weight: 600;
172
- cursor: pointer;
173
- transition: all 0.15s;
174
- letter-spacing: -0.3px;
175
- }
176
-
177
- .generate-btn:hover { opacity: 0.9; transform: translateY(-1px); }
178
- .generate-btn:active { transform: translateY(0); }
179
- .generate-btn:disabled {
180
- opacity: 0.3;
181
- cursor: not-allowed;
182
- transform: none;
183
- }
184
-
185
- .generate-btn.loading {
186
- background: var(--surface-2);
187
- color: var(--text-dim);
188
- border: 1px solid var(--border);
189
- }
190
- .generate-btn.loading::before {
191
- content: "";
192
- display: inline-block;
193
- width: 14px;
194
- height: 14px;
195
- border: 2px solid var(--text-dim);
196
- border-top-color: transparent;
197
- border-radius: 50%;
198
- animation: spin 0.8s linear infinite;
199
- margin-right: 8px;
200
- vertical-align: middle;
201
- }
202
- @keyframes spin { to { transform: rotate(360deg); } }
203
-
204
- .format-row {
205
- display: flex;
206
- gap: 6px;
207
- }
208
-
209
- select.format-select {
210
- flex: 1;
211
- padding: 9px 12px;
212
- background: var(--bg);
213
- border: 1px solid var(--border);
214
- border-radius: 8px;
215
- color: var(--text);
216
- font-family: var(--mono);
217
- font-size: 12px;
218
- cursor: pointer;
219
- outline: none;
220
- appearance: none;
221
- }
222
-
223
- /* ── Canvas area ── */
224
- .canvas {
225
- display: flex;
226
- align-items: center;
227
- justify-content: center;
228
- padding: 40px;
229
- position: relative;
230
- overflow: auto;
231
- }
232
-
233
- .canvas-empty {
234
- text-align: center;
235
- color: #333;
236
- font-size: 48px;
237
- font-weight: 700;
238
- letter-spacing: -2px;
239
- user-select: none;
240
- }
241
-
242
- .canvas-empty span { display: block; font-size: 14px; font-weight: 400; color: #333; letter-spacing: 0; margin-top: 12px; }
243
-
244
- .result-container {
245
- display: none;
246
- flex-direction: column;
247
- align-items: center;
248
- gap: 16px;
249
- max-width: 100%;
250
- max-height: 100%;
251
- }
252
-
253
- .result-container.visible { display: flex; }
254
-
255
- .result-img {
256
- max-width: 100%;
257
- max-height: calc(100dvh - 160px);
258
- border-radius: 12px;
259
- box-shadow: 0 20px 60px rgba(0,0,0,0.5);
260
- object-fit: contain;
261
- }
262
-
263
- .result-prompt {
264
- max-width: 600px;
265
- padding: 10px 16px;
266
- background: var(--surface);
267
- border: 1px solid var(--border);
268
- border-radius: 8px;
269
- font-family: var(--mono);
270
- font-size: 12px;
271
- color: var(--text-dim);
272
- line-height: 1.5;
273
- cursor: pointer;
274
- transition: all 0.15s;
275
- word-break: break-word;
276
- text-align: center;
277
- }
278
-
279
- .result-prompt:hover { border-color: #444; color: var(--text); }
280
-
281
- .result-meta {
282
- font-family: var(--mono);
283
- font-size: 12px;
284
- color: var(--text-dim);
285
- display: flex;
286
- gap: 20px;
287
- }
288
-
289
- .result-actions {
290
- display: flex;
291
- gap: 8px;
292
- }
293
-
294
- .action-btn {
295
- padding: 8px 16px;
296
- background: var(--surface);
297
- border: 1px solid var(--border);
298
- border-radius: 8px;
299
- color: var(--text);
300
- font-family: var(--mono);
301
- font-size: 12px;
302
- cursor: pointer;
303
- transition: all 0.15s;
304
- }
305
-
306
- .action-btn:hover { border-color: #444; background: var(--surface-2); }
307
-
308
- /* ── Progress ── */
309
- .progress-bar {
310
- position: absolute;
311
- top: 0; left: 0; right: 0;
312
- height: 2px;
313
- background: var(--border);
314
- overflow: hidden;
315
- opacity: 0;
316
- transition: opacity 0.3s;
317
- }
318
-
319
- .progress-bar.active { opacity: 1; }
320
-
321
- .progress-bar::after {
322
- content: '';
323
- position: absolute;
324
- top: 0; left: -40%;
325
- width: 40%; height: 100%;
326
- background: var(--accent-bright);
327
- animation: progress-slide 1.2s ease-in-out infinite;
328
- }
329
-
330
- @keyframes progress-slide {
331
- 0% { left: -40%; }
332
- 100% { left: 100%; }
333
- }
334
-
335
- .history-strip {
336
- display: flex;
337
- gap: 6px;
338
- overflow-x: auto;
339
- padding: 4px 0;
340
- margin-top: auto;
341
- }
342
-
343
- .history-strip::-webkit-scrollbar { height: 4px; }
344
- .history-strip::-webkit-scrollbar-track { background: transparent; }
345
- .history-strip::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
346
-
347
- .history-thumb {
348
- width: 48px; height: 48px;
349
- flex-shrink: 0;
350
- border-radius: 6px;
351
- object-fit: cover;
352
- cursor: pointer;
353
- border: 2px solid transparent;
354
- opacity: 0.6;
355
- transition: all 0.15s;
356
- }
357
-
358
- .history-thumb:hover { opacity: 1; }
359
- .history-thumb.active { border-color: var(--accent); opacity: 1; }
360
-
361
- /* ── Toast ── */
362
- .toast {
363
- position: fixed;
364
- bottom: 24px;
365
- right: 24px;
366
- background: var(--surface);
367
- border: 1px solid var(--border);
368
- border-radius: var(--radius);
369
- padding: 12px 20px;
370
- font-family: var(--mono);
371
- font-size: 13px;
372
- color: var(--text);
373
- transform: translateY(100px);
374
- opacity: 0;
375
- transition: all 0.3s ease;
376
- z-index: 100;
377
- }
378
-
379
- .toast.visible { transform: translateY(0); opacity: 1; }
380
- .toast.error { border-color: var(--red); color: var(--red); }
381
-
382
- /* ── Mode tabs ── */
383
- .mode-tabs {
384
- display: flex;
385
- gap: 4px;
386
- background: var(--bg);
387
- border-radius: 8px;
388
- padding: 3px;
389
- }
390
-
391
- .mode-tab {
392
- flex: 1;
393
- padding: 8px;
394
- background: transparent;
395
- border: none;
396
- border-radius: 6px;
397
- color: var(--text-dim);
398
- font-family: var(--mono);
399
- font-size: 12px;
400
- cursor: pointer;
401
- transition: all 0.15s;
402
- text-align: center;
403
- }
404
-
405
- .mode-tab:hover { color: var(--text); }
406
- .mode-tab.active { background: var(--surface-2); color: var(--accent-bright); }
407
-
408
- /* ── Upload zone ── */
409
- .upload-zone {
410
- display: none;
411
- flex-direction: column;
412
- gap: 8px;
413
- }
414
-
415
- .upload-zone.visible { display: flex; }
416
-
417
- .drop-area {
418
- border: 2px dashed var(--border);
419
- border-radius: var(--radius);
420
- padding: 20px;
421
- text-align: center;
422
- cursor: pointer;
423
- transition: all 0.2s;
424
- font-family: var(--mono);
425
- font-size: 12px;
426
- color: var(--text-dim);
427
- position: relative;
428
- overflow: hidden;
429
- }
430
-
431
- .drop-area:hover, .drop-area.dragover {
432
- border-color: #555;
433
- color: var(--text);
434
- background: var(--surface-2);
435
- }
436
-
437
- .drop-area.has-image {
438
- padding: 0;
439
- border-style: solid;
440
- border-color: var(--border);
441
- }
442
-
443
- .drop-area img {
444
- width: 100%;
445
- max-height: 180px;
446
- object-fit: contain;
447
- border-radius: calc(var(--radius) - 2px);
448
- }
449
-
450
- .drop-area .remove-btn {
451
- position: absolute;
452
- top: 6px;
453
- right: 6px;
454
- width: 22px; height: 22px;
455
- background: rgba(0,0,0,0.7);
456
- border: 1px solid var(--border);
457
- border-radius: 50%;
458
- color: var(--text);
459
- font-size: 12px;
460
- cursor: pointer;
461
- display: flex;
462
- align-items: center;
463
- justify-content: center;
464
- opacity: 0;
465
- transition: opacity 0.15s;
466
- }
467
-
468
- .drop-area:hover .remove-btn { opacity: 1; }
469
-
470
- .use-result-btn {
471
- padding: 7px 12px;
472
- background: var(--bg);
473
- border: 1px solid var(--border);
474
- border-radius: 8px;
475
- color: var(--text-dim);
476
- font-family: var(--mono);
477
- font-size: 11px;
478
- cursor: pointer;
479
- transition: all 0.15s;
480
- text-align: center;
481
- }
482
-
483
- .use-result-btn:hover { border-color: #444; color: var(--text); }
484
- .use-result-btn:disabled { opacity: 0.3; cursor: not-allowed; }
485
-
486
- /* ── Responsive ── */
487
- @media (max-width: 800px) {
488
- .app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
489
- .sidebar { border-right: none; border-bottom: 1px solid var(--border); max-height: 50dvh; }
490
- .canvas { min-height: 50dvh; padding: 20px; }
491
- }
492
- </style>
493
- </head>
494
- <body>
495
- <div class="app">
496
- <aside class="sidebar">
497
- <div class="logo">
498
- <div class="logo-dot"></div>
499
- Image Gen
500
- <span style="font-family:var(--mono);font-size:11px;color:var(--text-dim);margin-left:auto">gpt-image-2</span>
501
- </div>
502
-
503
- <div class="billing-bar" id="billingBar">
504
- <div class="label">API Status</div>
505
- <div class="value" id="billingValue" style="color:var(--text-dim)">checking...</div>
506
- </div>
507
-
508
- <div class="option-group">
509
- <div class="section-title">Provider</div>
510
- <div class="option-row" id="providerGroup">
511
- <button class="option-btn active" data-value="oauth" style="color:var(--green)">OAuth<br><span style="font-size:10px;color:var(--text-dim)">free / codex login</span></button>
512
- <button class="option-btn" data-value="api">API Key<br><span style="font-size:10px;color:var(--text-dim)">paid / .env</span></button>
513
- </div>
514
- <div id="oauthStatus" style="font-family:var(--mono);font-size:11px;color:var(--text-dim);padding:4px 0"></div>
515
- </div>
516
-
517
- <div class="mode-tabs">
518
- <button class="mode-tab active" data-mode="t2i">Text to Image</button>
519
- <button class="mode-tab" data-mode="i2i">Image to Image</button>
520
- </div>
521
-
522
- <div class="upload-zone" id="uploadZone">
523
- <div class="section-title">Source Image</div>
524
- <div class="drop-area" id="dropArea">
525
- Drop image here or click to upload
526
- <input type="file" accept="image/*" id="fileInput" style="display:none">
527
- </div>
528
- <button class="use-result-btn" id="useResultBtn" disabled>Use current result as source</button>
529
- </div>
530
-
531
- <div class="section-title">Prompt</div>
532
- <textarea class="prompt-area" id="prompt" placeholder="Describe the image you want to generate..."></textarea>
533
-
534
- <button class="generate-btn" id="generateBtn">Generate</button>
535
-
536
- <div class="option-group">
537
- <div class="section-title">Quality</div>
538
- <div class="option-row" id="qualityGroup">
539
- <button class="option-btn active" data-value="low">Low<br><span style="font-size:10px;color:var(--text-dim)">fast</span></button>
540
- <button class="option-btn" data-value="medium">Medium<br><span style="font-size:10px;color:var(--text-dim)">balanced</span></button>
541
- <button class="option-btn" data-value="high">High<br><span style="font-size:10px;color:var(--text-dim)">best</span></button>
542
- </div>
543
- </div>
544
-
545
- <div class="option-group">
546
- <div class="section-title">Size</div>
547
- <div class="option-row" id="sizeGroup1">
548
- <button class="option-btn active" data-value="1024x1024">1024x1024<br><span style="font-size:10px;color:var(--text-dim)">square</span></button>
549
- <button class="option-btn" data-value="1536x1024">1536x1024<br><span style="font-size:10px;color:var(--text-dim)">landscape</span></button>
550
- <button class="option-btn" data-value="1024x1536">1024x1536<br><span style="font-size:10px;color:var(--text-dim)">portrait</span></button>
551
- </div>
552
- <div class="option-row" id="sizeGroup2">
553
- <button class="option-btn" data-value="2048x2048">2048x2048<br><span style="font-size:10px;color:var(--text-dim)">2K sq</span></button>
554
- <button class="option-btn" data-value="2048x1152">2048x1152<br><span style="font-size:10px;color:var(--text-dim)">2K land</span></button>
555
- <button class="option-btn" data-value="auto">auto<br><span style="font-size:10px;color:var(--text-dim)">model picks</span></button>
556
- </div>
557
- <div class="option-row" id="sizeGroup3">
558
- <button class="option-btn" data-value="3840x2160">3840x2160<br><span style="font-size:10px;color:var(--text-dim)">4K land</span></button>
559
- <button class="option-btn" data-value="2160x3840">2160x3840<br><span style="font-size:10px;color:var(--text-dim)">4K port</span></button>
560
- <button class="option-btn" data-value="custom" id="customSizeBtn">Custom<br><span style="font-size:10px;color:var(--text-dim)">any ratio</span></button>
561
- </div>
562
- <div class="option-row" id="customSizeRow" style="display:none">
563
- <input type="number" id="customW" placeholder="W" min="256" max="3840" step="16" value="1920" style="flex:1;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);font-family:var(--mono);font-size:12px;text-align:center;outline:none">
564
- <span style="color:var(--text-dim);align-self:center;font-family:var(--mono);font-size:12px">x</span>
565
- <input type="number" id="customH" placeholder="H" min="256" max="3840" step="16" value="1088" style="flex:1;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);font-family:var(--mono);font-size:12px;text-align:center;outline:none">
566
- </div>
567
- <div id="sizeHint" style="font-family:var(--mono);font-size:10px;color:var(--text-dim);display:none">Both must be multiples of 16, max 3840, ratio &le; 3:1</div>
568
- </div>
569
-
570
- <div class="option-group">
571
- <div class="section-title">Format</div>
572
- <div class="option-row">
573
- <button class="option-btn active format-btn" data-value="png">PNG</button>
574
- <button class="option-btn format-btn" data-value="jpeg">JPEG</button>
575
- <button class="option-btn format-btn" data-value="webp">WebP</button>
576
- </div>
577
- </div>
578
-
579
- <div class="option-group">
580
- <div class="section-title">Moderation</div>
581
- <div class="option-row" id="moderationGroup">
582
- <button class="option-btn active" data-value="low" style="color:var(--amber)">Low<br><span style="font-size:10px;color:var(--text-dim)">less restrictive</span></button>
583
- <button class="option-btn" data-value="auto">Auto<br><span style="font-size:10px;color:var(--text-dim)">standard</span></button>
584
- </div>
585
- </div>
586
-
587
- <div class="option-group">
588
- <div class="section-title">Count</div>
589
- <div class="option-row" id="countGroup">
590
- <button class="option-btn active" data-value="1">1</button>
591
- <button class="option-btn" data-value="2">2</button>
592
- <button class="option-btn" data-value="4">4</button>
593
- </div>
594
- </div>
595
-
596
- <div class="cost-estimate">
597
- <span>Est. cost</span>
598
- <span class="price" id="costEstimate">~$0.006</span>
599
- </div>
600
-
601
- <div class="history-strip" id="historyStrip"></div>
602
- </aside>
603
-
604
- <main class="canvas">
605
- <div class="progress-bar" id="progressBar"></div>
606
- <div class="canvas-empty" id="emptyState" style="display:none"></div>
607
- <div class="result-container" id="resultContainer">
608
- <img class="result-img" id="resultImg">
609
- <div class="result-prompt" id="resultPrompt"></div>
610
- <div class="result-meta" id="resultMeta"></div>
611
- <div class="result-actions">
612
- <button class="action-btn" id="downloadBtn">Download</button>
613
- <button class="action-btn" id="copyBtn">Copy to clipboard</button>
614
- <button class="action-btn" id="copyPromptBtn">Copy prompt</button>
615
- </div>
616
- </div>
617
- </main>
618
- </div>
619
-
620
- <div class="toast" id="toast"></div>
621
-
622
- <script>
623
- const $ = (s) => document.querySelector(s);
624
- const $$ = (s) => document.querySelectorAll(s);
625
-
626
- let state = {
627
- mode: "t2i",
628
- provider: "oauth",
629
- quality: "low",
630
- size: "1024x1024",
631
- format: "png",
632
- moderation: "low",
633
- count: 1,
634
- generating: false,
635
- history: [],
636
- currentImage: null,
637
- sourceImageB64: null,
638
- };
639
-
640
- // ── Cost matrix (gpt-image-2) ──
641
- const COST_MAP = {
642
- "low": { "1024x1024": 0.006, "1024x1536": 0.005, "1536x1024": 0.005, "2048x2048": 0.012, "2048x1152": 0.009, "3840x2160": 0.023, "2160x3840": 0.023, "auto": 0.006 },
643
- "medium": { "1024x1024": 0.053, "1024x1536": 0.041, "1536x1024": 0.041, "2048x2048": 0.106, "2048x1152": 0.080, "3840x2160": 0.200, "2160x3840": 0.200, "auto": 0.053 },
644
- "high": { "1024x1024": 0.211, "1024x1536": 0.165, "1536x1024": 0.165, "2048x2048": 0.422, "2048x1152": 0.320, "3840x2160": 0.800, "2160x3840": 0.800, "auto": 0.211 },
645
- };
646
-
647
- function updateCost() {
648
- if (state.provider === "oauth") {
649
- $("#costEstimate").textContent = "free";
650
- $("#costEstimate").style.color = "var(--green)";
651
- return;
652
- }
653
- $("#costEstimate").style.color = "";
654
- const cost = COST_MAP[state.quality]?.[state.size] ?? 0;
655
- $("#costEstimate").textContent = `~$${cost.toFixed(3)}`;
656
- }
657
-
658
- // ── Provider ──
659
- setupOptionGroup($("#providerGroup"), "provider");
660
-
661
- async function checkOAuthStatus() {
662
- try {
663
- const res = await fetch("/api/oauth/status");
664
- const data = await res.json();
665
- const el = $("#oauthStatus");
666
- if (data.status === "ready") {
667
- el.textContent = "gpt-image-2 ready";
668
- el.style.color = "var(--green)";
669
- } else if (data.status === "auth_required") {
670
- el.innerHTML = 'Run <code style="color:var(--accent)">codex login</code> first';
671
- el.style.color = "var(--amber)";
672
- } else {
673
- el.textContent = "OAuth proxy starting...";
674
- el.style.color = "var(--text-dim)";
675
- setTimeout(checkOAuthStatus, 3000);
676
- }
677
- } catch { $("#oauthStatus").textContent = ""; }
678
- }
679
- checkOAuthStatus();
680
-
681
- // ── Custom size ──
682
- const allSizeButtons = $$("#sizeGroup1 .option-btn, #sizeGroup2 .option-btn, #sizeGroup3 .option-btn");
683
-
684
- function handleSizeClick(btn) {
685
- allSizeButtons.forEach((b) => b.classList.remove("active"));
686
- btn.classList.add("active");
687
- const val = btn.dataset.value;
688
- const isCustom = val === "custom";
689
- $("#customSizeRow").style.display = isCustom ? "flex" : "none";
690
- $("#sizeHint").style.display = isCustom ? "block" : "none";
691
- if (isCustom) {
692
- updateCustomSize();
693
- } else {
694
- state.size = val;
695
- }
696
- updateCost();
697
- }
698
-
699
- allSizeButtons.forEach((btn) => {
700
- btn.addEventListener("click", () => handleSizeClick(btn));
701
- });
702
-
703
- function snap16(n) { return Math.round(n / 16) * 16; }
704
-
705
- function updateCustomSize() {
706
- const w = snap16(parseInt($("#customW").value) || 1024);
707
- const h = snap16(parseInt($("#customH").value) || 1024);
708
- state.size = `${w}x${h}`;
709
- }
710
-
711
- $("#customW").addEventListener("change", updateCustomSize);
712
- $("#customH").addEventListener("change", updateCustomSize);
713
-
714
- // ── Mode tabs ──
715
- $$(".mode-tab").forEach((tab) => {
716
- tab.addEventListener("click", () => {
717
- $$(".mode-tab").forEach((t) => t.classList.remove("active"));
718
- tab.classList.add("active");
719
- state.mode = tab.dataset.mode;
720
- const uploadZone = $("#uploadZone");
721
- uploadZone.classList.toggle("visible", state.mode === "i2i");
722
- $("#generateBtn").textContent = state.mode === "i2i" ? "Edit Image" : "Generate";
723
- $("#prompt").placeholder = state.mode === "i2i"
724
- ? "Describe the edit you want to make..."
725
- : "Describe the image you want to generate...";
726
- });
727
- });
728
-
729
- // ── Image upload ──
730
- const dropArea = $("#dropArea");
731
- const fileInput = $("#fileInput");
732
-
733
- dropArea.addEventListener("click", () => {
734
- if (!dropArea.classList.contains("has-image")) fileInput.click();
735
- });
736
-
737
- dropArea.addEventListener("dragover", (e) => { e.preventDefault(); dropArea.classList.add("dragover"); });
738
- dropArea.addEventListener("dragleave", () => dropArea.classList.remove("dragover"));
739
- dropArea.addEventListener("drop", (e) => {
740
- e.preventDefault();
741
- dropArea.classList.remove("dragover");
742
- const file = e.dataTransfer.files[0];
743
- if (file && file.type.startsWith("image/")) loadSourceImage(file);
744
- });
745
-
746
- fileInput.addEventListener("change", () => {
747
- if (fileInput.files[0]) loadSourceImage(fileInput.files[0]);
748
- });
749
-
750
- function loadSourceImage(file) {
751
- const reader = new FileReader();
752
- reader.onload = () => {
753
- const b64 = reader.result.split(",")[1];
754
- state.sourceImageB64 = b64;
755
- dropArea.classList.add("has-image");
756
- dropArea.innerHTML = `<img src="${reader.result}"><button class="remove-btn" onclick="clearSourceImage(event)">&times;</button>`;
757
- };
758
- reader.readAsDataURL(file);
759
- }
760
-
761
- function loadSourceFromDataUrl(dataUrl) {
762
- state.sourceImageB64 = dataUrl.split(",")[1];
763
- dropArea.classList.add("has-image");
764
- dropArea.innerHTML = `<img src="${dataUrl}"><button class="remove-btn" onclick="clearSourceImage(event)">&times;</button>`;
765
- }
766
-
767
- function clearSourceImage(e) {
768
- if (e) e.stopPropagation();
769
- state.sourceImageB64 = null;
770
- dropArea.classList.remove("has-image");
771
- dropArea.innerHTML = 'Drop image here or click to upload<input type="file" accept="image/*" id="fileInput" style="display:none">';
772
- document.getElementById("fileInput").addEventListener("change", () => {
773
- const f = document.getElementById("fileInput").files[0];
774
- if (f) loadSourceImage(f);
775
- });
776
- }
777
-
778
- $("#useResultBtn").addEventListener("click", () => {
779
- if (!state.currentImage) return;
780
- loadSourceFromDataUrl(state.currentImage.image);
781
- // switch to i2i mode
782
- $$(".mode-tab").forEach((t) => t.classList.remove("active"));
783
- document.querySelector('.mode-tab[data-mode="i2i"]').classList.add("active");
784
- state.mode = "i2i";
785
- $("#uploadZone").classList.add("visible");
786
- $("#generateBtn").textContent = "Edit Image";
787
- $("#prompt").placeholder = "Describe the edit you want to make...";
788
- toast("Source image loaded from result");
789
- });
790
-
791
- // ── Option buttons ──
792
- function setupOptionGroup(container, key) {
793
- container.addEventListener("click", (e) => {
794
- const btn = e.target.closest(".option-btn");
795
- if (!btn) return;
796
- container.querySelectorAll(".option-btn").forEach((b) => b.classList.remove("active"));
797
- btn.classList.add("active");
798
- state[key] = btn.dataset.value;
799
- updateCost();
800
- });
801
- }
802
-
803
- setupOptionGroup($("#qualityGroup"), "quality");
804
- setupOptionGroup($("#moderationGroup"), "moderation");
805
- setupOptionGroup($("#countGroup"), "count");
806
-
807
- $$(".format-btn").forEach((btn) => {
808
- btn.addEventListener("click", () => {
809
- $$(".format-btn").forEach((b) => b.classList.remove("active"));
810
- btn.classList.add("active");
811
- state.format = btn.dataset.value;
812
- });
813
- });
814
-
815
- // ── Generate ──
816
- $("#generateBtn").addEventListener("click", generate);
817
- $("#prompt").addEventListener("keydown", (e) => {
818
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) generate();
819
- });
820
-
821
- let activeGenerations = 0;
822
-
823
- async function generate() {
824
- const prompt = $("#prompt").value.trim();
825
- if (!prompt) return;
826
-
827
- activeGenerations++;
828
- const btn = $("#generateBtn");
829
- btn.classList.add("loading");
830
- btn.textContent = `Generating (${activeGenerations})...`;
831
- $("#progressBar").classList.add("active");
832
-
833
- try {
834
- const isEdit = state.mode === "i2i" && state.sourceImageB64;
835
- const endpoint = isEdit ? "/api/edit" : "/api/generate";
836
- const count = parseInt(state.count) || 1;
837
- const payload = {
838
- prompt,
839
- quality: state.quality,
840
- size: state.size,
841
- format: state.format,
842
- moderation: state.moderation,
843
- provider: state.provider,
844
- n: isEdit ? 1 : count,
845
- };
846
- if (isEdit) payload.image = state.sourceImageB64;
847
-
848
- const res = await fetch(endpoint, {
849
- method: "POST",
850
- headers: { "Content-Type": "application/json" },
851
- body: JSON.stringify(payload),
852
- });
853
-
854
- const data = await res.json();
855
- if (!res.ok) throw new Error(data.error || "Generation failed");
856
-
857
- if (data.images && data.images.length > 1) {
858
- for (const img of data.images) {
859
- const item = { image: img.image, filename: img.filename, prompt, elapsed: data.elapsed, provider: data.provider, usage: data.usage };
860
- state.currentImage = item;
861
- showResult(item);
862
- addToHistory(item);
863
- }
864
- toast(`${data.images.length} images in ${data.elapsed}s`);
865
- } else {
866
- data.prompt = prompt;
867
- state.currentImage = data;
868
- showResult(data);
869
- addToHistory(data);
870
- toast(`Generated in ${data.elapsed}s`);
871
- }
872
- } catch (err) {
873
- toast(err.message, true);
874
- } finally {
875
- activeGenerations--;
876
- if (activeGenerations <= 0) {
877
- activeGenerations = 0;
878
- btn.classList.remove("loading");
879
- btn.textContent = state.mode === "i2i" ? "Edit Image" : "Generate";
880
- $("#progressBar").classList.remove("active");
881
- } else {
882
- btn.textContent = `Generating (${activeGenerations})...`;
883
- }
884
- }
885
- }
886
-
887
- function showResult(data) {
888
- const container = $("#resultContainer");
889
- const img = $("#resultImg");
890
- img.src = data.image;
891
- container.classList.add("visible");
892
- $("#useResultBtn").disabled = false;
893
- $("#resultPrompt").textContent = data.prompt || "";
894
-
895
- const meta = [];
896
- meta.push(`${data.elapsed}s`);
897
- if (data.usage) {
898
- meta.push(`${data.usage.total_tokens || "?"} tokens`);
899
- }
900
- meta.push(state.quality);
901
- meta.push(state.size);
902
- if (data.provider) meta.push(data.provider);
903
- $("#resultMeta").textContent = meta.join(" · ");
904
- }
905
-
906
- function addToHistory(data) {
907
- state.history.unshift(data);
908
- saveHistory();
909
- renderHistory();
910
- }
911
-
912
- function compressImage(dataUrl, maxW = 256) {
913
- return new Promise((resolve) => {
914
- const img = new Image();
915
- img.onload = () => {
916
- const scale = Math.min(1, maxW / Math.max(img.width, img.height));
917
- const c = document.createElement("canvas");
918
- c.width = img.width * scale;
919
- c.height = img.height * scale;
920
- c.getContext("2d").drawImage(img, 0, 0, c.width, c.height);
921
- resolve(c.toDataURL("image/jpeg", 0.6));
922
- };
923
- img.onerror = () => resolve(dataUrl);
924
- img.src = dataUrl;
925
- });
926
- }
927
-
928
- async function saveHistory() {
929
- try {
930
- const slim = [];
931
- for (const h of state.history.slice(0, 50)) {
932
- const thumb = h.thumb || await compressImage(h.image);
933
- slim.push({
934
- image: h.image,
935
- thumb,
936
- prompt: h.prompt,
937
- elapsed: h.elapsed,
938
- filename: h.filename,
939
- provider: h.provider,
940
- usage: h.usage,
941
- });
942
- }
943
- localStorage.setItem("ima2_history", JSON.stringify(slim));
944
- } catch (e) {
945
- if (e.name === "QuotaExceededError") {
946
- state.history = state.history.slice(0, Math.max(state.history.length - 5, 5));
947
- saveHistory();
948
- }
949
- }
950
- }
951
-
952
- function loadHistory() {
953
- try {
954
- const raw = localStorage.getItem("ima2_history");
955
- if (raw) {
956
- state.history = JSON.parse(raw);
957
- renderHistory();
958
- if (state.history.length > 0) {
959
- state.currentImage = state.history[0];
960
- showResult(state.history[0]);
961
- }
962
- }
963
- } catch {}
964
- }
965
-
966
- function renderHistory() {
967
- const strip = $("#historyStrip");
968
- strip.innerHTML = "";
969
- state.history.forEach((item, i) => {
970
- const thumb = document.createElement("img");
971
- thumb.className = "history-thumb" + (i === 0 ? " active" : "");
972
- thumb.src = item.thumb || item.image;
973
- thumb.addEventListener("click", () => {
974
- state.currentImage = item;
975
- showResult(item);
976
- strip.querySelectorAll(".history-thumb").forEach((t) => t.classList.remove("active"));
977
- thumb.classList.add("active");
978
- });
979
- strip.appendChild(thumb);
980
- });
981
- }
982
-
983
- // ── Download & Copy ──
984
- $("#downloadBtn").addEventListener("click", () => {
985
- if (!state.currentImage) return;
986
- const a = document.createElement("a");
987
- a.href = state.currentImage.image;
988
- a.download = state.currentImage.filename || "generated.png";
989
- a.click();
990
- });
991
-
992
- $("#copyBtn").addEventListener("click", async () => {
993
- if (!state.currentImage) return;
994
- try {
995
- const res = await fetch(state.currentImage.image);
996
- const blob = await res.blob();
997
- await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
998
- toast("Copied to clipboard");
999
- } catch {
1000
- toast("Copy failed", true);
1001
- }
1002
- });
1003
-
1004
- // ── Copy prompt ──
1005
- $("#copyPromptBtn").addEventListener("click", () => {
1006
- if (!state.currentImage?.prompt) return;
1007
- navigator.clipboard.writeText(state.currentImage.prompt);
1008
- toast("Prompt copied");
1009
- });
1010
-
1011
- $("#resultPrompt").addEventListener("click", () => {
1012
- if (!state.currentImage?.prompt) return;
1013
- navigator.clipboard.writeText(state.currentImage.prompt);
1014
- toast("Prompt copied");
1015
- });
1016
-
1017
- // ── Toast ──
1018
- function toast(msg, isError = false) {
1019
- const el = $("#toast");
1020
- el.textContent = msg;
1021
- el.className = "toast visible" + (isError ? " error" : "");
1022
- clearTimeout(el._timer);
1023
- el._timer = setTimeout(() => el.classList.remove("visible"), 3000);
1024
- }
1025
-
1026
- // ── Billing ──
1027
- async function loadBilling() {
1028
- try {
1029
- const res = await fetch("/api/billing");
1030
- const data = await res.json();
1031
- const el = $("#billingValue");
1032
-
1033
- if (data.credits) {
1034
- const total = data.credits.total_granted || 0;
1035
- const used = data.credits.total_used || 0;
1036
- const remaining = (total - used).toFixed(2);
1037
- el.textContent = `$${remaining} remaining`;
1038
- el.style.color = remaining > 5 ? "var(--green)" : remaining > 1 ? "var(--amber)" : "var(--red)";
1039
- return;
1040
- }
1041
-
1042
- if (data.costs?.data?.length) {
1043
- const totalCost = data.costs.data.reduce((sum, bucket) => {
1044
- return sum + bucket.results.reduce((s, r) => s + (r.amount?.value || 0), 0);
1045
- }, 0);
1046
- el.textContent = `$${(totalCost / 100).toFixed(2)} this month`;
1047
- el.style.color = "var(--accent)";
1048
- return;
1049
- }
1050
-
1051
- if (data.oauth) {
1052
- el.textContent = "OAuth mode (no API key)";
1053
- el.style.color = "var(--accent)";
1054
- return;
1055
- }
1056
-
1057
- if (data.apiKeyValid) {
1058
- el.textContent = "API key valid";
1059
- el.style.color = "var(--green)";
1060
- } else {
1061
- el.textContent = "Could not fetch billing";
1062
- el.style.color = "var(--text-dim)";
1063
- }
1064
- } catch {
1065
- $("#billingValue").textContent = "offline";
1066
- $("#billingValue").style.color = "var(--red)";
1067
- }
1068
- }
1069
-
1070
- loadBilling();
1071
- updateCost();
1072
- loadHistory();
1073
- </script>
1074
- </body>
1075
- </html>