PageSourceSearch

https://laserlab.app/static/js/imageprep2/smart-auto-adjust.js?v=1780950702?v=1786132325

js laserlab.app collected 2026-09-24 14:31:43 UTC 109,586 bytes, 2,528 lines download raw bytes

1// ===========================
2// LASERLAB IMAGEPREP v2 — SMART AUTO-ADJUST (Client)
3// Flow: CLAHE (silent) → Server analysis → Slider animation
4// CLAHE is baked in — user sees "Analysing", not "CLAHE"
5// + Gemini Engine routing (Profile 2)
6// ===========================
7
8import {
9    currentImage, imageHistory, adjustmentValues, levelsSlider,
10    fabricCanvas, setCurrentImage,
11    setAdjustmentProp, setImageHistoryProp,
12    safeSetText
13} from './state.js';
14
15import { applyAllFilters } from './filters.js';
16
17import { geminiSilentBake, geminiHaloGuardSharpen } from './gemini-engine.js';
18
19console.log('📦 smart-auto-adjust.js loading...');
20
21// ===========================
22// PROCESSING ENGINE STATE (Standard vs Gemini)
23// ===========================
24
25let activeEngine = 'gemini'; // 'standard' | 'gemini'
26
27export function getActiveEngine() { return activeEngine; }
28export function setActiveEngine(engine) {
29    activeEngine = engine;
30    console.log(`🔬 Processing engine: ${engine}`);
31}
32
33// ===========================
34// PROCESSING PARAMETERS (fetched from server)
35// ===========================
36
37let cachedParams = {};
38
39async function getProcessingParams(mode) {
40    const cacheKey = mode || 'default';
41    if (cachedParams[cacheKey]) return cachedParams[cacheKey];
42    try {
43        const url = mode ? `/api/processing-params?mode=${mode}` : '/api/processing-params';
44        const response = await fetch(url);
45        if (response.ok) {
46            cachedParams[cacheKey] = await response.json();
47            console.log(`✅ Processing params loaded for ${cacheKey}`);
48            return cachedParams[cacheKey];
49        }
50    } catch (err) {
51        console.error('Failed to fetch processing params:', err);
52    }
53    return null;
54}
55
56// ===========================
57// PROFILE UI METADATA
58// ===========================
59
60const PROFILE_UI = {
61    hd: { label: 'HD', description: 'High Detail', accentColor: '#f59e0b' },
62    hd_plus: { label: 'HD+', description: 'High Detail + Polish', accentColor: '#06b6d4' },
63    sd: { label: 'SD', description: 'Standard Detail', accentColor: '#8b5cf6' }
64};
65
66// Gemini UI overlay — used for popup display when Gemini + any mode
67const GEMINI_UI_OVERLAY = { badge: '⚡ Gemini', badgeColor: '#f97316' };
68
69// Saved state for undo (includes pre-CLAHE stores)
70let autoAdjustUndoState = null;
71
72// Re-entry guard — prevents overlapping runs
73let autoAdjustRunning = false;
74
75// Abort controller — allows cancellation mid-flight
76let autoAdjustAbortController = null;
77
78// Failsafe timeout (ms) — auto-closes popup if anything hangs
79const POPUP_TIMEOUT_MS = 20000;
80
81// ===========================
82// SILENT CLAHE — runs inside the auto-adjust flow
83// Updates originalCanvas + displayOriginalImageData stores
84// Returns: true if CLAHE was applied, false if skipped
85// ===========================
86
87function applyClaheToStores(mode) {
88    return new Promise(async (resolve) => {
89        // Skip silently if OpenCV not ready
90        if (typeof cv === 'undefined' || !window.cvReady) {
91            console.log('⚠ OpenCV not ready — skipping CLAHE');
92            resolve(false);
93            return;
94        }
95
96        try {
97            const srcCanvas = imageHistory.originalCanvas;
98            if (!srcCanvas) { resolve(false); return; }
99
100            // Fetch mode-specific processing params from server
101            const params = await getProcessingParams(mode);
102            const bilat = params ? params.bilateral : null;
103            const claheParams = params ? params.clahe : null;
104            const claheAdaptive = params ? params.claheAdaptive : null;
105
106            let src = cv.imread(srcCanvas);
107            let hasAlpha = src.channels() === 4;
108            let alphaChannel = null;
109
110            if (hasAlpha) {
111                let channels = new cv.MatVector();
112                cv.split(src, channels);
113                alphaChannel = channels.get(3).clone();
114                channels.delete();
115            }
116
117            let gray = new cv.Mat();
118            if (src.channels() === 4) {
119                cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
120            } else if (src.channels() === 3) {
121                cv.cvtColor(src, gray, cv.COLOR_RGB2GRAY);
122            } else {
123                gray = src.clone();
124            }
125
126            // Bilateral denoise before CLAHE — params from server
127            let denoised = new cv.Mat();
128            const bD = bilat ? bilat.diameter : 5;
129            const bC = bilat ? bilat.sigmaColor : 40;
130            const bS = bilat ? bilat.sigmaSpace : 40;
131            cv.bilateralFilter(gray, denoised, bD, bC, bS);
132
133            // Image-adaptive CLAHE clipLimit
134            let cClip = claheParams ? claheParams.clipLimit : 2.5;
135            const cTile = claheParams ? claheParams.tileSize : 8;
136
137            if (claheAdaptive) {
138                // Measure mean + stdDev (mean still used for high-contrast
139                // handling) and MEDIAN. Darkness is keyed on MEDIAN, not mean:
140                // a bright background (e.g. pale stone) drags the mean up and
141                // fools a mean-based test into treating a dark, moody image as
142                // "balanced", so it gets full-strength CLAHE and washes out.
143                // The median reflects the actual tonal centre of the image.
144                let meanMat = new cv.Mat();
145                let stdDevMat = new cv.Mat();
146                cv.meanStdDev(denoised, meanMat, stdDevMat);
147                const imgMean = meanMat.data64F[0];
148                const imgStdDev = stdDevMat.data64F[0];
149                meanMat.delete(); stdDevMat.delete();
150
151                // Median via a 256-bin histogram (cheap — runs once per image)
152                let imgMedian = imgMean; // safe fallback
153                try {
154                    let srcVec = new cv.MatVector();
155                    srcVec.push_back(denoised);
156                    let hist = new cv.Mat();
157                    let histMask = new cv.Mat();
158                    cv.calcHist(srcVec, [0], histMask, hist, [256], [0, 256], false);
159                    const totalPx = denoised.rows * denoised.cols;
160                    let cum = 0;
161                    for (let b = 0; b < 256; b++) {
162                        cum += hist.data32F[b];
163                        if (cum >= totalPx / 2) { imgMedian = b; break; }
164                    }
165                    hist.delete(); histMask.delete(); srcVec.delete();
166                } catch (e) {
167                    console.log('⚠ CLAHE median calc failed — falling back to mean', e);
168                }
169
170                const veryDark  = claheAdaptive.veryDarkMeanThreshold || 40;
171                const darkThresh = claheAdaptive.darkMeanThreshold || 70;
172                const darkClip  = claheAdaptive.darkClipLimit || 1.2;
173                const defClip   = cClip;                 // profile default (HD+ 2.0, HD 2.5, SD 3.2)
174                const rampTop   = darkThresh + 50;       // full clip reached by median ~120
175
176                if (imgMedian < veryDark) {
177                    // Very dark: skip CLAHE — too few tonal values, would band
178                    cClip = 0;
179                    console.log(`🔬 CLAHE SKIPPED: VERY DARK (median=${imgMedian}, mean=${imgMean.toFixed(1)}) — sliders handle tonal correction`);
180                } else {
181                    // CONTINUOUS clip ramp keyed on median: gentle (darkClip) at
182                    // the dark end, easing up to the profile default by rampTop.
183                    // No hard step, so near-identical images get near-identical
184                    // treatment instead of diverging across a threshold.
185                    const t = Math.max(0, Math.min(1, (imgMedian - veryDark) / (rampTop - veryDark)));
186                    cClip = darkClip + t * (defClip - darkClip);
187
188                    // High-contrast images: reduce on top to avoid over-boosting
189                    if (imgStdDev > claheAdaptive.highContrastThreshold) {
190                        cClip = Math.max(claheAdaptive.minClipLimit, cClip - claheAdaptive.clipReduction);
191                        console.log(`🔬 CLAHE adaptive: median=${imgMedian} (mean=${imgMean.toFixed(1)}) → clip ${cClip.toFixed(2)} [ramp + high-contrast stdDev=${imgStdDev.toFixed(1)}]`);
192                    } else {
193                        console.log(`🔬 CLAHE adaptive: median=${imgMedian} (mean=${imgMean.toFixed(1)}) → clip ${cClip.toFixed(2)} [ramp]`);
194                    }
195                    cClip = Math.round(cClip * 100) / 100;
196                }
197            }
198
199            let dst;
200            if (cClip > 0) {
201                dst = new cv.Mat();
202                let clahe = new cv.CLAHE(cClip, new cv.Size(cTile, cTile));
203                clahe.apply(denoised, dst);
204                clahe.delete();
205            } else {
206                // Skip CLAHE — pass through denoised image directly
207                dst = denoised.clone();
208            }
209            denoised.delete();
210
211            const outCanvas = document.createElement('canvas');
212            outCanvas.width = srcCanvas.width;
213            outCanvas.height = srcCanvas.height;
214
215            if (hasAlpha && alphaChannel) {
216                let finalRGBA = new cv.Mat();
217                let mergeChannels = new cv.MatVector();
218                mergeChannels.push_back(dst);
219                mergeChannels.push_back(dst);
220                mergeChannels.push_back(dst);
221                mergeChannels.push_back(alphaChannel);
222                cv.merge(mergeChannels, finalRGBA);
223                cv.imshow(outCanvas, finalRGBA);
224                finalRGBA.delete();
225                mergeChannels.delete();
226                alphaChannel.delete();
227            } else {
228                cv.imshow(outCanvas, dst);
229            }
230
231            src.delete(); gray.delete(); dst.delete();
232
233            // Update originalCanvas store
234            const newOrigCanvas = document.createElement('canvas');
235            newOrigCanvas.width = outCanvas.width;
236            newOrigCanvas.height = outCanvas.height;
237            newOrigCanvas.getContext('2d').drawImage(outCanvas, 0, 0);
238            setImageHistoryProp('originalCanvas', newOrigCanvas);
239
240            // Update display-res preview store
241            const MAX_DISPLAY = 1200;
242            let dw = outCanvas.width, dh = outCanvas.height;
243            if (dw > MAX_DISPLAY || dh > MAX_DISPLAY) {
244                const scale = MAX_DISPLAY / Math.max(dw, dh);
245                dw = Math.round(dw * scale);
246                dh = Math.round(dh * scale);
247            }
248            const displayCanvas = document.createElement('canvas');
249            displayCanvas.width = dw;
250            displayCanvas.height = dh;
251            const dCtx = displayCanvas.getContext('2d');
252            dCtx.drawImage(outCanvas, 0, 0, dw, dh);
253            setImageHistoryProp('displayOriginalImageData', dCtx.getImageData(0, 0, dw, dh));
254
255            // Update visible fabric image
256            fabric.Image.fromURL(outCanvas.toDataURL(), function(claheImg) {
257                // Preserve on-canvas display size: new image's pixel dimensions may
258                // differ from the previous currentImage, so compute scale from the
259                // target display footprint rather than reusing currentImage.scaleX/Y.
260                const targetDisplayW = currentImage.width * currentImage.scaleX;
261                const targetDisplayH = currentImage.height * currentImage.scaleY;
262                claheImg.set({
263                    left: currentImage.left, top: currentImage.top,
264                    originX: 'center', originY: 'center',
265                    scaleX: targetDisplayW / claheImg.width,
266                    scaleY: targetDisplayH / claheImg.height,
267                    selectable: false, evented: true, hoverCursor: 'default'
268                });
269
270                fabricCanvas.remove(currentImage);
271                fabricCanvas.add(claheImg);
272                fabricCanvas.renderAll();
273                setCurrentImage(claheImg);
274
275                console.log('✅ Internal CLAHE applied');
276                resolve(true);
277            });
278
279        } catch (error) {
280            console.error('Internal CLAHE failed:', error);
281            resolve(false); // Fail silently — smart-adjust continues without CLAHE
282        }
283    });
284}
285
286// ===========================
287// ADAPTIVE SHARPENING — runs after CLAHE
288// Sharpens high-detail areas (texture, edges) while leaving
289// smooth areas (backgrounds, gradients) untouched.
290// Uses local variance to build a mask, then applies
291// multi-radius unsharp mask blended by the mask.
292// ===========================
293
294export function applyAdaptiveSharpenToStores(preset) {
295    return new Promise(async (resolve) => {
296        if (typeof cv === 'undefined' || !window.cvReady) {
297            console.log('⚠ OpenCV not ready — skipping adaptive sharpen');
298            resolve(false);
299            return;
300        }
301
302        try {
303            const srcCanvas = imageHistory.originalCanvas;
304            if (!srcCanvas) { resolve(false); return; }
305
306            // Get params from server — use preset as mode for cache hit
307            const params = await getProcessingParams(preset);
308            // Use preset-specific params if available, otherwise default
309            let sp;
310            if (preset && params && params.adaptiveSharpenPresets && params.adaptiveSharpenPresets[preset]) {
311                sp = params.adaptiveSharpenPresets[preset];
312                console.log(`🔬 Using adaptive sharpen preset: ${preset}`);
313            } else {
314                sp = params ? params.adaptiveSharpen : null;
315            }
316            if (!sp) { console.log('⚠ No sharpen params — skipping'); resolve(false); return; }
317
318            // Read source as grayscale Mat
319            let src = cv.imread(srcCanvas);
320            let gray = new cv.Mat();
321            if (src.channels() === 4) {
322                cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
323            } else if (src.channels() === 3) {
324                cv.cvtColor(src, gray, cv.COLOR_RGB2GRAY);
325            } else {
326                gray = src.clone();
327            }
328
329            // Preserve alpha if present
330            let hasAlpha = src.channels() === 4;
331            let alphaChannel = null;
332            if (hasAlpha) {
333                let channels = new cv.MatVector();
334                cv.split(src, channels);
335                alphaChannel = channels.get(3).clone();
336                channels.delete();
337            }
338            src.delete();
339
340            // Convert to float32 for precision
341            let grayF = new cv.Mat();
342            gray.convertTo(grayF, cv.CV_32F);
343
344            // ── Build local variance mask ──
345            let ksize = new cv.Size(sp.varianceKernel, sp.varianceKernel);
346
347            let mean = new cv.Mat();
348            cv.boxFilter(grayF, mean, cv.CV_32F, ksize);
349
350            let sqr = new cv.Mat();
351            cv.multiply(grayF, grayF, sqr);
352            let meanSqr = new cv.Mat();
353            cv.boxFilter(sqr, meanSqr, cv.CV_32F, ksize);
354            sqr.delete();
355
356            let meanMean = new cv.Mat();
357            cv.multiply(mean, mean, meanMean);
358
359            let variance = new cv.Mat();
360            cv.subtract(meanSqr, meanMean, variance);
361            meanSqr.delete();
362            meanMean.delete();
363            mean.delete();
364
365            let zero = new cv.Mat(variance.rows, variance.cols, cv.CV_32F, new cv.Scalar(0));
366            cv.max(variance, zero, variance);
367            zero.delete();
368
369            let stdMap = new cv.Mat();
370            cv.sqrt(variance, stdMap);
371            variance.delete();
372
373            let minMax = cv.minMaxLoc(stdMap);
374            let maxStd = minMax.maxVal;
375            let lowThresh = maxStd * sp.lowThreshFactor;
376            let highThresh = maxStd * sp.highThreshFactor;
377
378            let mask = new cv.Mat(stdMap.rows, stdMap.cols, cv.CV_32F);
379            let maskData = mask.data32F;
380            let stdData = stdMap.data32F;
381            let range = Math.max(highThresh - lowThresh, 0.001);
382            for (let i = 0; i < maskData.length; i++) {
383                let t = (stdData[i] - lowThresh) / range;
384                t = Math.max(0, Math.min(1, t));
385                maskData[i] = t * t * (3 - 2 * t);
386            }
387            stdMap.delete();
388
389            let maskBlurred = new cv.Mat();
390            cv.GaussianBlur(mask, maskBlurred, new cv.Size(sp.maskBlurSize, sp.maskBlurSize), 0);
391            mask.delete();
392
393            // ── Fine detail sharpen ──
394            let blurFine = new cv.Mat();
395            cv.GaussianBlur(grayF, blurFine, new cv.Size(sp.fineRadius, sp.fineRadius), sp.fineSigma);
396            let diffFine = new cv.Mat();
397            cv.subtract(grayF, blurFine, diffFine);
398            blurFine.delete();
399
400            // ── Coarse edge sharpen ──
401            let blurCoarse = new cv.Mat();
402            cv.GaussianBlur(grayF, blurCoarse, new cv.Size(0, 0), sp.coarseSigma);
403            let diffCoarse = new cv.Mat();
404            cv.subtract(grayF, blurCoarse, diffCoarse);
405            blurCoarse.delete();
406
407            // ── Blend ──
408            let sharpened = new cv.Mat(grayF.rows, grayF.cols, cv.CV_32F);
409            let srcData = grayF.data32F;
410            let fineData = diffFine.data32F;
411            let coarseData = diffCoarse.data32F;
412            let mData = maskBlurred.data32F;
413            let outData = sharpened.data32F;
414
415            for (let i = 0; i < outData.length; i++) {
416                let m = mData[i];
417                let sharp = srcData[i] + m * (sp.fineAmount * fineData[i] + sp.coarseAmount * coarseData[i]);
418                outData[i] = Math.max(0, Math.min(255, sharp));
419            }
420
421            diffFine.delete();
422            diffCoarse.delete();
423            maskBlurred.delete();
424            grayF.delete();
425
426            // Convert back to uint8
427            let result = new cv.Mat();
428            sharpened.convertTo(result, cv.CV_8U);
429            sharpened.delete();
430
431            // Write to output canvas
432            const outCanvas = document.createElement('canvas');
433            outCanvas.width = srcCanvas.width;
434            outCanvas.height = srcCanvas.height;
435
436            if (hasAlpha && alphaChannel) {
437                let finalRGBA = new cv.Mat();
438                let mergeChannels = new cv.MatVector();
439                mergeChannels.push_back(result);
440                mergeChannels.push_back(result);
441                mergeChannels.push_back(result);
442                mergeChannels.push_back(alphaChannel);
443                cv.merge(mergeChannels, finalRGBA);
444                cv.imshow(outCanvas, finalRGBA);
445                finalRGBA.delete();
446                mergeChannels.delete();
447                alphaChannel.delete();
448            } else {
449                cv.imshow(outCanvas, result);
450            }
451
452            gray.delete();
453            result.delete();
454
455            // Update originalCanvas store
456            const newOrigCanvas = document.createElement('canvas');
457            newOrigCanvas.width = outCanvas.width;
458            newOrigCanvas.height = outCanvas.height;
459            newOrigCanvas.getContext('2d').drawImage(outCanvas, 0, 0);
460            setImageHistoryProp('originalCanvas', newOrigCanvas);
461
462            // Update display-res preview store
463            const MAX_DISPLAY = 1200;
464            let dw = outCanvas.width, dh = outCanvas.height;
465            if (dw > MAX_DISPLAY || dh > MAX_DISPLAY) {
466                const scale = MAX_DISPLAY / Math.max(dw, dh);
467                dw = Math.round(dw * scale);
468                dh = Math.round(dh * scale);
469            }
470            const displayCanvas = document.createElement('canvas');
471            displayCanvas.width = dw;
472            displayCanvas.height = dh;
473            const dCtx = displayCanvas.getContext('2d');
474            dCtx.drawImage(outCanvas, 0, 0, dw, dh);
475            setImageHistoryProp('displayOriginalImageData', dCtx.getImageData(0, 0, dw, dh));
476
477            // Update visible fabric image
478            fabric.Image.fromURL(outCanvas.toDataURL(), function(sharpImg) {
479                // Preserve on-canvas display size: new image's pixel dimensions may
480                // differ from the previous currentImage, so compute scale from the
481                // target display footprint rather than reusing currentImage.scaleX/Y.
482                const targetDisplayW = currentImage.width * currentImage.scaleX;
483                const targetDisplayH = currentImage.height * currentImage.scaleY;
484                sharpImg.set({
485                    left: currentImage.left, top: currentImage.top,
486                    originX: 'center', originY: 'center',
487                    scaleX: targetDisplayW / sharpImg.width,
488                    scaleY: targetDisplayH / sharpImg.height,
489                    selectable: false, evented: true, hoverCursor: 'default'
490                });
491
492                fabricCanvas.remove(currentImage);
493                fabricCanvas.add(sharpImg);
494                fabricCanvas.renderAll();
495                setCurrentImage(sharpImg);
496
497                console.log('✅ Adaptive sharpening applied');
498                resolve(true);
499            });
500
501        } catch (error) {
502            console.error('Adaptive sharpening failed:', error);
503            resolve(false); // Fail silently
504        }
505    });
506}
507
508// ===========================
509// SAVE / RESTORE UNDO STATE
510// ===========================
511
512// ===========================
513// HIGH PASS POLISH — Final step for HD+ mode
514// Runs AFTER CLAHE + Adaptive Sharpen + Slider application.
515// Extracts fine edges via Gaussian subtract, blends back at
516// server-calculated strength. Conservative by design — this
517// is a polish, not a primary sharpening pass.
518// ===========================
519
520function applyHighPassPolish(highPassParams) {
521    return new Promise(async (resolve) => {
522        if (typeof cv === 'undefined' || !window.cvReady) {
523            console.log('⚠ OpenCV not ready — skipping high pass polish');
524            resolve(false);
525            return;
526        }
527
528        if (!highPassParams || !highPassParams.radius) {
529            console.log('⚠ No high pass params — skipping');
530            resolve(false);
531            return;
532        }
533
534        try {
535            const srcCanvas = imageHistory.originalCanvas;
536            if (!srcCanvas) { resolve(false); return; }
537
538            const { radius, strength, ksize } = highPassParams;
539            console.log(`🔬 High Pass Polish: radius=${radius}, strength=${strength}, ksize=${ksize}`);
540
541            let src = cv.imread(srcCanvas);
542            let hasAlpha = src.channels() === 4;
543            let alphaChannel = null;
544
545            if (hasAlpha) {
546                let channels = new cv.MatVector();
547                cv.split(src, channels);
548                alphaChannel = channels.get(3).clone();
549                channels.delete();
550            }
551
552            // Convert to grayscale
553            let gray = new cv.Mat();
554            if (src.channels() === 4) {
555                cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
556            } else if (src.channels() === 3) {
557                cv.cvtColor(src, gray, cv.COLOR_RGB2GRAY);
558            } else {
559                gray = src.clone();
560            }
561            src.delete();
562
563            // Convert to float32 for precision
564            let grayF = new cv.Mat();
565            gray.convertTo(grayF, cv.CV_32F);
566
567            // Step 1: Gaussian blur (the "low frequency" version)
568            // ksize 0 = OpenCV auto-calculates kernel from sigma (radius)
569            let blurred = new cv.Mat();
570            let k = ksize || 0;
571            cv.GaussianBlur(grayF, blurred, new cv.Size(k, k), radius);
572
573            // Step 2: Subtract blur from original = high pass (edges only)
574            let highPass = new cv.Mat();
575            cv.subtract(grayF, blurred, highPass);
576            blurred.delete();
577
578            // Step 3: Blend high pass back onto original at controlled strength
579            // Clean flat blend — no per-pixel weighting to avoid artifacts
580            // when gamma/invert adjustments are applied later
581            let result = new cv.Mat();
582            cv.addWeighted(grayF, 1.0, highPass, strength, 0, result);
583            highPass.delete();
584            grayF.delete();
585
586            // Clamp to 0-255 and convert back to uint8
587            let resultU8 = new cv.Mat();
588            result.convertTo(resultU8, cv.CV_8U);
589            result.delete();
590
591            // Write to output canvas
592            const outCanvas = document.createElement('canvas');
593            outCanvas.width = srcCanvas.width;
594            outCanvas.height = srcCanvas.height;
595
596            if (hasAlpha && alphaChannel) {
597                let finalRGBA = new cv.Mat();
598                let mergeChannels = new cv.MatVector();
599                mergeChannels.push_back(resultU8);
600                mergeChannels.push_back(resultU8);
601                mergeChannels.push_back(resultU8);
602                mergeChannels.push_back(alphaChannel);
603                cv.merge(mergeChannels, finalRGBA);
604                cv.imshow(outCanvas, finalRGBA);
605                finalRGBA.delete();
606                mergeChannels.delete();
607                alphaChannel.delete();
608            } else {
609                cv.imshow(outCanvas, resultU8);
610            }
611
612            gray.delete();
613            resultU8.delete();
614
615            // Update originalCanvas store
616            const newOrigCanvas = document.createElement('canvas');
617            newOrigCanvas.width = outCanvas.width;
618            newOrigCanvas.height = outCanvas.height;
619            newOrigCanvas.getContext('2d').drawImage(outCanvas, 0, 0);
620            setImageHistoryProp('originalCanvas', newOrigCanvas);
621
622            // Update display-res preview store
623            const MAX_DISPLAY = 1200;
624            let dw = outCanvas.width, dh = outCanvas.height;
625            if (dw > MAX_DISPLAY || dh > MAX_DISPLAY) {
626                const scale = MAX_DISPLAY / Math.max(dw, dh);
627                dw = Math.round(dw * scale);
628                dh = Math.round(dh * scale);
629            }
630            const displayCanvas = document.createElement('canvas');
631            displayCanvas.width = dw;
632            displayCanvas.height = dh;
633            const dCtx = displayCanvas.getContext('2d');
634            dCtx.drawImage(outCanvas, 0, 0, dw, dh);
635            setImageHistoryProp('displayOriginalImageData', dCtx.getImageData(0, 0, dw, dh));
636
637            // Update visible fabric image
638            fabric.Image.fromURL(outCanvas.toDataURL(), function(polishedImg) {
639                // Preserve on-canvas display size: new image's pixel dimensions may
640                // differ from the previous currentImage, so compute scale from the
641                // target display footprint rather than reusing currentImage.scaleX
641/Y.
642                const targetDisplayW = currentImage.width * currentImage.scaleX;
643                const targetDisplayH = currentImage.height * currentImage.scaleY;
644                polishedImg.set({
645                    left: currentImage.left, top: currentImage.top,
646                    originX: 'center', originY: 'center',
647                    scaleX: targetDisplayW / polishedImg.width,
648                    scaleY: targetDisplayH / polishedImg.height,
649                    selectable: false, evented: true, hoverCursor: 'default'
650                });
651
652                fabricCanvas.remove(currentImage);
653                fabricCanvas.add(polishedImg);
654                fabricCanvas.renderAll();
655                setCurrentImage(polishedImg);
656
657                console.log('✅ High Pass Polish applied');
658                resolve(true);
659            });
660
661        } catch (error) {
662            console.error('High Pass Polish failed:', error);
663            resolve(false); // Fail silently
664        }
665    });
666}
667
668function saveUndoState() {
669    const state = {
670        originalCanvas: null,
671        displayOriginalImageData: null,
672        invertState: adjustmentValues.invert || false,
673        // Save ACTUAL current display position from the live image
674        view: {
675            scaleX: currentImage ? currentImage.scaleX : imageHistory.originalScaleX,
676            scaleY: currentImage ? currentImage.scaleY : imageHistory.originalScaleY,
677            left: currentImage ? currentImage.left : imageHistory.originalLeft,
678            top: currentImage ? currentImage.top : imageHistory.originalTop
679        },
680        // Also save imageHistory originals so they can be restored
681        historyView: {
682            scaleX: imageHistory.originalScaleX,
683            scaleY: imageHistory.originalScaleY,
684            left: imageHistory.originalLeft,
685            top: imageHistory.originalTop
686        }
687    };
688
689    // Save originalCanvas (deep copy)
690    if (imageHistory.originalCanvas) {
691        const c = document.createElement('canvas');
692        c.width = imageHistory.originalCanvas.width;
693        c.height = imageHistory.originalCanvas.height;
694        c.getContext('2d').drawImage(imageHistory.originalCanvas, 0, 0);
695        state.originalCanvas = c;
696    }
697
698    // Save displayOriginalImageData (deep copy)
699    if (imageHistory.displayOriginalImageData) {
700        const d = imageHistory.displayOriginalImageData;
701        state.displayOriginalImageData = new ImageData(new Uint8ClampedArray(d.data), d.width, d.height);
702    }
703
704    autoAdjustUndoState = state;
705}
706
707function restoreUndoState() {
708    if (!autoAdjustUndoState) return false;
709
710    const state = autoAdjustUndoState;
711
712    // Restore stores
713    if (state.originalCanvas) {
714        setImageHistoryProp('originalCanvas', state.originalCanvas);
715    }
716    if (state.displayOriginalImageData) {
717        setImageHistoryProp('displayOriginalImageData', state.displayOriginalImageData);
718    }
719
720    // Restore imageHistory view tracking
721    setImageHistoryProp('originalScaleX', state.historyView.scaleX);
722    setImageHistoryProp('originalScaleY', state.historyView.scaleY);
723    setImageHistoryProp('originalLeft', state.historyView.left);
724    setImageHistoryProp('originalTop', state.historyView.top);
725
726    // Restore visible image FROM the saved originalCanvas (dimensions guaranteed to match scale)
727    if (state.originalCanvas) {
728        const img = new fabric.Image(state.originalCanvas);
729        img.set({
730            left: state.view.left, top: state.view.top,
731            originX: 'center', originY: 'center',
732            scaleX: state.view.scaleX, scaleY: state.view.scaleY,
733            selectable: false, evented: true, hoverCursor: 'default'
734        });
735
736        fabricCanvas.remove(currentImage);
737        fabricCanvas.add(img);
738        fabricCanvas.renderAll();
739        setCurrentImage(img);
740    }
741
742    autoAdjustUndoState = null;
743    return true;
744}
745
746// Export for filters.js undo/reset
747export function undoSmartAutoAdjust() {
748    // Save invert state before restore nullifies the undo state
749    const savedInvert = autoAdjustUndoState ? autoAdjustUndoState.invertState : false;
750    
751    if (restoreUndoState()) {
752        setImageHistoryProp('autoEnhanceApplied', false);
753        setImageHistoryProp('autoAdjustMode', null);
754
755        // Reset sliders to zero
756        resetSlidersToDefaults();
757
758        // Restore invert state
759        setAdjustmentProp('invert', savedInvert);
760        const invertToggle = document.getElementById('invert-toggle');
761        if (invertToggle) invertToggle.checked = savedInvert;
762        const invertBtn = document.querySelector('[data-tool="invert"]');
763        if (invertBtn) {
764            if (savedInvert) invertBtn.classList.add('active');
765            else invertBtn.classList.remove('active');
766        }
767
768        // Restore UI
769        const undoBtn = document.getElementById('undo-auto-btn');
770        if (undoBtn) undoBtn.style.display = 'none';
771        const profileGroup = document.getElementById('auto-adjust-profiles');
772        if (profileGroup) profileGroup.style.display = 'grid';
773
774        console.log('✅ Smart Auto-Adjust undone (including CLAHE + invert)');
775        return true;
776    }
777    return false;
778}
779
780function resetSlidersToDefaults() {
781    setAdjustmentProp('brightness', 0);
782    setAdjustmentProp('contrast', 0);
783    setAdjustmentProp('gamma', [1, 1, 1]);
784    setAdjustmentProp('sharpenAmount', 0);
785    setAdjustmentProp('smooth', 0);
786    setAdjustmentProp('levelsBlack', 0);
787    setAdjustmentProp('levelsWhite', 255);
788    setAdjustmentProp('curveMidtones', 0);
789    setAdjustmentProp('curveShadow', 0);
790    setAdjustmentProp('curveHighlight', 0);
791
792    const el = id => document.getElementById(id);
793    if (el('brightness-slider')) el('brightness-slider').value = 0;
794    if (el('brightness-value')) el('brightness-value').value = 0;
795    safeSetText('brightness-display', 0);
796    if (el('contrast-slider')) el('contrast-slider').value = 0;
797    if (el('contrast-value')) el('contrast-value').value = 0;
798    safeSetText('contrast-display', 0);
799    if (el('gamma-slider')) el('gamma-slider').value = 100;
800    if (el('gamma-value')) el('gamma-value').value = '1.00';
801    safeSetText('gamma-display', '1.0');
802    if (el('sharpen-amount-slider')) el('sharpen-amount-slider').value = 0;
803    if (el('sharpen-amount-value')) el('sharpen-amount-value').value = 0;
804    safeSetText('sharpen-display', '0%');
805    if (levelsSlider) levelsSlider.set([0, 255]);
806    if (el('curve-midtones-slider')) el('curve-midtones-slider').value = 0;
807    if (el('curve-midtones-value')) el('curve-midtones-value').value = 0;
808    if (el('curve-shadow-slider')) el('curve-shadow-slider').value = 0;
809    if (el('curve-shadow-value')) el('curve-shadow-value').value = 0;
810    if (el('curve-highlight-slider')) el('curve-highlight-slider').value = 0;
811    if (el('curve-highlight-value')) el('curve-highlight-value').value = 0;
812}
813
814// Export for filters.js reset
815export function clearAutoAdjustState() {
816    // Release canvas memory before nullifying
817    if (autoAdjustUndoState) {
818        if (autoAdjustUndoState.originalCanvas) {
819            autoAdjustUndoState.originalCanvas.width = 0;
820            autoAdjustUndoState.originalCanvas.height = 0;
821        }
822        autoAdjustUndoState.displayOriginalImageData = null;
823        autoAdjustUndoState.originalCanvas = null;
824    }
825    autoAdjustUndoState = null;
826    autoAdjustRunning = false;
827    if (autoAdjustAbortController) {
828        autoAdjustAbortController.abort();
829        autoAdjustAbortController = null;
830    }
831    forceClosePopup();
832    console.log('🧹 Auto-adjust state cleared');
833}
834
835// ===========================
836// GET DISPLAY-RES IMAGE AS DATA URL
837// ===========================
838
839function getDisplayImageDataURL() {
840    if (imageHistory.displayOriginalImageData) {
841        const d = imageHistory.displayOriginalImageData;
842        const canvas = document.createElement('canvas');
843        canvas.width = d.width;
844        canvas.height = d.height;
845        canvas.getContext('2d').putImageData(d, 0, 0);
846        return canvas.toDataURL('image/jpeg', 0.85);
847    } else if (imageHistory.originalCanvas) {
848        const src = imageHistory.originalCanvas;
849        const maxDim = 1200;
850        let w = src.width, h = src.height;
851        if (w > maxDim || h > maxDim) {
852            const scale = maxDim / Math.max(w, h);
853            w = Math.round(w * scale);
854            h = Math.round(h * scale);
855        }
856        const canvas = document.createElement('canvas');
857        canvas.width = w;
858        canvas.height = h;
859        canvas.getContext('2d').drawImage(src, 0, 0, w, h);
860        return canvas.toDataURL('image/jpeg', 0.85);
861    }
862    return null;
863}
864
865// ===========================
866// POPUP
867// ===========================
868
869function createAnalysisPopup(mode, isGemini) {
870    const existing = document.getElementById('smart-adjust-popup');
871    if (existing) existing.remove();
872
873    const ui = PROFILE_UI[mode] || PROFILE_UI.sd;
874    const accentColor = isGemini ? GEMINI_UI_OVERLAY.badgeColor : ui.accentColor;
875    const titleSuffix = isGemini ? ' <span style="color:#f97316;font-size:10px;font-weight:700;">⚡ GEMINI</span>' : '';
876
877    const popup = document.createElement('div');
878    popup.id = 'smart-adjust-popup';
879    popup.innerHTML = `
880        <div class="saa-backdrop"></div>
881        <div class="saa-card">
882            <button class="saa-close-btn" title="Cancel">&times;</button>
883            <div class="saa-header">
884                <div class="saa-laser-icon">
885                    <svg viewBox="0 0 40 40" width="40" height="40">
886                        <circle cx="20" cy="20" r="16" fill="none" stroke="${accentColor}" stroke-width="2" stroke-dasharray="6 4" class="saa-ring"/>
887                        <circle cx="20" cy="20" r="3" fill="${accentColor}" class="saa-core"/>
888                    </svg>
889                </div>
890                <div class="saa-header-text">
891                    <span class="saa-title">Analysing · ${ui.label}${titleSuffix}</span>
892                    <span class="saa-subtitle">${ui.description}</span>
893                </div>
894            </div>
895            <div class="saa-histogram-area">
896                <canvas id="saa-histogram-canvas" width="240" height="80"></canvas>
897                <div class="saa-scan-line" style="background:linear-gradient(180deg,transparent 0%,${accentColor}ee 40%,${accentColor}ee 60%,transparent 100%);box-shadow:0 0 12px ${accentColor}99;"></div>
898            </div>
899            <div class="saa-metrics">
900                <div class="saa-metric" data-metric="brightness">
901                    <span class="saa-metric-label">Brightness</span>
902                    <span class="saa-metric-value">—</span>
903                </div>
904                <div class="saa-metric" data-metric="contrast">
905                    <span class="saa-metric-label">Contrast</span>
906                    <span class="saa-metric-value">—</span>
907                </div>
908                <div class="saa-metric" data-metric="range">
909                    <span class="saa-metric-label">Range</span>
910                    <span class="saa-metric-value">—</span>
911                </div>
912                <div class="saa-metric" data-metric="detail">
913                    <span class="saa-metric-label">Detail</span>
914                    <span class="saa-metric-value">—</span>
915                </div>
916            </div>
917            <div class="saa-progress-track">
918                <div class="saa-progress-fill" style="background:linear-gradient(90deg,${accentColor},${accentColor}cc);"></div>
919            </div>
920            <div class="saa-status">${isGemini ? 'Pre-analysing tonal range…' : 'Applying bilateral denoise…'}</div>
921        </div>
922    `;
923
924    document.body.appendChild(popup);
925    requestAnimationFrame(() => popup.classList.add('saa-visible'));
926
927    // Close button — allows user to cancel at any time
928    const closeBtn = popup.querySelector('.saa-close-btn');
929    if (closeBtn) {
930        closeBtn.addEventListener('click', () => {
931            if (autoAdjustAbortController) autoAdjustAbortController.abort();
932        });
933    }
934
935    return popup;
936}
937
938function drawScanAnimation(canvas, progress) {
939    if (!canvas) return;
940    const ctx = canvas.getContext('2d');
941    const w = canvas.width;
942    const h = canvas.height;
943    ctx.clearRect(0, 0, w, h);
944
945    const barCount = 64;
946    const barWidth = w / barCount;
947    const revealX = progress * barCount;
948
949    for (let i = 0; i < barCount; i++) {
950        const alpha = i < revealX ? 0.7 : 0.06;
951        const seed = Math.sin(i * 12.9898 + 78.233) * 43758.5453;
952        const barH = (Math.abs(seed - Math.floor(seed))) * (h - 8) + 4;
953        const t = i / barCount;
954        const r = Math.round(59 + t * 108);
955        const g = Math.round(130 - t * 30);
956        const b = Math.round(246 - t * 50);
957        ctx.fillStyle = `rgba(${r},${g},${b},${alpha})`;
958        ctx.fillRect(i * barWidth, h - barH, Math.max(1, barWidth - 1), barH);
959    }
960}
961
962let histogramAnimFrame = null;
963
964function drawHistogramFromStats(canvas, stats, animate = true) {
965    if (!canvas || !stats) return;
966
967    // Cancel any existing animation
968    if (histogramAnimFrame) {
969        cancelAnimationFrame(histogramAnimFrame);
970        histogramAnimFrame = null;
971    }
972
973    const ctx = canvas.getContext('2d');
974    const w = canvas.width;
975    const h = canvas.height;
976    const mean = stats.mean || 128;
977    const std = stats.stdDev || 50;
978    const barCount = 64;
979    const barWidth = w / barCount;
980
981    // Pre-compute base heights with some randomised variation for organic feel
982    const baseHeights = [];
983    for (let i = 0; i < barCount; i++) {
984        const x = (i / barCount) * 255;
985        const dist = (x - mean) / Math.max(std, 1);
986        baseHeights[i] = Math.exp(-0.5 * dist * dist) * (h - 8) + 2;
987    }
988
989    function renderFrame() {
990        ctx.clearRect(0, 0, w, h);
991        const time = Date.now() * 0.003;
992
993        for (let i = 0; i < barCount; i++) {
994            // Gentle sine-based pulse — each bar has a slightly different phase
995            const pulse = 1 + 0.08 * Math.sin(time + i * 0.4) + 0.04 * Math.sin(time * 1.7 + i * 0.7);
996            const barH = baseHeights[i] * pulse;
997            const t = i / barCount;
998            ctx.fillStyle = `rgba(${Math.round(59 + t * 108)},${Math.round(130 - t * 30)},${Math.round(246 - t * 50)},0.85)`;
999            ctx.fillRect(i * barWidth, h - barH, Math.max(1, barWidth - 1), barH);
1000        }
1001
1002        if (animate) {
1003            histogramAnimFrame = requestAnimationFrame(renderFrame);
1004        }
1005    }
1006
1007    renderFrame();
1008}
1009
1010function stopHistogramAnimation() {
1011    if (histogramAnimFrame) {
1012        cancelAnimationFrame(histogramAnimFrame);
1013        histogramAnimFrame = null;
1014    }
1015}
1016
1017// Gemini-specific popup status messages
1018const geminiMessages = {
1019    'pre-analysis':  'Pre-analysing tonal range…',
1020    'bake':          'Applying Silent Bake…',
1021    'haloguard':     'Adaptive halo guard…',
1022    'scanning':      'Re-analysing optimised image…',
1023    'received':      'Calculating Gemini adjustments…',
1024    'applying':      'Applying adjustments…',
1025    'finalising':    'Finalising…',
1026    'custom':        'Applying custom preset…',
1027    'error':         'Gemini processing failed — try again'
1028};
1029
1030function updatePopupMetrics(popup, stats, phase, isGemini) {
1031    if (!popup) return;
1032    const statusEl = popup.querySelector('.saa-status');
1033
1034    // Standard messages
1035    const standardMessages = {
1036        'bilateral':   'Applying bilateral denoise…',
1037        'clahe':       'Running CLAHE enhancement…',
1038        'sharpen':     'Adaptive sharpening…',
1039        'scanning':    'Analysing tonal distribution…',
1040        'received':    'Calculating optimal adjustments…',
1041        'edgepolish':  'Applying edge polish…',
1042        'applying':    'Applying adjustments…',
1043        'finalising':  'Finalising…',
1044        'custom':      'Applying custom preset…',
1045        'error':       'Analysis failed — try again'
1046    };
1047
1048    const messages = isGemini ? { ...standardMessages, ...geminiMessages } : standardMessages;
1049
1050    if (messages[phase]) {
1051        if (statusEl) {
1052            statusEl.textContent = messages[phase];
1053            if (phase === 'error') statusEl.style.color = '#f87171';
1054        }
1055    }
1056
1057    if ((phase === 'received') && stats) {
1058        const metricData = [
1059            { key: 'brightness', value: `${Math.round(stats.mean)}` },
1060            { key: 'contrast', value: `${Math.round(stats.stdDev)}σ` },
1061            { key: 'range', value: `${stats.dynamicRange}` },
1062            { key: 'detail', value: stats.histShape },
1063        ];
1064        metricData.forEach((m) => {
1065            const el = popup.querySelector(`[data-metric="${m.key}"] .saa-metric-value`);
1066            if (el) el.textContent = m.value;
1067        });
1068        popup.querySelectorAll('.saa-metric').forEach(m => m.classList.add('saa-revealed'));
1069    } else if (phase === 'finalising') {
1070        popup.querySelector('.saa-card')?.classList.add('saa-done');
1071    }
1072}
1073
1074function closePopup(popup) {
1075    if (!popup) return;
1076    stopHistogramAnimation();
1077    popup.classList.remove('saa-visible');
1078    popup.classList.add('saa-closing');
1079    setTimeout(() => popup.remove(), 400);
1080}
1081
1082// Force-kill any stuck popup
1083function forceClosePopup() {
1084    stopHistogramAnimation();
1085    const popup = document.getElementById('smart-adjust-popup');
1086    if (popup) {
1087        console.warn('⚠ Force-closing stuck popup');
1088        popup.remove();
1089    }
1090    const postPopup = document.getElementById('post-adjust-popup');
1091    if (postPopup) {
1092        postPopup.remove();
1093    }
1094}
1095
1096// ===========================
1097// SLIDER ANIMATION
1098// ===========================
1099
1100function animateSliders(targetValues, duration = 800) {
1101    return new Promise((resolve) => {
1102        const startValues = {
1103            brightness: adjustmentValues.brightness * 100,
1104            contrast: adjustmentValues.contrast * 100,
1105            gamma: adjustmentValues.gamma[0] * 100,
1106            sharpenAmount: adjustmentValues.sharpenAmount,
1107            smooth: adjustmentValues.smooth,
1108            levelsBlack: adjustmentValues.levelsBlack,
1109            levelsWhite: adjustmentValues.levelsWhite,
1110            curveMidtones: adjustmentValues.curveMidtones,
1111            curveShadow: adjustmentValues.curveShadow,
1112            curveHighlight: adjustmentValues.curveHighlight,
1113        };
1114        const endValues = {
1115            brightness: targetValues.brightness,
1116            contrast: targetValues.contrast,
1117            gamma: targetValues.gamma * 100,
1118            sharpenAmount: targetValues.sharpenAmount,
1119            smooth: 0,
1120            levelsBlack: targetValues.levelsBlack,
1121            levelsWhite: targetValues.levelsWhite,
1122            curveMidtones: targetValues.curveMidtones,
1123            curveShadow: targetValues.curveShadow,
1124            curveHighlight: targetValues.curveHighlight,
1125        };
1126
1127        const startTime = performance.now();
1128        let lastApplyTime = 0;
1129        function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
1130
1131        function tick(now) {
1132            const elapsed = now - startTime;
1133            const rawT = Math.min(1, elapsed / duration);
1134            const t = easeOutCubic(rawT);
1135
1136            const current = {};
1137            for (const key of Object.keys(startValues)) {
1138                current[key] = startValues[key] + (endValues[key] - startValues[key]) * t;
1139            }
1140
1141            const el = id => document.getElementById(id);
1142
1143            const bVal = Math.round(current.brightness);
1144            if (el('brightness-slider')) el('brightness-slider').value = bVal;
1145            if (el('brightness-value')) el('brightness-value').value = bVal;
1146            safeSetText('brightness-display', bVal);
1147
1148            const cVal = Math.round(current.contrast);
1149            if (el('contrast-slider')) el('contrast-slider').value = cVal;
1150            if (el('contrast-value')) el('contrast-value').value = cVal;
1151            safeSetText('contrast-display', cVal);
1152
1153            const gVal = current.gamma / 100;
1154            if (el('gamma-slider')) el('gamma-slider').value = Math.round(current.gamma);
1155            if (el('gamma-value')) el('gamma-value').value = gVal.toFixed(2);
1156            safeSetText('gamma-display', gVal.toFixed(1));
1157
1158            const sVal = Math.round(current.sharpenAmount);
1159            if (el('sharpen-amount-slider')) el('sharpen-amount-slider').value = sVal;
1160            if (el('sharpen-amount-value')) el('sharpen-amount-value').value = sVal;
1161            safeSetText('sharpen-display', sVal + '%');
1162
1163            if (levelsSlider) levelsSlider.set([Math.round(current.levelsBlack), Math.round(current.levelsWhite)]);
1164
1165            if (el('curve-midtones-slider')) el('curve-midtones-slider').value = Math.round(current.curveMidtones);
1166            if (el('curve-midtones-value')) el('curve-midtones-value').value = Math.round(current.curveMidtones);
1167            if (el('curve-shadow-slider')) el('curve-shadow-slider').value = Math.round(current.curveShadow);
1168            if (el('curve-shadow-value')) el('curve-shadow-value').value = Math.round(current.curveShadow);
1169            if (el('curve-highlight-slider')) el('curve-highlight-slider').value = Math.round(current.curveHighlight);
1170            if (el('curve-highlight-value')) el('curve-highlight-value').value = Math.round(current.curveHighlight);
1171
1172            if (now - lastApplyTime > 80 || rawT >= 1) {
1173                setAdjustmentProp('brightness', current.brightness / 100);
1174                setAdjustmentProp('contrast', current.contrast / 100);
1175                const g = current.gamma / 100;
1176                setAdjustmentProp('gamma', [g, g, g]);
1177                setAdjustmentProp('sharpenAmount', Math.round(current.sharpenAmount));
1178                setAdjustmentProp('levelsBlack', Math.round(current.levelsBlack));
1179                setAdjustmentProp('levelsWhite', Math.round(current.levelsWhite));
1180                setAdjustmentProp('curveMidtones', Math.round(current.curveMidtones));
1181                setAdjustmentProp('curveShadow', Math.round(current.curveShadow));
1182                setAdjustmentProp('curveHighlight', Math.round(current.curveHighlight));
1183                applyAllFilters();
1184                lastApplyTime = now;
1185            }
1186
1187            if (rawT < 1) requestAnimationFrame(tick);
1188            else resolve();
1189        }
1190        requestAnimationFrame(tick);
1191    });
1192}
1193
1194// ===========================
1195// MAIN ENTRY POINT
1196// ===========================
1197
1198export async function smartAutoAdjust(mode = 'sd', recipeOverlay = null) {
1199    const ui = PROFILE_UI[mode] || PROFILE_UI.sd;
1200
1201    // ── Engine routing ──
1202    const isGemini = activeEngine === 'gemini';
1203
1204    console.log(`🎯 Smart Auto-Adjust [${ui.label}]${isGemini ? ' ⚡ GEMINI' : ''} starting...`);
1205
1206    // ── Guard: no image ──
1207    if (!currentImage) { alert('Please upload an image first.'); return; }
1208
1209    // ── Guard: already running ──
1210    if (autoAdjustRunning) {
1211        console.warn('⚠ Smart Auto-Adjust already running — ignoring');
1212        return;
1213    }
1214
1215    // ── Validate image data is actually available before committing ──
1216    if (!imageHistory.originalCanvas && !imageHistory.displayOriginalImageData) {
1217        console.error('⚠ No image stores available — cannot run auto-adjust');
1218        alert('Image data not ready. Please re-upload your image.');
1219        return;
1220    }
1221
1222    autoAdjustRunning = true;
1223
1224    // ── Abort controller for cancellation ──
1225    autoAdjustAbortController = new AbortController();
1226    const signal = autoAdjustAbortController.signal;
1227
1228    // ── Save COMPLETE undo state (before anything touches the image) ──
1229    saveUndoState();
1230
1231    // ── Show popup ──
1232    const popup = createAnalysisPopup(mode, isGemini);
1233    const canvas = document.getElementById('saa-histogram-canvas');
1234    const progressFill = popup.querySelector('.saa-progress-fill');
1235    const startTime = Date.now();
1236    const MIN_DISPLAY_MS = 6000;
1237
1238    // Helper: check if cancelled
1239    const cancelled = () => signal.aborted;
1240
1241    // Helper: clean up on failure/cancel
1242    function cleanupOnFailure() {
1243        autoAdjustRunning = false;
1244        autoAdjustAbortController = null;
1245        restoreUndoState();
1246        const profileGroup = document.getElementById('auto-adjust-profiles');
1247        if (profileGroup) profileGroup.style.display = 'grid';
1248        const undoBtn = document.getElementById('undo-auto-btn');
1249        if (undoBtn) undoBtn.style.display = 'none';
1250    }
1251
1252    // ── Failsafe timeout — auto-close if anything hangs ──
1253    const failsafeTimer = setTimeout(() => {
1254        console.error('⚠ Smart Auto-Adjust timed out');
1255        forceClosePopup();
1256        cleanupOnFailure();
1257    }, POPUP_TIMEOUT_MS);
1258
1259    try {
1260        await new Promise(r => setTimeout(r, 100)); // Let popup render
1261        if (cancelled()) throw new DOMException('Cancelled', 'AbortError');
1262
1263        // ════════════════════════════════════════════
1264        // GEMINI FLOW
1265        // ════════════════════════════════════════════
1266        if (isGemini) {
1267
1268            // ── Gemini Step 1: Pre-analysis (need p3/p95 before bake) ──
1269            updatePopupMetrics(popup, null, 'pre-analysis', true);
1270            if (progressFill) progressFill.style.width = '8%';
1271            await new Promise(r => setTimeout(r, 400));
1272            if (cancelled()) throw new DOMException('Cancelled', 'AbortError');
1273
1274            // Get the original (un-modified) image for first analysis
1275            const preImageDataURL = getDisplayImageDataURL();
1276            if (!preImageDataURL) throw new Error('No image data available for analysis');
1277
1278            // Scan animation during server fetch
1279            let scanRunning = true;
1280            const scanStart = Date.now();
1281            const scanDuration = 1200;
1282            function runPreScan() {
1283                if (!scanRunning) return;
1284                const progress = Math.min(1, (Date.now() - scanStart) / scanDuration);
1285                drawScanAnimation(canvas, progress);
1286                if (progressFill) progressFill.style.width = (8 + progress * 17) + '%';
1287                if (progress < 1) requestAnimationFrame(runPreScan);
1288            }
1289            requestAnimationFrame(runPreScan);
1290
1291            // Server fetch — pre-analysis with gemini mode
1292            const preResponse = await fetch('/api/smart-adjust', {
1293                method: 'POST',
1294                headers: { 'Content-Type': 'application/json' },
1295                body: JSON.stringify({ image: preImageDataURL, mode: 'gemini', profile: mode }),
1296                signal: signal
1297            });
1298
1299            if (!preResponse.ok) throw new Error(`Server returned ${preResponse.status}`);
1300            const preResult = await preResponse.json();
1301            if (preResult.error) throw new Error(preResult.error);
1302            if (cancelled()) throw new DOMException('Cancelled', 'AbortError');
1303
1304            scanRunning = false;
1305            const preStats = preResult.stats;
1306            console.log('⚡ Gemini pre-analysis:', preStats);
1307
1308            if (progressFill) progressFill.style.width = '25%';
1309            await new Promise(r => setTimeout(r, 300));
1310            if (cancelled()) throw new DOMException('Cancelled', 'AbortError');
1311
1312            // ── Gemini Step 2: Silent Bake (DRE + Gamma Pivot) ──
1313            updatePopupMetrics(popup, null, 'bake', true);
1314            if (progressFill) progressFill.style.width = '30%';
1315
1316            const bakeResult = await geminiSilentBake(preStats, mode);
1317            console.log(bakeResult ? '⚡ Silent Bake applied' : '⚡ Silent Bake skipped');
1318            if (cancelled()) throw new DOMException('Cancelled', 'AbortError');
1319
1320            if (progressFill) progressFill.style.width = '45%';
1321            await new Promise(r => setTimeout(r, 400));
1322
1323            // ── Gemini Step 3: Halo Guard Sharpen ──
1324            updatePopupMetrics(popup, null, 'haloguard', true);
1325            if (progressFill) progressFill.style.width = '50%';
1326
1327            // v9.8: Profile-aware Halo Guard — radius AND amount
1328            // HD+ (aluminum): thin edges (1.2), gentle (0.35) — shows every pixel
1329            // HD  (fracture):  standard (1.5), moderate (0.45) — controlled for slate
1330            // SD  (wood):      thick edges (2.0), aggressive (0.65) — survives grain
1331            const haloAmounts = { 'hd_plus': 0.35, 'hd': 0.45, 'sd': 0.65 };
1332            const haloRadii   = { 'hd_plus': 1.2,  'hd': 1.5,  'sd': 2.0 };
1333            const haloResult = await geminiHaloGuardSharpen({
1334                radius: haloRadii[mode] || 1.5,
1335                amount: haloAmounts[mode] || 0.50,
1336                edgeDensity: preStats.edgeDensity || 0.25,
1337                shadowSNR: preStats.shadowSNR || 10.0
1338            });
1339            console.log(haloResult ? '⚡ Halo Guard applied' : '⚡ Halo Guard skipped');
1340            if (cancelled()) throw new DOMException('Cancelled', 'AbortError');
1341
1342            if (progressFill) progressFill.style.width = '58%';
1343            await new Promise(r => setTimeout(r, 400));
1344
1345            // ── Gemini Step 4: Re-analysis on BAKED image ──
1346            updatePopupMetrics(popup, null, 'scanning', true);
1347
1348            const postImageDataURL = getDisplayImageDataURL();
1349            if (!postImageDataURL) throw new Error('No baked image data available for re-analysis');
1350
1351            let scanRunning2 = true;
1352            const scanStart2 = Date.now();
1353            const scanDuration2 = 1200;
1354            function runPostScan() {
1355                if (!scanRunning2) return;
1356                const progress = Math.min(1, (Date.now() - scanStart2) / scanDuration2);
1357                drawScanAnimation(canvas, progress);
1358                if (progressFill) progressFill.style.width = (58 + progress * 12) + '%';
1359                if (progress < 1) requestAnimationFrame(runPostScan);
1360            }
1361            requestAnimationFrame(runPostScan);
1362
1363            // Second server analysis — on baked image
1364            const postResponse = await fetch('/api/smart-adjust', {
1365                method: 'POST',
1366                headers: { 'Content-Type': 'application/json' },
1367                body: JSON.stringify({ image: postImageDataURL, mode: 'gemini', profile: mode }),
1368                signal: signal
1369            });
1370
1371            if (!postResponse.ok) throw new Error(`Server returned ${postResponse.status}`);
1372            const postResult = await postResponse.json();
1373            if (postResult.error) throw new Error(postResult.error);
1374            if (cancelled()) throw new DOMException('Cancelled', 'AbortError');
1375
1376            scanRunning2 = false;
1377            const stats = postResult.stats;
1378            const sliderValues = postResult.sliders;
1379
1380            console.log('⚡ Gemini post-bake analysis:', stats);
1381            console.log(`⚡ Gemini [${ui.label}] Slider values:`, sliderValues);
1382
1383            // ── Gemini Step 5: Show results ──
1384            updatePopupMetrics(popup, stats, 'received', true);
1385            drawHistogramFromStats(canvas, stats);
1386            if (progressFill) progressFill.style.width = '78%';
1387            await new Promise(r => setTimeout(r, 800));
1388            if (cancelled()) throw new DOMException('Cancelled', 'AbortError');
1389
1390            // ── Gemini Step 6: Applying adjustments ──
1391            updatePopupMetrics(popup, null, 'applying', true);
1392            if (progressFill) progressFill.style.width = '88%';
1393            await new Promise(r => setTimeout(r, 500));
1394
1395            // ── Gemini Step 7: Finalising ──
1396            updatePopupMetrics(popup, null, 'finalising', true);
1397            if (progressFill) progressFill.style.width = '100%';
1398
1399            // Ensure minimum total display time
1400            const elapsed = Date.now() - startTime;
1401            if (elapsed < MIN_DISPLAY_MS) {
1402                await new Promise(r => setTimeout(r, MIN_DISPLAY_MS - elapsed));
1403            }
1404            if (cancelled()) throw new DOMException('Cancelled', 'AbortError');
1405
1406            // ── Custom preset preview phase (only if a recipe was provided) ──
1407            // Shows "Applying custom preset…" in the popup for a beat so the
1408            // user sees that custom is running. The actual overlay work still
1409            // runs after the popup closes (further down).
1410            if (recipeOverlay) {
1411                updatePopupMetrics(popup, null, 'custom', true);
1412                await new Promise(r => setTimeout(r, 900));
1413            }
1414
1415            await new Promise(r => setTimeout(r, 300));
1416            clearTimeout(failsafeTimer);
1417            closePopup(popup);
1418
1419            // ── Gemini Step 8: Animate sliders ──
1420            await new Promise(r => setTimeout(r, 200));
1421            await animateSliders(sliderValues, 900);
1422
1423            // Final precise apply
1424            setAdjustmentProp('brightness', sliderValues.brightness / 100);
1425            setAdjustmentProp('contrast', sliderValues.contrast / 100);
1426            const g = sliderValues.gamma;
1427            setAdjustmentProp('gamma', [g, g, g]);
1428            setAdjustmentProp('sharpenAmount', sliderValues.sharpenAmount);
1429            setAdjustmentProp('levelsBlack', sliderValues.levelsBlack);
1430            setAdjustmentProp('levelsWhite', sliderValues.levelsWhite);
1431            setAdjustmentProp('curveMidtones', sliderValues.curveMidtones);
1432            setAdjustmentProp('curveShadow', sliderValues.curveShadow);
1433            setAdjustmentProp('curveHighlight', sliderValues.curveHighlight);
1434            applyAllFilters();
1435
1436            // ── Gemini Step 8b: High-Pass Polish (HD+ and HD) ──
1437            // Profile-specific edge definition. SD skips this — its aggressive
1438            // DRE + curves handle detail through tonal weight instead.
1439            const hpParams = {
1440                'hd_plus': { radius: 1.0, strength: 0.35, ksize: 3 },  // Aluminum: thin, crisp
1441                'hd':      { radius: 1.2, strength: 0.30, ksize: 3 },  // Fracture: slightly coarser, controlled
1442            };
1443            if (hpParams[mode]) {
1444                const hp = hpParams[mode];
1445                const hpApplied = await applyHighPassPolish({
1446                    radius: hp.radius,
1447                    strength: hp.strength,
1448                    ksize: hp.ksize
1449                });
1450                if (hpApplied) {
1451                    applyAllFilters();
1452                    console.log(`⚡ ${mode.toUpperCase()} High-Pass Polish applied (r=${hp.radius}, s=${hp.strength})`);
1453                }
1454            }
1455
1456            // Mark auto-enhance applied
1457            setImageHistoryProp('autoEnhanceApplied', true);
1458            setImageHistoryProp('autoAdjustMode', mode);
1459            const undoBtn = document.getElementById('undo-auto-btn');
1460            if (undoBtn) undoBtn.style.display = 'none';
1461            const profileGroup = document.getElementById('auto-adjust-profiles');
1462            if (profileGroup) profileGroup.style.display = 'none';
1463
1464            autoAdjustRunning = false;
1465            autoAdjustAbortController = null;
1466
1467            // ── Gemini Step 9: Flatten / Bake ──
1468            await performBakeStep();
1469
1470            console.log(`✅ Smart Auto-Adjust [${ui.label}] ⚡ GEMINI complete`);
1471
1472            // ── Gemini Step 10a: Apply Custom recipe overlay (if this is a Custom run) ──
1473            if (recipeOverlay) {
1474                await applyRecipeOverlay(recipeOverlay);
1475            }
1476
1477            // ── Gemini Step 10: Post-Adjust Fine-Tune Popup ──
1478            await showPostAdjustPopup(ui);
1479
1480        } else {
1481
1482            // ════════════════════════════════════════════
1483            // STANDARD FLOW (unchanged)
1484            // ════════════════════════════════════════════
1485
1486            // ── Step 1: Bilateral Denoise + CLAHE ──
1487            updatePopupMetrics(popup, null, 'bilateral', false);
1488            if (progressFill) progressFill.style.width = '8%';
1489            await new Promise(r => setTimeout(r, 600));
1490            if (cancelled()) throw new DOMException('Cancelled', 'AbortError');
1491
1492            updatePopupMetrics(popup, null, 'clahe', false);
1493            if (progressFill) progressFill.style.width = '15%';
1494
1495            const claheApplied = await applyClaheToStores(mode);
1496            console.log(claheApplied ? '🔬 CLAHE applied to stores' : '🔬 CLAHE skipped');
1497            if (cancelled()) throw new DOMException('Cancelled', 'AbortError');
1498
1499            await new Promise(r => setTimeout(r, 500));
1500            if (progressFill) progressFill.style.width = '25%';
1501
1502            // ── Step 2: Adaptive Sharpening ──
1503            updatePopupMetrics(popup, null, 'sharpen', false);
1504            if (progressFill) progressFill.style.width = '30%';
1505
1506            const sharpenApplied = await applyAdaptiveSharpenToStores(mode);
1507            console.log(sharpenApplied ? '🔬 Adaptive sharpen applied' : '🔬 Adaptive sharpen skipped');
1508            if (cancelled()) throw new DOMException('Cancelled', 'AbortError');
1509
1510            await new Promise(r => setTimeout(r, 500));
1511            if (progressFill) progressFill.style.width = '38%';
1512            if (cancelled()) throw new DOMException('Cancelled', 'AbortError');
1513
1514            // ── Step 3: Get display image (now CLAHE'd + sharpened) ──
1515            const imageDataURL = getDisplayImageDataURL();
1516            if (!imageDataURL) {
1517                throw new Error('No image data available for analysis');
1518            }
1519
1520            // ── Step 4: Tonal analysis + server fetch ──
1521            updatePopupMetrics(popup, null, 'scanning', false);
1522
1523            let scanRunning = true;
1524            const scanStart = Date.now();
1525            const scanDuration = 1500;
1526
1527            function runScan() {
1528                if (!scanRunning) return;
1529                const progress = Math.min(1, (Date.now() - scanStart) / scanDuration);
1530                drawScanAnimation(canvas, progress);
1531                if (progressFill) progressFill.style.width = (38 + progress * 22) + '%';
1532                if (progress < 1) requestAnimationFrame(runScan);
1533            }
1534            requestAnimationFrame(runScan);
1535
1536            // Server fetch with abort signal
1537            const response = await fetch('/api/smart-adjust', {
1538                method: 'POST',
1539                headers: { 'Content-Type': 'application/json' },
1540                body: JSON.stringify({ image: imageDataURL, mode: mode }),
1541                signal: signal
1542            });
1543
1544            if (!response.ok) throw new Error(`Server returned ${response.status}`);
1545            const serverResult = await response.json();
1546            if (serverResult.error) throw new Error(serverResult.error);
1547            if (cancelled()) throw new DOMException('Cancelled', 'AbortError');
1548
1549            scanRunning = false;
1550
1551            const stats = serverResult.stats;
1552            const sliderValues = serverResult.sliders;
1553
1554            console.log('📊 Server analysis:', stats);
1555            console.log(`🎛️ [${ui.label}] Slider values:`, sliderValues);
1556
1557            // ── Step 5: Show results ──
1558            updatePopupMetrics(popup, stats, 'received', false);
1559            drawHistogramFromStats(canvas, stats);
1560            if (progressFill) progressFill.style.width = '68%';
1561            await new Promise(r => setTimeout(r, 800));
1562            if (cancelled()) throw new DOMException('Cancelled', 'AbortError');
1563
1564            // ── Step 6: Edge Polish (HD+ and SD only) ──
1565            if ((mode === 'hd_plus' || mode === 'sd') && serverResult.highPass) {
1566                updatePopupMetrics(popup, null, 'edgepolish', false);
1567                if (progressFill) progressFill.style.width = '78%';
1568                await new Promise(r => setTimeout(r, 600));
1569            }
1570
1571            // ── Step 7: Applying adjustments ──
1572            updatePopupMetrics(popup, null, 'applying', false);
1573            if (progressFill) progressFill.style.width = '88%';
1574            await new Promise(r => setTimeout(r, 500));
1575
1576            // ── Step 8: Finalising ──
1577            updatePopupMetrics(popup, null, 'finalising', false);
1578            if (progressFill) progressFill.style.width = '100%';
1579
1580            // Ensure minimum total display time
1581            const elapsed = Date.now() - startTime;
1582            if (elapsed < MIN_DISPLAY_MS) {
1583                await new Promise(r => setTimeout(r, MIN_DISPLAY_MS - elapsed));
1584            }
1585            if (cancelled()) throw new DOMException('Cancelled', 'AbortError');
1586
1587            // ── Custom preset preview phase (only if a recipe was provided) ──
1588            if (recipeOverlay) {
1589                updatePopupMetrics(popup, null, 'custom', false);
1590                await new Promise(r => setTimeout(r, 900));
1591            }
1592
1593            await new Promise(r => setTimeout(r, 300));
1594            clearTimeout(failsafeTimer);
1595            closePopup(popup);
1596
1597            // ── Phase 7: Animate sliders ──
1598            await new Promise(r => setTimeout(r, 200));
1599            await animateSliders(sliderValues, 900);
1600
1601            // Final precise apply
1602            setAdjustmentProp('brightness', sliderValues.brightness / 100);
1603            setAdjustmentProp('contrast', sliderValues.contrast / 100);
1604            const g = sliderValues.gamma;
1605            setAdjustmentProp('gamma', [g, g, g]);
1606            setAdjustmentProp('sharpenAmount', sliderValues.sharpenAmount);
1607            setAdjustmentProp('levelsBlack', sliderValues.levelsBlack);
1608            setAdjustmentProp('levelsWhite', sliderValues.levelsWhite);
1609            setAdjustmentProp('curveMidtones', sliderValues.curveMidtones);
1610            setAdjustmentProp('curveShadow', sliderValues.curveShadow);
1611            setAdjustmentProp('curveHighlight', sliderValues.curveHighlight);
1612            applyAllFilters();
1613
1614            // ── Phase 8 (HD+ and SD): High Pass Polish ──
1615            if ((mode === 'hd_plus' || mode === 'sd') && serverResult.highPass) {
1616                console.log('🔬 HD+ High Pass params:', serverResult.highPass);
1617                const hpApplied = await applyHighPassPolish(serverResult.highPass);
1618                console.log(hpApplied ? '✅ High Pass Polish applied' : '⚠ High Pass Polish skipped');
1619                // Re-apply filters on the polished base
1620                if (hpApplied) applyAllFilters();
1621            }
1622
1623            // Mark auto-enhance applied, hide profiles (undo handled by RESET ALL)
1624            setImageHistoryProp('autoEnhanceApplied', true);
1625            setImageHistoryProp('autoAdjustMode', mode);
1626            const undoBtn = document.getElementById('undo-auto-btn');
1627            if (undoBtn) undoBtn.style.display = 'none';
1628            const profileGroup = document.getElementById('auto-adjust-profiles');
1629            if (profileGroup) profileGroup.style.display = 'none';
1630
1631            autoAdjustRunning = false;
1632            autoAdjustAbortController = null;
1633
1634            // ── Phase 9: Flatten / Bake ──
1635            await performBakeStep();
1636
1637            console.log(`✅ Smart Auto-Adjust [${ui.label}] complete`);
1638
1639            // ── Phase 10a: Apply Custom recipe overlay (if this is a Custom run) ──
1640            if (recipeOverlay) {
1641                await applyRecipeOverlay(recipeOverlay);
1642            }
1643
1644            // ── Phase 10: Post-Adjust Fine-Tune Popup ──
1645            await showPostAdjustPopup(ui);
1646        }
1647
1648    } catch (err) {
1649        clearTimeout(failsafeTimer);
1650
1651        if (err.name === 'AbortError') {
1652            console.log('🛑 Smart Auto-Adjust cancelled by user');
1653            forceClosePopup();
1654            cleanupOnFailure();
1655            return;
1656        }
1657
1658        console.error('Smart Auto-Adjust error:', err);
1659        updatePopupMetrics(popup, null, 'error', isGemini);
1660        await new Promise(r => setTimeout(r, 2000));
1661        closePopup(popup);
1662        cleanupOnFailure();
1663    }
1664}
1665
1666// ===========================
1667// BAKE STEP — extracted to shared function for both flows
1668// ===========================
1669
1670function performBakeStep() {
1671    return new Promise((resolveBake) => {
1672        // Wait for the last applyAllFilters to finish rendering
1673        setTimeout(() => {
1674            try {
1675                // Get the current display-res filtered result from the fabric canvas
1676                const fabricEl = fabricCanvas.getElement();
1677                const dispW = fabricEl.width;
1678                const dispH = fabricEl.height;
1679
1680                // Render fabric canvas to get the composited result
1681                const bakeCanvas = document.createElement('canvas');
1682                bakeCanvas.width = dispW;
1683                bakeCanvas.height = dispH;
1684                const bakeCtx = bakeCanvas.getContext('2d');
1685                bakeCtx.drawImage(fabricEl, 0, 0);
1686
1687                // Also bake the full-res originalCanvas with current adju
1687stments
1688                // by running the filter worker synchronously on full-res data
1689                const origCanvas = imageHistory.originalCanvas;
1690                if (origCanvas) {
1691                    const fullW = origCanvas.width;
1692                    const fullH = origCanvas.height;
1693                    const fullCtx = origCanvas.getContext('2d');
1694                    const fullData = fullCtx.getImageData(0, 0, fullW, fullH);
1695
1696                    // Apply current LUT to full-res data inline
1697                    const adj = {
1698                        brightness: adjustmentValues.brightness,
1699                        contrast: adjustmentValues.contrast,
1700                        gamma: adjustmentValues.gamma,
1701                        sharpenAmount: adjustmentValues.sharpenAmount,
1702                        smooth: adjustmentValues.smooth,
1703                        levelsBlack: adjustmentValues.levelsBlack,
1704                        levelsWhite: adjustmentValues.levelsWhite,
1705                        curveMidtones: adjustmentValues.curveMidtones,
1706                        curveShadow: adjustmentValues.curveShadow,
1707                        curveHighlight: adjustmentValues.curveHighlight,
1708                        invert: false, // Don't bake invert — user controls that
1709                    };
1710
1711                    // Build LUT
1712                    const lut = new Uint8Array(256);
1713                    for (let i = 0; i < 256; i++) {
1714                        let v = i;
1715                        if (adj.levelsBlack > 0 || adj.levelsWhite < 255) {
1716                            const range = adj.levelsWhite - adj.levelsBlack;
1717                            v = Math.max(0, Math.min(255, ((v - adj.levelsBlack) / range) * 255));
1718                        }
1719                        if (adj.brightness !== 0) v = v + (adj.brightness * 255);
1720                        if (adj.contrast !== 0) {
1721                            const factor = (1 + adj.contrast);
1722                            v = ((v / 255 - 0.5) * factor + 0.5) * 255;
1723                        }
1724                        if (adj.gamma && adj.gamma[0] !== 1) {
1725                            v = Math.max(0, Math.min(255, v));
1726                            v = 255 * Math.pow(v / 255, 1 / adj.gamma[0]);
1727                        }
1728                        // Curves
1729                        if (adj.curveMidtones !== 0) {
1730                            let t = Math.max(0, Math.min(255, v)) / 255;
1731                            let shift = adj.curveMidtones / 100;
1732                            v = Math.max(0, Math.min(255, (t + shift * t * (1 - t) * 4) * 255));
1733                        }
1734                        if (adj.curveShadow !== 0) {
1735                            let t = Math.max(0, Math.min(255, v)) / 255;
1736                            let shift = adj.curveShadow / 100;
1737                            v = v + shift * (1 - t) * (1 - t) * 128;
1738                        }
1739                        if (adj.curveHighlight !== 0) {
1740                            let t = Math.max(0, Math.min(255, v)) / 255;
1741                            let shift = adj.curveHighlight / 100;
1742                            v = v + shift * t * t * 128;
1743                        }
1744                        lut[i] = Math.max(0, Math.min(255, Math.round(v)));
1745                    }
1746
1747                    // Apply LUT to full-res
1748                    const d = fullData.data;
1749                    for (let i = 0; i < d.length; i += 4) {
1750                        d[i] = lut[d[i]];
1751                        d[i + 1] = lut[d[i + 1]];
1752                        d[i + 2] = lut[d[i + 2]];
1753                    }
1754
1755                    // Write baked full-res back to originalCanvas
1756                    const newOrig = document.createElement('canvas');
1757                    newOrig.width = fullW;
1758                    newOrig.height = fullH;
1759                    newOrig.getContext('2d').putImageData(fullData, 0, 0);
1760                    setImageHistoryProp('originalCanvas', newOrig);
1761
1762                    // Update display-res store
1763                    const MAX_DISPLAY = 1200;
1764                    let dw = fullW, dh = fullH;
1765                    if (dw > MAX_DISPLAY || dh > MAX_DISPLAY) {
1766                        const scale = MAX_DISPLAY / Math.max(dw, dh);
1767                        dw = Math.round(dw * scale);
1768                        dh = Math.round(dh * scale);
1769                    }
1770                    const dispCanvas = document.createElement('canvas');
1771                    dispCanvas.width = dw;
1772                    dispCanvas.height = dh;
1773                    const dCtx = dispCanvas.getContext('2d');
1774                    dCtx.drawImage(newOrig, 0, 0, dw, dh);
1775                    setImageHistoryProp('displayOriginalImageData', dCtx.getImageData(0, 0, dw, dh));
1776
1777                    // Update fabric image from the baked display
1778                    fabric.Image.fromURL(dispCanvas.toDataURL(), function(bakedImg) {
1779                        // Preserve on-canvas display size: dispCanvas is at display-res
1780                        // (max 1200px), which usually differs from currentImage's pixel
1781                        // dimensions, so compute scale from target display footprint.
1782                        const targetDisplayW = currentImage.width * currentImage.scaleX;
1783                        const targetDisplayH = currentImage.height * currentImage.scaleY;
1784                        bakedImg.set({
1785                            left: currentImage.left, top: currentImage.top,
1786                            originX: 'center', originY: 'center',
1787                            scaleX: targetDisplayW / bakedImg.width,
1788                            scaleY: targetDisplayH / bakedImg.height,
1789                            selectable: false, evented: true, hoverCursor: 'default'
1790                        });
1791                        fabricCanvas.remove(currentImage);
1792                        fabricCanvas.add(bakedImg);
1793                        fabricCanvas.renderAll();
1794                        setCurrentImage(bakedImg);
1795
1796                        // Reset all sliders to zero — adjustments are now baked in
1797                        resetSlidersToDefaults();
1798                        applyAllFilters();
1799
1800                        console.log('✅ Auto-adjust baked — stores flattened, sliders reset');
1801                        resolveBake();
1802                    });
1803                } else {
1804                    resolveBake();
1805                }
1806            } catch (err) {
1807                console.error('Bake step failed:', err);
1808                resolveBake(); // Continue anyway
1809            }
1810        }, 200); // Brief delay for last filter render to complete
1811    });
1812}
1813
1814console.log('✅ smart-auto-adjust.js loaded');
1815
1816// ===========================
1817// CUSTOM PRESET RECIPE OVERLAY
1818// Called after the bake, before the post-adjust popup, when the user runs
1819// a Custom preset. Applies (in order):
1820//   1. The nudge replay — same gamma + curve deltas as clicking Darken/
1821//      Lighten in the post-adjust popup N times.
1822//   2. Slider overrides — absolute values for brightness/contrast/sharpen/
1823//      gamma, overriding whatever the engine produced.
1824//   3. Invert override — forces invert on, off, or leaves it as the engine
1825//      decided.
1826// ===========================
1827
1828async function applyRecipeOverlay(recipe) {
1829    if (!recipe) return;
1830    console.log('★ Applying recipe overlay:', recipe);
1831
1832    // ----- 0. Adaptive sharpening (image-content operation, goes first) -----
1833    // Runs on the baked canvas before the LUT-based nudge/overrides. Works
1834    // with both Gemini and Claude runs — it's a standalone CV operation that
1835    // doesn't care what produced the input image. Stacks on top of whatever
1836    // sharpening the engine already applied.
1837    if (recipe.adaptiveSharpen) {
1838        try {
1839            console.log(`★ Running adaptive sharpen: ${recipe.adaptiveSharpen}`);
1840            await applyAdaptiveSharpenToStores(recipe.adaptiveSharpen);
1841        } catch (err) {
1842            console.error('★ Adaptive sharpen failed during recipe overlay:', err);
1843            // Non-fatal — keep going with the rest of the overlay
1844        }
1845    }
1846
1847    // ----- 1. Nudge replay -----
1848    // Constants match PA_GAMMA_STEP and PA_CURVE_STEP defined near the
1849    // post-adjust popup below. Positive nudge = darken (per popup convention).
1850    const nudge = recipe.nudge || 0;
1851    if (nudge !== 0) {
1852        const GAMMA_STEP = 0.03;
1853        const CURVE_STEP = { midtones: 2, shadow: 1, highlight: 1 };
1854
1855        const currentGamma = adjustmentValues.gamma[0];
1856        const newGamma = currentGamma - (GAMMA_STEP * nudge);
1857        setAdjustmentProp('gamma', [newGamma, newGamma, newGamma]);
1858
1859        setAdjustmentProp('curveMidtones', adjustmentValues.curveMidtones + (CURVE_STEP.midtones * nudge));
1860        setAdjustmentProp('curveShadow',   adjustmentValues.curveShadow   + (CURVE_STEP.shadow   * nudge));
1861        setAdjustmentProp('curveHighlight', adjustmentValues.curveHighlight + (CURVE_STEP.highlight * nudge));
1862        console.log(`★ Nudge replayed: ${nudge > 0 ? 'darken +' : 'lighten '}${nudge}`);
1863    }
1864
1865    // ----- 2. Slider overrides -----
1866    // Null/undefined means "don't override" — leave whatever the engine set.
1867    if (recipe.brightness !== null && recipe.brightness !== undefined) {
1868        setAdjustmentProp('brightness', recipe.brightness / 100);
1869    }
1870    if (recipe.contrast !== null && recipe.contrast !== undefined) {
1871        setAdjustmentProp('contrast', recipe.contrast / 100);
1872    }
1873    if (recipe.sharpenAmount !== null && recipe.sharpenAmount !== undefined) {
1874        setAdjustmentProp('sharpenAmount', recipe.sharpenAmount);
1875    }
1876    if (recipe.gamma !== null && recipe.gamma !== undefined) {
1877        setAdjustmentProp('gamma', [recipe.gamma, recipe.gamma, recipe.gamma]);
1878    }
1879
1880    // ----- 3. Invert override -----
1881    if (recipe.invertOverride === 'on') {
1882        setAdjustmentProp('invert', true);
1883    } else if (recipe.invertOverride === 'off') {
1884        setAdjustmentProp('invert', false);
1885    }
1886    // 'leave' → do nothing
1887
1888    // ----- Sync UI sliders so the form matches what we've set -----
1889    syncAdjustmentUI();
1890
1891    // ----- Apply everything to the image -----
1892    applyAllFilters();
1893}
1894
1895// Update all the main adjustment sliders on the page to match the current
1896// adjustmentValues. Used after applying a recipe overlay so the user sees
1897// the values in the UI.
1898function syncAdjustmentUI() {
1899    const el = id => document.getElementById(id);
1900    if (el('brightness-slider')) el('brightness-slider').value = Math.round(adjustmentValues.brightness * 100);
1901    if (el('brightness-value'))  el('brightness-value').value  = Math.round(adjustmentValues.brightness * 100);
1902    if (el('contrast-slider'))   el('contrast-slider').value   = Math.round(adjustmentValues.contrast * 100);
1903    if (el('contrast-value'))    el('contrast-value').value    = Math.round(adjustmentValues.contrast * 100);
1904    if (el('gamma-slider'))      el('gamma-slider').value      = Math.round(adjustmentValues.gamma[0] * 100);
1905    if (el('gamma-value'))       el('gamma-value').value       = adjustmentValues.gamma[0].toFixed(2);
1906    if (el('sharpen-amount-slider')) el('sharpen-amount-slider').value = adjustmentValues.sharpenAmount;
1907    if (el('sharpen-amount-value'))  el('sharpen-amount-value').value  = adjustmentValues.sharpenAmount;
1908    if (el('curve-midtones-slider')) el('curve-midtones-slider').value = adjustmentValues.curveMidtones;
1909    if (el('curve-midtones-value'))  el('curve-midtones-value').value  = adjustmentValues.curveMidtones;
1910    if (el('curve-shadow-slider'))   el('curve-shadow-slider').value   = adjustmentValues.curveShadow;
1911    if (el('curve-shadow-value'))    el('curve-shadow-value').value    = adjustmentValues.curveShadow;
1912    if (el('curve-highlight-slider')) el('curve-highlight-slider').value = adjustmentValues.curveHighlight;
1913    if (el('curve-highlight-value'))  el('curve-highlight-value').value  = adjustmentValues.curveHighlight;
1914    if (el('invert-toggle')) el('invert-toggle').checked = !!adjustmentValues.invert;
1915}
1916
1917// ===========================
1918// POST-ADJUST FINE-TUNE POPUP
1919// Appears top-right after auto-adjust completes.
1920// Lighten/Darken via gamma, Invert toggle for material preview.
1921// ===========================
1922
1923const PA_GAMMA_STEP = 0.03;
1924const PA_CURVE_STEP = { midtones: 2, shadow: 1, highlight: 1 };
1925const PA_MAX_CLICKS = 10;
1926
1927function showPostAdjustPopup(profileUI) {
1928    return new Promise((resolve) => {
1929        const accentColor = profileUI.accentColor || '#06b6d4';
1930        let nudgeCount = 0;
1931
1932        const popup = document.createElement('div');
1933        popup.id = 'post-adjust-popup';
1934        popup.innerHTML = `
1935            <div class="pa-card">
1936                <div class="pa-glow" style="background: radial-gradient(circle at 50% 0%, ${accentColor}15 0%, transparent 70%);"></div>
1937                <div class="pa-header">
1938                    <div class="pa-check-ring" style="border-color: ${accentColor};">
1939                        <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="${accentColor}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
1940                            <polyline points="20 6 9 17 4 12"></polyline>
1941                        </svg>
1942                    </div>
1943                    <div class="pa-title-group">
1944                        <span class="pa-title">${profileUI.label} Complete</span>
1945                        <span class="pa-subtitle">Fine-tune before finishing</span>
1946                    </div>
1947                </div>
1948
1949                <div class="pa-controls">
1950                    <div class="pa-nudge-row">
1951                        <button class="pa-nudge-btn pa-darken" id="pa-darken-btn">
1952                            <svg viewBox="0 0 20 20" width="14" height="14" fill="currentColor"><circle cx="10" cy="10" r="8" opacity="0.9"/></svg>
1953                            Darken
1954                        </button>
1955                        <div class="pa-nudge-indicator" id="pa-nudge-indicator">
1956                            <div class="pa-nudge-dots" id="pa-nudge-dots"></div>
1957                        </div>
1958                        <button class="pa-nudge-btn pa-lighten" id="pa-lighten-btn">
1959                            <svg viewBox="0 0 20 20" width="14" height="14" fill="currentColor"><circle cx="10" cy="10" r="6" fill="none" stroke="currentColor" stroke-width="2"/><line x1="10" y1="1" x2="10" y2="4" stroke="currentColor" stroke-width="1.5"/><line x1="10" y1="16" x2="10" y2="19" stroke="currentColor" stroke-width="1.5"/><line x1="1" y1="10" x2="4" y2="10" stroke="currentColor" stroke-width="1.5"/><line x1="16" y1="10" x2="19" y2="10" stroke="currentColor" stroke-width="1.5"/></svg>
1960                            Lighten
1961                        </button>
1962                    </div>
1963
1964                    <div class="pa-invert-row">
1965                        <button class="pa-invert-btn" id="pa-invert-btn">
1966                            <span class="pa-invert-indicator" id="pa-invert-indicator"></span>
1967                            <span class="pa-invert-text">Invert</span>
1968                        </button>
1969                    </div>
1970                </div>
1971
1972                <button class="pa-done-btn" id="pa-done-btn" style="border-color: ${accentColor}40; color: ${accentColor};">
1973                    Done
1974                </button>
1975            </div>
1976        `;
1977
1978        document.body.appendChild(popup);
1979        requestAnimationFrame(() => popup.classList.add('pa-visible'));
1980
1981        // Track invert state locally
1982        let invertState = adjustmentValues.invert || false;
1983        function updateInvertUI() {
1984            const btn = document.getElementById('pa-invert-btn');
1985            if (btn) {
1986                if (invertState) {
1987                    btn.classList.add('pa-invert-active');
1988                } else {
1989                    btn.classList.remove('pa-invert-active');
1990                }
1991            }
1992        }
1993        updateInvertUI();
1994
1995        function updateNudgeDots() {
1996            const dots = document.getElementById('pa-nudge-dots');
1997            if (!dots) return;
1998            let html = '';
1999            for (let i = -PA_MAX_CLICKS; i <= PA_MAX_CLICKS; i++) {
2000                if (i === 0) {
2001                    html += `<span class="pa-dot pa-dot-center ${nudgeCount === 0 ? 'pa-dot-active' : ''}"></span>`;
2002                } else {
2003                    const active = (i < 0 && nudgeCount <= i) || (i > 0 && nudgeCount >= i);
2004                    const side = i < 0 ? 'pa-dot-dark' : 'pa-dot-light';
2005                    html += `<span class="pa-dot ${side} ${active ? 'pa-dot-active' : ''}"></span>`;
2006                }
2007            }
2008            dots.innerHTML = html;
2009        }
2010        updateNudgeDots();
2011
2012        function applyNudge(direction) {
2013            const newCount = nudgeCount + direction;
2014            if (Math.abs(newCount) > PA_MAX_CLICKS) return;
2015            nudgeCount = newCount;
2016
2017            // Gamma nudge
2018            const currentGamma = adjustmentValues.gamma[0];
2019            const newGamma = currentGamma + (PA_GAMMA_STEP * direction);
2020            setAdjustmentProp('gamma', [newGamma, newGamma, newGamma]);
2021
2022            // Curve nudge — midtones shift most, shadows moderate, highlights least
2023            // Darken (direction=1): reduce curves. Lighten (direction=-1): increase curves.
2024            setAdjustmentProp('curveMidtones', adjustmentValues.curveMidtones - (PA_CURVE_STEP.midtones * direction));
2025            setAdjustmentProp('curveShadow', adjustmentValues.curveShadow - (PA_CURVE_STEP.shadow * direction));
2026            setAdjustmentProp('curveHighlight', adjustmentValues.curveHighlight - (PA_CURVE_STEP.highlight * direction));
2027
2028            applyAllFilters();
2029
2030            // Sync gamma slider UI
2031            const gSlider = document.getElementById('gamma-slider');
2032            const gValue = document.getElementById('gamma-value');
2033            if (gSlider) gSlider.value = Math.round(newGamma * 100);
2034            if (gValue) gValue.value = newGamma.toFixed(2);
2035            safeSetText('gamma-display', newGamma.toFixed(1));
2036
2037            // Sync curve slider UI
2038            const cmSlider = document.getElementById('curve-midtones-slider');
2039            const cmValue = document.getElementById('curve-midtones-value');
2040            if (cmSlider) cmSlider.value = adjustmentValues.curveMidtones;
2041            if (cmValue) cmValue.value = adjustmentValues.curveMidtones;
2042            const csSlider = document.getElementById('curve-shadow-slider');
2043            const csValue = document.getElementById('curve-shadow-value');
2044            if (csSlider) csSlider.value = adjustmentValues.curveShadow;
2045            if (csValue) csValue.value = adjustmentValues.curveShadow;
2046            const chSlider = document.getElementById('curve-highlight-slider');
2047            const chValue = document.getElementById('curve-highlight-value');
2048            if (chSlider) chSlider.value = adjustmentValues.curveHighlight;
2049            if (chValue) chValue.value = adjustmentValues.curveHighlight;
2050
2051            updateNudgeDots();
2052        }
2053
2054        // Darken / Lighten — flip direction when inverted so the visual
2055        // result matches the label (darken always darkens what's on 
2055screen)
2056        document.getElementById('pa-darken-btn').addEventListener('click', () => {
2057            applyNudge(invertState ? -1 : 1);
2058        });
2059        document.getElementById('pa-lighten-btn').addEventListener('click', () => {
2060            applyNudge(invertState ? 1 : -1);
2061        });
2062
2063        // Invert — simple button toggle, no checkbox
2064        document.getElementById('pa-invert-btn').addEventListener('click', function() {
2065            invertState = !invertState;
2066            setAdjustmentProp('invert', invertState);
2067            applyAllFilters();
2068            updateInvertUI();
2069        });
2070
2071        // Done button
2072        document.getElementById('pa-done-btn').addEventListener('click', function() {
2073            // Sync invert state to the main UI
2074            const invertToggle = document.getElementById('invert-toggle');
2075            if (invertToggle) invertToggle.checked = invertState;
2076            const invertBtn = document.querySelector('[data-tool="invert"]');
2077            if (invertBtn) {
2078                if (invertState) invertBtn.classList.add('active');
2079                else invertBtn.classList.remove('active');
2080            }
2081
2082            popup.classList.remove('pa-visible');
2083            setTimeout(() => popup.remove(), 300);
2084            resolve();
2085        });
2086    });
2087}
2088
2089// ===========================
2090// MANUAL HIGH PASS — Interactive popup with live preview
2091// Fetches recommended params from server, user adjusts via sliders,
2092// preview updates in real-time on display-res copy, merge applies
2093// to full-res originalCanvas stores.
2094// ===========================
2095
2096let hpPopupOpen = false;
2097let hpPreviewMats = null; // { grayF, srcCanvas } — cached for slider updates
2098
2099function getDisplayCanvas() {
2100    if (imageHistory.displayOriginalImageData) {
2101        const d = imageHistory.displayOriginalImageData;
2102        const c = document.createElement('canvas');
2103        c.width = d.width;
2104        c.height = d.height;
2105        c.getContext('2d').putImageData(d, 0, 0);
2106        return c;
2107    } else if (imageHistory.originalCanvas) {
2108        const src = imageHistory.originalCanvas;
2109        const maxDim = 1200;
2110        let w = src.width, h = src.height;
2111        if (w > maxDim || h > maxDim) {
2112            const scale = maxDim / Math.max(w, h);
2113            w = Math.round(w * scale);
2114            h = Math.round(h * scale);
2115        }
2116        const c = document.createElement('canvas');
2117        c.width = w; c.height = h;
2118        c.getContext('2d').drawImage(src, 0, 0, w, h);
2119        return c;
2120    }
2121    return null;
2122}
2123
2124function createHPPopup() {
2125    const existing = document.getElementById('hp-popup');
2126    if (existing) existing.remove();
2127
2128    const popup = document.createElement('div');
2129    popup.id = 'hp-popup';
2130    popup.innerHTML = `
2131        <div class="hp-backdrop"></div>
2132        <div class="hp-card">
2133            <div class="hp-header">
2134                <span class="hp-title">Edge Polish</span>
2135                <div style="display: flex; align-items: center; gap: 8px;">
2136                    <span class="saa-guide-tip" id="hp-guide-tip" title="Open Guide" style="cursor:pointer;">?</span>
2137                    <button class="hp-close-btn" id="hp-close-btn">&times;</button>
2138                </div>
2139            </div>
2140            <div class="hp-view-toggle">
2141                <button class="hp-view-btn active" data-view="edges">Edge Preview</button>
2142                <button class="hp-view-btn" data-view="blend">Blended Preview</button>
2143            </div>
2144            <div class="hp-preview-wrap" id="hp-preview-wrap">
2145                <canvas id="hp-preview-canvas"></canvas>
2146                <div class="hp-preview-loading" id="hp-loading">Analysing…</div>
2147                <div class="hp-pan-hint" id="hp-pan-hint">Drag to pan</div>
2148            </div>
2149            <div class="hp-zoom-row">
2150                <input type="range" class="hp-slider hp-zoom-slider" id="hp-zoom-slider"
2151                       min="100" max="400" step="10" value="100">
2152                <span class="hp-zoom-val" id="hp-zoom-val">100%</span>
2153                <button class="hp-zoom-reset" id="hp-zoom-reset" title="Reset zoom">⟳</button>
2154            </div>
2155            <div class="hp-slider-group">
2156                <div class="hp-slider-label">
2157                    <span class="hp-slider-name">Radius</span>
2158                    <span class="hp-slider-value" id="hp-radius-val">1.0</span>
2159                </div>
2160                <input type="range" class="hp-slider" id="hp-radius-slider"
2161                       min="0.5" max="10.0" step="0.1" value="1.0">
2162            </div>
2163            <input type="hidden" id="hp-strength-slider" value="0.55">
2164            <div class="hp-stats" id="hp-stats">
2165                <span>Mean: <span class="hp-stat-val" id="hp-stat-mean">—</span></span>
2166                <span>StdDev: <span class="hp-stat-val" id="hp-stat-std">—</span></span>
2167                <span>Range: <span class="hp-stat-val" id="hp-stat-range">—</span></span>
2168            </div>
2169            <div class="hp-actions">
2170                <button class="hp-btn hp-btn-cancel" id="hp-cancel-btn">Cancel</button>
2171                <button class="hp-btn hp-btn-merge" id="hp-merge-btn">Merge</button>
2172            </div>
2173        </div>
2174    `;
2175
2176    document.body.appendChild(popup);
2177    requestAnimationFrame(() => popup.classList.add('hp-visible'));
2178    return popup;
2179}
2180
2181let hpCurrentView = 'edges';
2182let hpDebounceTimer = null;
2183
2184function updateHPPreview(radius, strength) {
2185    if (!hpPreviewMats || !hpPreviewMats.grayF) return;
2186
2187    const canvas = document.getElementById('hp-preview-canvas');
2188    if (!canvas) return;
2189
2190    const { grayF } = hpPreviewMats;
2191
2192    // Use ksize 0 so OpenCV auto-calculates kernel from sigma (radius)
2193    // This matches Photoshop's behaviour where larger radius = larger kernel
2194    let blurred = new cv.Mat();
2195    cv.GaussianBlur(grayF, blurred, new cv.Size(0, 0), radius);
2196
2197    // High pass = original - blur
2198    let highPass = new cv.Mat();
2199    cv.subtract(grayF, blurred, highPass);
2200    blurred.delete();
2201
2202    let display = new cv.Mat();
2203
2204    if (hpCurrentView === 'edges') {
2205        // Grey + edges preview — 2x boost for Photoshop-style visibility
2206        // Display only — does not affect merge values
2207        let boosted = new cv.Mat();
2208        let scaleMat = new cv.Mat(highPass.rows, highPass.cols, cv.CV_32F, new cv.Scalar(2.0));
2209        cv.multiply(highPass, scaleMat, boosted);
2210        scaleMat.delete();
2211        let offset = new cv.Mat(grayF.rows, grayF.cols, cv.CV_32F, new cv.Scalar(128));
2212        cv.add(boosted, offset, display);
2213        boosted.delete();
2214        offset.delete();
2215    } else {
2216        // Blended preview: original + (highPass * strength)
2217        cv.addWeighted(grayF, 1.0, highPass, strength, 0, display);
2218    }
2219    highPass.delete();
2220
2221    // Convert to uint8 and show
2222    let displayU8 = new cv.Mat();
2223    display.convertTo(displayU8, cv.CV_8U);
2224    display.delete();
2225
2226    cv.imshow(canvas, displayU8);
2227    displayU8.delete();
2228}
2229
2230function onHPSliderInput() {
2231    const radius = parseFloat(document.getElementById('hp-radius-slider').value);
2232    const strength = parseFloat(document.getElementById('hp-strength-slider').value); // Fixed by server
2233
2234    document.getElementById('hp-radius-val').textContent = radius.toFixed(1);
2235
2236    // Debounce the OpenCV recalculation
2237    if (hpDebounceTimer) clearTimeout(hpDebounceTimer);
2238    hpDebounceTimer = setTimeout(() => {
2239        updateHPPreview(radius, strength);
2240    }, 30);
2241}
2242
2243function cleanupHPMats() {
2244    if (hpPreviewMats) {
2245        if (hpPreviewMats.grayF) hpPreviewMats.grayF.delete();
2246        hpPreviewMats = null;
2247    }
2248}
2249
2250function closeHPPopup() {
2251    const popup = document.getElementById('hp-popup');
2252    if (popup) {
2253        popup.classList.remove('hp-visible');
2254        setTimeout(() => popup.remove(), 300);
2255    }
2256    cleanupHPMats();
2257    hpPopupOpen = false;
2258}
2259
2260export async function openManualHighPass() {
2261    if (!currentImage) {
2262        alert('Please upload an image first.');
2263        return;
2264    }
2265    if (hpPopupOpen) return;
2266    if (typeof cv === 'undefined' || !window.cvReady) {
2267        alert('OpenCV is still loading — please wait a moment and try again.');
2268        return;
2269    }
2270
2271    // Force cleanup any stale state from previous session
2272    cleanupHPMats();
2273    const stalePopup = document.getElementById('hp-popup');
2274    if (stalePopup) stalePopup.remove();
2275
2276    hpPopupOpen = true;
2277    const popup = createHPPopup();
2278
2279    // Wire up close/cancel
2280    document.getElementById('hp-close-btn').addEventListener('click', closeHPPopup);
2281    document.getElementById('hp-cancel-btn').addEventListener('click', closeHPPopup);
2282
2283    // Wire up ? guide button — opens guide ABOVE the Edge Polish popup
2284    const hpGuideTip = document.getElementById('hp-guide-tip');
2285    if (hpGuideTip) {
2286        hpGuideTip.addEventListener('click', function(e) {
2287            e.preventDefault(); e.stopPropagation();
2288            const modal = document.getElementById('adjustments-guide-modal');
2289            if (!modal) return;
2290            modal.style.display = 'flex';
2291            modal.style.zIndex = '10001'; // Above Edge Polish popup (10000)
2292            // Scroll to Edge Polish section
2293            const guideBody = document.getElementById('adjustments-guide-body');
2294            if (guideBody) {
2295                const edgeSection = guideBody.querySelector('h4');
2296                // Find the Edge Polish heading
2297                const headings = guideBody.querySelectorAll('h4');
2298                for (const h of headings) {
2299                    if (h.textContent.includes('EDGE POLISH')) {
2300                        setTimeout(() => h.scrollIntoView({ behavior: 'smooth', block: 'start' }), 100);
2301                        break;
2302                    }
2303                }
2304            }
2305            const closeBtn = document.getElementById('adjustments-guide-close-btn');
2306            if (closeBtn) closeBtn.onclick = function() { modal.style.display = 'none'; };
2307        });
2308    }
2309
2310    // Wire up view toggle
2311    popup.querySelectorAll('.hp-view-btn').forEach(btn => {
2312        btn.addEventListener('click', function() {
2313            popup.querySelectorAll('.hp-view-btn').forEach(b => b.classList.remove('active'));
2314            this.classList.add('active');
2315            hpCurrentView = this.getAttribute('data-view');
2316            const radius = parseFloat(document.getElementById('hp-radius-slider').value);
2317            const strength = parseFloat(document.getElementById('hp-strength-slider').value);
2318            updateHPPreview(radius, strength);
2319        });
2320    });
2321
2322    // Wire up radius slider (strength is adaptive — set by server)
2323    document.getElementById('hp-radius-slider').addEventListener('input', onHPSliderInput);
2324
2325    // Wire up zoom/pan
2326    const zoomSlider = document.getElementById('hp-zoom-slider');
2327    const zoomVal = document.getElementById('hp-zoom-val');
2328    const zoomReset = document.getElementById('hp-zoom-reset');
2329    const previewWrap = document.getElementById('hp-preview-wrap');
2330    const panHint = document.getElementById('hp-pan-hint');
2331
2332    let hpZoom = 100;
2333    let hpPanX = 0, hpPanY = 0;
2334    let hpIsPanning = false;
2335    let hpPanStartX = 0, hpPanStartY = 0;
2336    let hpPanOriginX = 0, hpPanOriginY = 0;
2337
2338    function applyZoomPan() {
2339        const canvas = document.getElementById('hp-preview-canvas');
2340        if (!canvas) return;
2341        const scale = hpZoom / 100;
2342        canvas.style.transform = `scale(${scale}) translate(${hpPanX}px, ${hpPanY}px)`;
2343        canvas.style.transformOrigin = 'center center';
2344        // Show pan hint when zoomed in
2345        if (panHint) panHint.style.opacity = hpZoom > 100 ? '1' : '0';
2346    }
2347
2348    zoomSlider.addEventListener('input', function() {
2349        hpZoom = parseInt(this.value);
2350        zoomVal.textContent = hpZoom + '%';
2351        // Reset pan when zooming out to fit
2352        if (hpZoom <= 100) { hpPanX = 0; hpPanY = 0; }
2353        applyZoomPan();
2354    });
2355
2356    zoomReset.addEventListener('click', function() {
2357        hpZoom = 100;
2358        hpPanX = 0;
2359        hpPanY = 0;
2360        zoomSlider.value = 100;
2361        zoomVal.textContent = '100%';
2362        applyZoomPan();
2363    });
2364
2365    // Pan by left-click drag on preview
2366    previewWrap.addEventListener('mousedown', function(e) {
2367        if (hpZoom <= 100) return;
2368        e.preventDefault();
2369        hpIsPanning = true;
2370        hpPanStartX = e.clientX;
2371        hpPanStartY = e.clientY;
2372        hpPanOriginX = hpPanX;
2373        hpPanOriginY = hpPanY;
2374        previewWrap.style.cursor = 'grabbing';
2375    });
2376
2377    window.addEventListener('mousemove', function hpPanMove(e) {
2378        if (!hpIsPanning) return;
2379        const scale = hpZoom / 100;
2380        hpPanX = hpPanOriginX + (e.clientX - hpPanStartX) / scale;
2381        hpPanY = hpPanOriginY + (e.clientY - hpPanStartY) / scale;
2382        applyZoomPan();
2383    });
2384
2385    window.addEventListener('mouseup', function hpPanEnd() {
2386        if (hpIsPanning) {
2387            hpIsPanning = false;
2388            if (previewWrap) previewWrap.style.cursor = hpZoom > 100 ? 'grab' : 'default';
2389        }
2390    });
2391
2392    // Touch support for pan
2393    previewWrap.addEventListener('touchstart', function(e) {
2394        if (hpZoom <= 100 || e.touches.length !== 1) return;
2395        e.preventDefault();
2396        hpIsPanning = true;
2397        hpPanStartX = e.touches[0].clientX;
2398        hpPanStartY = e.touches[0].clientY;
2399        hpPanOriginX = hpPanX;
2400        hpPanOriginY = hpPanY;
2401    }, { passive: false });
2402
2403    window.addEventListener('touchmove', function(e) {
2404        if (!hpIsPanning || e.touches.length !== 1) return;
2405        const scale = hpZoom / 100;
2406        hpPanX = hpPanOriginX + (e.touches[0].clientX - hpPanStartX) / scale;
2407        hpPanY = hpPanOriginY + (e.touches[0].clientY - hpPanStartY) / scale;
2408        applyZoomPan();
2409    });
2410
2411    window.addEventListener('touchend', function() {
2412        hpIsPanning = false;
2413    });
2414
2415    // Get display canvas and prepare OpenCV mat
2416    const displayCanvas = getDisplayCanvas();
2417    if (!displayCanvas) {
2418        alert('No image data available.');
2419        closeHPPopup();
2420        return;
2421    }
2422
2423    // Set preview canvas size
2424    const previewCanvas = document.getElementById('hp-preview-canvas');
2425    previewCanvas.width = displayCanvas.width;
2426    previewCanvas.height = displayCanvas.height;
2427
2428    // Convert to greyscale float32
2429    let src = cv.imread(displayCanvas);
2430    let gray = new cv.Mat();
2431    if (src.channels() === 4) {
2432        cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
2433    } else if (src.channels() === 3) {
2434        cv.cvtColor(src, gray, cv.COLOR_RGB2GRAY);
2435    } else {
2436        gray = src.clone();
2437    }
2438    src.delete();
2439
2440    let grayF = new cv.Mat();
2441    gray.convertTo(grayF, cv.CV_32F);
2442    gray.delete();
2443
2444    hpPreviewMats = { grayF };
2445
2446    // Fetch recommended params from server (with timeout)
2447    // Send the auto-adjust mode so server returns profile-tuned strength
2448    const hpMode = imageHistory.autoAdjustMode || null;  // null = no auto-adjust run
2449    try {
2450        const imageDataURL = displayCanvas.toDataURL('image/jpeg', 0.85);
2451        const controller = new AbortController();
2452        const timeoutId = setTimeout(() => controller.abort(), 8000); // 8s timeout
2453
2454        const response = await fetch('/api/highpass-params', {
2455            method: 'POST',
2456            headers: { 'Content-Type': 'application/json' },
2457            body: JSON.stringify({ image: imageDataURL, mode: hpMode }),
2458            signal: controller.signal
2459        });
2460        clearTimeout(timeoutId);
2461
2462        if (response.ok) {
2463            const data = await response.json();
2464            const rec = data.recommended;
2465            const bounds = data.bounds;
2466
2467            // Set radius slider bounds and recommended value
2468            const rSlider = document.getElementById('hp-radius-slider');
2469            rSlider.min = bounds.radiusMin;
2470            rSlider.max = bounds.radiusMax;
2471            rSlider.value = rec.radius;
2472            document.getElementById('hp-radius-val').textContent = rec.radius.toFixed(1);
2473
2474            // Set adaptive strength (hidden input — no UI display)
2475            document.getElementById('hp-strength-slider').value = rec.strength;
2476
2477            // Show stats
2478            if (data.stats) {
2479                document.getElementById('hp-stat-mean').textContent = data.stats.mean;
2480                document.getElementById('hp-stat-std').textContent = data.stats.stdDev;
2481                document.getElementById('hp-stat-range').textContent = data.stats.dynamicRange;
2482            }
2483
2484            console.log('✅ HP recommended params:', rec, '(mode:', hpMode || 'default', ')');
2485        } else {
2486            console.warn('HP params server error:', response.status, '— using defaults');
2487        }
2488    } catch (err) {
2489        if (err.name === 'AbortError') {
2490            console.warn('HP params fetch timed out — using defaults');
2491        } else {
2492            console.warn('Failed to fetch HP params, using defaults:', err);
2493        }
2494    }
2495
2496    // Hide loading, show initial preview
2497    const loading = document.getElementById('hp-loading');
2498    if (loading) loading.style.opacity = '0';
2499
2500    const radius = parseFloat(document.getElementById('hp-radius-slider').value);
2501    const strength = parseFloat(document.getElementById('hp-strength-slider').value);
2502    updateHPPreview(radius, strength);
2503
2504    // Wire up merge button
2505    document.getElementById('hp-merge-btn').addEventListener('click', async function() {
2506        this.disabled = true;
2507        this.textContent = 'Applying…';
2508
2509        const finalRadius = parseFloat(document.getElementById('hp-radius-slider').value);
2510        const finalStrength = parseFloat(document.getElementById('hp-strength-slider').value);
2511
2512        // Apply to full-resolution stores (ksize 0 = auto from radius)
2513        const applied = await applyHighPassPolish({
2514            radius: finalRadius,
2515            strength: finalStrength,
2516            ksize: 0
2517        });
2518
2519        if (applied) {
2520            applyAllFilters();
2521            console.log(`✅ Manual Edge Polish merged: radius=${finalRadius}, strength=${finalStrength}`);
2522        }
2523
2524        closeHPPopup();
2525    });
2526}
2527
2528console.log('✅ manual high pass module loaded');

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.