lightweight image tools
at main 1084 lines 41 kB view raw
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>