lightweight image tools

feat: add token validation, file size tracking, and polling support

- Validate Hack Club AI token format (sk-hc-v1-<64 hex chars>)
- Show file size before/after with percentage change
- Display dimensions and current size in info bar
- Add polling infrastructure for async background removal
- Falls back to synchronous mode if proxy doesn't support async
- Show elapsed time during processing
- Move status messages below image preview

💘 Generated with Crush

Assisted-by: Claude Sonnet 4.5 via Crush <crush@charm.land>

dunkirk.sh 593cdbe6 95e1c87d

verified
+275 -37
+184 -24
public/index.html
··· 112 112 padding: 1rem; 113 113 color: #666; 114 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; } 115 125 .spinner { 116 126 display: inline-block; 117 127 width: 20px; ··· 240 250 </div> 241 251 </div> 242 252 243 - <div id="statusArea" class="status hidden"> 244 - <span class="spinner"></span><span id="statusText">Processing...</span> 245 - </div> 253 + <div id="fileInfo" class="file-info hidden"></div> 246 254 247 255 <!-- Normal preview --> 248 256 <div id="previewArea" class="preview-area"> ··· 271 279 <button onclick="cancelCrop()">Cancel</button> 272 280 </div> 273 281 </div> 282 + </div> 283 + 284 + <div id="statusArea" class="status hidden"> 285 + <span class="spinner"></span><span id="statusText">Processing...</span> 274 286 </div> 275 287 </div> 276 288 ··· 286 298 let dragStart = { x: 0, y: 0 }; 287 299 let cropStart = { x: 0, y: 0, w: 0, h: 0 }; 288 300 let inCropMode = false; 301 + let currentFileSize = 0; 289 302 290 303 // Elements 291 304 const tokenRow = document.getElementById('tokenRow'); ··· 304 317 const bgColor = document.getElementById('bgColor'); 305 318 const showTransparent = document.getElementById('showTransparent'); 306 319 const aspectRatio = document.getElementById('aspectRatio'); 320 + const fileInfo = document.getElementById('fileInfo'); 307 321 308 322 // Token handling 309 323 if (localStorage.getItem('hc_token')) { ··· 311 325 } 312 326 313 327 function saveToken() { 314 - localStorage.setItem('hc_token', tokenInput.value); 328 + const token = tokenInput.value.trim(); 329 + 330 + // Validate token format: sk-hc-v1-<64 hex chars> 331 + const tokenRegex = /^sk-hc-v1-[a-f0-9]{64}$/i; 332 + if (!tokenRegex.test(token)) { 333 + showError('Invalid token format. Expected: sk-hc-v1-<64 hex characters>'); 334 + return; 335 + } 336 + 337 + localStorage.setItem('hc_token', token); 315 338 tokenRow.classList.add('hidden'); 339 + hideError(); 316 340 } 317 341 318 342 function showError(msg) { ··· 354 378 function loadFile(f) { 355 379 if (!f) return; 356 380 hideError(); 381 + currentFileSize = f.size; 357 382 const reader = new FileReader(); 358 383 reader.onload = e => { 359 384 const img = new Image(); ··· 362 387 dropZone.classList.add('hidden'); 363 388 editor.classList.remove('hidden'); 364 389 updatePreview(); 390 + updateFileInfo(); 365 391 }; 366 392 img.src = e.target.result; 367 393 }; ··· 409 435 } 410 436 411 437 ctx.drawImage(currentImage, 0, 0); 438 + updateFileInfo(); 439 + } 440 + 441 + function formatFileSize(bytes) { 442 + if (bytes < 1024) return bytes + ' B'; 443 + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; 444 + return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; 445 + } 446 + 447 + function getCanvasSize() { 448 + return new Promise(resolve => { 449 + previewCanvas.toBlob(blob => resolve(blob.size), 'image/png'); 450 + }); 451 + } 452 + 453 + async function updateFileInfo() { 454 + if (!currentImage) { 455 + fileInfo.classList.add('hidden'); 456 + return; 457 + } 458 + 459 + const currentSize = await getCanvasSize(); 460 + const dims = `${currentImage.width}×${currentImage.height}px`; 461 + 462 + let info = `<strong>Dimensions:</strong> ${dims} | <strong>Current size:</strong> ${formatFileSize(currentSize)}`; 463 + 464 + if (currentFileSize && currentFileSize !== currentSize) { 465 + const diff = currentSize - currentFileSize; 466 + const percent = ((diff / currentFileSize) * 100).toFixed(1); 467 + const color = diff > 0 ? '#c00' : '#0a0'; 468 + info += ` | <strong>Original:</strong> ${formatFileSize(currentFileSize)} `; 469 + info += `<span style="color: ${color}">(${diff > 0 ? '+' : ''}${percent}%)</span>`; 470 + } 471 + 472 + fileInfo.innerHTML = info; 473 + fileInfo.classList.remove('hidden'); 412 474 } 413 475 414 476 function getCanvasDataUrl() { ··· 460 522 tempCanvas.getContext('2d').drawImage(currentImage, sx, sy, sw, sh, 0, 0, sw, sh); 461 523 462 524 const img = new Image(); 463 - img.onload = () => { 525 + img.onload = async () => { 464 526 currentImage = img; 527 + // Update file size after crop 528 + currentFileSize = await new Promise(resolve => { 529 + tempCanvas.toBlob(blob => resolve(blob.size), 'image/png'); 530 + }); 465 531 inCropMode = false; 466 532 cropArea.classList.add('hidden'); 467 533 previewArea.classList.remove('hidden'); ··· 618 684 } 619 685 } 620 686 621 - // Remove background 687 + // Remove background with polling 622 688 async function removeBg() { 623 - const tk = localStorage.getItem('hc_token') || tokenInput.value; 689 + const tk = localStorage.getItem('hc_token') || tokenInput.value.trim(); 624 690 if (!tk) { showError('Enter token first'); return; } 691 + 692 + // Validate token format 693 + const tokenRegex = /^sk-hc-v1-[a-f0-9]{64}$/i; 694 + if (!tokenRegex.test(tk)) { 695 + showError('Invalid token format. Expected: sk-hc-v1-<64 hex characters>'); 696 + return; 697 + } 698 + 625 699 if (!currentImage) return; 626 700 627 701 hideError(); 628 - showStatus('Removing background...'); 702 + showStatus('Starting background removal...'); 629 703 document.getElementById('removeBgBtn').disabled = true; 630 704 631 705 // Get current image as data URL ··· 639 713 const selectedModel = document.getElementById('bgRemoverModel').value; 640 714 641 715 try { 716 + // Start the prediction 642 717 const res = await fetch('/api/remove-bg', { 643 718 method: 'POST', 644 719 headers: { 'Content-Type': 'application/json' }, ··· 647 722 const data = await res.json(); 648 723 if (!res.ok) throw new Error(typeof data.error === 'string' ? data.error : JSON.stringify(data.error)); 649 724 650 - const img = new Image(); 651 - img.crossOrigin = 'anonymous'; 652 - img.onload = () => { 653 - currentImage = img; 654 - hideStatus(); 655 - document.getElementById('removeBgBtn').disabled = false; 656 - updatePreview(); 657 - }; 658 - img.onerror = () => { 659 - hideStatus(); 660 - document.getElementById('removeBgBtn').disabled = false; 661 - showError('Failed to load result image'); 662 - }; 663 - img.src = data.output; 725 + // Check if we got direct output (no polling needed) or prediction ID 726 + if (data.predictionId) { 727 + // Poll for status 728 + await pollStatus(data.predictionId, tk); 729 + } else if (data.status === 'succeeded' && data.output) { 730 + // Direct output, load immediately 731 + const img = new Image(); 732 + img.crossOrigin = 'anonymous'; 733 + img.onload = async () => { 734 + currentImage = img; 735 + currentFileSize = await new Promise(resolve => { 736 + const tempCanvas = document.createElement('canvas'); 737 + tempCanvas.width = img.width; 738 + tempCanvas.height = img.height; 739 + tempCanvas.getContext('2d').drawImage(img, 0, 0); 740 + tempCanvas.toBlob(blob => resolve(blob.size), 'image/png'); 741 + }); 742 + hideStatus(); 743 + document.getElementById('removeBgBtn').disabled = false; 744 + updatePreview(); 745 + }; 746 + img.onerror = () => { 747 + hideStatus(); 748 + document.getElementById('removeBgBtn').disabled = false; 749 + showError('Failed to load result image'); 750 + }; 751 + img.src = data.output; 752 + } 664 753 } catch (e) { 665 754 hideStatus(); 666 755 document.getElementById('removeBgBtn').disabled = false; ··· 668 757 } 669 758 } 670 759 760 + async function pollStatus(predictionId, token) { 761 + const startTime = Date.now(); 762 + 763 + const poll = async () => { 764 + try { 765 + const res = await fetch(`/api/check-status?id=${predictionId}&token=${encodeURIComponent(token)}`); 766 + const data = await res.json(); 767 + 768 + if (!res.ok) throw new Error(typeof data.error === 'string' ? data.error : JSON.stringify(data.error)); 769 + 770 + const elapsed = Math.floor((Date.now() - startTime) / 1000); 771 + 772 + if (data.status === 'succeeded') { 773 + showStatus(`Processing complete (${elapsed}s)`); 774 + 775 + const img = new Image(); 776 + img.crossOrigin = 'anonymous'; 777 + img.onload = async () => { 778 + currentImage = img; 779 + // Reset currentFileSize to the new processed image size 780 + currentFileSize = await new Promise(resolve => { 781 + const tempCanvas = document.createElement('canvas'); 782 + tempCanvas.width = img.width; 783 + tempCanvas.height = img.height; 784 + tempCanvas.getContext('2d').drawImage(img, 0, 0); 785 + tempCanvas.toBlob(blob => resolve(blob.size), 'image/png'); 786 + }); 787 + hideStatus(); 788 + document.getElementById('removeBgBtn').disabled = false; 789 + updatePreview(); 790 + }; 791 + img.onerror = () => { 792 + hideStatus(); 793 + document.getElementById('removeBgBtn').disabled = false; 794 + showError('Failed to load result image'); 795 + }; 796 + img.src = data.output; 797 + } else if (data.status === 'failed') { 798 + hideStatus(); 799 + document.getElementById('removeBgBtn').disabled = false; 800 + showError(data.error || 'Background removal failed'); 801 + } else if (data.status === 'processing' || data.status === 'starting') { 802 + showStatus(`Processing... ${elapsed}s elapsed`); 803 + // Poll again in 2 seconds 804 + setTimeout(poll, 2000); 805 + } else { 806 + // Unknown status, keep polling 807 + showStatus(`Status: ${data.status} (${elapsed}s)`); 808 + setTimeout(poll, 2000); 809 + } 810 + } catch (e) { 811 + hideStatus(); 812 + document.getElementById('removeBgBtn').disabled = false; 813 + showError(e.message); 814 + } 815 + }; 816 + 817 + // Start polling 818 + poll(); 819 + } 820 + 671 821 // Apply background color (bakes it into the image) 672 822 function applyBgColor() { 673 823 if (!currentImage) return; ··· 682 832 ctx.drawImage(currentImage, 0, 0); 683 833 684 834 const img = new Image(); 685 - img.onload = () => { 835 + img.onload = async () => { 686 836 currentImage = img; 837 + // Update file size after applying bg color 838 + currentFileSize = await new Promise(resolve => { 839 + tempCanvas.toBlob(blob => resolve(blob.size), 'image/png'); 840 + }); 687 841 updatePreview(); 688 842 }; 689 843 img.src = tempCanvas.toDataURL('image/png'); ··· 710 864 // Reset 711 865 function resetAll() { 712 866 currentImage = null; 867 + currentFileSize = 0; 713 868 inCropMode = false; 714 869 editor.classList.add('hidden'); 715 870 dropZone.classList.remove('hidden'); ··· 717 872 previewArea.classList.remove('hidden'); 718 873 hideError(); 719 874 hideStatus(); 875 + fileInfo.classList.add('hidden'); 720 876 fileInput.value = ''; 721 877 } 722 878 ··· 759 915 760 916 // Load back as current image 761 917 const img = new Image(); 762 - img.onload = () => { 918 + img.onload = async () => { 763 919 currentImage = img; 920 + // Update file size after optimization 921 + currentFileSize = await new Promise(resolve => { 922 + tempCanvas.toBlob(blob => resolve(blob.size), 'image/jpeg', quality); 923 + }); 764 924 updatePreview(); 765 925 }; 766 926 img.src = optimized;
+91 -13
src/index.js
··· 16 16 return handleRemoveBg(request, env); 17 17 } 18 18 19 + if (url.pathname === "/api/check-status" && request.method === "GET") { 20 + return handleCheckStatus(request, env); 21 + } 22 + 19 23 // For non-API routes, return 404 to let assets handle it 20 24 return new Response("Not Found", { status: 404 }); 21 25 }, ··· 40 44 41 45 const input = { image }; 42 46 43 - // Select model based on parameter 47 + // Select model based on parameter - use full model ID format 44 48 const modelId = model === '851labs' 45 49 ? "851-labs/background-remover:a029dff38972b5fda4ec5d75d7d1cd25aeff621d2cf4946a41055d7db66b80bc" 46 50 : "lucataco/remove-bg:95fcc2a26d3899cd6c2691c900465aaeff466285a65c14638cc5f36f34befaf1"; 47 51 48 - const output = await replicate.run(modelId, { input }); 52 + console.log("Running model:", modelId); 49 53 50 - // Handle different output formats 51 - let outputUrl; 52 - if (typeof output === "string") { 53 - outputUrl = output; 54 - } else if (output && typeof output.url === "function") { 55 - outputUrl = output.url(); 56 - } else if (output && output.url) { 57 - outputUrl = output.url; 54 + // Use replicate.run with stream option to get prediction ID 55 + const prediction = await replicate.run(modelId, { 56 + input, 57 + wait: false // Don't wait for completion 58 + }); 59 + 60 + console.log("Prediction response:", prediction); 61 + 62 + // Check if we got a prediction object or direct output 63 + if (prediction && prediction.id) { 64 + // Got a prediction object, can poll it 65 + return jsonResponse({ 66 + predictionId: prediction.id, 67 + status: prediction.status 68 + }); 58 69 } else { 59 - outputUrl = output; 70 + // Got direct output (proxy may have waited), return it 71 + let outputUrl; 72 + if (typeof prediction === "string") { 73 + outputUrl = prediction; 74 + } else if (Array.isArray(prediction) && prediction.length > 0) { 75 + outputUrl = prediction[0]; 76 + } else if (prediction && prediction.url) { 77 + outputUrl = prediction.url; 78 + } else { 79 + outputUrl = prediction; 80 + } 81 + 82 + return jsonResponse({ 83 + status: 'succeeded', 84 + output: outputUrl 85 + }); 60 86 } 61 - 62 - return jsonResponse({ output: outputUrl }); 63 87 } catch (e) { 64 88 console.error("Error:", e); 89 + return jsonResponse({ error: e.message }, 500); 90 + } 91 + } 92 + 93 + async function handleCheckStatus(request, env) { 94 + try { 95 + const url = new URL(request.url); 96 + const predictionId = url.searchParams.get('id'); 97 + const token = url.searchParams.get('token'); 98 + 99 + if (!predictionId) { 100 + return jsonResponse({ error: "Missing prediction ID" }, 400); 101 + } 102 + 103 + if (!token) { 104 + return jsonResponse({ error: "Missing token" }, 400); 105 + } 106 + 107 + const replicate = new Replicate({ 108 + auth: token, 109 + baseUrl: "https://ai.hackclub.com/proxy/v1/replicate", 110 + }); 111 + 112 + const prediction = await replicate.predictions.get(predictionId); 113 + 114 + // Return status and output if available 115 + const response = { 116 + status: prediction.status, 117 + }; 118 + 119 + if (prediction.status === 'succeeded' && prediction.output) { 120 + // Handle different output formats 121 + let outputUrl; 122 + if (typeof prediction.output === "string") { 123 + outputUrl = prediction.output; 124 + } else if (prediction.output && typeof prediction.output.url === "function") { 125 + outputUrl = prediction.output.url(); 126 + } else if (prediction.output && prediction.output.url) { 127 + outputUrl = prediction.output.url; 128 + } else if (Array.isArray(prediction.output) && prediction.output.length > 0) { 129 + outputUrl = prediction.output[0]; 130 + } else { 131 + outputUrl = prediction.output; 132 + } 133 + response.output = outputUrl; 134 + } 135 + 136 + if (prediction.status === 'failed') { 137 + response.error = prediction.error || 'Prediction failed'; 138 + } 139 + 140 + return jsonResponse(response); 141 + } catch (e) { 142 + console.error("Error checking status:", e); 65 143 return jsonResponse({ error: e.message }, 500); 66 144 } 67 145 }