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">×</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">×</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.