Files
farolero/tmp_avatar_cropper.html
2026-05-09 23:58:58 +02:00

155 lines
10 KiB
HTML

<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Farolero Avatar Cropper</title>
<style>
:root { color-scheme: dark; --bg:#0b121c; --panel:#111b28; --gold:#ffc247; --line:#00e5ff; }
body { margin:0; font-family: system-ui, Segoe UI, sans-serif; background:var(--bg); color:#ffecbe; }
header { padding:16px 20px; border-bottom:1px solid #263241; background:#08101a; }
h1 { margin:0; font-size:18px; color:var(--gold); }
main { display:grid; grid-template-columns:340px 1fr; min-height:calc(100vh - 58px); }
aside { padding:16px; background:var(--panel); border-right:1px solid #263241; overflow:auto; }
.stageWrap { padding:16px; overflow:auto; }
label { display:block; font-size:12px; color:#cbd5e1; margin:12px 0 6px; }
input, button { width:100%; box-sizing:border-box; border-radius:10px; border:1px solid #344256; background:#0b121c; color:#ffecbe; padding:10px; }
button { cursor:pointer; background:linear-gradient(#ffb232,#e88400); color:#1b1200; font-weight:700; border:0; margin-top:8px; }
button.secondary { background:#162130; color:#ffecbe; border:1px solid #344256; }
button.danger { background:#4a1212; color:#ffd6d6; border:1px solid #923333; }
.row { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.hint { font-size:12px; color:#93a4b8; line-height:1.35; margin-top:12px; }
canvas { background:#111; box-shadow:0 0 0 1px #344256,0 12px 40px #0008; cursor:crosshair; }
.coords { font-family:ui-monospace,Consolas,monospace; font-size:12px; white-space:pre-wrap; background:#08101a; border:1px solid #263241; border-radius:10px; padding:10px; margin-top:10px; color:#b8f7ff; max-height:220px; overflow:auto; }
.ok { color:#8cff9b; }
</style>
</head>
<body>
<header><h1>Farolero Avatar Cropper — cortes manuales</h1></header>
<main>
<aside>
<label>Imagen sprite sheet</label>
<input id="file" type="file" accept="image/*" />
<div class="row">
<div><label>Prefijo</label><input id="prefix" value="avatar_" /></div>
<div><label>Inicio</label><input id="start" type="number" value="31" /></div>
</div>
<label>Tamaño exportado</label>
<input id="outSize" type="number" value="256" />
<div class="row"><button id="grid4">Reset 4x4</button><button id="addV" class="secondary">+ vertical</button></div>
<div class="row"><button id="addH" class="secondary">+ horizontal</button><button id="clear" class="danger">Limpiar líneas</button></div>
<label><input id="removeChroma" type="checkbox" checked style="width:auto" /> Quitar chroma verde/magenta al exportar</label>
<button id="export">Exportar ZIP con recortes PNG</button>
<button id="copyCoords" class="secondary">Copiar coordenadas</button>
<p class="hint">Arrastrá las líneas cyan. Doble click añade línea vertical y horizontal. Exporta un ZIP único con todos los PNGs para evitar el bloqueo de múltiples descargas.</p>
<div id="status" class="hint ok"></div>
<div id="coords" class="coords"></div>
</aside>
<section class="stageWrap"><canvas id="canvas"></canvas></section>
</main>
<script>
const canvas=document.getElementById('canvas');
const ctx=canvas.getContext('2d');
const file=document.getElementById('file');
const coordsEl=document.getElementById('coords');
const statusEl=document.getElementById('status');
let bitmap=null, vLines=[], hLines=[], dragging=null;
const hit=8;
function pad(n){ return String(n).padStart(2,'0'); }
function sortLines(){ vLines=[...new Set(vLines.map(Math.round))].sort((a,b)=>a-b); hLines=[...new Set(hLines.map(Math.round))].sort((a,b)=>a-b); }
function reset4x4(){ if(!bitmap) return; vLines=[0,bitmap.width/4,bitmap.width/2,bitmap.width*3/4,bitmap.width]; hLines=[0,bitmap.height/4,bitmap.height/2,bitmap.height*3/4,bitmap.height]; sortLines(); draw(); }
function draw(){
if(!bitmap) return;
canvas.width=bitmap.width; canvas.height=bitmap.height;
ctx.clearRect(0,0,canvas.width,canvas.height);
ctx.drawImage(bitmap,0,0);
ctx.save(); ctx.lineWidth=3; ctx.strokeStyle='rgba(0,229,255,.95)'; ctx.fillStyle='rgba(0,229,255,.95)'; ctx.font='14px ui-monospace, Consolas';
for(const x of vLines){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,canvas.height); ctx.stroke(); ctx.fillText(Math.round(x),x+4,14); }
for(const y of hLines){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(canvas.width,y); ctx.stroke(); ctx.fillText(Math.round(y),4,Math.max(14,y-4)); }
ctx.restore(); updateCoords();
}
function updateCoords(){
sortLines(); const rects=[];
for(let r=0;r<hLines.length-1;r++) for(let c=0;c<vLines.length-1;c++) rects.push([vLines[c],hLines[r],vLines[c+1],hLines[r+1]]);
coordsEl.textContent=JSON.stringify({vLines,hLines,rects},null,2);
statusEl.textContent=bitmap?`${rects.length} recortes listos`:'';
}
file.onchange=e=>{
const f=e.target.files[0]; if(!f) return;
const url=URL.createObjectURL(f);
const img=new Image();
img.onload=()=>{ bitmap=img; reset4x4(); URL.revokeObjectURL(url); };
img.onerror=()=>{ statusEl.textContent='No pude cargar la imagen.'; URL.revokeObjectURL(url); };
img.src=url;
};
function mousePos(e){ const r=canvas.getBoundingClientRect(); return {x:(e.clientX-r.left)*(canvas.width/r.width), y:(e.clientY-r.top)*(canvas.height/r.height)}; }
canvas.onmousedown=e=>{
if(!bitmap) return; const p=mousePos(e); let best=null;
for(let i=0;i<vLines.length;i++){ const d=Math.abs(vLines[i]-p.x); if(d<hit&&(!best||d<best.d)) best={type:'v',i,d}; }
for(let i=0;i<hLines.length;i++){ const d=Math.abs(hLines[i]-p.y); if(d<hit&&(!best||d<best.d)) best={type:'h',i,d}; }
dragging=best;
};
canvas.onmousemove=e=>{ if(!dragging||!bitmap) return; const p=mousePos(e); if(dragging.type==='v') vLines[dragging.i]=Math.max(0,Math.min(bitmap.width,p.x)); else hLines[dragging.i]=Math.max(0,Math.min(bitmap.height,p.y)); draw(); };
window.onmouseup=()=>{ if(dragging){ sortLines(); dragging=null; draw(); } };
canvas.ondblclick=e=>{ if(!bitmap) return; const p=mousePos(e); vLines.push(p.x); hLines.push(p.y); sortLines(); draw(); };
document.getElementById('grid4').onclick=reset4x4;
document.getElementById('addV').onclick=()=>{ if(bitmap){ vLines.push(bitmap.width/2); sortLines(); draw(); } };
document.getElementById('addH').onclick=()=>{ if(bitmap){ hLines.push(bitmap.height/2); sortLines(); draw(); } };
document.getElementById('clear').onclick=()=>{ if(bitmap){ vLines=[0,bitmap.width]; hLines=[0,bitmap.height]; draw(); } };
document.getElementById('copyCoords').onclick=async()=>{ await navigator.clipboard.writeText(coordsEl.textContent); statusEl.textContent='Coordenadas copiadas'; };
function chromaToAlpha(imageData){
const d=imageData.data; let green=0,mag=0;
for(let i=0;i<d.length;i+=4){ if(d[i+1]>180&&d[i]<140&&d[i+2]<140) green++; if(d[i]>180&&d[i+2]>180&&d[i+1]<160) mag++; }
for(let i=0;i<d.length;i+=4){ const r=d[i],g=d[i+1],b=d[i+2]; if(green>=mag){ if(g>150&&r<150&&b<150) d[i+3]=0; } else { if(r>150&&b>150&&g<160) d[i+3]=0; } }
return imageData;
}
function crc32(bytes){
let table=crc32.table; if(!table){ table=crc32.table=[]; for(let n=0;n<256;n++){ let c=n; for(let k=0;k<8;k++) c=(c&1)?(0xedb88320^(c>>>1)):(c>>>1); table[n]=c>>>0; } }
let c=0xffffffff; for(const b of bytes) c=table[(c^b)&0xff]^(c>>>8); return (c^0xffffffff)>>>0;
}
function u16(n){ return [n&255,(n>>>8)&255]; }
function u32(n){ return [n&255,(n>>>8)&255,(n>>>16)&255,(n>>>24)&255]; }
function bytes(s){ return new TextEncoder().encode(s); }
async function canvasBlob(c){ return await new Promise(resolve=>c.toBlob(resolve,'image/png')); }
async function makeZip(entries){
const chunks=[], central=[]; let offset=0;
for(const e of entries){
const nameBytes=bytes(e.name); const data=new Uint8Array(await e.blob.arrayBuffer()); const crc=crc32(data);
const local=new Uint8Array([...u32(0x04034b50),...u16(20),...u16(0),...u16(0),...u16(0),...u16(0),...u32(crc),...u32(data.length),...u32(data.length),...u16(nameBytes.length),...u16(0)]);
chunks.push(local,nameBytes,data);
central.push({fileName:e.name,dataLen:data.length,crc,offset});
offset+=local.length+nameBytes.length+data.length;
}
const centralStart=offset;
for(const e of central){
const nameBytes=bytes(e.fileName);
const cdir=new Uint8Array([...u32(0x02014b50),...u16(20),...u16(20),...u16(0),...u16(0),...u16(0),...u16(0),...u32(e.crc),...u32(e.dataLen),...u32(e.dataLen),...u16(nameBytes.length),...u16(0),...u16(0),...u16(0),...u16(0),...u32(0),...u32(e.offset)]);
chunks.push(cdir,nameBytes); offset+=cdir.length+nameBytes.length;
}
const centralSize=offset-centralStart;
chunks.push(new Uint8Array([...u32(0x06054b50),...u16(0),...u16(0),...u16(central.length),...u16(central.length),...u32(centralSize),...u32(centralStart),...u16(0)]));
return new Blob(chunks,{type:'application/zip'});
}
document.getElementById('export').onclick=async()=>{
if(!bitmap) return; sortLines();
const outSize=Number(document.getElementById('outSize').value)||256;
const prefix=document.getElementById('prefix').value||'avatar_';
let n=Number(document.getElementById('start').value)||1;
const remove=document.getElementById('removeChroma').checked;
const entries=[];
for(let r=0;r<hLines.length-1;r++) for(let c=0;c<vLines.length-1;c++){
const sx=vLines[c],sy=hLines[r],sw=vLines[c+1]-sx,sh=hLines[r+1]-sy; if(sw<8||sh<8) continue;
const off=document.createElement('canvas'); off.width=outSize; off.height=outSize;
const ox=off.getContext('2d'); ox.clearRect(0,0,outSize,outSize); ox.drawImage(bitmap,sx,sy,sw,sh,0,0,outSize,outSize);
if(remove){ const data=ox.getImageData(0,0,outSize,outSize); ox.putImageData(chromaToAlpha(data),0,0); }
entries.push({name:`${prefix}${pad(n)}.png`, blob:await canvasBlob(off)}); n++;
}
const zip=await makeZip(entries);
const a=document.createElement('a'); a.download=`${prefix}${pad(Number(document.getElementById('start').value)||1)}_recortes.zip`; a.href=URL.createObjectURL(zip); a.click();
setTimeout(()=>URL.revokeObjectURL(a.href),30000);
statusEl.textContent=`ZIP exportado con ${entries.length} PNGs.`;
};
</script>
</body>
</html>