From c6d116024c291e0f20cd14bcf8a75444d13996c3 Mon Sep 17 00:00:00 2001 From: zgredex <112968378+zgredex@users.noreply.github.com> Date: Sun, 17 May 2026 11:12:56 +0200 Subject: [PATCH] fix: harden EPUB optimiser UI gating, size reporting, and picker teardown (#1947) Co-authored-by: Justin Mitchell --- src/network/CrossPointWebServer.cpp | 2 + src/network/html/FilesPage.html | 164 +++++++++++++++++++++++----- 2 files changed, 137 insertions(+), 29 deletions(-) diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 33a1348d..aeaad11e 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -374,6 +375,7 @@ void CrossPointWebServer::handleStatus() const { doc["rssi"] = apMode ? 0 : WiFi.RSSI(); doc["freeHeap"] = ESP.getFreeHeap(); doc["uptime"] = millis() / 1000; + doc["device"] = gpio.deviceIsX3() ? "X3" : "X4"; String json; serializeJson(doc, json); diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index eb21ae37..1220f084 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -1539,7 +1539,7 @@
⚫ True-Grayscale - 📏 Max 480×800px + 📏 Max 480×800px 📦 85% JPEG 🔧 Fix SVG
@@ -1574,6 +1574,19 @@ --> + +
+
📱 Target Device
+
+
+ + + +
+
+
↻ Rotation Direction
@@ -2374,13 +2387,13 @@ const isTiny = (dims.width < 200 && dims.height < 200); // Images that fit screen can only rotate, not split - const fitsScreen = (dims.width <= 480 && dims.height <= 800); - + const fitsScreen = (dims.width <= MAX_WIDTH && dims.height <= MAX_HEIGHT); + // Split capability - no upscaling allowed - // H-Split scales width to 800, so needs width >= 800 - // V-Split scales height to 800, so needs height >= 800 - const canHSplit = dims.width >= 800; - const canVSplit = dims.height >= 800; + // H-Split scales width to MAX_HEIGHT (long edge), so needs width >= MAX_HEIGHT + // V-Split scales height to MAX_HEIGHT, so needs height >= MAX_HEIGHT + const canHSplit = dims.width >= MAX_HEIGHT; + const canVSplit = dims.height >= MAX_HEIGHT; images.push({ path: path, @@ -2618,16 +2631,16 @@ if (showSplitLines) { let finalWidth; if (state === 1) { - // H-Split: scale width to 800, rotate, then check width - const scaledH = Math.round(img.height * (800 / img.width)); + // H-Split: scale width to MAX_HEIGHT, rotate, then check width + const scaledH = Math.round(img.height * (MAX_HEIGHT / img.width)); finalWidth = scaledH; // After rotation, height becomes width } else { - // V-Split: scale height to 800, then check width - finalWidth = Math.round(img.width * (800 / img.height)); + // V-Split: scale height to MAX_HEIGHT, then check width + finalWidth = Math.round(img.width * (MAX_HEIGHT / img.height)); } - if (finalWidth > 480) { - const minOverlapPx = Math.round(480 * (OVERLAP_PERCENT / 100)); - const maxStep = 480 - minOverlapPx; + if (finalWidth > MAX_WIDTH) { + const minOverlapPx = Math.round(MAX_WIDTH * (OVERLAP_PERCENT / 100)); + const maxStep = MAX_WIDTH - minOverlapPx; numParts = Math.ceil((finalWidth - minOverlapPx) / maxStep); if (numParts < 2) numParts = 2; } @@ -2933,11 +2946,20 @@ const convertOptions = document.getElementById('convertOptions'); fileInput.classList.toggle('has-files', files.length > 0); - // Show/hide convert options based on file selection - if (files.length > 0) { + // Show convert options only when at least one selected file is an EPUB. + const hasEpub = Array.from(files).some(f => f.name.toLowerCase().endsWith('.epub')); + if (files.length > 0 && hasEpub) { convertOptions.style.display = 'block'; } else { - clearImagePicker(); + convertOptions.style.display = 'none'; + // Clear stale checkbox state so the "Optimize & Upload" button doesn't linger + // when the user re-picks a non-EPUB after having ticked Optimize for an EPUB. + const cb = document.getElementById('convertBeforeUpload'); + if (cb && cb.checked) { + cb.checked = false; + toggleConvertOptions(); + } + if (files.length === 0) clearImagePicker(); } if (files.length > 0) { @@ -2946,6 +2968,11 @@ const convertEnabled = document.getElementById('convertBeforeUpload').checked; if (advancedContent.classList.contains('visible') && files.length === 1 && convertEnabled && files[0].name.toLowerCase().endsWith('.epub')) { showImagePicker(files[0]).catch(err => console.error('Image picker error:', err)); + } else { + // New selection no longer matches single-EPUB-with-advanced-expanded — + // tear down any picker from a previous file so the grid and picker-mode + // layout don't linger. clearImagePicker is idempotent. + clearImagePicker(); } // If multiple files with conversion, inform user about batch mode @@ -2978,14 +3005,24 @@ const WS_CHUNK_SIZE = 4096; // 4KB chunks - smaller for ESP32 stability // EPUB Image Conversion Functions (from Baseline JPEG Converter) // ============================================================================ +// Device profiles (short edge × long edge in portrait orientation) +const DEVICE_PROFILES = { + X4: { width: 480, height: 800, label: 'X4' }, + X3: { width: 528, height: 792, label: 'X3' }, +}; + // Default conversion settings -const DEFAULT_MAX_WIDTH = 480; -const DEFAULT_MAX_HEIGHT = 800; +const DEFAULT_DEVICE = 'X4'; +const DEFAULT_MAX_WIDTH = DEVICE_PROFILES[DEFAULT_DEVICE].width; +const DEFAULT_MAX_HEIGHT = DEVICE_PROFILES[DEFAULT_DEVICE].height; const DEFAULT_JPEG_QUALITY = 85; const DEFAULT_ENABLE_GRAYSCALE = true; // Note: Overlap is now always centered distribution (min 5%) // Dynamic conversion settings (updated by UI) +let DEVICE_TARGET = 'auto'; // 'auto' | 'X4' | 'X3' +let DETECTED_DEVICE = null; // populated from /api/status +let ACTIVE_DEVICE = DEFAULT_DEVICE; let MAX_WIDTH = DEFAULT_MAX_WIDTH; let MAX_HEIGHT = DEFAULT_MAX_HEIGHT; let JPEG_QUALITY = DEFAULT_JPEG_QUALITY; @@ -3021,12 +3058,55 @@ async function fetchVersion() { if (response.ok) { const data = await response.json(); crosspointVersion = data.version || 'Unknown'; + if (data.device === 'X3' || data.device === 'X4') { + DETECTED_DEVICE = data.device; + applyDeviceTarget(); + } } } catch (e) { console.error('Failed to fetch version:', e); } } +// Resolve DEVICE_TARGET ('auto' | 'X4' | 'X3') to a concrete profile and update UI. +function applyDeviceTarget() { + const resolved = DEVICE_TARGET === 'auto' ? (DETECTED_DEVICE || DEFAULT_DEVICE) : DEVICE_TARGET; + const profile = DEVICE_PROFILES[resolved] || DEVICE_PROFILES[DEFAULT_DEVICE]; + ACTIVE_DEVICE = resolved; + MAX_WIDTH = profile.width; + MAX_HEIGHT = profile.height; + + const summary = document.getElementById('convertSizeSummary'); + if (summary) { + summary.textContent = `📏 Max ${profile.width}×${profile.height}px`; + } + document.querySelectorAll('.device-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.value === DEVICE_TARGET); + }); + const autoLabel = document.getElementById('deviceAutoLabel'); + if (autoLabel) { + autoLabel.textContent = DETECTED_DEVICE ? `Auto (${DETECTED_DEVICE})` : 'Auto'; + } + + // Recompute picker classification with new dimensions, then refresh grid. + if (Array.isArray(epubImagesCache) && epubImagesCache.length > 0) { + for (const img of epubImagesCache) { + img.fitsScreen = (img.width <= MAX_WIDTH && img.height <= MAX_HEIGHT); + img.canHSplit = img.width >= MAX_HEIGHT; + img.canVSplit = img.height >= MAX_HEIGHT; + } + const pickerSection = document.getElementById('imagePickerSection'); + if (pickerSection && pickerSection.style.display !== 'none' && typeof renderImageGrid === 'function') { + renderImageGrid(); + } + } +} + +function setDeviceTarget(value) { + DEVICE_TARGET = value; + applyDeviceTarget(); +} + // Batch logging system for multiple files let batchLogEntries = []; let batchStats = { filesProcessed: 0, filesSucceeded: 0, filesFailed: 0, totalImages: 0, totalSplits: 0, totalFixes: 0, totalErrors: 0, totalOriginalSize: 0, totalNewSize: 0 }; @@ -3183,7 +3263,7 @@ function startBatchLog(fileCount) { } // Save current file's log to batch entries -function saveToFileBatchLog(fileName, succeeded) { +function saveToFileBatchLog(fileName, succeeded, originalSize = 0, newSize = 0) { if (!isBatchMode) return; const entries = Array.from(logContainer.querySelectorAll('.log-entry')); @@ -3205,6 +3285,10 @@ function saveToFileBatchLog(fileName, succeeded) { batchStats.totalSplits += conversionStats.splits; batchStats.totalFixes += conversionStats.fixes; batchStats.totalErrors += conversionStats.errors; + // Defaults of 0 keep failure-path callers safe — files that never + // produced a converted blob contribute nothing to the totals. + batchStats.totalOriginalSize += originalSize; + batchStats.totalNewSize += newSize; // Clear for next file logContainer.innerHTML = ''; @@ -3237,6 +3321,22 @@ function finalizeBatchLog() { }); }); + // Aggregate size totals: only emit rows when at least one file was successfully + // converted (totalOriginalSize stays 0 for batches where conversion was off or + // every file fell back to original upload). + const totalSaved = batchStats.totalOriginalSize - batchStats.totalNewSize; + const totalSavedPct = batchStats.totalOriginalSize > 0 + ? ((totalSaved / batchStats.totalOriginalSize) * 100).toFixed(1) + : '0.0'; + const sizeRowsHtml = batchStats.totalOriginalSize > 0 ? ` + Total original${formatBytes(batchStats.totalOriginalSize)} + Total optimised${formatBytes(batchStats.totalNewSize)} + Total saved${ + totalSaved > 0 + ? `${formatBytes(totalSaved)} (${totalSavedPct}%)` + : `+${formatBytes(-totalSaved)}` + }` : ''; + // Add batch summary const batchSummaryHtml = `
@@ -3248,7 +3348,7 @@ function finalizeBatchLog() { Total images processed${batchStats.totalImages} Total splits${batchStats.totalSplits} Total fixes applied${batchStats.totalFixes} - ${batchStats.totalErrors > 0 ? `Total errors${batchStats.totalErrors}` : ''} + ${batchStats.totalErrors > 0 ? `Total errors${batchStats.totalErrors}` : ''}${sizeRowsHtml} Total time${batchTime.toFixed(1)}s
@@ -4180,8 +4280,6 @@ async function processImage(data, imageState = 0, imagePath = '') { async function convertEpubFile(file, progressCallback) { const startTime = Date.now(); const originalSize = file.size; - let totalImageSize = 0; - let totalNewSize = 0; // Initialize logging clearLog(); @@ -4265,9 +4363,6 @@ async function convertEpubFile(file, progressCallback) { const origFormat = path.split('.').pop(); logImage(imgName, meta.origW, meta.origH, origFormat, meta.origSize, meta.finalW, meta.finalH, meta.finalSize, meta.wasSplit, meta.splitCount || 0, parts, meta.imageState || 0); - totalImageSize += meta.origSize; - totalNewSize += meta.finalSize; - if (parts.length === 1 && parts[0].suffix === '') { const newPath = renamed[path] || path.replace(/\.[^.]+$/, newExt); out.file(newPath, parts[0].data, { compression: 'STORE', createFolders: false }); @@ -4518,7 +4613,7 @@ async function convertEpubFile(file, progressCallback) { // Log completion log('Conversion complete!', 'success', 'DONE'); - logSummary(totalImageSize > 0 ? totalImageSize : originalSize, totalNewSize > 0 ? totalNewSize : newSize, timeElapsed); + logSummary(originalSize, newSize, timeElapsed); // Auto-export only if NOT in batch mode (batch mode exports at the end) if (!isBatchMode && exportLogCheckbox && exportLogCheckbox.checked) { @@ -4787,6 +4882,8 @@ function uploadFile() { const needsConversion = isEpub && convertEnabled; let conversionSucceeded = false; let conversionFailed = false; // Track if conversion actually failed + let convOriginalSize = 0; // Picked-file size; 0 unless conversion succeeded + let convNewSize = 0; // Generated blob size; 0 unless conversion succeeded const methodText = useWebSocket ? ' [WS]' : ' [HTTP]'; const stageText = needsConversion ? 'Converting & uploading' : 'Uploading'; @@ -4806,7 +4903,8 @@ function uploadFile() { // Save file log to batch if in batch mode and this file was converted // Consider it successful only if conversion didn't fail if (useBatchLog && needsConversion) { - saveToFileBatchLog(file.name, !conversionFailed && conversionSucceeded); + const ok = !conversionFailed && conversionSucceeded; + saveToFileBatchLog(file.name, ok, ok ? convOriginalSize : 0, ok ? convNewSize : 0); } currentIndex++; @@ -4817,7 +4915,9 @@ function uploadFile() { // Save failed file log to batch if in batch mode if (useBatchLog && needsConversion) { logError(`Upload failed: ${error}`); - saveToFileBatchLog(file.name, false); + // Preserve conversion size totals when the convert step succeeded but + // the upload failed; otherwise nothing was produced and 0/0 is correct. + saveToFileBatchLog(file.name, false, convOriginalSize, convNewSize); } failedFiles.push({ name: file.name, error: error, file: originalFile }); @@ -4862,6 +4962,10 @@ function uploadFile() { showLog(); } + // Snapshot before the `file =` reassignment below, which swaps in the + // converted blob and makes file.size point at the optimised size. + const origFileSize = file.size; + try { const convertedBlob = await convertEpubFile(file, (percent) => { // Pass current quality setting to converter @@ -4872,6 +4976,8 @@ function uploadFile() { file = new File([convertedBlob], file.name, { type: 'application/epub+zip' }); progressFill.style.backgroundColor = '#27ae60'; // Back to green for upload conversionSucceeded = true; + convOriginalSize = origFileSize; + convNewSize = convertedBlob.size; } catch (convError) { if (operationCancelled) { if (uploadGeneration === myGeneration) restoreAfterCancel(); return; } console.error('Conversion error:', convError);