155 lines
10 KiB
HTML
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>
|