lightweight image tools
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 Editor</title>
7 <style>
8 * { box-sizing: border-box; }
9 body {
10 font-family: system-ui, sans-serif;
11 max-width: 800px;
12 margin: 2rem auto;
13 padding: 1rem;
14 }
15 input, button, select {
16 padding: 0.5rem;
17 font-size: 1rem;
18 }
19 button { cursor: pointer; }
20 button:disabled { opacity: 0.5; cursor: not-allowed; }
21 .row {
22 display: flex;
23 gap: 0.5rem;
24 margin-bottom: 1rem;
25 align-items: center;
26 flex-wrap: wrap;
27 }
28 #tokenRow input { flex: 1; min-width: 200px; }
29 #drop {
30 border: 2px dashed #ccc;
31 padding: 3rem;
32 text-align: center;
33 cursor: pointer;
34 margin-bottom: 1rem;
35 border-radius: 8px;
36 }
37 #drop:hover { border-color: #666; }
38 #drop.over { border-color: #000; background: #f5f5f5; }
39 .hidden { display: none !important; }
40 #error {
41 color: #c00;
42 background: #fee;
43 padding: 0.75rem;
44 border-radius: 4px;
45 margin-bottom: 1rem;
46 }
47
48 /* Editor */
49 #editor { margin-bottom: 1rem; }
50 .toolbar {
51 display: flex;
52 gap: 0.5rem;
53 margin-bottom: 1rem;
54 flex-wrap: wrap;
55 padding: 0.5rem;
56 background: #f5f5f5;
57 border-radius: 4px;
58 }
59 .toolbar-group {
60 display: flex;
61 gap: 0.25rem;
62 align-items: center;
63 }
64 .toolbar-group::after {
65 content: '';
66 width: 1px;
67 height: 24px;
68 background: #ccc;
69 margin: 0 0.5rem;
70 }
71 .toolbar-group:last-child::after { display: none; }
72
73 .preview-area {
74 text-align: center;
75 min-height: 200px;
76 display: flex;
77 align-items: center;
78 justify-content: center;
79 }
80 .checker {
81 background: repeating-conic-gradient(#ddd 0% 25%, #fff 0% 50%) 50% / 16px 16px;
82 padding: 0.5rem;
83 display: inline-block;
84 border-radius: 4px;
85 }
86 #previewCanvas { max-width: 100%; max-height: 500px; display: block; }
87
88 /* Crop mode */
89 #cropMode {
90 position: relative;
91 display: inline-block;
92 user-select: none;
93 }
94 #cropCanvas { display: block; max-width: 100%; }
95 #overlayCanvas {
96 position: absolute;
97 top: 0;
98 left: 0;
99 cursor: crosshair;
100 }
101 .crop-controls {
102 margin-top: 1rem;
103 display: flex;
104 gap: 0.5rem;
105 justify-content: center;
106 align-items: center;
107 }
108
109 /* Status */
110 .status {
111 text-align: center;
112 padding: 1rem;
113 color: #666;
114 }
115 .file-info {
116 text-align: center;
117 padding: 0.5rem;
118 color: #666;
119 font-size: 0.9rem;
120 background: #f9f9f9;
121 border-radius: 4px;
122 margin-bottom: 1rem;
123 }
124 .file-info strong { color: #333; }
125 .spinner {
126 display: inline-block;
127 width: 20px;
128 height: 20px;
129 border: 2px solid #ccc;
130 border-top-color: #333;
131 border-radius: 50%;
132 animation: spin 0.8s linear infinite;
133 vertical-align: middle;
134 margin-right: 0.5rem;
135 }
136 @keyframes spin { to { transform: rotate(360deg); } }
137
138 /* Color picker inline */
139 input[type="color"] {
140 width: 32px;
141 height: 32px;
142 padding: 0;
143 border: 1px solid #ccc;
144 border-radius: 4px;
145 cursor: pointer;
146 }
147 label { font-size: 0.9rem; color: #666; display: flex; align-items: center; gap: 0.25rem; }
148 input[type="range"] {
149 -webkit-appearance: none;
150 appearance: none;
151 vertical-align: middle;
152 margin: 0;
153 height: 20px;
154 background: transparent;
155 }
156 input[type="range"]::-webkit-slider-runnable-track {
157 height: 6px;
158 background: #ccc;
159 border-radius: 3px;
160 }
161 input[type="range"]::-webkit-slider-thumb {
162 -webkit-appearance: none;
163 width: 16px;
164 height: 16px;
165 background: #666;
166 border-radius: 50%;
167 margin-top: -5px;
168 cursor: pointer;
169 }
170 input[type="range"]::-moz-range-track {
171 height: 6px;
172 background: #ccc;
173 border-radius: 3px;
174 }
175 input[type="range"]::-moz-range-thumb {
176 width: 16px;
177 height: 16px;
178 background: #666;
179 border-radius: 50%;
180 border: none;
181 cursor: pointer;
182 }
183 input[type="number"] { width: 70px; }
184
185 @media (max-width: 500px) {
186 body { margin: 1rem auto; padding: 0.5rem; }
187 .toolbar { flex-direction: column; }
188 .toolbar-group::after { display: none; }
189 }
190 </style>
191</head>
192<body>
193 <h2>Image Editor</h2>
194
195 <div class="row" id="tokenRow">
196 <input type="password" id="token" placeholder="Hack Club AI token" onkeydown="if(event.key==='Enter'){saveToken()}">
197 <button onclick="saveToken()">Save</button>
198 </div>
199
200 <div id="drop">Drop image or click, or paste</div>
201 <input type="file" id="file" accept="image/*" class="hidden">
202
203 <div id="error" class="hidden"></div>
204
205 <div id="editor" class="hidden">
206 <div class="toolbar">
207 <div class="toolbar-group">
208 <button onclick="startCrop()" id="cropBtn">Crop</button>
209 </div>
210 <div class="toolbar-group">
211 <label>
212 Model:
213 <select id="bgRemoverModel">
214 <option value="lucataco">Lucataco (default)</option>
215 <option value="851labs">851-labs</option>
216 </select>
217 </label>
218 <button onclick="removeBg()" id="removeBgBtn">Remove BG</button>
219 </div>
220 <div class="toolbar-group">
221 <button onclick="openBackgroundUpload()" id="icLightBtn">IC-Light Background</button>
222 <input type="file" id="backgroundFile" accept="image/*" class="hidden">
223 </div>
224 <div class="toolbar-group">
225 <label>
226 <input type="color" id="bgColor" value="#ffffff" onchange="applyBgColor()">
227 Fill BG
228 </label>
229 <label>
230 <input type="checkbox" id="showTransparent" checked onchange="updatePreview()">
231 Transparent
232 </label>
233 </div>
234 <div class="toolbar-group">
235 <label>
236 Quality:
237 <input type="range" id="optimizeQuality" min="10" max="100" value="80" style="width: 100px;">
238 <span id="qualityValue">80%</span>
239 </label>
240 <label>
241 Max:
242 <input type="number" id="maxDimension" value="2048" min="100" max="8192" style="width: 70px;">
243 px
244 </label>
245 <label style="font-size: 0.75rem; color: #999;">(scales down only)</label>
246 <button onclick="optimizeImage()">Optimize</button>
247 </div>
248 <div class="toolbar-group">
249 <button onclick="downloadImage()">Download</button>
250 <button onclick="copyImage()">Copy</button>
251 </div>
252 <div class="toolbar-group">
253 <button onclick="undoOperation()" id="undoBtn" disabled>Undo</button>
254 <button onclick="resetAll()">New Image</button>
255 </div>
256 </div>
257
258 <div id="fileInfo" class="file-info hidden"></div>
259
260 <!-- Normal preview -->
261 <div id="previewArea" class="preview-area">
262 <div class="checker">
263 <canvas id="previewCanvas"></canvas>
264 </div>
265 </div>
266
267 <!-- Crop mode -->
268 <div id="cropArea" class="preview-area hidden">
269 <div>
270 <div id="cropMode">
271 <canvas id="cropCanvas"></canvas>
272 <canvas id="overlayCanvas"></canvas>
273 </div>
274 <div class="crop-controls">
275 <label>Aspect:
276 <select id="aspectRatio">
277 <option value="free">Free</option>
278 <option value="square">1:1</option>
279 <option value="4:3">4:3</option>
280 <option value="16:9">16:9</option>
281 </select>
282 </label>
283 <button onclick="applyCrop()">Apply</button>
284 <button onclick="cancelCrop()">Cancel</button>
285 </div>
286 </div>
287 </div>
288
289 <div id="statusArea" class="status hidden">
290 <span class="spinner"></span><span id="statusText">Processing...</span>
291 </div>
292 </div>
293
294 <script>
295 const HANDLE_SIZE = 10;
296 const SNAP_THRESHOLD = 8;
297
298 // State - Layer system
299 let originalImage = null; // Never modified
300 let operations = []; // Stack of operations to apply
301 let displayScale = 1;
302 let crop = { x: 0, y: 0, w: 0, h: 0 };
303 let dragMode = null;
304 let dragStart = { x: 0, y: 0 };
305 let cropStart = { x: 0, y: 0, w: 0, h: 0 };
306 let inCropMode = false;
307 let currentFileSize = 0;
308
309 // Elements
310 const tokenRow = document.getElementById('tokenRow');
311 const tokenInput = document.getElementById('token');
312 const dropZone = document.getElementById('drop');
313 const fileInput = document.getElementById('file');
314 const errorDiv = document.getElementById('error');
315 const editor = document.getElementById('editor');
316 const previewArea = document.getElementById('previewArea');
317 const previewCanvas = document.getElementById('previewCanvas');
318 const cropArea = document.getElementById('cropArea');
319 const cropCanvas = document.getElementById('cropCanvas');
320 const overlayCanvas = document.getElementById('overlayCanvas');
321 const statusArea = document.getElementById('statusArea');
322 const statusText = document.getElementById('statusText');
323 const bgColor = document.getElementById('bgColor');
324 const showTransparent = document.getElementById('showTransparent');
325 const aspectRatio = document.getElementById('aspectRatio');
326 const fileInfo = document.getElementById('fileInfo');
327
328 // Token handling
329 if (localStorage.getItem('hc_token')) {
330 tokenRow.classList.add('hidden');
331 }
332
333 function saveToken() {
334 const token = tokenInput.value.trim();
335
336 // Validate token format: sk-hc-v1-<64 hex chars>
337 const tokenRegex = /^sk-hc-v1-[a-f0-9]{64}$/i;
338 if (!tokenRegex.test(token)) {
339 showError('Invalid token format. Expected: sk-hc-v1-<64 hex characters>');
340 return;
341 }
342
343 localStorage.setItem('hc_token', token);
344 tokenRow.classList.add('hidden');
345 hideError();
346 }
347
348 function showError(msg) {
349 errorDiv.textContent = msg;
350 errorDiv.classList.remove('hidden');
351 }
352
353 function hideError() {
354 errorDiv.classList.add('hidden');
355 }
356
357 function showStatus(msg) {
358 statusText.textContent = msg;
359 statusArea.classList.remove('hidden');
360 }
361
362 function hideStatus() {
363 statusArea.classList.add('hidden');
364 }
365
366 // File loading
367 dropZone.onclick = () => fileInput.click();
368 dropZone.ondragover = e => { e.preventDefault(); dropZone.classList.add('over'); };
369 dropZone.ondragleave = () => dropZone.classList.remove('over');
370 dropZone.ondrop = e => { e.preventDefault(); dropZone.classList.remove('over'); loadFile(e.dataTransfer.files[0]); };
371 fileInput.onchange = () => loadFile(fileInput.files[0]);
372
373 document.onpaste = e => {
374 const items = e.clipboardData?.items;
375 if (!items) return;
376 for (const item of items) {
377 if (item.type.startsWith('image/')) {
378 loadFile(item.getAsFile());
379 break;
380 }
381 }
382 };
383
384 function loadFile(f) {
385 if (!f) return;
386 hideError();
387 currentFileSize = f.size;
388 const reader = new FileReader();
389 reader.onload = e => {
390 const img = new Image();
391 img.onload = () => {
392 originalImage = img;
393 operations = []; // Reset operations
394 dropZone.classList.add('hidden');
395 editor.classList.remove('hidden');
396 renderLayers();
397 };
398 img.src = e.target.result;
399 };
400 reader.readAsDataURL(f);
401 }
402
403 function loadImageFromDataUrl(dataUrl) {
404 return new Promise((resolve, reject) => {
405 const img = new Image();
406 img.onload = () => resolve(img);
407 img.onerror = reject;
408 img.src = dataUrl;
409 });
410 }
411
412 // Render pipeline: apply all operations to get final image
413 async function renderLayers() {
414 if (!originalImage) return;
415
416 let sourceImage = originalImage;
417 let width = originalImage.width;
418 let height = originalImage.height;
419
420 // Apply operations in order
421 for (const op of operations) {
422 if (op.type === 'removeBg') {
423 sourceImage = op.image;
424 width = sourceImage.width;
425 height = sourceImage.height;
426 } else if (op.type === 'icLight') {
427 sourceImage = op.image;
428 width = sourceImage.width;
429 height = sourceImage.height;
430 } else if (op.type === 'crop') {
431 // Crop will be applied during render
432 width = op.w;
433 height = op.h;
434 }
435 }
436
437 // Find active operations
438 const bgRemoveOp = operations.find(op => op.type === 'removeBg');
439 const icLightOp = operations.find(op => op.type === 'icLight');
440 const cropOp = operations.find(op => op.type === 'crop');
441 const bgColorOp = operations.find(op => op.type === 'bgColor');
442
443 // Set canvas dimensions
444 const ctx = previewCanvas.getContext('2d');
445 previewCanvas.width = width;
446 previewCanvas.height = height;
447
448 // Calculate display size
449 const maxDisplayWidth = Math.min(700, window.innerWidth - 80);
450 const maxDisplayHeight = 500;
451 const imgAspect = width / height;
452 const maxAspect = maxDisplayWidth / maxDisplayHeight;
453
454 let displayWidth, displayHeight;
455 if (imgAspect > maxAspect) {
456 displayWidth = Math.min(width, maxDisplayWidth);
457 displayHeight = displayWidth / imgAspect;
458 } else {
459 displayHeight = Math.min(height, maxDisplayHeight);
460 displayWidth = displayHeight * imgAspect;
461 }
462
463 previewCanvas.style.width = displayWidth + 'px';
464 previewCanvas.style.height = displayHeight + 'px';
465
466 ctx.clearRect(0, 0, width, height);
467
468 // Apply background color if not showing transparent
469 if (!showTransparent.checked && (bgColorOp || bgRemoveOp)) {
470 ctx.fillStyle = bgColorOp ? bgColorOp.color : bgColor.value;
471 ctx.fillRect(0, 0, width, height);
472 }
473
474 // Draw the image (with crop if applicable)
475 // Priority: icLight > bgRemove > original
476 const imgToDraw = icLightOp ? icLightOp.image : (bgRemoveOp ? bgRemoveOp.image : originalImage);
477
478 if (cropOp) {
479 // Draw cropped portion
480 ctx.drawImage(imgToDraw, cropOp.x, cropOp.y, cropOp.w, cropOp.h, 0, 0, width, height);
481 } else {
482 // Draw full image
483 ctx.drawImage(imgToDraw, 0, 0);
484 }
485
486 // Update undo button state
487 document.getElementById('undoBtn').disabled = operations.length === 0;
488
489 await updateFileInfo();
490 }
491
492 function updatePreview() {
493 renderLayers();
494 }
495
496 function formatFileSize(bytes) {
497 if (bytes < 1024) return bytes + ' B';
498 if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
499 return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
500 }
501
502 function getCanvasSize() {
503 return new Promise(resolve => {
504 previewCanvas.toBlob(blob => resolve(blob.size), 'image/png');
505 });
506 }
507
508 async function updateFileInfo() {
509 if (!originalImage) {
510 fileInfo.classList.add('hidden');
511 return;
512 }
513
514 const currentSize = await getCanvasSize();
515 const dims = `${previewCanvas.width}×${previewCanvas.height}px`;
516
517 let info = `<strong>Dimensions:</strong> ${dims} | <strong>Current size:</strong> ${formatFileSize(currentSize)}`;
518
519 if (currentFileSize && currentFileSize !== currentSize) {
520 const diff = currentSize - currentFileSize;
521 const percent = ((diff / currentFileSize) * 100).toFixed(1);
522 const color = diff > 0 ? '#c00' : '#0a0';
523 info += ` | <strong>Original:</strong> ${formatFileSize(currentFileSize)} `;
524 info += `<span style="color: ${color}">(${diff > 0 ? '+' : ''}${percent}%)</span>`;
525 }
526
527 fileInfo.innerHTML = info;
528 fileInfo.classList.remove('hidden');
529 }
530
531 function getCanvasDataUrl() {
532 return previewCanvas.toDataURL('image/png');
533 }
534
535 // Crop functionality
536 function startCrop() {
537 if (!originalImage) return;
538 inCropMode = true;
539
540 // Get current rendered image
541 const currentImg = getCurrentImage();
542
543 const maxW = Math.min(600, window.innerWidth - 40);
544 displayScale = Math.min(1, maxW / currentImg.width);
545 const w = currentImg.width * displayScale;
546 const h = currentImg.height * displayScale;
547
548 cropCanvas.width = overlayCanvas.width = w;
549 cropCanvas.height = overlayCanvas.height = h;
550 cropCanvas.getContext('2d').drawImage(currentImg, 0, 0, w, h);
551
552 // Find existing crop if any
553 const existingCrop = operations.find(op => op.type === 'crop');
554 if (existingCrop) {
555 crop = {
556 x: existingCrop.x * displayScale,
557 y: existingCrop.y * displayScale,
558 w: existingCrop.w * displayScale,
559 h: existingCrop.h * displayScale
560 };
561 } else {
562 crop = { x: 0, y: 0, w: 0, h: 0 };
563 }
564
565 drawOverlay();
566
567 previewArea.classList.add('hidden');
568 cropArea.classList.remove('hidden');
569 }
570
571 function getCurrentImage() {
572 // Return the current state of the image
573 // Priority: icLight > bgRemove > original
574 const icLightOp = operations.find(op => op.type === 'icLight');
575 const bgRemoveOp = operations.find(op => op.type === 'removeBg');
576 return icLightOp ? icLightOp.image : (bgRemoveOp ? bgRemoveOp.image : originalImage);
577 }
578
579 function cancelCrop() {
580 inCropMode = false;
581 cropArea.classList.add('hidden');
582 previewArea.classList.remove('hidden');
583 }
584
585 function applyCrop() {
586 const hasCrop = crop.w > 10 && crop.h > 10;
587 if (!hasCrop) {
588 cancelCrop();
589 return;
590 }
591
592 const sx = crop.x / displayScale;
593 const sy = crop.y / displayScale;
594 const sw = crop.w / displayScale;
595 const sh = crop.h / displayScale;
596
597 // Remove existing crop operation if any
598 operations = operations.filter(op => op.type !== 'crop');
599
600 // Add new crop operation
601 operations.push({
602 type: 'crop',
603 x: sx,
604 y: sy,
605 w: sw,
606 h: sh
607 });
608
609 inCropMode = false;
610 cropArea.classList.add('hidden');
611 previewArea.classList.remove('hidden');
612 renderLayers();
613 }
614
615 // Crop overlay logic
616 function getPos(e) {
617 const rect = overlayCanvas.getBoundingClientRect();
618 const pt = e.touches ? e.touches[0] : e;
619 return {
620 x: Math.max(0, Math.min(pt.clientX - rect.left, overlayCanvas.width)),
621 y: Math.max(0, Math.min(pt.clientY - rect.top, overlayCanvas.height))
622 };
623 }
624
625 function getHandle(pos) {
626 if (crop.w < 10 || crop.h < 10) return null;
627 const { x, y, w, h } = crop;
628 const mx = x + w / 2, my = y + h / 2;
629 const handles = [
630 { name: 'nw', hx: x, hy: y }, { name: 'n', hx: mx, hy: y },
631 { name: 'ne', hx: x + w, hy: y }, { name: 'e', hx: x + w, hy: my },
632 { name: 'se', hx: x + w, hy: y + h }, { name: 's', hx: mx, hy: y + h },
633 { name: 'sw', hx: x, hy: y + h }, { name: 'w', hx: x, hy: my },
634 ];
635 for (const hdl of handles) {
636 if (Math.abs(pos.x - hdl.hx) < HANDLE_SIZE && Math.abs(pos.y - hdl.hy) < HANDLE_SIZE) {
637 return hdl.name;
638 }
639 }
640 if (pos.x > x && pos.x < x + w && pos.y > y && pos.y < y + h) return 'move';
641 return null;
642 }
643
644 function getCursor(handle) {
645 const cursors = {
646 nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize',
647 n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize', move: 'move'
648 };
649 return cursors[handle] || 'crosshair';
650 }
651
652 overlayCanvas.onmousemove = e => {
653 if (dragMode) return;
654 overlayCanvas.style.cursor = getCursor(getHandle(getPos(e)));
655 };
656
657 overlayCanvas.onmousedown = overlayCanvas.ontouchstart = e => {
658 e.preventDefault();
659 const pos = getPos(e);
660 dragStart = pos;
661 cropStart = { ...crop };
662 const handle = getHandle(pos);
663 dragMode = handle || 'create';
664 if (!handle) crop = { x: pos.x, y: pos.y, w: 0, h: 0 };
665 };
666
667 function getAspectValue() {
668 const val = aspectRatio.value;
669 if (val === 'free') return null;
670 if (val === 'square') return 1;
671 if (val === '4:3') return 4/3;
672 if (val === '16:9') return 16/9;
673 return null;
674 }
675
676 document.onmousemove = document.ontouchmove = e => {
677 if (!dragMode || !inCropMode) return;
678 e.preventDefault();
679 const pos = getPos(e);
680 const dx = pos.x - dragStart.x;
681 const dy = pos.y - dragStart.y;
682 const cw = overlayCanvas.width;
683 const ch = overlayCanvas.height;
684 const aspect = getAspectValue();
685
686 if (dragMode === 'move') {
687 let nx = Math.max(0, Math.min(cropStart.x + dx, cw - cropStart.w));
688 let ny = Math.max(0, Math.min(cropStart.y + dy, ch - cropStart.h));
689 crop = { x: nx, y: ny, w: cropStart.w, h: cropStart.h };
690 } else if (dragMode === 'create') {
691 let x = Math.min(dragStart.x, pos.x);
692 let y = Math.min(dragStart.y, pos.y);
693 let w = Math.abs(dx);
694 let h = Math.abs(dy);
695 if (aspect) {
696 if (w / h > aspect) w = h * aspect;
697 else h = w / aspect;
698 if (pos.x < dragStart.x) x = dragStart.x - w;
699 if (pos.y < dragStart.y) y = dragStart.y - h;
700 }
701 crop = clampCrop({ x, y, w, h }, cw, ch);
702 } else {
703 let { x, y, w, h } = cropStart;
704 if (dragMode.includes('e')) w = cropStart.w + dx;
705 if (dragMode.includes('w')) { x = cropStart.x + dx; w = cropStart.w - dx; }
706 if (dragMode.includes('s')) h = cropStart.h + dy;
707 if (dragMode.includes('n')) { y = cropStart.y + dy; h = cropStart.h - dy; }
708 if (aspect) {
709 if (Math.abs(w) / Math.abs(h) > aspect) {
710 w = Math.sign(w) * Math.abs(h) * aspect;
711 if (dragMode.includes('w')) x = cropStart.x + cropStart.w - Math.abs(w);
712 } else {
713 h = Math.sign(h) * Math.abs(w) / aspect;
714 if (dragMode.includes('n')) y = cropStart.y + cropStart.h - Math.abs(h);
715 }
716 }
717 if (w < 0) { x += w; w = -w; }
718 if (h < 0) { y += h; h = -h; }
719 crop = clampCrop({ x, y, w, h }, cw, ch);
720 }
721 drawOverlay();
722 };
723
724 document.onmouseup = document.ontouchend = () => { dragMode = null; };
725
726 function clampCrop(c, cw, ch) {
727 let { x, y, w, h } = c;
728 x = Math.max(0, x);
729 y = Math.max(0, y);
730 if (x + w > cw) w = cw - x;
731 if (y + h > ch) h = ch - y;
732 return { x, y, w, h };
733 }
734
735 function drawOverlay() {
736 const ctx = overlayCanvas.getContext('2d');
737 const cw = overlayCanvas.width;
738 const ch = overlayCanvas.height;
739 ctx.clearRect(0, 0, cw, ch);
740 if (crop.w > 5 && crop.h > 5) {
741 ctx.fillStyle = 'rgba(0,0,0,0.5)';
742 ctx.fillRect(0, 0, cw, crop.y);
743 ctx.fillRect(0, crop.y + crop.h, cw, ch - crop.y - crop.h);
744 ctx.fillRect(0, crop.y, crop.x, crop.h);
745 ctx.fillRect(crop.x + crop.w, crop.y, cw - crop.x - crop.w, crop.h);
746 ctx.strokeStyle = '#fff';
747 ctx.lineWidth = 2;
748 ctx.strokeRect(crop.x, crop.y, crop.w, crop.h);
749 // Handles
750 const { x, y, w, h } = crop;
751 const mx = x + w / 2, my = y + h / 2;
752 const handles = [[x,y],[mx,y],[x+w,y],[x,my],[x+w,my],[x,y+h],[mx,y+h],[x+w,y+h]];
753 ctx.fillStyle = '#fff';
754 ctx.strokeStyle = '#333';
755 ctx.lineWidth = 1;
756 for (const [hx, hy] of handles) {
757 ctx.fillRect(hx - HANDLE_SIZE/2, hy - HANDLE_SIZE/2, HANDLE_SIZE, HANDLE_SIZE);
758 ctx.strokeRect(hx - HANDLE_SIZE/2, hy - HANDLE_SIZE/2, HANDLE_SIZE, HANDLE_SIZE);
759 }
760 }
761 }
762
763 // Remove background
764 async function removeBg() {
765 const tk = localStorage.getItem('hc_token') || tokenInput.value.trim();
766 if (!tk) { showError('Enter token first'); return; }
767
768 // Validate token format
769 const tokenRegex = /^sk-hc-v1-[a-f0-9]{64}$/i;
770 if (!tokenRegex.test(tk)) {
771 showError('Invalid token format. Expected: sk-hc-v1-<64 hex characters>');
772 return;
773 }
774
775 if (!originalImage) return;
776
777 hideError();
778 showStatus('Removing background...');
779 document.getElementById('removeBgBtn').disabled = true;
780
781 // Always work with the original image for background removal
782 const tempCanvas = document.createElement('canvas');
783 tempCanvas.width = originalImage.width;
784 tempCanvas.height = originalImage.height;
785 tempCanvas.getContext('2d').drawImage(originalImage, 0, 0);
786 const dataUrl = tempCanvas.toDataURL('image/png');
787
788 // Get selected model
789 const selectedModel = document.getElementById('bgRemoverModel').value;
790
791 try {
792 const res = await fetch('/api/remove-bg', {
793 method: 'POST',
794 headers: { 'Content-Type': 'application/json' },
795 body: JSON.stringify({ image: dataUrl, token: tk, model: selectedModel })
796 });
797 const data = await res.json();
798 if (!res.ok) throw new Error(typeof data.error === 'string' ? data.error : JSON.stringify(data.error));
799
800 const img = new Image();
801 img.crossOrigin = 'anonymous';
802 img.onload = () => {
803 // Remove existing bg removal operation if any
804 operations = operations.filter(op => op.type !== 'removeBg');
805
806 // Add new bg removal operation
807 operations.push({
808 type: 'removeBg',
809 image: img
810 });
811
812 hideStatus();
813 document.getElementById('removeBgBtn').disabled = false;
814 renderLayers();
815 };
816 img.onerror = () => {
817 hideStatus();
818 document.getElementById('removeBgBtn').disabled = false;
819 showError('Failed to load result image');
820 };
821 img.src = data.output;
822 } catch (e) {
823 hideStatus();
824 document.getElementById('removeBgBtn').disabled = false;
825 showError(e.message);
826 }
827 }
828
829 // Apply background color (non-destructive)
830 function applyBgColor() {
831 if (!originalImage) return;
832
833 // Remove existing bg color operation if any
834 operations = operations.filter(op => op.type !== 'bgColor');
835
836 // Add new bg color operation
837 operations.push({
838 type: 'bgColor',
839 color: bgColor.value
840 });
841
842 renderLayers();
843 }
844
845 // Download
846 function downloadImage() {
847 const a = document.createElement('a');
848 a.href = getCanvasDataUrl();
849 a.download = 'edited-image.png';
850 a.click();
851 }
852
853 // Copy
854 async function copyImage() {
855 try {
856 const blob = await new Promise(r => previewCanvas.toBlob(r, 'image/png'));
857 await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
858 } catch (e) {
859 showError('Copy failed: ' + e.message);
860 }
861 }
862
863 // Undo last operation
864 function undoOperation() {
865 if (operations.length === 0) return;
866 operations.pop();
867 renderLayers();
868 }
869
870 // Reset
871 function resetAll() {
872 originalImage = null;
873 operations = [];
874 currentFileSize = 0;
875 inCropMode = false;
876 editor.classList.add('hidden');
877 dropZone.classList.remove('hidden');
878 cropArea.classList.add('hidden');
879 previewArea.classList.remove('hidden');
880 hideError();
881 hideStatus();
882 fileInfo.classList.add('hidden');
883 fileInput.value = '';
884 }
885
886 // Optimize
887 document.getElementById('optimizeQuality').oninput = function() {
888 document.getElementById('qualityValue').textContent = this.value + '%';
889 };
890
891 function optimizeImage() {
892 if (!originalImage) return;
893
894 const quality = document.getElementById('optimizeQuality').value / 100;
895 const maxDim = parseInt(document.getElementById('maxDimension').value) || 2048;
896
897 // Check if image has transparency (bg removed but not ic-light applied)
898 const hasTransparency = operations.some(op => op.type === 'removeBg') && !operations.some(op => op.type === 'icLight');
899
900 // Get current canvas dimensions
901 let newW = previewCanvas.width;
902 let newH = previewCanvas.height;
903
904 // Only resize if image exceeds maxDim
905 if (newW > maxDim || newH > maxDim) {
906 if (newW > newH) {
907 newH = Math.round(newH * (maxDim / newW));
908 newW = maxDim;
909 } else {
910 newW = Math.round(newW * (maxDim / newH));
911 newH = maxDim;
912 }
913 }
914
915 // Create optimized canvas
916 const tempCanvas = document.createElement('canvas');
917 tempCanvas.width = newW;
918 tempCanvas.height = newH;
919 const ctx = tempCanvas.getContext('2d');
920
921 // If has transparency, use PNG; otherwise use JPEG
922 if (hasTransparency) {
923 // Scale down the preview canvas
924 ctx.drawImage(previewCanvas, 0, 0, newW, newH);
925 const optimized = tempCanvas.toDataURL('image/png');
926
927 // Load back as original image (destructive - replaces original)
928 const img = new Image();
929 img.onload = () => {
930 originalImage = img;
931 operations = []; // Clear all operations since we're baking them in
932 renderLayers();
933 };
934 img.src = optimized;
935 } else {
936 // No transparency, safe to use JPEG
937 ctx.drawImage(previewCanvas, 0, 0, newW, newH);
938 const optimized = tempCanvas.toDataURL('image/jpeg', quality);
939
940 // Load back as original image
941 const img = new Image();
942 img.onload = () => {
943 originalImage = img;
944 operations = []; // Clear all operations
945 renderLayers();
946 };
947 img.src = optimized;
948 }
949 }
950
951 // IC-Light Background functionality
952 function openBackgroundUpload() {
953 const bgFileInput = document.getElementById('backgroundFile');
954 bgFileInput.onchange = () => {
955 const file = bgFileInput.files[0];
956 if (file) {
957 applyIcLightBackground(file);
958 }
959 };
960 bgFileInput.click();
961 }
962
963 // Helper function to round dimension to nearest allowed value for IC-Light
964 function roundToAllowedDimension(dim) {
965 const allowed = [256, 320, 384, 448, 512, 576, 640, 704, 768, 832, 896, 960, 1024];
966 return allowed.reduce((prev, curr) =>
967 Math.abs(curr - dim) < Math.abs(prev - dim) ? curr : prev
968 );
969 }
970
971 async function applyIcLightBackground(backgroundFile) {
972 const tk = localStorage.getItem('hc_token') || tokenInput.value.trim();
973 if (!tk) { showError('Enter token first'); return; }
974
975 // Validate token format
976 const tokenRegex = /^sk-hc-v1-[a-f0-9]{64}$/i;
977 if (!tokenRegex.test(tk)) {
978 showError('Invalid token format. Expected: sk-hc-v1-<64 hex characters>');
979 return;
980 }
981
982 if (!originalImage) return;
983
984 hideError();
985 showStatus('Applying IC-Light background...');
986 document.getElementById('icLightBtn').disabled = true;
987
988 // Get current image (after background removal if applied)
989 const currentImg = getCurrentImage();
990
991 // Convert to RGB by compositing on white background (remove alpha channel)
992 const tempCanvas = document.createElement('canvas');
993 tempCanvas.width = currentImg.width;
994 tempCanvas.height = currentImg.height;
995 const ctx = tempCanvas.getContext('2d');
996
997 // Fill with white background first to eliminate alpha channel
998 ctx.fillStyle = '#FFFFFF';
999 ctx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
1000 ctx.drawImage(currentImg, 0, 0);
1001
1002 // Use JPEG to ensure no alpha channel
1003 const subjectDataUrl = tempCanvas.toDataURL('image/jpeg', 0.95);
1004
1005 // Convert background file to RGB (no alpha)
1006 const backgroundDataUrl = await new Promise((resolve, reject) => {
1007 const reader = new FileReader();
1008 reader.onload = (e) => {
1009 const img = new Image();
1010 img.onload = () => {
1011 const bgCanvas = document.createElement('canvas');
1012 bgCanvas.width = img.width;
1013 bgCanvas.height = img.height;
1014 const bgCtx = bgCanvas.getContext('2d');
1015
1016 // Fill with white background to eliminate alpha channel
1017 bgCtx.fillStyle = '#FFFFFF';
1018 bgCtx.fillRect(0, 0, bgCanvas.width, bgCanvas.height);
1019 bgCtx.drawImage(img, 0, 0);
1020
1021 // Use JPEG to ensure no alpha channel
1022 resolve(bgCanvas.toDataURL('image/jpeg', 0.95));
1023 };
1024 img.onerror = reject;
1025 img.src = e.target.result;
1026 };
1027 reader.onerror = reject;
1028 reader.readAsDataURL(backgroundFile);
1029 });
1030
1031 try {
1032 // Round dimensions to nearest allowed values
1033 const icWidth = roundToAllowedDimension(Math.min(currentImg.width, 1024));
1034 const icHeight = roundToAllowedDimension(Math.min(currentImg.height, 1024));
1035
1036 const res = await fetch('/api/ic-light-background', {
1037 method: 'POST',
1038 headers: { 'Content-Type': 'application/json' },
1039 body: JSON.stringify({
1040 subject_image: subjectDataUrl,
1041 background_image: backgroundDataUrl,
1042 token: tk,
1043 prompt: "high quality relit image with background",
1044 width: icWidth,
1045 height: icHeight
1046 })
1047 });
1048 const data = await res.json();
1049
1050 if (!res.ok) {
1051 throw new Error(data.error || 'IC-Light processing failed');
1052 }
1053
1054 const img = new Image();
1055 img.crossOrigin = 'anonymous';
1056 img.onload = () => {
1057 // Remove existing ic-light operation if any
1058 operations = operations.filter(op => op.type !== 'icLight');
1059
1060 // Add new ic-light operation
1061 operations.push({
1062 type: 'icLight',
1063 image: img
1064 });
1065
1066 hideStatus();
1067 document.getElementById('icLightBtn').disabled = false;
1068 renderLayers();
1069 };
1070 img.onerror = () => {
1071 hideStatus();
1072 document.getElementById('icLightBtn').disabled = false;
1073 showError('Failed to load result image');
1074 };
1075 img.src = data.output;
1076 } catch (e) {
1077 hideStatus();
1078 document.getElementById('icLightBtn').disabled = false;
1079 showError('IC-Light failed: ' + e.message);
1080 }
1081 }
1082 </script>
1083</body>
1084</html>