sprite / static /app.js
Cnass's picture
Upload 3 files
de08a38 verified
Raw History Blame Contribute Delete
50.3 kB
/* ==========================================================================
Sprite Forge — application logic
Vanilla JS, no build step (this is a Flask-served static app). Talks to
the /api/* endpoints in app.py. State lives in one `state` object;
every mutation goes through a small set of render*() functions so the
DOM never drifts out of sync with what the server actually has.
========================================================================== */
const API = {
async uploadImages(files, padding) {
const fd = new FormData();
for (const f of files) fd.append('files', f);
fd.append('padding', padding);
return fetchJSON('/api/upload/images', { method: 'POST', body: fd });
},
async uploadZip(file, padding) {
const fd = new FormData();
fd.append('file', file);
fd.append('padding', padding);
return fetchJSON('/api/upload/zip', { method: 'POST', body: fd });
},
async listSheets() {
return fetchJSON('/api/sheets');
},
async listSprites(params = {}) {
const qs = new URLSearchParams(params).toString();
return fetchJSON(`/api/sprites${qs ? '?' + qs : ''}`);
},
async getSprite(id) {
return fetchJSON(`/api/sprite/${id}`);
},
async acceptSprite(id) {
return fetchJSON(`/api/sprite/${id}/accept`, { method: 'POST' });
},
async rejectSprite(id) {
return fetchJSON(`/api/sprite/${id}/reject`, { method: 'POST' });
},
async updateMetadata(id, payload) {
return fetchJSON(`/api/sprite/${id}/metadata`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
},
async editBbox(id, bbox) {
return fetchJSON(`/api/sprite/${id}/edit-bbox`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bbox),
});
},
async mergeSprites(ids) {
return fetchJSON('/api/sprites/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sprite_ids: ids }),
});
},
async splitSprite(id, boxes) {
return fetchJSON('/api/sprites/split', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sprite_id: id, boxes }),
});
},
async batchAcceptAbove(threshold) {
return fetchJSON('/api/batch/accept-above', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ threshold }),
});
},
async batchRejectDuplicates() {
return fetchJSON('/api/batch/reject-duplicates', { method: 'POST' });
},
async batchDedupeKeepOne() {
return fetchJSON('/api/batch/dedupe-keep-one', { method: 'POST' });
},
async batchAssign(payload) {
return fetchJSON('/api/batch/assign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
},
async animationFrames(sheetId) {
return fetchJSON(`/api/animation/frames?sheet_id=${encodeURIComponent(sheetId)}`);
},
async datasetBalance() {
return fetchJSON('/api/dataset/balance');
},
async datasetCalibration() {
return fetchJSON('/api/dataset/calibration');
},
async datasetSplitPreview() {
return fetchJSON('/api/dataset/split-preview');
},
async batchLowConfidence() {
return fetchJSON('/api/batch/low-confidence');
},
async datasetSummary() {
return fetchJSON('/api/dataset/summary');
},
async datasetUpload() {
return fetchJSON('/api/dataset/upload', { method: 'POST' });
},
async getCategories() {
return fetchJSON('/api/categories');
},
async addCategory(name, kind) {
return fetchJSON('/api/categories', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, kind }),
});
},
};
async function fetchJSON(url, opts) {
const res = await fetch(url, opts);
let body;
try {
body = await res.json();
} catch (e) {
body = { error: `Server returned ${res.status}` };
}
if (!res.ok) {
const err = new Error(body.error || `Request failed (${res.status})`);
err.status = res.status;
throw err;
}
return body;
}
/* ==========================================================================
State
========================================================================== */
const state = {
sheets: [],
activeSheetId: null,
spritesBySheet: {}, // sheet_id -> [sprite, ...] (all statuses, for the canvas)
selectedSpriteId: null,
padding: 0,
zoom: 1,
categories: [],
styles: [],
mergeSelection: [], // sprite ids picked for a merge, in canvas click order
mode: 'browse', // 'browse' | 'merge-pick' | 'split-pick'
};
function currentSprites() {
return state.spritesBySheet[state.activeSheetId] || [];
}
function findSprite(id) {
return currentSprites().find(s => s.id === id) || null;
}
/* ==========================================================================
Toasts
========================================================================== */
function toast(message, isError = false) {
const stack = document.getElementById('toast-stack');
const el = document.createElement('div');
el.className = 'toast' + (isError ? ' error' : '');
el.textContent = message;
stack.appendChild(el);
setTimeout(() => el.remove(), 4200);
}
function reportError(err) {
console.error(err);
toast(err.message || 'Something went wrong', true);
}
/* ==========================================================================
Upload flow
========================================================================== */
function setupUpload() {
const imagesInput = document.getElementById('file-input-images');
const zipInput = document.getElementById('file-input-zip');
document.getElementById('btn-upload-images').addEventListener('click', () => imagesInput.click());
document.getElementById('btn-upload-zip').addEventListener('click', () => zipInput.click());
imagesInput.addEventListener('change', async (e) => {
if (e.target.files.length) await handleImageUpload(Array.from(e.target.files));
e.target.value = '';
});
zipInput.addEventListener('change', async (e) => {
if (e.target.files.length) await handleZipUpload(e.target.files[0]);
e.target.value = '';
});
const dropzone = document.getElementById('dropzone');
dropzone.addEventListener('click', () => imagesInput.click());
['dragenter', 'dragover'].forEach(evt =>
dropzone.addEventListener(evt, (e) => { e.preventDefault(); dropzone.classList.add('drag-over'); })
);
['dragleave', 'drop'].forEach(evt =>
dropzone.addEventListener(evt, (e) => { e.preventDefault(); dropzone.classList.remove('drag-over'); })
);
dropzone.addEventListener('drop', async (e) => {
const files = Array.from(e.dataTransfer.files || []);
if (!files.length) return;
const zip = files.find(f => f.name.toLowerCase().endsWith('.zip'));
if (zip && files.length === 1) {
await handleZipUpload(zip);
} else {
await handleImageUpload(files.filter(f => !f.name.toLowerCase().endsWith('.zip')));
}
});
document.querySelectorAll('.padding-chip').forEach(chip => {
chip.addEventListener('click', () => {
document.querySelectorAll('.padding-chip').forEach(c => c.classList.remove('active'));
chip.classList.add('active');
state.padding = parseInt(chip.dataset.padding, 10);
});
});
}
async function handleImageUpload(files) {
const dropzone = document.getElementById('dropzone');
const original = dropzone.innerHTML;
dropzone.innerHTML = `<div class="dropzone-icon">···</div>Processing ${files.length} file(s)…`;
try {
const result = await API.uploadImages(files, state.padding);
reportUploadResult(result);
await refreshSheets();
} catch (err) {
reportError(err);
} finally {
dropzone.innerHTML = original;
}
}
async function handleZipUpload(file) {
const dropzone = document.getElementById('dropzone');
const original = dropzone.innerHTML;
dropzone.innerHTML = `<div class="dropzone-icon">···</div>Extracting ${file.name}…`;
try {
const result = await API.uploadZip(file, state.padding);
reportUploadResult(result);
if (result.skipped && result.skipped.length) {
toast(`Skipped ${result.skipped.length} file(s) in the archive (unsupported or unsafe)`, false);
}
await refreshSheets();
} catch (err) {
reportError(err);
} finally {
dropzone.innerHTML = original;
}
}
function reportUploadResult(result) {
const errored = (result.sheets || []).filter(s => s.error);
if (errored.length) {
toast(`${errored.length} file(s) could not be processed`, true);
}
toast(`Detected ${result.total_sprites} sprite(s) across ${result.total_sheets} sheet(s)`);
}
/* ==========================================================================
Sheets list + canvas rendering
========================================================================== */
async function refreshSheets() {
const { sheets } = await API.listSheets();
state.sheets = sheets;
renderSheetList();
if (!state.activeSheetId && sheets.length) {
await selectSheet(sheets[0].id);
} else if (state.activeSheetId) {
// Keep the active sheet's sprite counts fresh (e.g. after an upload
// added sprites to it, or a merge/split changed its count).
await selectSheet(state.activeSheetId, /*keepSelection=*/true);
}
await refreshSummary();
}
function renderSheetList() {
const container = document.getElementById('sheet-list');
if (!state.sheets.length) {
container.innerHTML = '<p class="empty-hint">Nothing uploaded yet. A sheet can be a single sprite or a full sheet with hundreds — either works.</p>';
return;
}
container.innerHTML = '';
for (const sheet of state.sheets) {
const el = document.createElement('div');
el.className = 'sheet-item' + (sheet.id === state.activeSheetId ? ' active' : '');
el.innerHTML = `<span class="sheet-item-name">${escapeHtml(sheet.filename)}</span><span class="sheet-item-count">${sheet.sprite_count}</span>`;
el.addEventListener('click', () => selectSheet(sheet.id));
container.appendChild(el);
}
document.getElementById('topbar-summary').innerHTML =
`<b>${state.sheets.length}</b> sheet${state.sheets.length === 1 ? '' : 's'} · <b>${state.sheets.reduce((a, s) => a + s.sprite_count, 0)}</b> sprites detected`;
}
async function selectSheet(sheetId, keepSelection = false) {
state.activeSheetId = sheetId;
const { sprites } = await API.listSprites({ sheet_id: sheetId });
state.spritesBySheet[sheetId] = sprites;
if (!keepSelection) {
state.selectedSpriteId = null;
exitPickMode();
}
renderSheetList();
renderCanvas();
renderInspector();
updateBulkScopeHint();
}
function renderCanvas() {
const scroll = document.getElementById('canvas-scroll');
const sheet = state.sheets.find(s => s.id === state.activeSheetId);
const zoomControls = document.getElementById('zoom-controls');
if (!sheet) {
document.getElementById('canvas-title').textContent = 'No sheet selected';
zoomControls.style.display = 'none';
scroll.innerHTML = `
<div class="canvas-empty" id="canvas-empty">
<div class="canvas-empty-mark">░▒▓</div>
Upload a sprite sheet to begin.<br>
Detected sprites will appear here as numbered boxes —
<span style="color:var(--accent)">green</span> for high confidence,
<span style="color:var(--warn)">amber</span> for ones worth a second look.
</div>`;
return;
}
document.getElementById('canvas-title').textContent =
`${sheet.filename} — ${sheet.width}×${sheet.height}px — ${sheet.sprite_count} sprite${sheet.sprite_count === 1 ? '' : 's'} detected`;
zoomControls.style.display = 'flex';
const sprites = currentSprites();
const displayScale = computeDisplayScale(sheet.width, sheet.height) * state.zoom;
const stage = document.createElement('div');
stage.className = 'sheet-stage';
stage.style.width = `${sheet.width * displayScale}px`;
stage.style.height = `${sheet.height * displayScale}px`;
const img = document.createElement('img');
img.src = `/api/sheet/${sheet.id}/image`;
img.width = sheet.width * displayScale;
img.height = sheet.height * displayScale;
img.alt = sheet.filename;
img.draggable = false;
stage.appendChild(img);
for (const sprite of sprites) {
stage.appendChild(buildBboxEl(sprite, displayScale));
}
scroll.innerHTML = '';
scroll.appendChild(stage);
}
function computeDisplayScale(w, h) {
// Pick a scale that keeps small sheets legible and large sheets
// reasonably sized, always a whole-pixel-friendly value where possible.
// The target viewport width adapts to the actual canvas area instead of
// a desktop-sized constant, so a phone-width screen doesn't force an
// oversized sheet into permanent horizontal scroll on first load.
const scrollEl = document.getElementById('canvas-scroll');
const available = scrollEl ? scrollEl.clientWidth - 32 : 900; // minus padding
const target = Math.max(240, Math.min(900, available));
let scale = target / Math.max(w, h);
if (scale > 8) scale = 8;
if (scale < 0.15) scale = 0.15;
return scale;
}
function confidenceTier(confidence) {
if (confidence >= 85) return 'high';
if (confidence >= 70) return 'mid';
return 'low';
}
function confidenceColorVar(confidence) {
const tier = confidenceTier(confidence);
if (tier === 'high') return 'var(--accent)';
if (tier === 'mid') return 'var(--warn)';
return 'var(--danger)';
}
function buildBboxEl(sprite, displayScale) {
const el = document.createElement('div');
el.className = 'bbox' + (sprite.id === state.selectedSpriteId ? ' selected' : '') + ` status-${sprite.status}`;
el.style.setProperty('--bbox-color', confidenceColorVar(sprite.confidence));
el.style.left = `${sprite.bbox.x * displayScale}px`;
el.style.top = `${sprite.bbox.y * displayScale}px`;
el.style.width = `${Math.max(sprite.bbox.width * displayScale, 4)}px`;
el.style.height = `${Math.max(sprite.bbox.height * displayScale, 4)}px`;
const tag = document.createElement('span');
tag.className = 'bbox-tag';
tag.textContent = `#${sprite.sprite_index}`;
el.appendChild(tag);
el.title = `Sprite #${sprite.sprite_index} — ${Math.round(sprite.confidence)}% confidence — ${sprite.status}`;
el.addEventListener('click', (e) => {
e.stopPropagation();
handleBboxClick(sprite.id);
});
return el;
}
function handleBboxClick(spriteId) {
if (state.mode === 'merge-pick') {
toggleMergeSelection(spriteId);
return;
}
selectSprite(spriteId);
}
function selectSprite(spriteId) {
state.selectedSpriteId = spriteId;
renderCanvas();
renderInspector();
scrollInspectorIntoViewOnMobile();
}
function scrollInspectorIntoViewOnMobile() {
// On the stacked mobile layout the inspector pane sits below the
// canvas in normal page flow, so selecting a sprite can leave the
// reviewer looking at a canvas with no visible feedback that anything
// happened. On desktop the inspector is always in view in its own
// column, so this is a no-op there.
if (window.innerWidth > 880) return;
const pane = document.getElementById('pane-right');
if (pane) pane.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
/* ==========================================================================
Inspector panel
========================================================================== */
function renderInspector() {
const root = document.getElementById('inspector');
const sprite = state.selectedSpriteId ? findSprite(state.selectedSpriteId) : null;
if (state.mode === 'merge-pick') {
root.innerHTML = buildMergePickPanel();
wireMergePickPanel();
return;
}
if (!sprite) {
root.innerHTML = '<div class="inspector-empty">Select a sprite on the canvas to review it here.</div>';
return;
}
const q = sprite.quality || {};
const tier = confidenceTier(sprite.confidence);
const tierLabel = tier === 'high' ? 'Confident detection' : tier === 'mid' ? 'Worth a second look' : 'Possible segmentation problem';
root.innerHTML = `
<div class="pane-section">
<p class="pane-label">Sprite #${sprite.sprite_index}</p>
<div class="preview-frame">
<img src="${sprite.preview_url}" alt="Sprite #${sprite.sprite_index} preview">
</div>
<div class="sprite-id">${sprite.id}</div>
<div class="dim-row">
<span class="dims">${sprite.bbox.width} × ${sprite.bbox.height}px</span>
<span class="source">${escapeHtml(sprite.source_file)}</span>
</div>
<div class="confidence-banner ${tier}">
<span class="conf-value">${Math.round(sprite.confidence)}%</span>
<span>${tierLabel}</span>
</div>
${sprite.confidence_reasons && sprite.confidence_reasons.length ? `
<ul class="confidence-reasons">
${sprite.confidence_reasons.map(r => `<li>${escapeHtml(r)}</li>`).join('')}
</ul>` : ''}
${sprite.is_duplicate_of ? `
<div class="dup-banner">
<b>Possible duplicate</b> — ${Math.round(sprite.duplicate_similarity)}% similar to another sprite already in the set.
</div>` : ''}
${sprite.auto_tagged ? `
<div class="auto-tag-banner">
<b>Auto-labelled</b> — category, style and tags below are a machine suggestion
(${Math.round(sprite.auto_tag_confidence || 0)}% confidence), not a reviewed decision.
Changing any of them marks this sprite as human-checked.
</div>` : ''}
<div class="score-grid">
${scoreRow('Overall quality', q.overall_quality_score)}
${scoreRow('Pixel art probability', q.pixel_art_probability)}
${scoreRow('Sharpness', q.sharpness_score)}
${scoreRow('Color consistency', q.color_consistency)}
${scoreRow('Compression', q.compression_artifact_score)}
${scoreRow('Transparency quality', q.transparency_quality)}
${scoreRow('Resolution suitability', q.resolution_suitability)}
${scoreRow('Noise', q.noise_score)}
<div class="k">Unique colors</div><div class="v">${q.unique_colors ?? '—'}</div>
<div class="k">Components merged</div><div class="v">${sprite.component_count}</div>
</div>
<div class="bg-toggle">
<button class="padding-chip active" data-bg="kept" id="bg-kept">Keep Background</button>
<button class="padding-chip" data-bg="transparent" id="bg-transparent">Transparent</button>
</div>
<div class="field-group">
<label class="field-label">Category</label>
<select class="field-select" id="field-category">
${state.categories.map(c => `<option value="${c}" ${c === sprite.category ? 'selected' : ''}>${c}</option>`).join('')}
</select>
</div>
<div class="field-group">
<label class="field-label">Style</label>
<select class="field-select" id="field-style">
${state.styles.map(s => `<option value="${s}" ${s === sprite.style ? 'selected' : ''}>${s}</option>`).join('')}
</select>
</div>
<div class="field-group">
<label class="field-label">Tags</label>
<input class="field-input" id="field-tag-input" placeholder="Type a tag and press Enter">
<div class="tag-list" id="tag-list">
${(sprite.tags || []).map(t => `<span class="tag-chip">${escapeHtml(t)}<button data-tag="${escapeHtml(t)}">×</button></span>`).join('')}
</div>
</div>
<div class="field-group">
<label class="field-label">Edit bounding box</label>
<div class="edit-row">
<div><label>X</label><input type="number" id="edit-x" value="${sprite.bbox.x}"></div>
<div><label>Y</label><input type="number" id="edit-y" value="${sprite.bbox.y}"></div>
<div><label>W</label><input type="number" id="edit-w" value="${sprite.bbox.width}"></div>
<div><label>H</label><input type="number" id="edit-h" value="${sprite.bbox.height}"></div>
</div>
<button class="btn btn-sm btn-block" id="btn-apply-edit" style="margin-top:8px;" ${sprite.status !== 'pending' ? 'disabled' : ''}>Apply Edit</button>
</div>
<div class="field-group">
<button class="btn btn-sm btn-block" id="btn-start-merge" ${sprite.status !== 'pending' ? 'disabled' : ''}>Merge with another sprite…</button>
<button class="btn btn-sm btn-block" id="btn-start-split" style="margin-top:6px;" ${sprite.status !== 'pending' ? 'disabled' : ''}>Split this sprite…</button>
</div>
<div class="action-row">
<button class="btn btn-primary" id="btn-accept" ${sprite.status === 'accepted' ? 'disabled' : ''}>Accept</button>
<button class="btn btn-danger" id="btn-reject" ${sprite.status === 'rejected' ? 'disabled' : ''}>Reject</button>
</div>
</div>
`;
wireInspectorEvents(sprite);
}
function scoreRow(label, value) {
const v = value === undefined || value === null ? null : Math.round(value);
return `
<div class="k">${label}</div><div class="v">${v === null ? '—' : v}</div>
<div class="score-bar"><div class="score-bar-fill" style="width:${v ?? 0}%; background:${scoreColor(v)}"></div></div>
`;
}
function scoreColor(v) {
if (v === null) return 'var(--ink-faint)';
if (v >= 80) return 'var(--accent)';
if (v >= 55) return 'var(--warn)';
return 'var(--danger)';
}
function wireInspectorEvents(sprite) {
document.getElementById('btn-accept').addEventListener('click', () => doAccept(sprite.id));
document.getElementById('btn-reject').addEventListener('click', () => doReject(sprite.id));
document.getElementById('bg-kept').addEventListener('click', () => setPreviewVariant(sprite, 'kept'));
document.getElementById('bg-transparent').addEventListener('click', () => setPreviewVariant(sprite, 'transparent'));
document.getElementById('field-category').addEventListener('change', (e) => doUpdateMetadata(sprite.id, { category: e.target.value }));
document.getElementById('field-style').addEventListener('change', (e) => doUpdateMetadata(sprite.id, { style: e.target.value }));
const tagInput = document.getElementById('field-tag-input');
tagInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && tagInput.value.trim()) {
e.preventDefault();
const newTags = [...(sprite.tags || []), tagInput.value.trim()];
doUpdateMetadata(sprite.id, { tags: newTags });
}
});
document.querySelectorAll('#tag-list button').forEach(btn => {
btn.addEventListener('click', () => {
const newTags = (sprite.tags || []).filter(t => t !== btn.dataset.tag);
doUpdateMetadata(sprite.id, { tags: newTags });
});
});
document.getElementById('btn-apply-edit').addEventListener('click', () => {
const bbox = {
x: parseInt(document.getElementById('edit-x').value, 10),
y: parseInt(document.getElementById('edit-y').value, 10),
width: parseInt(document.getElementById('edit-w').value, 10),
height: parseInt(document.getElementById('edit-h').value, 10),
};
if ([bbox.x, bbox.y, bbox.width, bbox.height].some(Number.isNaN) || bbox.width <= 0 || bbox.height <= 0) {
toast('Enter valid numbers — width and height must be positive', true);
return;
}
doEditBbox(sprite.id, bbox);
});
document.getElementById('btn-start-merge').addEventListener('click', () => enterMergeMode(sprite.id));
document.getElementById('btn-start-split').addEventListener('click', () => openSplitModal(sprite));
}
function setPreviewVariant(sprite, variant) {
document.getElementById('bg-kept').classList.toggle('active', variant === 'kept');
document.getElementById('bg-transparent').classList.toggle('active', variant === 'transparent');
const img = document.querySelector('.preview-frame img');
img.src = variant === 'kept' ? sprite.preview_url : sprite.png_transparent_url + '&preview=1';
// Transparent variant has no dedicated NEAREST-preview endpoint of its
// own beyond the raw crop; browsers still render it crisp because the
// frame and <img> both carry image-rendering:pixelated, so this is a
// safe fallback that still looks correct at typical sprite sizes.
}
async function doAccept(id) {
try {
await API.acceptSprite(id);
toast('Sprite accepted');
await afterSpriteMutation();
} catch (err) { reportError(err); }
}
async function doReject(id) {
try {
await API.rejectSprite(id);
toast('Sprite rejected');
await afterSpriteMutation();
} catch (err) { reportError(err); }
}
async function doUpdateMetadata(id, payload) {
try {
const updated = await API.updateMetadata(id, payload);
patchSpriteInState(updated);
if (payload.category || payload.style) await refreshCategories();
renderInspector();
} catch (err) { reportError(err); }
}
async function doEditBbox(id, bbox) {
try {
const updated = await API.editBbox(id, bbox);
patchSpriteInState(updated);
renderCanvas();
renderInspector();
toast('Bounding box updated');
} catch (err) { reportError(err); }
}
function patchSpriteInState(updatedSprite) {
const list = state.spritesBySheet[updatedSprite.sheet_id];
if (!list) return;
const idx = list.findIndex(s => s.id === updatedSprite.id);
if (idx >= 0) list[idx] = updatedSprite;
else list.push(updatedSprite);
}
async function afterSpriteMutation() {
await selectSheet(state.activeSheetId, true);
await refreshSummary();
}
/* ==========================================================================
Merge flow
========================================================================== */
function enterMergeMode(firstSpriteId) {
state.mode = 'merge-pick';
state.mergeSelection = [firstSpriteId];
renderCanvas();
renderInspector();
toast('Click another sprite on the canvas to merge with this one');
}
function toggleMergeSelection(spriteId) {
const i = state.mergeSelection.indexOf(spriteId);
if (i >= 0) {
state.mergeSelection.splice(i, 1);
} else {
state.mergeSelection.push(spriteId);
}
if (state.mergeSelection.length > 2) state.mergeSelection.shift();
renderCanvas();
renderInspector();
}
function buildMergePickPanel() {
const picked = state.mergeSelection.map(id => findSprite(id)).filter(Boolean);
return `
<div class="pane-section">
<p class="pane-label">Merge sprites</p>
<p class="modal-desc">Click a second sprite on the canvas. Merge combines both into one sprite using their combined bounding box — pixels are re-cropped from the original sheet, nothing is resampled.</p>
<div class="tag-list">
${picked.map(s => `<span class="tag-chip">#${s.sprite_index}</span>`).join('') || '<span class="tag-chip">none yet</span>'}
</div>
<div class="action-row">
<button class="btn btn-primary" id="btn-confirm-merge" ${picked.length === 2 ? '' : 'disabled'}>Merge</button>
<button class="btn btn-ghost" id="btn-cancel-merge">Cancel</button>
</div>
</div>
`;
}
function wireMergePickPanel() {
document.getElementById('btn-cancel-merge').addEventListener('click', exitPickMode);
const confirmBtn = document.getElementById('btn-confirm-merge');
if (confirmBtn) {
confirmBtn.addEventListener('click', async () => {
try {
const merged = await API.mergeSprites(state.mergeSelection);
toast(`Merged into sprite #${merged.sprite_index}`);
exitPickMode();
await selectSheet(state.activeSheetId, true);
state.selectedSpriteId = merged.id;
renderCanvas();
renderInspector();
await refreshSummary();
} catch (err) { reportError(err); }
});
}
}
function exitPickMode() {
state.mode = 'browse';
state.mergeSelection = [];
}
/* ==========================================================================
Split flow (modal with two editable boxes seeded from the sprite bbox)
========================================================================== */
function openSplitModal(sprite) {
const root = document.getElementById('modal-root');
const b = sprite.bbox;
// Seed two side-by-side halves as a starting point the user can then
// adjust — most splits are "this merged two side-by-side things", so a
// horizontal half-split is the most common useful default.
const half = Math.max(1, Math.floor(b.width / 2));
root.innerHTML = `
<div class="modal-backdrop" id="split-backdrop">
<div class="modal">
<p class="modal-title">Split sprite #${sprite.sprite_index}</p>
<p class="modal-desc">Define two bounding boxes within the original sheet. Both are re-cropped from the source sheet — pixels are never resampled.</p>
<p class="pane-label" style="margin-top:2px;">Box A</p>
<div class="edit-row">
<div><label>X</label><input type="number" id="split-a-x" value="${b.x}"></div>
<div><label>Y</label><input type="number" id="split-a-y" value="${b.y}"></div>
<div><label>W</label><input type="number" id="split-a-w" value="${half}"></div>
<div><label>H</label><input type="number" id="split-a-h" value="${b.height}"></div>
</div>
<p class="pane-label" style="margin-top:12px;">Box B</p>
<div class="edit-row">
<div><label>X</label><input type="number" id="split-b-x" value="${b.x + half}"></div>
<div><label>Y</label><input type="number" id="split-b-y" value="${b.y}"></div>
<div><label>W</label><input type="number" id="split-b-w" value="${b.width - half}"></div>
<div><label>H</label><input type="number" id="split-b-h" value="${b.height}"></div>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" id="split-cancel">Cancel</button>
<button class="btn btn-primary" id="split-confirm">Split</button>
</div>
</div>
</div>
`;
document.getElementById('split-cancel').addEventListener('click', closeModal);
document.getElementById('split-backdrop').addEventListener('click', (e) => {
if (e.target.id === 'split-backdrop') closeModal();
});
document.getElementById('split-confirm').addEventListener('click', async () => {
const boxA = readBoxInputs('split-a');
const boxB = readBoxInputs('split-b');
if (!boxA || !boxB) {
toast('Enter valid numbers for both boxes — width and height must be positive', true);
return;
}
try {
const result = await API.splitSprite(sprite.id, [boxA, boxB]);
toast(`Split into #${result.sprite_a.sprite_index} and #${result.sprite_b.sprite_index}`);
closeModal();
await selectSheet(state.activeSheetId, true);
state.selectedSpriteId = result.sprite_a.id;
renderCanvas();
renderInspector();
await refreshSummary();
} catch (err) { reportError(err); }
});
}
function readBoxInputs(prefix) {
const x = parseInt(document.getElementById(`${prefix}-x`).value, 10);
const y = parseInt(document.getElementById(`${prefix}-y`).value, 10);
const width = parseInt(document.getElementById(`${prefix}-w`).value, 10);
const height = parseInt(document.getElementById(`${prefix}-h`).value, 10);
if ([x, y, width, height].some(Number.isNaN) || width <= 0 || height <= 0) return null;
return { x, y, width, height };
}
function closeModal() {
document.getElementById('modal-root').innerHTML = '';
}
/* ==========================================================================
Batch controls + dataset summary + HF upload
========================================================================== */
async function refreshSummary() {
try {
const summary = await API.datasetSummary();
document.getElementById('stat-pending').textContent = summary.pending;
document.getElementById('stat-accepted').textContent = summary.accepted;
document.getElementById('stat-rejected').textContent = summary.rejected;
document.getElementById('stat-duplicates').textContent = summary.duplicates;
} catch (err) {
console.error(err);
}
}
async function refreshCategories() {
const { categories, styles } = await API.getCategories();
state.categories = categories;
state.styles = styles;
renderCategoryOptions();
}
function setupBatchControls() {
document.getElementById('btn-accept-above').addEventListener('click', async () => {
try {
const result = await API.batchAcceptAbove(90);
toast(`Accepted ${result.accepted_count} sprite(s) scoring 90+`);
await selectSheet(state.activeSheetId, true);
await refreshSummary();
} catch (err) { reportError(err); }
});
document.getElementById('btn-dedupe-keep-one').addEventListener('click', async () => {
try {
const result = await API.batchDedupeKeepOne();
if (!result.cluster_count) {
toast('No duplicate clusters among pending sprites');
} else {
toast(`${result.cluster_count} cluster(s): kept ${result.kept_count}, rejected ${result.rejected_count} redundant copy(ies)`);
}
await selectSheet(state.activeSheetId, true);
await refreshSummary();
} catch (err) { reportError(err); }
});
document.getElementById('btn-dataset-report').addEventListener('click', openDatasetReport);
document.getElementById('btn-reject-dupes').addEventListener('click', async () => {
try {
const result = await API.batchRejectDuplicates();
toast(`Rejected ${result.rejected_count} duplicate(s)`);
await selectSheet(state.activeSheetId, true);
await refreshSummary();
} catch (err) { reportError(err); }
});
document.getElementById('btn-review-low-conf').addEventListener('click', async () => {
try {
const { sprites } = await API.batchLowConfidence();
if (!sprites.length) {
toast('No low-confidence sprites pending review');
return;
}
// Jump to the first low-confidence sprite's sheet and select it —
// the fastest path into what actually needs a human look.
await selectSheet(sprites[0].sheet_id);
selectSprite(sprites[0].id);
toast(`${sprites.length} sprite(s) need review — showing the first`);
} catch (err) { reportError(err); }
});
document.getElementById('btn-upload-hf').addEventListener('click', async () => {
const btn = document.getElementById('btn-upload-hf');
const original = btn.textContent;
btn.disabled = true;
btn.textContent = 'Uploading…';
try {
const result = await API.datasetUpload();
if (result.success) {
if (result.uploaded_count === 0) {
toast('Nothing to upload — accept some sprites first');
} else {
toast(`Uploaded ${result.uploaded_count} sprite(s) to the dataset in one batch commit`);
}
await refreshSummary();
} else {
toast(result.error || 'Upload failed', true);
}
} catch (err) {
reportError(err);
} finally {
btn.disabled = false;
btn.textContent = original;
}
});
}
/* ==========================================================================
Bulk metadata assignment
The realistic labelling workflow for a sheet of 300 tiles is not "click
300 times", it is "this whole sheet is trees". Tags are ADDITIVE by
default so a bulk pass adds "forest" on top of whatever the auto-tagger
worked out per sprite, instead of flattening every sprite to the same
tag list.
========================================================================== */
function setupBulkAssign() {
const scopeSelect = document.getElementById('bulk-scope');
scopeSelect.addEventListener('change', updateBulkScopeHint);
document.getElementById('btn-bulk-apply').addEventListener('click', async () => {
const scope = scopeSelect.value;
if (scope === 'sheet' && !state.activeSheetId) {
toast('Select a sheet first', true);
return;
}
const category = document.getElementById('bulk-category').value.trim();
const style = document.getElementById('bulk-style').value.trim();
const rawTags = document.getElementById('bulk-tags').value.trim();
const replace = document.getElementById('bulk-replace-tags').checked;
const tags = rawTags ? rawTags.split(',').map(t => t.trim()).filter(Boolean) : [];
if (!category && !style && !tags.length) {
toast('Enter a category, a style or some tags first', true);
return;
}
const payload = { scope, category, style };
if (scope === 'sheet') payload.sheet_id = state.activeSheetId;
if (scope === 'status') { payload.scope = 'status'; payload.status = 'pending'; }
if (replace) payload.replace_tags = tags;
else payload.add_tags = tags;
try {
const result = await API.batchAssign(payload);
toast(`Updated ${result.updated_count} sprite(s)`);
document.getElementById('bulk-tags').value = '';
await refreshCategories();
renderCategoryOptions();
if (state.activeSheetId) await selectSheet(state.activeSheetId, true);
} catch (err) { reportError(err); }
});
updateBulkScopeHint();
}
function updateBulkScopeHint() {
const scope = document.getElementById('bulk-scope').value;
const hint = document.getElementById('bulk-scope-hint');
const sheet = state.sheets.find(s => s.id === state.activeSheetId);
if (scope === 'sheet') {
hint.textContent = sheet
? `${sheet.sprite_count} sprite(s) on ${sheet.filename}`
: 'No sheet selected yet.';
} else if (scope === 'status') {
hint.textContent = 'Every sprite still awaiting review, across all sheets.';
} else {
hint.textContent = 'Every sprite in the database, whatever its status.';
}
}
function renderCategoryOptions() {
const catList = document.getElementById('bulk-category-options');
const styleList = document.getElementById('bulk-style-options');
if (catList) catList.innerHTML = state.categories.map(c => `<option value="${escapeHtml(c)}">`).join('');
if (styleList) styleList.innerHTML = state.styles.map(s => `<option value="${escapeHtml(s)}">`).join('');
}
/* ==========================================================================
Animation preview
Frames are drawn onto ONE shared canvas at their original sheet
positions. Playing the tight crops back-to-back instead would make the
animation wobble: each sprite is cropped to its own content, so a frame
whose flames reach one pixel further left would start one pixel further
left. The server returns per-frame offsets into a common canvas exactly
so this can be avoided.
========================================================================== */
const animation = { timer: null, frames: [], index: 0, fps: 12, playing: false, canvas: null };
async function openAnimationModal() {
if (!state.activeSheetId) {
toast('Select a sheet first', true);
return;
}
let data;
try {
data = await API.animationFrames(state.activeSheetId);
} catch (err) { reportError(err); return; }
if (!data.frames.length) {
toast('No sprites on this sheet to animate', true);
return;
}
animation.frames = data.frames;
animation.canvas = data.canvas;
animation.index = 0;
animation.playing = true;
const sheet = state.sheets.find(s => s.id === state.activeSheetId);
const scale = Math.max(1, Math.min(8, Math.floor(340 / Math.max(1, Math.max(data.canvas.width, data.canvas.height)))));
document.getElementById('modal-root').innerHTML = `
<div class="modal-backdrop" id="anim-backdrop">
<div class="modal modal-wide">
<p class="modal-title">Animation preview — ${escapeHtml(sheet ? sheet.filename : '')}</p>
<p class="modal-desc">
${data.frames.length} frames, played in sheet order. Each frame is drawn at its original
position on the sheet, so this shows the motion as authored rather than a sequence of
re-centred crops.
</p>
<div class="anim-stage" id="anim-stage"
style="width:${data.canvas.width * scale}px; height:${data.canvas.height * scale}px;">
${data.frames.map((f, i) => `
<img class="anim-frame${i === 0 ? ' visible' : ''}" data-i="${i}" src="${f.url}"
style="left:${f.offset_x * scale}px; top:${f.offset_y * scale}px;
width:${f.width * scale}px; height:${f.height * scale}px;">`).join('')}
</div>
<div class="anim-controls">
<button class="btn btn-sm" id="anim-play">Pause</button>
<button class="btn btn-sm btn-ghost" id="anim-prev">◀</button>
<span class="anim-counter" id="anim-counter">1 / ${data.frames.length}</span>
<button class="btn btn-sm btn-ghost" id="anim-next">▶</button>
<label class="anim-fps">
<span id="anim-fps-label">${animation.fps} fps</span>
<input type="range" id="anim-fps" min="1" max="30" value="${animation.fps}">
</label>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" id="anim-close">Close</button>
</div>
</div>
</div>`;
document.getElementById('anim-close').addEventListener('click', closeAnimationModal);
document.getElementById('anim-backdrop').addEventListener('click', (e) => {
if (e.target.id === 'anim-backdrop') closeAnimationModal();
});
document.getElementById('anim-play').addEventListener('click', toggleAnimationPlayback);
document.getElementById('anim-prev').addEventListener('click', () => stepAnimation(-1));
document.getElementById('anim-next').addEventListener('click', () => stepAnimation(1));
document.getElementById('anim-fps').addEventListener('input', (e) => {
animation.fps = parseInt(e.target.value, 10);
document.getElementById('anim-fps-label').textContent = `${animation.fps} fps`;
if (animation.playing) startAnimationTimer();
});
startAnimationTimer();
}
function startAnimationTimer() {
clearInterval(animation.timer);
animation.timer = setInterval(() => {
animation.index = (animation.index + 1) % animation.frames.length;
showAnimationFrame();
}, 1000 / animation.fps);
}
function showAnimationFrame() {
const stage = document.getElementById('anim-stage');
if (!stage) { clearInterval(animation.timer); return; }
stage.querySelectorAll('.anim-frame').forEach(img => {
img.classList.toggle('visible', parseInt(img.dataset.i, 10) === animation.index);
});
const counter = document.getElementById('anim-counter');
if (counter) counter.textContent = `${animation.index + 1} / ${animation.frames.length}`;
}
function stepAnimation(delta) {
pauseAnimation();
const n = animation.frames.length;
animation.index = (animation.index + delta + n) % n;
showAnimationFrame();
}
function pauseAnimation() {
animation.playing = false;
clearInterval(animation.timer);
const btn = document.getElementById('anim-play');
if (btn) btn.textContent = 'Play';
}
function toggleAnimationPlayback() {
if (animation.playing) {
pauseAnimation();
} else {
animation.playing = true;
document.getElementById('anim-play').textContent = 'Pause';
startAnimationTimer();
}
}
function closeAnimationModal() {
clearInterval(animation.timer);
animation.playing = false;
closeModal();
}
/* ==========================================================================
Dataset report — composition, quality calibration, split
========================================================================== */
async function openDatasetReport() {
let balance, calibration, split;
try {
[balance, calibration, split] = await Promise.all([
API.datasetBalance(), API.datasetCalibration(), API.datasetSplitPreview(),
]);
} catch (err) { reportError(err); return; }
const warnings = [...(balance.warnings || []), ...(split.warnings || [])];
document.getElementById('modal-root').innerHTML = `
<div class="modal-backdrop" id="report-backdrop">
<div class="modal modal-wide modal-scroll">
<p class="modal-title">Dataset report</p>
<p class="modal-desc">
What the ${balance.total} accepted sprite(s) look like as training data — not how many
there are, but whether they are worth training on.
</p>
${warnings.length ? `
<div class="report-warnings">
${warnings.map(w => `<div class="report-warning">${escapeHtml(w)}</div>`).join('')}
</div>` : `<div class="report-ok">No composition problems detected.</div>`}
${distributionBlock('Category', balance.by_category, balance.total)}
${distributionBlock('Size', balance.by_size, balance.total)}
${distributionBlock('Style', balance.by_style, balance.total)}
${distributionBlock('Segmentation source', balance.by_source, balance.total)}
<p class="pane-label" style="margin-top:16px;">Train / validation / test</p>
<p class="modal-desc" style="margin-top:2px;">
${split.grouped_by_sheet
? 'Grouped by source sheet, so an animation\'s near-identical frames never straddle train and test — otherwise the evaluation measures memorisation.'
: 'Split per sprite. Frames of one animation can land on both sides, which inflates evaluation scores.'}
</p>
${distributionBlock('', split.counts, split.total)}
<p class="pane-label" style="margin-top:16px;">Quality score calibration</p>
<div class="report-note">${escapeHtml(calibration.note || '')}</div>
<div class="score-grid" style="margin-top:8px;">
<div class="k">Accepted (mean score)</div><div class="v">${calibration.accepted_mean ?? '—'}</div><div></div>
<div class="k">Rejected (mean score)</div><div class="v">${calibration.rejected_mean ?? '—'}</div><div></div>
<div class="k">Current threshold</div><div class="v">${calibration.current_threshold}</div><div></div>
<div class="k">Suggested threshold</div><div class="v">${calibration.suggested_threshold ?? '—'}</div><div></div>
<div class="k">Agreement with you</div><div class="v">${calibration.agreement_at_suggested != null ? calibration.agreement_at_suggested + '%' : '—'}</div><div></div>
</div>
<p class="pane-label" style="margin-top:16px;">Metadata provenance</p>
<div class="score-grid">
<div class="k">Unreviewed machine labels</div><div class="v">${balance.auto_tagged_count} / ${balance.total}</div><div></div>
<div class="k">No category</div><div class="v">${balance.unknown_category_count}</div><div></div>
<div class="k">Still flagged duplicate</div><div class="v">${balance.duplicate_count}</div><div></div>
<div class="k">Source sheets</div><div class="v">${balance.sheet_count}</div><div></div>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" id="report-close">Close</button>
</div>
</div>
</div>`;
document.getElementById('report-close').addEventListener('click', closeModal);
document.getElementById('report-backdrop').addEventListener('click', (e) => {
if (e.target.id === 'report-backdrop') closeModal();
});
}
function distributionBlock(title, counts, total) {
const entries = Object.entries(counts || {});
if (!entries.length) return '';
const max = Math.max(...entries.map(([, n]) => n));
return `
${title ? `<p class="pane-label" style="margin-top:16px;">${title}</p>` : ''}
<div class="dist-list">
${entries.map(([name, n]) => `
<div class="dist-row">
<span class="dist-name">${escapeHtml(name)}</span>
<span class="dist-bar"><span class="dist-bar-fill" style="width:${(n / max) * 100}%"></span></span>
<span class="dist-count">${n}${total ? ` · ${Math.round((n / total) * 100)}%` : ''}</span>
</div>`).join('')}
</div>`;
}
/* ==========================================================================
Zoom controls
========================================================================== */
function setupZoomControls() {
document.getElementById('btn-animate').addEventListener('click', openAnimationModal);
document.getElementById('zoom-in').addEventListener('click', () => {
state.zoom = Math.min(4, state.zoom * 1.25);
updateZoomLabel();
renderCanvas();
});
document.getElementById('zoom-out').addEventListener('click', () => {
state.zoom = Math.max(0.25, state.zoom / 1.25);
updateZoomLabel();
renderCanvas();
});
document.getElementById('zoom-reset').addEventListener('click', () => {
state.zoom = 1;
updateZoomLabel();
renderCanvas();
});
}
function updateZoomLabel() {
document.getElementById('zoom-label').textContent = `${Math.round(state.zoom * 100)}%`;
}
/* ==========================================================================
Utilities + init
========================================================================== */
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
async function init() {
setupUpload();
setupBatchControls();
setupZoomControls();
setupBulkAssign();
setupResizeHandling();
try {
await refreshCategories();
renderCategoryOptions();
await refreshSheets();
} catch (err) {
reportError(err);
}
}
function setupResizeHandling() {
// Re-render the canvas on viewport size changes (phone rotation, browser
// chrome show/hide) so the sheet's initial-fit scale stays correct
// instead of freezing at whatever size it first loaded at. Debounced
// since resize/orientationchange can fire in rapid bursts.
let resizeTimer = null;
const onResize = () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (state.activeSheetId) renderCanvas();
}, 150);
};
window.addEventListener('resize', onResize);
window.addEventListener('orientationchange', onResize);
}
document.addEventListener('DOMContentLoaded', init);