diff --git a/.gitignore b/.gitignore index 7a60b85..b4bc537 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ __pycache__/ *.pyc +node_modules/ diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..efcf7ac --- /dev/null +++ b/babel.config.js @@ -0,0 +1,5 @@ +module.exports = { + presets: [ + ['@babel/preset-env', {targets: {node: 'current'}}], + ], +}; diff --git a/docs/app.js b/docs/app.js new file mode 100644 index 0000000..b78db54 --- /dev/null +++ b/docs/app.js @@ -0,0 +1,275 @@ +import { + initializeImageMagick, + ImageMagick, + MagickFormat, +} from "@imagemagick/magick-wasm"; + +const fileInput = document.getElementById("fileInput"); +const targetFormatSelect = document.getElementById("targetFormat"); +const convertBtn = document.getElementById("convertBtn"); +const statusDiv = document.getElementById("status"); +const downloadsContainer = document.getElementById("downloads-container"); +const previewContainer = document.getElementById("preview-container"); +const fileStatusText = document.getElementById("fileStatusText"); + +let isEngineReady = false; +// Make selectedFiles accessible for export +let selectedFiles = []; // Maintain our own list of files + +// 1. Fetch our comprehensive JSON database to populate dropdown and input filter +async function loadFormats() { + try { + const response = await fetch("format_database.json"); + if (!response.ok) throw new Error("Network response was not ok"); + const db = await response.json(); + + targetFormatSelect.innerHTML = ""; + + // Get unique formats + const validOutputs = [ + ...new Set( + db.filter((f) => f.can_write).map((f) => f.extension.toLowerCase()), + ), + ].sort(); + const validInputs = [ + ...new Set( + db + .filter((f) => f.can_read) + .map((f) => `.${f.extension.toLowerCase()}`), + ), + ]; + + // Restrict input file picker + if (validInputs.length > 0) { + fileInput.accept = validInputs.join(","); + } + + validOutputs.forEach((ext) => { + const option = document.createElement("option"); + option.value = ext; + option.textContent = ext.toUpperCase(); + targetFormatSelect.appendChild(option); + }); + + targetFormatSelect.value = "png"; // default + } catch (err) { + statusDiv.textContent = + "Failed to load format database. Defaulting to basic formats."; + targetFormatSelect.innerHTML = ` + + + + +`; + fileInput.accept = ".png,.jpg,.jpeg,.webp,.pdf"; + } +} + +// Handle file selection +fileInput.addEventListener("change", (e) => { + if (e.target.files.length > 0) { + // Add new files to our array + selectedFiles = selectedFiles.concat(Array.from(e.target.files)); + renderPreviews(); + } + // Clear input so selecting the same file again triggers change event + fileInput.value = ""; +}); + +// Render thumbnails +function renderPreviews() { + previewContainer.innerHTML = ""; + + if (selectedFiles.length === 0) { + fileStatusText.textContent = "No file chosen"; + previewContainer.innerHTML = `
No files selected.
`; + return; + } else if (selectedFiles.length === 1) { + fileStatusText.textContent = selectedFiles[0].name; + } else { + fileStatusText.textContent = `${selectedFiles.length} files selected`; + } + + selectedFiles.forEach((file, index) => { + const wrapper = document.createElement("div"); + wrapper.className = "thumbnail-wrapper"; + wrapper.title = file.name; + + // Create remove button + const removeBtn = document.createElement("button"); + removeBtn.className = "remove-btn"; + removeBtn.innerHTML = "×"; + removeBtn.onclick = () => { + selectedFiles.splice(index, 1); + renderPreviews(); + }; + + // Create image preview + const img = document.createElement("img"); + img.className = "thumbnail-img"; + + // If it's a standard web image, browsers can render it natively + if ( + file.type.startsWith("image/") && + !file.name.toLowerCase().endsWith(".heic") + ) { + img.src = URL.createObjectURL(file); + img.onload = () => URL.revokeObjectURL(img.src); // Free memory + } else { + // Fallback for RAW/HEIC that browsers can't render natively + img.src = + 'data:image/svg+xml;utf8,'; + } + + wrapper.appendChild(img); + wrapper.appendChild(removeBtn); + + // Overlay the name at the bottom + const nameLabel = document.createElement("div"); + nameLabel.className = "thumbnail-name"; + nameLabel.style.position = "absolute"; + nameLabel.style.bottom = "0"; + nameLabel.style.width = "100%"; + nameLabel.style.background = "rgba(255,255,255,0.8)"; + nameLabel.textContent = + file.name.length > 12 ? file.name.substring(0, 10) + "..." : file.name; + + wrapper.appendChild(nameLabel); + previewContainer.appendChild(wrapper); + }); +} + +// 2. Initialize the WebAssembly Engine +async function initWasm() { + try { + // Fetch the wasm file explicitly (use unpkg to ensure raw binary response) + const wasmUrl = + "https://unpkg.com/@imagemagick/magick-wasm@0.0.30/dist/magick.wasm"; + const response = await fetch(wasmUrl); + const wasmBytes = new Uint8Array(await response.arrayBuffer()); + + await initializeImageMagick(wasmBytes); + + isEngineReady = true; + convertBtn.textContent = "Convert Image"; + convertBtn.disabled = false; + statusDiv.textContent = "Engine Ready. Select a file to convert."; + } catch (err) { + console.error(err); + statusDiv.textContent = "Failed to initialize ImageMagick WASM engine."; + } +} + +// 3. Conversion Logic +convertBtn.addEventListener("click", async () => { + if (selectedFiles.length === 0) { + alert("Please select files first."); + return; + } + + const files = [...selectedFiles]; + const targetExt = targetFormatSelect.value; + const isBatch = files.length > 1; + + convertBtn.disabled = true; + downloadsContainer.innerHTML = ""; // clear previous links + + let successCount = 0; + let failCount = 0; + const zip = new JSZip(); + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + statusDiv.textContent = `Processing file ${i + 1} of ${files.length}: ${file.name}...`; + + try { + const arrayBuffer = await file.arrayBuffer(); + const sourceBytes = new Uint8Array(arrayBuffer); + + // Await a Promise that resolves when the setTimeout for this image finishes + await new Promise((resolve) => { + setTimeout(() => { + try { + ImageMagick.read(sourceBytes, (image) => { + if (targetExt === "jpg" || targetExt === "jpeg") { + image.hasAlpha = false; + } + + // Title Case for enum + const titleCaseFormat = + targetExt.charAt(0).toUpperCase() + + targetExt.slice(1).toLowerCase(); + const formatEnum = + MagickFormat[titleCaseFormat] || targetExt.toUpperCase(); + + image.write(formatEnum, (data) => { + let originalName = file.name.split(".").slice(0, -1).join("."); + if (!originalName) originalName = "image"; // fallback if no extension + const outFileName = `${originalName}.${targetExt}`; + + if (isBatch) { + // Add to zip + // CRITICAL: Must clone the Uint8Array because 'data' is a view into + // WebAssembly memory which will be overwritten by the next image processing. + zip.file(outFileName, new Uint8Array(data)); + } else { + // Single file, provide direct link + const blob = new Blob([data], { + type: "application/octet-stream", + }); + const url = URL.createObjectURL(blob); + const dlLink = document.createElement("a"); + dlLink.href = url; + dlLink.download = outFileName; + dlLink.textContent = `⬇️ Download ${outFileName}`; + dlLink.style.fontWeight = "bold"; + dlLink.style.color = "#10b981"; + downloadsContainer.appendChild(dlLink); + } + successCount++; + }); + }); + } catch (err) { + console.error(`Error processing ${file.name}:`, err); + failCount++; + } + resolve(); + }, 50); + }); + } catch (err) { + console.error(`Error reading ${file.name}:`, err); + failCount++; + } + } + + if (isBatch && successCount > 0) { + statusDiv.textContent = `Compressing ${successCount} files into ZIP...`; + + try { + const content = await zip.generateAsync({ type: "blob" }); + const url = URL.createObjectURL(content); + const dlLink = document.createElement("a"); + dlLink.href = url; + dlLink.download = `converted_images.zip`; + dlLink.textContent = `📦 Download All as ZIP (${successCount} files)`; + dlLink.style.fontWeight = "bold"; + dlLink.style.color = "#10b981"; + downloadsContainer.appendChild(dlLink); + } catch (err) { + console.error("ZIP Generation Failed:", err); + failCount += successCount; // Consider it a failure if we can't zip + } + } + + statusDiv.textContent = `Job complete! Successfully processed ${successCount} files. Failed: ${failCount}.`; + convertBtn.disabled = false; +}); + +// Bootstrap +loadFormats(); +initWasm(); + +export { renderPreviews, selectedFiles }; +export function setSelectedFiles(files) { + selectedFiles = files; +} diff --git a/docs/index.html b/docs/index.html index 9d406c0..f49c6f0 100644 --- a/docs/index.html +++ b/docs/index.html @@ -131,248 +131,6 @@