"use strict"; // ─── State ─────────────────────────────────────────────────────────────────── const state = { pixels: null, // Uint8ClampedArray RGBA at original resolution width: 0, height: 0, scale: 1, bg: "grid", format: DTF.FORMAT_RGBA, // output DTF format redAsAlpha: false, // Alpha format: use red channel instead of alpha filename: "texture", // basename without extension }; // ─── DOM refs ──────────────────────────────────────────────────────────────── const canvas = document.getElementById("canvas"); const ctx = canvas.getContext("2d"); const fileInput = document.getElementById("file-input"); const loadLabel = document.getElementById("load-label"); const scaleInput = document.getElementById("scale-input"); const bgSwatches = document.getElementById("bg-swatches"); const previewArea = document.getElementById("preview-area"); const previewEmpty = document.getElementById("preview-empty"); const previewScroll = document.getElementById("preview-scroll"); const infoSection = document.getElementById("info-section"); const fileNameEl = document.getElementById("file-name"); const infoFilename = document.getElementById("info-filename"); const infoSize = document.getElementById("info-size"); const infoFormat = document.getElementById("info-format"); const btnDtf = document.getElementById("btn-dtf"); const btnPng = document.getElementById("btn-png"); const formatSelect = document.getElementById("format-select"); const infoDtfSize = document.getElementById("info-dtf-size"); const warningsSection = document.getElementById("warnings-section"); const warningList = document.getElementById("warning-list"); const redAsAlphaRow = document.getElementById("red-as-alpha-row"); const redAsAlphaCheck = document.getElementById("red-as-alpha"); // ─── Rendering ─────────────────────────────────────────────────────────────── const CHECKER_CELL = 8; function drawCheckerboard(w, h) { for (let y = 0; y < h; y += CHECKER_CELL) { for (let x = 0; x < w; x += CHECKER_CELL) { ctx.fillStyle = ((x / CHECKER_CELL + y / CHECKER_CELL) % 2 === 0) ? "#c8c8c8" : "#ffffff"; ctx.fillRect(x, y, Math.min(CHECKER_CELL, w - x), Math.min(CHECKER_CELL, h - y)); } } } function render() { if (!state.pixels) return; const { pixels, width, height, scale, bg, format } = state; const cw = width * scale; const ch = height * scale; canvas.width = cw; canvas.height = ch; // 1. Background if (bg === "grid") { drawCheckerboard(cw, ch); } else { ctx.fillStyle = bg; ctx.fillRect(0, 0, cw, ch); } // 2. Composite image on top at scaled size (pixelated) const src = Object.assign(document.createElement("canvas"), { width, height }); src.getContext("2d").putImageData(DTF.toImageData(width, height, pixels, format, state.redAsAlpha), 0, 0); ctx.imageSmoothingEnabled = false; ctx.drawImage(src, 0, 0, cw, ch); } // ─── Loading ───────────────────────────────────────────────────────────────── function loadStandardImage(file) { const url = URL.createObjectURL(file); const img = new Image(); img.onload = () => { const offscreen = Object.assign(document.createElement("canvas"), { width: img.naturalWidth, height: img.naturalHeight, }); offscreen.getContext("2d").drawImage(img, 0, 0); const imageData = offscreen.getContext("2d").getImageData(0, 0, img.naturalWidth, img.naturalHeight); applyImageData( img.naturalWidth, img.naturalHeight, imageData.data, file.name, file.type || "image", ); URL.revokeObjectURL(url); }; img.onerror = () => { showError("Failed to load image. The file may be corrupt or unsupported."); URL.revokeObjectURL(url); }; img.src = url; } function loadDTF(file) { const reader = new FileReader(); reader.onload = e => { try { const { width, height, format, data } = DTF.decode(e.target.result); const label = format === DTF.FORMAT_ALPHA ? "DTF (Alpha)" : format === DTF.FORMAT_RGB ? "DTF (RGB)" : "DTF (RGBA)"; applyImageData(width, height, data, file.name, label, format); } catch (err) { showError(`Failed to load DTF: ${err.message}`); } }; reader.readAsArrayBuffer(file); } function updateWarnings() { const warnings = []; if (state.pixels) { const { width, height } = state; const isPow2 = n => n > 0 && (n & (n - 1)) === 0; if (width < 4) warnings.push(`Width is below 4 px (${width})`); if (height < 4) warnings.push(`Height is below 4 px (${height})`); if (!isPow2(width)) warnings.push(`Width is not a power of two (${width})`); if (!isPow2(height)) warnings.push(`Height is not a power of two (${height})`); const bytes = DTF.HEADER_SIZE + width * height * DTF.BPP[state.format]; if (bytes > 256 * 1024) { warnings.push(`Output exceeds 256 KB (${(bytes / 1024).toFixed(1)} KB)`); } } warningList.replaceChildren( ...warnings.map(msg => Object.assign(document.createElement("li"), { textContent: msg })), ); warningsSection.hidden = warnings.length === 0; } function updateDtfSize() { if (!state.pixels) return; const bytes = DTF.HEADER_SIZE + state.width * state.height * DTF.BPP[state.format]; infoDtfSize.textContent = `${(bytes / 1024).toFixed(1)} KB`; updateWarnings(); } function applyImageData(width, height, data, filename, formatLabel, format) { state.pixels = new Uint8ClampedArray(data); // defensive copy state.width = width; state.height = height; state.filename = filename.replace(/\.[^/.]+$/, ""); // Sync format selector when loading an existing DTF if (format !== undefined) { state.format = format; formatSelect.value = format; } // Sidebar info fileNameEl.textContent = filename; infoFilename.textContent = filename; infoSize.textContent = `${width} × ${height}`; infoFormat.textContent = formatLabel; infoSection.hidden = false; updateDtfSize(); // Show canvas previewEmpty.hidden = true; previewScroll.hidden = false; // Enable export btnDtf.disabled = false; btnPng.disabled = false; render(); } function handleFile(file) { if (!file) return; if (file.name.toLowerCase().endsWith(".dtf")) { loadDTF(file); } else { loadStandardImage(file); } } function showError(msg) { alert(msg); } // ─── Export ─────────────────────────────────────────────────────────────────── function exportDTF() { if (!state.pixels) return; const buf = DTF.encode(state.width, state.height, state.pixels, state.format, state.redAsAlpha); const blob = new Blob([buf], { type: "application/octet-stream" }); const url = URL.createObjectURL(blob); const a = Object.assign(document.createElement("a"), { href: url, download: `${state.filename}.dtf`, }); a.click(); URL.revokeObjectURL(url); } function exportPNG() { if (!state.pixels) return; DuskPNG.download(`${state.filename}.png`, state.width, state.height, state.pixels); } // ─── Event listeners ───────────────────────────────────────────────────────── fileInput.addEventListener("change", e => handleFile(e.target.files[0])); scaleInput.addEventListener("input", () => { const v = Math.max(1, Math.min(10, parseInt(scaleInput.value, 10) || 1)); scaleInput.value = v; state.scale = v; render(); }); bgSwatches.addEventListener("click", e => { const btn = e.target.closest(".bg-swatch"); if (!btn) return; bgSwatches.querySelectorAll(".bg-swatch").forEach(b => b.classList.remove("active")); btn.classList.add("active"); state.bg = btn.dataset.bg; render(); }); btnDtf.addEventListener("click", exportDTF); btnPng.addEventListener("click", exportPNG); formatSelect.addEventListener("change", () => { state.format = parseInt(formatSelect.value, 10); redAsAlphaRow.hidden = state.format !== DTF.FORMAT_ALPHA; updateDtfSize(); render(); }); redAsAlphaCheck.addEventListener("change", () => { state.redAsAlpha = redAsAlphaCheck.checked; render(); }); // Drag-and-drop on the load label function setupDrop(target) { target.addEventListener("dragover", e => { e.preventDefault(); loadLabel.classList.add("drag-over"); previewArea.classList.add("drag-over"); }); target.addEventListener("dragleave", () => { loadLabel.classList.remove("drag-over"); previewArea.classList.remove("drag-over"); }); target.addEventListener("drop", e => { e.preventDefault(); loadLabel.classList.remove("drag-over"); previewArea.classList.remove("drag-over"); handleFile(e.dataTransfer.files[0]); }); } setupDrop(loadLabel); setupDrop(previewArea);