Toby Chui 13 часов назад
Родитель
Сommit
b273383480

+ 0 - 264
src/web/Raw Editor/css/style.css

@@ -1,264 +0,0 @@
-/* Raw Editor — Lightroom / Camera-Raw (2024) inspired dark UI */
-:root {
-    --bg: #262626;
-    --titlebar: #2b2b2b;
-    --panel: #303030;
-    --panel2: #363636;
-    --stage: #191919;
-    --line: #404040;
-    --line2: #4a4a4a;
-    --text: #dcdcdc;
-    --muted: #9a9a9a;
-    --muted2: #7d7d7d;
-    --accent: #2f7fe0;
-    --track: #5a5a5a;
-    --fill: #8a8a8a;
-    --thumb: #e6e6e6;
-}
-
-* { box-sizing: border-box; }
-
-html, body {
-    margin: 0; padding: 0; height: 100%;
-    background: var(--bg);
-    color: var(--text);
-    font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-    font-size: 12.5px;
-    overflow: hidden;
-    user-select: none;
-}
-
-#app { display: flex; flex-direction: column; height: 100vh; width: 100vw; }
-
-/* ---- Title bar ---- */
-#titlebar {
-    display: flex; align-items: center;
-    height: 34px;
-    padding: 0 10px;
-    background: var(--titlebar);
-    border-bottom: 1px solid var(--line);
-}
-.tb-left, .tb-right { flex: 0 0 120px; display: flex; align-items: center; gap: 2px; }
-.tb-right { justify-content: flex-end; }
-.tb-title {
-    flex: 1; text-align: center;
-    color: var(--muted); font-size: 12.5px;
-    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
-}
-.ic-btn {
-    background: transparent; color: var(--muted);
-    border: none; border-radius: 4px;
-    height: 26px; min-width: 28px; padding: 0 7px; cursor: pointer;
-}
-.ic-btn:hover { background: rgba(255,255,255,0.08); color: var(--text); }
-.ic-btn i.icon { margin: 0; }
-
-/* ---- Body ---- */
-#body { flex: 1; display: flex; min-height: 0; }
-
-#stage {
-    flex: 1; position: relative; background: var(--stage);
-    display: flex; align-items: center; justify-content: center; overflow: hidden;
-}
-#view {
-    position: absolute; left: 50%; top: 50%;
-    transform-origin: center center; will-change: transform;
-    box-shadow: 0 2px 26px rgba(0,0,0,0.55);
-    image-rendering: auto;
-}
-#view { cursor: grab; }
-#stage.panning, #stage.panning #view { cursor: grabbing; }
-#stage.dragover { outline: 2px dashed var(--accent); outline-offset: -12px; }
-
-#dropHint { text-align: center; color: var(--muted); }
-#dropHint p { margin: 12px 0; }
-#dropHint .tiny-note { font-size: 11px; opacity: 0.7; }
-
-#loader {
-    position: absolute; inset: 0; display: flex; flex-direction: column;
-    align-items: center; justify-content: center;
-    background: rgba(10,12,15,0.65); color: var(--text); gap: 14px;
-}
-.spinner {
-    width: 42px; height: 42px;
-    border: 4px solid rgba(255,255,255,0.15); border-top-color: var(--accent);
-    border-radius: 50%; animation: spin 0.9s linear infinite;
-}
-@keyframes spin { to { transform: rotate(360deg); } }
-
-/* ---- Develop panel ---- */
-#panel {
-    width: 328px; background: var(--panel);
-    border-left: 1px solid var(--line);
-    display: flex; flex-direction: column; overflow: hidden;
-}
-
-/* Histogram */
-#histoWrap {
-    position: relative;
-    padding: 6px 8px 4px 8px;
-    border-bottom: 1px solid var(--line);
-    background: #2a2a2a;
-}
-#histogram { display: block; width: 100%; height: 112px; }
-.clip {
-    position: absolute; top: 6px; width: 0; height: 0;
-    border-style: solid; opacity: 0.55; cursor: default;
-}
-.clip-l { left: 8px; border-width: 8px 8px 0 0; border-color: #7a7a7a transparent transparent transparent; }
-.clip-r { right: 8px; border-width: 8px 0 0 8px; border-color: #7a7a7a transparent transparent transparent; }
-.clip.active-shadow { border-top-color: #4a90ff; opacity: 1; }
-.clip.active-high { border-top-color: #ff5555; opacity: 1; }
-#exifLine {
-    display: flex; justify-content: space-between;
-    color: var(--muted2); font-size: 11px; padding-top: 3px;
-}
-
-.panel-scroll { flex: 1; overflow-y: auto; padding-bottom: 26px; }
-.panel-scroll::-webkit-scrollbar { width: 9px; }
-.panel-scroll::-webkit-scrollbar-thumb { background: #4c4c4c; border-radius: 5px; }
-.panel-scroll::-webkit-scrollbar-track { background: transparent; }
-
-/* Edit header */
-.edit-head {
-    display: flex; align-items: center; justify-content: space-between;
-    padding: 9px 12px; border-bottom: 1px solid var(--line);
-}
-.edit-title { font-size: 13px; color: var(--text); }
-.edit-actions { display: flex; gap: 6px; }
-.pill {
-    background: var(--panel2); color: var(--text);
-    border: 1px solid var(--line2); border-radius: 4px;
-    height: 24px; padding: 0 10px; font-size: 11.5px; cursor: pointer;
-}
-.pill:hover { background: #414141; }
-.pill.active { background: var(--accent); border-color: var(--accent); color: #fff; }
-
-.mini-select {
-    flex: 1; min-width: 0; max-width: 100%;
-    background: #262626; color: var(--text);
-    border: 1px solid var(--line2); border-radius: 4px; height: 26px; padding: 0 6px;
-    text-overflow: ellipsis;
-}
-
-/* Groups */
-.group { border-top: 1px solid var(--line); }
-.group-head {
-    display: flex; align-items: center; gap: 6px;
-    padding: 9px 12px; cursor: pointer;
-}
-.group-head:hover { background: rgba(255,255,255,0.02); }
-.group-head .chev { color: var(--muted); transition: transform 0.15s; margin: 0; }
-.group.collapsed .chev { transform: rotate(-90deg); }
-.group-title { flex: 1; color: var(--text); font-size: 12.5px; }
-.eye-toggle { color: var(--muted2); margin: 0; opacity: 0.8; }
-.eye-toggle:hover { color: var(--text); }
-.group.bypassed .eye-toggle { color: var(--muted2); opacity: 0.4; }
-.group.bypassed .group-body { opacity: 0.4; pointer-events: none; }
-.group-body { padding: 2px 12px 12px 12px; }
-.group.collapsed .group-body { display: none; }
-.group-reset { text-align: right; margin-top: 6px; }
-
-.lbl { color: var(--muted); font-size: 11.5px; }
-.wb-row { display: flex; align-items: center; gap: 8px; margin: 6px 0; }
-.wb-row .lbl { min-width: 88px; }
-.toggle-row { justify-content: space-between; }
-.eyedrop { border: 1px solid var(--line2); border-radius: 4px; height: 26px; }
-
-.link-btn { background: none; border: none; color: var(--accent); cursor: pointer; font-size: 11.5px; padding: 0; }
-.link-btn:hover { text-decoration: underline; }
-
-/* Slider row: name + value on one line, track below (Lightroom style) */
-.slider { display: flex; flex-wrap: wrap; align-items: center; margin: 9px 0; }
-.slider .s-name {
-    order: 0; flex: 1;
-    color: var(--text); font-size: 12px;
-}
-.slider .s-val {
-    order: 1; width: 50px; background: transparent; color: var(--text);
-    border: 1px solid transparent; height: 20px; font-size: 12px; text-align: right;
-    -moz-appearance: textfield;
-}
-.slider .s-val:hover, .slider .s-val:focus {
-    background: #262626; border: 1px solid var(--line2); border-radius: 3px; outline: none;
-}
-.slider .s-val::-webkit-outer-spin-button,
-.slider .s-val::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
-
-.s-track { order: 2; flex-basis: 100%; width: 100%; display: block; margin-top: 4px; }
-.slider input[type=range] {
-    -webkit-appearance: none; appearance: none;
-    width: 100%; height: 14px; background: transparent; outline: none; margin: 0;
-}
-.slider input[type=range]::-webkit-slider-runnable-track {
-    height: 4px; border-radius: 3px; background: var(--track);
-}
-.slider input[type=range]::-moz-range-track {
-    height: 4px; border-radius: 3px; background: var(--track);
-}
-.slider input[type=range]::-webkit-slider-thumb {
-    -webkit-appearance: none; appearance: none;
-    width: 12px; height: 12px; border-radius: 50%;
-    background: var(--thumb); border: 1px solid #444;
-    margin-top: -4px; cursor: pointer;
-    box-shadow: 0 1px 2px rgba(0,0,0,0.4);
-}
-.slider input[type=range]::-moz-range-thumb {
-    width: 12px; height: 12px; border-radius: 50%;
-    background: var(--thumb); border: 1px solid #444; cursor: pointer;
-}
-
-/* Coloured gradient tracks */
-.grad-temp input[type=range]::-webkit-slider-runnable-track {
-    background: linear-gradient(90deg, #2f6fd6 0%, #8fb0dd 30%, #c9c9c9 50%, #e8d58c 72%, #f2b73e 100%);
-}
-.grad-temp input[type=range]::-moz-range-track {
-    background: linear-gradient(90deg, #2f6fd6 0%, #8fb0dd 30%, #c9c9c9 50%, #e8d58c 72%, #f2b73e 100%);
-}
-.grad-tint input[type=range]::-webkit-slider-runnable-track {
-    background: linear-gradient(90deg, #4bb34b 0%, #b6b6b6 50%, #c353c3 100%);
-}
-.grad-tint input[type=range]::-moz-range-track {
-    background: linear-gradient(90deg, #4bb34b 0%, #b6b6b6 50%, #c353c3 100%);
-}
-.grad-vib input[type=range]::-webkit-slider-runnable-track,
-.grad-sat input[type=range]::-webkit-slider-runnable-track {
-    background: linear-gradient(90deg, #6f6f6f 0%, #a06a6a 20%, #a0a05a 40%, #5aa05a 60%, #5a7aa0 80%, #9a5aa0 100%);
-}
-.grad-vib input[type=range]::-moz-range-track,
-.grad-sat input[type=range]::-moz-range-track {
-    background: linear-gradient(90deg, #6f6f6f 0%, #a06a6a 20%, #a0a05a 40%, #5aa05a 60%, #5a7aa0 80%, #9a5aa0 100%);
-}
-
-/* LUT */
-.lut-intro { color: var(--muted); font-size: 11.5px; margin: 6px 0 10px 0; line-height: 1.45; }
-.lut-intro .mono { font-family: "SFMono-Regular", Menlo, Consolas, monospace; color: var(--text); font-size: 11px; }
-.lut-hint { color: var(--muted2); font-size: 11px; margin: 4px 0 10px 0; }
-.lut-info { margin-top: 12px; }
-.lut-name { color: var(--text); font-size: 11.5px; word-break: break-all; text-align: right; flex: 1; }
-.switch { position: relative; display: inline-block; width: 36px; height: 19px; }
-.switch input { display: none; }
-.switch .track { position: absolute; inset: 0; background: var(--track); border-radius: 20px; transition: background 0.2s; }
-.switch .track:before { content: ""; position: absolute; width: 15px; height: 15px; left: 2px; top: 2px; background: #fff; border-radius: 50%; transition: transform 0.2s; }
-.switch input:checked + .track { background: var(--accent); }
-.switch input:checked + .track:before { transform: translateX(17px); }
-
-/* ---- Status bar ---- */
-#statusbar {
-    height: 46px; display: flex; align-items: center; gap: 12px;
-    padding: 0 12px; background: var(--titlebar); border-top: 1px solid var(--line);
-}
-.sb-left { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; }
-#filmstrip {
-    width: 60px; height: 40px; background: #111;
-    border: 1px solid var(--line2); border-radius: 2px; object-fit: cover;
-}
-.zoom-label { color: var(--muted); font-size: 11.5px; cursor: pointer; }
-.zoom-label:hover { color: var(--text); }
-.zoom-readout { color: var(--muted2); font-size: 11.5px; min-width: 40px; }
-.status-info {
-    flex: 1; text-align: center; color: var(--muted2); font-size: 11.5px;
-    text-decoration: underline; text-underline-offset: 2px; text-decoration-color: var(--line2);
-}
-.sb-right { display: flex; gap: 8px; flex: 0 0 auto; }
-.ui.button { font-size: 12px; }

+ 0 - 19
src/web/Raw Editor/img/module_icon.svg

@@ -1,19 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
-  <defs>
-    <linearGradient id="rawbg" x1="0" y1="0" x2="1" y2="1">
-      <stop offset="0" stop-color="#3a3f4b"/>
-      <stop offset="1" stop-color="#1b1e24"/>
-    </linearGradient>
-    <linearGradient id="rawlens" x1="0" y1="0" x2="1" y2="1">
-      <stop offset="0" stop-color="#6db3ff"/>
-      <stop offset="1" stop-color="#2d6cff"/>
-    </linearGradient>
-  </defs>
-  <rect x="2" y="2" width="60" height="60" rx="12" fill="url(#rawbg)"/>
-  <rect x="10" y="16" width="44" height="32" rx="5" fill="#0e1013" stroke="#4b75ff" stroke-width="1.5"/>
-  <rect x="24" y="12" width="16" height="7" rx="2" fill="#0e1013" stroke="#4b75ff" stroke-width="1.5"/>
-  <circle cx="32" cy="33" r="11" fill="url(#rawlens)"/>
-  <circle cx="32" cy="33" r="6.5" fill="#0e1013"/>
-  <circle cx="28.5" cy="29.5" r="2.4" fill="#ffffff" opacity="0.85"/>
-  <text x="32" y="57" text-anchor="middle" font-family="Arial, sans-serif" font-size="9" font-weight="700" fill="#cfe0ff">RAW</text>
-</svg>

+ 0 - 190
src/web/Raw Editor/index.html

@@ -1,190 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <meta name="theme-color" content="#262626">
-    <title>Raw Editor</title>
-    <link rel="stylesheet" href="../script/semantic/semantic.min.css">
-    <script src="../script/jquery.min.js"></script>
-    <script src="../script/ao_module.js"></script>
-    <link rel="stylesheet" href="css/style.css">
-</head>
-<body class="dark">
-    <div id="app">
-        <!-- Title bar -->
-        <div id="titlebar">
-            <div class="tb-left">
-                <button class="ic-btn" id="btnOpen" title="Open image / RAW"><i class="folder open outline icon"></i></button>
-            </div>
-            <div id="fileTitle" class="tb-title">No image loaded</div>
-            <div class="tb-right">
-                <button class="ic-btn" id="btnSave" title="Save developed image (JPEG)"><i class="download icon"></i></button>
-                <button class="ic-btn" id="btnReset" title="Reset all adjustments"><i class="undo icon"></i></button>
-                <button class="ic-btn" id="btnFit" title="Fit to window"><i class="expand arrows alternate icon"></i></button>
-            </div>
-        </div>
-
-        <div id="body">
-            <!-- Stage -->
-            <div id="stage">
-                <div id="dropHint">
-                    <i class="images outline icon huge"></i>
-                    <p>Open a RAW (ARW, DNG, NEF, CR2 ...) or image file to begin.</p>
-                    <button class="ui inverted button" id="btnOpen2"><i class="folder open icon"></i> Open file</button>
-                    <p class="tiny-note">You can also drag a file here.</p>
-                </div>
-                <canvas id="view" style="display:none;"></canvas>
-                <div id="loader" style="display:none;">
-                    <div class="spinner"></div>
-                    <div id="loaderText">Decoding RAW...</div>
-                </div>
-            </div>
-
-            <!-- Develop panel -->
-            <div id="panel">
-                <!-- Histogram -->
-                <div id="histoWrap">
-                    <span class="clip clip-l" id="clipShadow" title="Shadow clipping"></span>
-                    <span class="clip clip-r" id="clipHigh" title="Highlight clipping"></span>
-                    <canvas id="histogram" width="300" height="118"></canvas>
-                    <div id="exifLine">
-                        <span id="exifIso">--</span>
-                        <span id="exifFocal">--</span>
-                        <span id="exifAperture">--</span>
-                        <span id="exifShutter">--</span>
-                    </div>
-                </div>
-
-                <div class="panel-scroll">
-                    <!-- Edit header -->
-                    <div class="edit-head">
-                        <span class="edit-title">Edit</span>
-                        <div class="edit-actions">
-                            <button class="pill" id="btnAuto">Auto</button>
-                            <button class="pill" id="btnBW">B&amp;W</button>
-                        </div>
-                    </div>
-
-                    <!-- LIGHT -->
-                    <div class="group" data-group="light">
-                        <div class="group-head">
-                            <i class="dropdown icon chev"></i>
-                            <span class="group-title">Light</span>
-                            <i class="eye icon eye-toggle" title="Toggle Light adjustments"></i>
-                        </div>
-                        <div class="group-body">
-                            <div class="slider" data-key="exposure" data-min="-5" data-max="5" data-def="0" data-step="0.01"><span class="s-name">Exposure</span><span class="s-track"><input type="range"></span><input class="s-val" type="number"></div>
-                            <div class="slider" data-key="contrast" data-min="-100" data-max="100" data-def="0" data-step="1"><span class="s-name">Contrast</span><span class="s-track"><input type="range"></span><input class="s-val" type="number"></div>
-                            <div class="slider" data-key="highlights" data-min="-100" data-max="100" data-def="0" data-step="1"><span class="s-name">Highlights</span><span class="s-track"><input type="range"></span><input class="s-val" type="number"></div>
-                            <div class="slider" data-key="shadows" data-min="-100" data-max="100" data-def="0" data-step="1"><span class="s-name">Shadows</span><span class="s-track"><input type="range"></span><input class="s-val" type="number"></div>
-                            <div class="slider" data-key="whites" data-min="-100" data-max="100" data-def="0" data-step="1"><span class="s-name">Whites</span><span class="s-track"><input type="range"></span><input class="s-val" type="number"></div>
-                            <div class="slider" data-key="blacks" data-min="-100" data-max="100" data-def="0" data-step="1"><span class="s-name">Blacks</span><span class="s-track"><input type="range"></span><input class="s-val" type="number"></div>
-                            <div class="group-reset"><button class="link-btn" id="btnDefault">Reset Light</button></div>
-                        </div>
-                    </div>
-
-                    <!-- COLOR -->
-                    <div class="group" data-group="color">
-                        <div class="group-head">
-                            <i class="dropdown icon chev"></i>
-                            <span class="group-title">Color</span>
-                            <i class="eye icon eye-toggle" title="Toggle Color adjustments"></i>
-                        </div>
-                        <div class="group-body">
-                            <div class="wb-row">
-                                <span class="lbl">White Balance</span>
-                                <select id="wbPreset" class="mini-select">
-                                    <option value="asshot">As Shot</option>
-                                    <option value="auto">Auto</option>
-                                    <option value="daylight">Daylight</option>
-                                    <option value="cloudy">Cloudy</option>
-                                    <option value="shade">Shade</option>
-                                    <option value="tungsten">Tungsten</option>
-                                    <option value="fluorescent">Fluorescent</option>
-                                    <option value="custom">Custom</option>
-                                </select>
-                                <button class="ic-btn eyedrop" id="btnEyedrop" title="Auto white balance"><i class="eye dropper icon"></i></button>
-                            </div>
-                            <div class="slider grad-temp" data-key="temperature" data-min="2000" data-max="50000" data-def="5500" data-step="10"><span class="s-name">Temperature</span><span class="s-track"><input type="range"></span><input class="s-val" type="number"></div>
-                            <div class="slider grad-tint" data-key="tint" data-min="-150" data-max="150" data-def="0" data-step="1"><span class="s-name">Tint</span><span class="s-track"><input type="range"></span><input class="s-val" type="number"></div>
-                            <div class="slider grad-vib" data-key="vibrance" data-min="-100" data-max="100" data-def="0" data-step="1"><span class="s-name">Vibrance</span><span class="s-track"><input type="range"></span><input class="s-val" type="number"></div>
-                            <div class="slider grad-sat" data-key="saturation" data-min="-100" data-max="100" data-def="0" data-step="1"><span class="s-name">Saturation</span><span class="s-track"><input type="range"></span><input class="s-val" type="number"></div>
-                        </div>
-                    </div>
-
-                    <!-- EFFECTS -->
-                    <div class="group" data-group="effects">
-                        <div class="group-head">
-                            <i class="dropdown icon chev"></i>
-                            <span class="group-title">Effects</span>
-                            <i class="eye icon eye-toggle" title="Toggle Effects"></i>
-                        </div>
-                        <div class="group-body">
-                            <div class="slider" data-key="texture" data-min="-100" data-max="100" data-def="0" data-step="1"><span class="s-name">Texture</span><span class="s-track"><input type="range"></span><input class="s-val" type="number"></div>
-                            <div class="slider" data-key="clarity" data-min="-100" data-max="100" data-def="0" data-step="1"><span class="s-name">Clarity</span><span class="s-track"><input type="range"></span><input class="s-val" type="number"></div>
-                            <div class="slider" data-key="dehaze" data-min="-100" data-max="100" data-def="0" data-step="1"><span class="s-name">Dehaze</span><span class="s-track"><input type="range"></span><input class="s-val" type="number"></div>
-                            <div class="slider" data-key="vignette" data-min="-100" data-max="100" data-def="0" data-step="1"><span class="s-name">Vignette</span><span class="s-track"><input type="range"></span><input class="s-val" type="number"></div>
-                            <div class="slider" data-key="grain" data-min="0" data-max="100" data-def="0" data-step="1"><span class="s-name">Grain</span><span class="s-track"><input type="range"></span><input class="s-val" type="number"></div>
-                        </div>
-                    </div>
-
-                    <!-- LUT -->
-                    <div class="group" data-group="lut">
-                        <div class="group-head">
-                            <i class="dropdown icon chev"></i>
-                            <span class="group-title">LUT / Color Grade</span>
-                            <i class="eye icon eye-toggle" title="Toggle LUT"></i>
-                        </div>
-                        <div class="group-body">
-                            <div class="lut-intro">Drop <b>.cube</b> LUT files into <span class="mono">user:/RawEditor/LUTs</span> with the Files app, then pick one here. LUTs apply after all adjustments.</div>
-                            <div class="wb-row">
-                                <span class="lbl">Library</span>
-                                <select id="lutLibrary" class="mini-select"><option value="">&mdash; Select a LUT &mdash;</option></select>
-                                <button class="ic-btn eyedrop" id="btnLutRefresh" title="Rescan LUT folder"><i class="sync icon"></i></button>
-                            </div>
-                            <div id="lutEmpty" class="lut-hint">Scanning LUT folder&hellip;</div>
-                            <button class="ui tiny inverted fluid button" id="btnLoadLut"><i class="upload icon"></i> Import .cube to library</button>
-                            <input type="file" id="lutFile" accept=".cube" style="display:none;">
-                            <div id="lutInfo" class="lut-info" style="display:none;">
-                                <div class="wb-row"><span class="lbl">LUT</span><span id="lutName" class="lut-name">-</span></div>
-                                <div class="wb-row toggle-row">
-                                    <span class="lbl">Enabled</span>
-                                    <label class="switch"><input type="checkbox" id="lutEnabled" checked><span class="track"></span></label>
-                                </div>
-                                <div class="wb-row toggle-row">
-                                    <span class="lbl">Log input</span>
-                                    <label class="switch"><input type="checkbox" id="lutLogInput"><span class="track"></span></label>
-                                </div>
-                                <div class="lut-hint">Turn on for F-Log / S-Log / V-Log &rarr; display conversion LUTs so they look as intended on a normal photo.</div>
-                                <div class="slider" data-key="lutAmount" data-min="0" data-max="100" data-def="100" data-step="1"><span class="s-name">Amount</span><span class="s-track"><input type="range"></span><input class="s-val" type="number"></div>
-                                <div class="group-reset"><button class="link-btn" id="btnClearLut">Remove LUT</button></div>
-                            </div>
-                        </div>
-                    </div>
-                </div>
-            </div>
-        </div>
-
-        <!-- Bottom bar -->
-        <div id="statusbar">
-            <div class="sb-left">
-                <canvas id="filmstrip" width="72" height="48"></canvas>
-                <span class="zoom-label" id="btnZoomFit">Fit</span>
-                <button class="pill" id="btnZoom100">100%</button>
-                <span class="zoom-readout" id="zoomReadout"></span>
-            </div>
-            <div class="status-info" id="statusInfo">-</div>
-            <div class="sb-right">
-                <button class="ui tiny inverted button" id="btnCancel">Cancel</button>
-                <button class="ui tiny primary button" id="btnDone" title="Save and open in Pixel Studio">Open in Pixel Studio</button>
-            </div>
-        </div>
-    </div>
-
-    <script src="js/rawdecoder.js"></script>
-    <script src="js/lut.js"></script>
-    <script src="js/glrender.js"></script>
-    <script src="js/editor.js"></script>
-</body>
-</html>

+ 0 - 28
src/web/Raw Editor/init.agi

@@ -1,28 +0,0 @@
-/*
-	Raw Editor Module Register Script
-
-	Registers the "Raw Editor" WebApp on the ArozOS desktop. The app decodes
-	camera RAW files (ARW, DNG, NEF, CR2, ORF, RAF ...), demosaics them into a
-	viewable image and provides Camera-Raw style develop controls plus 3D LUT
-	(.cube) colour grading, entirely inside the browser sandbox.
-*/
-
-//Setup the module information
-var moduleLaunchInfo = {
-	Name: "Raw Editor",
-	Desc: "Develop camera RAW files with Camera-Raw style controls and LUT grading",
-	Group: "Media",
-	IconPath: "img/module_icon.svg",
-	Version: "1.0.0",
-	StartDir: "index.html",
-	SupportFW: true,
-	LaunchFWDir: "index.html",
-	SupportEmb: true,
-	LaunchEmb: "index.html",
-	InitFWSize: [1200, 760],
-	InitEmbSize: [1200, 760],
-	SupportedExt: [".arw", ".dng", ".nef", ".cr2", ".cr3", ".orf", ".raf", ".rw2", ".pef", ".srw", ".tiff", ".tif", ".jpg", ".jpeg", ".png", ".webp"]
-}
-
-//Register the module
-registerModule(JSON.stringify(moduleLaunchInfo));

+ 0 - 815
src/web/Raw Editor/js/editor.js

@@ -1,815 +0,0 @@
-/*
-    editor.js — UI controller for the Raw Editor WebApp.
-
-    Wires the Camera-Raw style controls to the WebGL develop pipeline, handles
-    file loading (ArozOS input files, file picker, drag & drop), the live
-    histogram, LUT loading, auto white-balance / auto tone, and saving the
-    developed image back to the user's storage as a JPEG.
-*/
-
-(function () {
-    "use strict";
-
-    var renderer = null;
-    var glOK = true;
-    var decoded = null;           // last decoded {data,width,height,meta,source}
-    var sourceFile = null;        // {filename, filepath} of the opened file
-    var lut = null;               // parsed LUT
-    var renderQueued = false;
-
-    var defaults = {
-        temperature: 5500, tint: 0, exposure: 0, contrast: 0,
-        highlights: 0, shadows: 0, whites: 0, blacks: 0,
-        texture: 0, clarity: 0, dehaze: 0, vibrance: 0, saturation: 0,
-        vignette: 0, grain: 0, lutAmount: 100
-    };
-    var state = Object.assign({}, defaults);
-    state.baseTemp = 5500;
-    state.treatment = "color";
-    state.lutEnabled = true;
-    state.lutLogInput = false;
-    // Per-group bypass ("eye") toggles.
-    state.groupOn = { light: true, color: true, effects: true, lut: true };
-
-    // ---- init WebGL ------------------------------------------------------
-    try {
-        renderer = GLRender.create(document.getElementById("view"));
-    } catch (e) {
-        glOK = false;
-        showFatal(e.message);
-    }
-
-    // =====================================================================
-    //  Slider wiring
-    // =====================================================================
-    function clampToRange(el, v) {
-        var min = parseFloat(el.dataset.min), max = parseFloat(el.dataset.max);
-        if (v < min) v = min;
-        if (v > max) v = max;
-        return v;
-    }
-
-    function initSliders() {
-        document.querySelectorAll(".slider").forEach(function (row) {
-            var key = row.dataset.key;
-            var range = row.querySelector("input[type=range]");
-            var num = row.querySelector(".s-val");
-            var step = row.dataset.step || "1";
-            range.min = row.dataset.min; range.max = row.dataset.max; range.step = step;
-            num.min = row.dataset.min; num.max = row.dataset.max; num.step = step;
-            var def = parseFloat(row.dataset.def);
-            setSlider(row, def);
-
-            range.addEventListener("input", function () {
-                var v = parseFloat(range.value);
-                num.value = fmt(v, step);
-                state[key] = v;
-                scheduleRender();
-            });
-            num.addEventListener("change", function () {
-                var v = parseFloat(num.value);
-                if (isNaN(v)) v = parseFloat(row.dataset.def);
-                v = clampToRange(row, v);
-                num.value = fmt(v, step);
-                range.value = v;
-                state[key] = v;
-                scheduleRender();
-            });
-            // double click resets one slider to its default
-            row.querySelector(".s-name").addEventListener("dblclick", function () {
-                setSlider(row, parseFloat(row.dataset.def));
-                state[key] = parseFloat(row.dataset.def);
-                scheduleRender();
-            });
-        });
-    }
-
-    function fmt(v, step) {
-        return (parseFloat(step) < 1) ? (Math.round(v * 100) / 100).toString() : Math.round(v).toString();
-    }
-
-    function setSlider(row, v) {
-        var range = row.querySelector("input[type=range]");
-        var num = row.querySelector(".s-val");
-        range.value = v;
-        num.value = fmt(v, row.dataset.step || "1");
-    }
-
-    function setSliderByKey(key, v) {
-        var row = document.querySelector('.slider[data-key="' + key + '"]');
-        if (row) setSlider(row, v);
-        state[key] = v;
-    }
-
-    // =====================================================================
-    //  Render
-    // =====================================================================
-    function scheduleRender() {
-        if (renderQueued || !renderer || !decoded) return;
-        renderQueued = true;
-        requestAnimationFrame(function () {
-            renderQueued = false;
-            doRender();
-        });
-    }
-
-    function getParams() {
-        var g = state.groupOn;
-        var p = {
-            baseTemp: state.baseTemp,
-            // Color group
-            temperature: g.color ? state.temperature : state.baseTemp,
-            tint: g.color ? state.tint : 0,
-            vibrance: g.color ? state.vibrance : 0,
-            saturation: g.color ? state.saturation : 0,
-            // Light group
-            exposure: g.light ? state.exposure : 0,
-            contrast: g.light ? state.contrast : 0,
-            highlights: g.light ? state.highlights : 0,
-            shadows: g.light ? state.shadows : 0,
-            whites: g.light ? state.whites : 0,
-            blacks: g.light ? state.blacks : 0,
-            // Effects group
-            texture: g.effects ? state.texture : 0,
-            clarity: g.effects ? state.clarity : 0,
-            dehaze: g.effects ? state.dehaze : 0,
-            vignette: g.effects ? state.vignette : 0,
-            grain: g.effects ? state.grain : 0,
-            // LUT group
-            lutEnabled: !!(lut && state.lutEnabled && g.lut),
-            lutAmount: state.lutAmount / 100,
-            lutLogInput: state.lutLogInput
-        };
-        if (state.treatment === "bw") { p.saturation = -100; p.vibrance = 0; }
-        return p;
-    }
-
-    function doRender() {
-        if (!renderer || !decoded) return;
-        renderer.render(getParams());
-        updateHistogram();
-        updateFilmstrip();
-    }
-
-    // =====================================================================
-    //  Histogram
-    // =====================================================================
-    var histSmall = document.createElement("canvas");
-    histSmall.width = 252; histSmall.height = 84;
-    var histSmallCtx = histSmall.getContext("2d");
-
-    function updateHistogram() {
-        var view = document.getElementById("view");
-        var hc = document.getElementById("histogram");
-        var ctx = hc.getContext("2d");
-        ctx.clearRect(0, 0, hc.width, hc.height);
-        if (!decoded) return;
-        try {
-            histSmallCtx.drawImage(view, 0, 0, histSmall.width, histSmall.height);
-        } catch (e) { return; }
-        var img = histSmallCtx.getImageData(0, 0, histSmall.width, histSmall.height).data;
-        var r = new Uint32Array(256), g = new Uint32Array(256), b = new Uint32Array(256);
-        for (var i = 0; i < img.length; i += 4) {
-            r[img[i]]++; g[img[i + 1]]++; b[img[i + 2]]++;
-        }
-        var max = 1;
-        for (var k = 1; k < 255; k++) { // ignore pure black/white spikes for scaling
-            if (r[k] > max) max = r[k];
-            if (g[k] > max) max = g[k];
-            if (b[k] > max) max = b[k];
-        }
-        drawChannel(ctx, r, max, "rgba(255,80,80,0.75)");
-        drawChannel(ctx, g, max, "rgba(90,220,90,0.75)");
-        drawChannel(ctx, b, max, "rgba(90,140,255,0.75)");
-
-        // Clipping indicators (fraction of pixels pinned to 0 / 255).
-        var totalPx = histSmall.width * histSmall.height;
-        var hi = Math.max(r[255], g[255], b[255]);
-        var lo = Math.max(r[0], g[0], b[0]);
-        var ch = document.getElementById("clipHigh");
-        var cs = document.getElementById("clipShadow");
-        if (ch) ch.classList.toggle("active-high", hi / totalPx > 0.01);
-        if (cs) cs.classList.toggle("active-shadow", lo / totalPx > 0.01);
-    }
-
-    // Small preview thumbnail in the bottom filmstrip.
-    function updateFilmstrip() {
-        var fs = document.getElementById("filmstrip");
-        var v = document.getElementById("view");
-        if (!fs || !v.width) return;
-        var ctx = fs.getContext("2d");
-        ctx.fillStyle = "#111"; ctx.fillRect(0, 0, fs.width, fs.height);
-        var s = Math.min(fs.width / v.width, fs.height / v.height);
-        var w = v.width * s, h = v.height * s;
-        try { ctx.drawImage(v, (fs.width - w) / 2, (fs.height - h) / 2, w, h); } catch (e) { /* ignore */ }
-    }
-
-    function drawChannel(ctx, arr, max, color) {
-        var w = ctx.canvas.width, h = ctx.canvas.height;
-        ctx.globalCompositeOperation = "lighter";
-        ctx.fillStyle = color;
-        ctx.beginPath();
-        ctx.moveTo(0, h);
-        for (var x = 0; x < 256; x++) {
-            var v = Math.min(1, arr[x] / max);
-            var px = (x / 255) * w;
-            var py = h - v * (h - 2);
-            ctx.lineTo(px, py);
-        }
-        ctx.lineTo(w, h);
-        ctx.closePath();
-        ctx.fill();
-        ctx.globalCompositeOperation = "source-over";
-    }
-
-    // =====================================================================
-    //  File loading
-    // =====================================================================
-    function showLoader(text) {
-        document.getElementById("loaderText").textContent = text || "Working...";
-        document.getElementById("loader").style.display = "flex";
-    }
-    function hideLoader() { document.getElementById("loader").style.display = "none"; }
-
-    function showFatal(msg) {
-        var dh = document.getElementById("dropHint");
-        if (dh) dh.innerHTML = '<i class="warning circle icon huge"></i><p>' + escapeHtml(msg) + "</p>";
-    }
-
-    function escapeHtml(s) {
-        return String(s).replace(/[&<>"]/g, function (c) {
-            return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c];
-        });
-    }
-
-    function loadFromPath(filepath, filename) {
-        if (!glOK) return;
-        sourceFile = { filepath: filepath, filename: filename };
-        showLoader("Reading file...");
-        document.getElementById("dropHint").style.display = "none";
-        var url = "../media?file=" + encodeURIComponent(filepath);
-        fetch(url).then(function (resp) {
-            if (!resp.ok) throw new Error("Could not read file (HTTP " + resp.status + ")");
-            return resp.arrayBuffer();
-        }).then(function (buf) {
-            showLoader("Decoding " + (filename || "image") + " ...");
-            // Defer so the loader paints before the heavy decode.
-            setTimeout(function () { decodeBuffer(buf, filename); }, 30);
-        }).catch(function (err) {
-            hideLoader();
-            showFatal(err.message || String(err));
-        });
-    }
-
-    function loadFromArrayBuffer(buf, filename) {
-        showLoader("Decoding " + (filename || "image") + " ...");
-        document.getElementById("dropHint").style.display = "none";
-        setTimeout(function () { decodeBuffer(buf, filename); }, 30);
-    }
-
-    function decodeBuffer(buf, filename) {
-        RawDecoder.decode(buf, filename).then(function (res) {
-            decoded = res;
-            renderer.setImage(res);
-            document.getElementById("view").style.display = "block";
-            applyMeta(res.meta, filename, res);
-            resetAll(true);
-            fitToWindow();
-            hideLoader();
-        }).catch(function (err) {
-            hideLoader();
-            showFatal(err.message || String(err));
-        });
-    }
-
-    function applyMeta(meta, filename, res) {
-        state.baseTemp = (meta && meta.temp) ? meta.temp : 5500;
-        defaults.temperature = state.baseTemp;
-        document.getElementById("fileTitle").textContent =
-            (filename || "Untitled") + (meta && meta.camera ? "   —   " + meta.camera : "");
-        // EXIF line (set before the title call so nothing can block it)
-        setText("exifShutter", meta && meta.shutter ? formatShutter(meta.shutter) : "--");
-        setText("exifAperture", meta && meta.aperture ? "f/" + round1(meta.aperture) : "--");
-        setText("exifIso", meta && meta.iso ? "ISO " + meta.iso : "--");
-        setText("exifFocal", meta && meta.focal ? Math.round(meta.focal) + " mm" : "--");
-        try { ao_module_setWindowTitle("Raw Editor - " + (filename || "Untitled")); } catch (e) { /* ignore */ }
-        // Status line
-        var srcLabel = { "raw-demosaic": "RAW demosaiced", "embedded-preview": "Embedded preview", "image": "Image" }[res.source] || res.source;
-        setText("statusInfo", srcLabel + "   ·   " + res.width + " x " + res.height + " px");
-    }
-
-    function formatShutter(t) {
-        if (t >= 1) return round1(t) + " s";
-        return "1/" + Math.round(1 / t) + " s";
-    }
-    function round1(v) { return Math.round(v * 10) / 10; }
-    function setText(id, t) { var e = document.getElementById(id); if (e) e.textContent = t; }
-
-    // =====================================================================
-    //  White balance presets + auto
-    // =====================================================================
-    var wbPresets = { daylight: 5500, cloudy: 6500, shade: 7500, tungsten: 2850, fluorescent: 3800 };
-
-    document.getElementById("wbPreset").addEventListener("change", function () {
-        var v = this.value;
-        if (v === "asshot") { setSliderByKey("temperature", state.baseTemp); setSliderByKey("tint", 0); }
-        else if (v === "auto") { autoWhiteBalance(); }
-        else if (wbPresets[v] != null) { setSliderByKey("temperature", wbPresets[v]); setSliderByKey("tint", 0); }
-        scheduleRender();
-    });
-
-    // When temp/tint are edited manually flip the preset to Custom.
-    ["temperature", "tint"].forEach(function (key) {
-        var row = document.querySelector('.slider[data-key="' + key + '"]');
-        row.querySelector("input[type=range]").addEventListener("input", function () {
-            document.getElementById("wbPreset").value = "custom";
-        });
-    });
-
-    function autoWhiteBalance() {
-        if (!decoded) return;
-        var d = decoded.data, n = d.length;
-        var sr = 0, sg = 0, sb = 0, cnt = 0;
-        var stride = Math.max(4, Math.floor(n / 4 / 40000) * 4);
-        for (var i = 0; i < n; i += stride) { sr += d[i]; sg += d[i + 1]; sb += d[i + 2]; cnt++; }
-        var ar = sr / cnt, ag = sg / cnt, ab = sb / cnt;
-        if (ar <= 0 || ab <= 0) return;
-        // Solve kelvinGain to equalise R and B: (1+0.9wr)/(1-0.9wr) = ab/ar.
-        var ratio = ab / ar;
-        var wr = (ratio - 1) / (0.9 * (ratio + 1));
-        wr = Math.max(-1, Math.min(1, wr));
-        var temp = state.baseTemp * Math.exp(wr * (Math.log(50000) - Math.log(2000)));
-        temp = Math.max(2000, Math.min(50000, temp));
-        // Tint: green vs magenta balance.
-        var tint = ((ar + ab) / 2 - ag) / ((ar + ab) / 2 + ag) * 150;
-        tint = Math.max(-150, Math.min(150, tint));
-        setSliderByKey("temperature", Math.round(temp));
-        setSliderByKey("tint", Math.round(tint));
-    }
-
-    // =====================================================================
-    //  Auto tone / reset
-    // =====================================================================
-    document.getElementById("btnAuto").addEventListener("click", function () { autoTone(); scheduleRender(); });
-    document.getElementById("btnDefault").addEventListener("click", function () {
-        ["exposure", "contrast", "highlights", "shadows", "whites", "blacks"].forEach(function (k) {
-            setSliderByKey(k, defaults[k]);
-        });
-        scheduleRender();
-    });
-
-    function autoTone() {
-        if (!decoded) return;
-        var d = decoded.data, n = d.length;
-        var sum = 0, cnt = 0, hiClip = 0, loClip = 0;
-        var stride = Math.max(4, Math.floor(n / 4 / 40000) * 4);
-        for (var i = 0; i < n; i += stride) {
-            var lum = Math.pow(Math.max(0, 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]), 1 / 2.2);
-            sum += lum; cnt++;
-            if (lum > 0.96) hiClip++;
-            if (lum < 0.03) loClip++;
-        }
-        var mean = sum / cnt;
-        var exposure = Math.log(0.46 / Math.max(0.03, mean)) / Math.log(2);
-        exposure = Math.max(-2.5, Math.min(2.5, exposure));
-        setSliderByKey("exposure", Math.round(exposure * 100) / 100);
-        setSliderByKey("contrast", 8);
-        setSliderByKey("highlights", hiClip / cnt > 0.02 ? -35 : -10);
-        setSliderByKey("shadows", loClip / cnt > 0.02 ? 35 : 12);
-        setSliderByKey("whites", 8);
-        setSliderByKey("blacks", -6);
-    }
-
-    function resetAll(keepImage) {
-        Object.keys(defaults).forEach(function (k) {
-            var def = (k === "temperature") ? state.baseTemp : defaults[k];
-            setSliderByKey(k, def);
-        });
-        state.treatment = "color";
-        document.getElementById("btnBW").classList.remove("active");
-        document.getElementById("wbPreset").value = "asshot";
-        if (keepImage) scheduleRender();
-    }
-
-    document.getElementById("btnReset").addEventListener("click", function () { resetAll(true); });
-
-    // Treatment (B&W) toggle in the Edit header
-    document.getElementById("btnBW").addEventListener("click", function () {
-        state.treatment = (state.treatment === "bw") ? "color" : "bw";
-        this.classList.toggle("active", state.treatment === "bw");
-        scheduleRender();
-    });
-
-    // Auto white balance eyedropper
-    document.getElementById("btnEyedrop").addEventListener("click", function () {
-        autoWhiteBalance();
-        document.getElementById("wbPreset").value = "auto";
-        scheduleRender();
-    });
-
-    // =====================================================================
-    //  Collapsible groups (chevron) + per-group bypass (eye)
-    // =====================================================================
-    document.querySelectorAll(".group-head").forEach(function (head) {
-        var group = head.parentElement;
-        head.addEventListener("click", function (e) {
-            if (e.target.classList.contains("eye-toggle")) return;
-            group.classList.toggle("collapsed");
-        });
-        var eye = head.querySelector(".eye-toggle");
-        if (eye) {
-            eye.addEventListener("click", function (e) {
-                e.stopPropagation();
-                var key = group.dataset.group;
-                state.groupOn[key] = !state.groupOn[key];
-                group.classList.toggle("bypassed", !state.groupOn[key]);
-                eye.className = state.groupOn[key] ? "eye icon eye-toggle" : "eye slash icon eye-toggle";
-                scheduleRender();
-            });
-        }
-    });
-
-    // =====================================================================
-    //  LUT  (library folder in the user's ArozOS storage + local import)
-    // =====================================================================
-    var LUT_ROOT = "user:/RawEditor";       // parent folder
-    var LUT_DIR = "user:/RawEditor/LUTs";   // where .cube files live
-
-    function inDesktop() {
-        return (typeof ao_module_virtualDesktop !== "undefined" && ao_module_virtualDesktop) || window.parent !== window;
-    }
-
-    // --- minimal ArozOS file-system helpers ---
-    function fsPost(url, body) {
-        return fetch(url, {
-            method: "POST",
-            headers: { "Content-Type": "application/x-www-form-urlencoded" },
-            body: body
-        });
-    }
-    function fsListDir(dir) {
-        return fsPost("../system/file_system/listDir", "dir=" + encodeURIComponent(dir)).then(function (r) { return r.json(); });
-    }
-    function fsCSRF() {
-        return fetch("../system/csrf/new").then(function (r) { return r.text(); });
-    }
-    function fsNewFolder(src, name) {
-        return fsCSRF().then(function (token) {
-            return fsPost("../system/file_system/newItem",
-                "type=folder&src=" + encodeURIComponent(src) + "&filename=" + encodeURIComponent(name) + "&csrft=" + encodeURIComponent(token));
-        });
-    }
-
-    function applyLut(parsed, displayName) {
-        lut = parsed;
-        renderer.setLUT(lut);
-        state.lutEnabled = true;
-        var g = document.querySelector('.group[data-group="lut"]');
-        if (g) { g.classList.remove("bypassed"); }
-        state.groupOn.lut = true;
-        document.getElementById("lutEnabled").checked = true;
-        // Auto-enable "Log input" for log->display conversion LUTs (F-Log, S-Log,
-        // V-Log, Log-C, C-Log ...), which expect a flat log signal.
-        var tag = ((parsed.title || "") + " " + (displayName || "")).toLowerCase();
-        var isLog = /(^|[^a-z])(f-?log|s-?log|v-?log|log-?c|c-?log|n-?log|d-?log|z-?log|log3|rec709|to_rec|to709|_log|log_)/.test(tag) || tag.indexOf("log") >= 0;
-        state.lutLogInput = isLog;
-        document.getElementById("lutLogInput").checked = isLog;
-        document.getElementById("lutInfo").style.display = "block";
-        document.getElementById("lutName").textContent = (parsed.title ? parsed.title + "  " : "") + displayName + "  (" + parsed.size + "³)";
-        scheduleRender();
-    }
-
-    // Load a LUT stored in the user's storage by virtual path.
-    function loadLutFromPath(filepath, name) {
-        fetch("../media?file=" + encodeURIComponent(filepath)).then(function (r) {
-            if (!r.ok) throw new Error("Could not read file (HTTP " + r.status + ")");
-            return r.text();
-        }).then(function (text) {
-            applyLut(LUTParser.parse(text), name);
-        }).catch(function (e) { alert("Could not load LUT: " + e.message); });
-    }
-
-    // Populate the library dropdown from a listDir result.
-    function populateLutLibrary(list) {
-        var sel = document.getElementById("lutLibrary");
-        var hint = document.getElementById("lutEmpty");
-        var current = sel.value;
-        sel.innerHTML = '<option value="">— Select a LUT —</option>';
-        var cubes = (list || []).filter(function (f) { return !f.IsDir && /\.cube$/i.test(f.Filename); });
-        cubes.sort(function (a, b) { return a.Filename.toLowerCase() < b.Filename.toLowerCase() ? -1 : 1; });
-        cubes.forEach(function (f) {
-            var o = document.createElement("option");
-            o.value = f.Filepath;
-            o.textContent = f.Filename.replace(/\.cube$/i, "");
-            sel.appendChild(o);
-        });
-        if (current) sel.value = current;
-        if (hint) {
-            hint.textContent = cubes.length
-                ? (cubes.length + " LUT" + (cubes.length > 1 ? "s" : "") + " in library")
-                : "No LUTs yet — add .cube files to user:/RawEditor/LUTs or import one below.";
-        }
-    }
-
-    // List the LUT folder, creating it (and its parent) on first use.
-    function refreshLutLibrary() {
-        if (!inDesktop()) {
-            var hint = document.getElementById("lutEmpty");
-            if (hint) hint.textContent = "Library needs the ArozOS desktop — use Import below.";
-            return;
-        }
-        fsListDir(LUT_DIR).then(function (list) {
-            if (list && list.error) {
-                // Folder missing — create parent then LUT folder, then retry.
-                return fsNewFolder("user:/", "RawEditor").catch(function () { }).then(function () {
-                    return fsNewFolder(LUT_ROOT + "/", "LUTs").catch(function () { });
-                }).then(function () { return fsListDir(LUT_DIR); });
-            }
-            return list;
-        }).then(function (list) {
-            populateLutLibrary(Array.isArray(list) ? list : []);
-        }).catch(function () {
-            var hint = document.getElementById("lutEmpty");
-            if (hint) hint.textContent = "Could not read the LUT folder.";
-        });
-    }
-
-    document.getElementById("lutLibrary").addEventListener("change", function () {
-        if (!this.value) return;
-        loadLutFromPath(this.value, this.options[this.selectedIndex].text);
-    });
-    document.getElementById("btnLutRefresh").addEventListener("click", refreshLutLibrary);
-
-    // Import: load a local .cube immediately and (in desktop) save it to the library.
-    document.getElementById("btnLoadLut").addEventListener("click", function () {
-        document.getElementById("lutFile").click();
-    });
-    document.getElementById("lutFile").addEventListener("change", function () {
-        var f = this.files[0];
-        this.value = "";
-        if (!f) return;
-        var reader = new FileReader();
-        reader.onload = function () {
-            var parsed;
-            try { parsed = LUTParser.parse(reader.result); }
-            catch (e) { alert("Could not load LUT: " + e.message); return; }
-            applyLut(parsed, f.name.replace(/\.cube$/i, ""));
-            // Persist into the library so it shows up next time.
-            if (inDesktop() && typeof ao_module_uploadFile === "function") {
-                fsNewFolder("user:/", "RawEditor").catch(function () { }).then(function () {
-                    return fsNewFolder(LUT_ROOT + "/", "LUTs").catch(function () { });
-                }).then(function () {
-                    ao_module_uploadFile(f, LUT_DIR, function () { refreshLutLibrary(); });
-                });
-            }
-        };
-        reader.readAsText(f);
-    });
-
-    document.getElementById("lutEnabled").addEventListener("change", function () {
-        state.lutEnabled = this.checked;
-        scheduleRender();
-    });
-    document.getElementById("lutLogInput").addEventListener("change", function () {
-        state.lutLogInput = this.checked;
-        scheduleRender();
-    });
-    document.getElementById("btnClearLut").addEventListener("click", function () {
-        lut = null;
-        renderer.setLUT(null);
-        document.getElementById("lutInfo").style.display = "none";
-        document.getElementById("lutLibrary").value = "";
-        scheduleRender();
-    });
-
-    // =====================================================================
-    //  Open / drag & drop
-    // =====================================================================
-    function openPicker() {
-        if (typeof ao_module_openFileSelector === "function" && window.parent !== window) {
-            ao_module_openFileSelector(function (files) {
-                if (files && files.length) loadFromPath(files[0].filepath, files[0].filename);
-            }, "user:/", "file", false);
-        } else {
-            var inp = document.createElement("input");
-            inp.type = "file";
-            inp.accept = ".arw,.dng,.nef,.cr2,.cr3,.orf,.raf,.rw2,.pef,.srw,.tif,.tiff,.jpg,.jpeg,.png,.webp";
-            inp.onchange = function () {
-                var f = inp.files[0];
-                if (!f) return;
-                sourceFile = null;
-                f.arrayBuffer().then(function (buf) { loadFromArrayBuffer(buf, f.name); });
-            };
-            inp.click();
-        }
-    }
-    document.getElementById("btnOpen").addEventListener("click", openPicker);
-    document.getElementById("btnOpen2").addEventListener("click", openPicker);
-
-    var stage = document.getElementById("stage");
-    stage.addEventListener("dragover", function (e) { e.preventDefault(); stage.classList.add("dragover"); });
-    stage.addEventListener("dragleave", function () { stage.classList.remove("dragover"); });
-    stage.addEventListener("drop", function (e) {
-        e.preventDefault();
-        stage.classList.remove("dragover");
-        // Local OS file drop.
-        if (e.dataTransfer.files && e.dataTransfer.files.length) {
-            var f = e.dataTransfer.files[0];
-            sourceFile = null;
-            f.arrayBuffer().then(function (buf) { loadFromArrayBuffer(buf, f.name); });
-            return;
-        }
-        // ArozOS file-explorer drop.
-        try {
-            var info = ao_module_utils.getDropFileInfo(e);
-            if (info && info.length) loadFromPath(info[0].filepath, info[0].filename);
-        } catch (err) { /* ignore */ }
-    });
-
-    // =====================================================================
-    //  Zoom & pan  (scroll to zoom at cursor, right/middle-drag to pan)
-    // =====================================================================
-    var vz = { zoom: 1, fit: 1, panX: 0, panY: 0 };
-
-    function applyTransform() {
-        var v = document.getElementById("view");
-        v.style.transform = "translate(-50%,-50%) translate(" + vz.panX + "px," + vz.panY + "px) scale(" + vz.zoom + ")";
-    }
-    function updateZoomLabel() {
-        var el = document.getElementById("zoomReadout");
-        if (el) el.textContent = Math.round(vz.zoom * 100) + "%";
-    }
-    function computeFit() {
-        var v = document.getElementById("view");
-        if (!v.width) return 1;
-        var pad = 32;
-        return Math.min((stage.clientWidth - pad) / v.width, (stage.clientHeight - pad) / v.height);
-    }
-    function fitToWindow() {
-        vz.fit = computeFit();
-        vz.zoom = vz.fit; vz.panX = 0; vz.panY = 0;
-        applyTransform(); updateZoomLabel();
-    }
-    function zoomActual() {
-        vz.zoom = 1; vz.panX = 0; vz.panY = 0;
-        applyTransform(); updateZoomLabel();
-    }
-    function setZoomAt(z2, cx, cy) {
-        z2 = Math.max(vz.fit * 0.5, Math.min(16, z2));
-        // Keep the image point under the cursor fixed while zooming.
-        vz.panX = cx - (z2 / vz.zoom) * (cx - vz.panX);
-        vz.panY = cy - (z2 / vz.zoom) * (cy - vz.panY);
-        vz.zoom = z2;
-        applyTransform(); updateZoomLabel();
-    }
-
-    document.getElementById("btnFit").addEventListener("click", fitToWindow);
-    document.getElementById("btnZoomFit").addEventListener("click", fitToWindow);
-    document.getElementById("btnZoom100").addEventListener("click", zoomActual);
-
-    // Scroll to zoom, centred on the cursor.
-    stage.addEventListener("wheel", function (e) {
-        if (!decoded) return;
-        e.preventDefault();
-        var rect = stage.getBoundingClientRect();
-        var cx = e.clientX - (rect.left + rect.width / 2);
-        var cy = e.clientY - (rect.top + rect.height / 2);
-        var factor = Math.pow(1.0016, -e.deltaY);
-        setZoomAt(vz.zoom * factor, cx, cy);
-    }, { passive: false });
-
-    // Left (or middle) mouse button drag to pan.
-    var panning = false, lastX = 0, lastY = 0;
-    stage.addEventListener("mousedown", function (e) {
-        if (!decoded || (e.button !== 0 && e.button !== 1)) return;
-        panning = true; lastX = e.clientX; lastY = e.clientY;
-        stage.classList.add("panning");
-        e.preventDefault();
-    });
-    window.addEventListener("mousemove", function (e) {
-        if (!panning) return;
-        vz.panX += e.clientX - lastX; vz.panY += e.clientY - lastY;
-        lastX = e.clientX; lastY = e.clientY;
-        applyTransform();
-    });
-    window.addEventListener("mouseup", function () {
-        if (panning) { panning = false; stage.classList.remove("panning"); }
-    });
-
-    // Re-fit on window resize while the view is at fit zoom.
-    window.addEventListener("resize", function () {
-        if (!decoded) return;
-        if (Math.abs(vz.zoom - vz.fit) < 0.001 && vz.panX === 0 && vz.panY === 0) fitToWindow();
-        else vz.fit = computeFit();
-    });
-
-    // =====================================================================
-    //  Save + Done
-    // =====================================================================
-    document.getElementById("btnSave").addEventListener("click", saveImage);
-    document.getElementById("btnDone").addEventListener("click", openInPixelStudio);
-    document.getElementById("btnCancel").addEventListener("click", function () {
-        if (typeof ao_module_close === "function") ao_module_close();
-    });
-
-    // Hand the developed image off to Pixel Studio: write the current develop to
-    // a temporary file (tmp:/ is cleared automatically) then launch Pixel Studio
-    // as a float window with that file as its input.
-    function openInPixelStudio() {
-        var inDesktop = (typeof ao_module_virtualDesktop !== "undefined" && ao_module_virtualDesktop);
-        if (!decoded || !renderer) {
-            if (typeof ao_module_close === "function") ao_module_close();
-            return;
-        }
-        if (!inDesktop || typeof ao_module_uploadFile !== "function") {
-            // Outside the ArozOS desktop we cannot open another module — just save.
-            saveImage();
-            return;
-        }
-        doRender(); // ensure the canvas holds the latest develop
-        var view = document.getElementById("view");
-        view.toBlob(function (blob) {
-            if (!blob) { alert("Failed to encode image."); return; }
-            var base = (sourceFile && sourceFile.filename) ? stripExt(sourceFile.filename) : "Untitled";
-            var fname = base + "_raw_" + Date.now() + ".jpg";
-            var tmpDir = "tmp:/RawEditor";
-            var file = ao_module_utils.blobToFile(blob, fname);
-            showLoader("Opening in Pixel Studio...");
-            ao_module_uploadFile(file, tmpDir, function () {
-                hideLoader();
-                launchPixelStudio(tmpDir + "/" + fname, fname);
-                if (typeof ao_module_close === "function") ao_module_close();
-            }, undefined, function () {
-                hideLoader();
-                alert("Could not hand the image to Pixel Studio. Try 'Save Image...' instead.");
-            });
-        }, "image/jpeg", 0.95);
-    }
-
-    function launchPixelStudio(filepath, filename) {
-        var hash = encodeURIComponent(JSON.stringify([{ filename: filename, filepath: filepath }]));
-        ao_module_newfw({
-            url: "Pixel Studio/index.html#" + hash,
-            width: 1280,
-            height: 820,
-            appicon: "Pixel Studio/img/module_icon.png",
-            title: "Pixel Studio - " + filename
-        });
-    }
-
-    function saveImage() {
-        if (!decoded || !renderer) { alert("Nothing to save yet."); return; }
-        doRender(); // make sure the canvas holds the latest develop
-        var view = document.getElementById("view");
-        view.toBlob(function (blob) {
-            if (!blob) { alert("Failed to encode image."); return; }
-            var baseName = (sourceFile && sourceFile.filename ? stripExt(sourceFile.filename) : "Untitled") + "_edited.jpg";
-            if (window.parent !== window && typeof ao_module_openFileSelector === "function") {
-                var defDir = "user:/Desktop";
-                if (sourceFile && sourceFile.filepath) {
-                    var parts = sourceFile.filepath.split("/"); parts.pop(); defDir = parts.join("/");
-                }
-                ao_module_openFileSelector(function (files) {
-                    if (!files || !files.length) return;
-                    var fp = files[0].filepath.split("/"); var fn = fp.pop(); var dir = fp.join("/");
-                    if (!/\.jpe?g$/i.test(fn)) fn = stripExt(fn) + ".jpg";
-                    uploadBlob(blob, fn, dir);
-                }, defDir, "new", false, { defaultName: baseName });
-            } else {
-                // Fallback: browser download.
-                var a = document.createElement("a");
-                a.href = URL.createObjectURL(blob);
-                a.download = baseName;
-                a.click();
-                setTimeout(function () { URL.revokeObjectURL(a.href); }, 4000);
-            }
-        }, "image/jpeg", 0.92);
-    }
-
-    function uploadBlob(blob, filename, dir) {
-        var file = ao_module_utils.blobToFile(blob, filename);
-        showLoader("Saving " + filename + " ...");
-        ao_module_uploadFile(file, dir, function () {
-            hideLoader();
-            setText("statusInfo", "Saved: " + dir + "/" + filename);
-        }, undefined, function () {
-            hideLoader();
-            alert("Failed to save image to " + dir);
-        });
-    }
-
-    function stripExt(name) { var i = name.lastIndexOf("."); return i < 0 ? name : name.substring(0, i); }
-
-    // =====================================================================
-    //  Boot
-    // =====================================================================
-    initSliders();
-    refreshLutLibrary();
-
-    if (glOK) {
-        var inputFiles = (typeof ao_module_loadInputFiles === "function") ? ao_module_loadInputFiles() : null;
-        if (inputFiles && inputFiles.length) {
-            loadFromPath(inputFiles[0].filepath, inputFiles[0].filename);
-        }
-    }
-})();

+ 0 - 500
src/web/Raw Editor/js/glrender.js

@@ -1,500 +0,0 @@
-/*
-    glrender.js — WebGL2 develop pipeline for the Raw Editor.
-
-    Uploads the decoded linear-light image into a half-float texture and renders
-    it through a fragment shader that implements the Camera-Raw style controls
-    (white balance, exposure, contrast, highlights/shadows/whites/blacks,
-    clarity, dehaze, vibrance, saturation) followed by an optional 3D LUT grade.
-
-    A separable Gaussian blur of the base image is pre-computed into a texture so
-    "clarity" and "dehaze" have a low-pass reference for local contrast — all in
-    a single real-time pass while a slider is dragged.
-
-    The image is edited in an approximate sRGB display space: after WB + exposure
-    the linear values are gamma encoded, every tonal / colour op runs on those
-    display values, and the canvas (sRGB) shows the result directly.
-*/
-
-const GLRender = (function () {
-
-    const VERT = `#version 300 es
-    in vec2 aPos;
-    out vec2 vUv;
-    void main(){
-        vUv = vec2(aPos.x * 0.5 + 0.5, 1.0 - (aPos.y * 0.5 + 0.5));
-        gl_Position = vec4(aPos, 0.0, 1.0);
-    }`;
-
-    // Simple separable Gaussian blur (5-tap), reused for H and V passes.
-    const BLUR_FRAG = `#version 300 es
-    precision highp float;
-    in vec2 vUv;
-    out vec4 frag;
-    uniform sampler2D uTex;
-    uniform vec2 uDir;      // texel step in one axis
-    void main(){
-        vec4 c = texture(uTex, vUv) * 0.227027;
-        c += texture(uTex, vUv + uDir * 1.0) * 0.194595;
-        c += texture(uTex, vUv - uDir * 1.0) * 0.194595;
-        c += texture(uTex, vUv + uDir * 2.0) * 0.121622;
-        c += texture(uTex, vUv - uDir * 2.0) * 0.121622;
-        c += texture(uTex, vUv + uDir * 3.0) * 0.070270;
-        c += texture(uTex, vUv - uDir * 3.0) * 0.070270;
-        frag = c;
-    }`;
-
-    const MAIN_FRAG = `#version 300 es
-    precision highp float;
-    precision highp sampler3D;
-    in vec2 vUv;
-    out vec4 frag;
-
-    uniform sampler2D uImage;
-    uniform sampler2D uBlur;
-    uniform sampler3D uLUT;
-
-    uniform vec3  uWB;
-    uniform float uExposure;
-    uniform float uContrast;
-    uniform float uHighlights;
-    uniform float uShadows;
-    uniform float uWhites;
-    uniform float uBlacks;
-    uniform float uTexture;
-    uniform float uClarity;
-    uniform float uDehaze;
-    uniform float uVibrance;
-    uniform float uSaturation;
-    uniform float uVignette;
-    uniform float uGrain;
-    uniform int   uLutEnabled;
-    uniform float uLutAmount;
-    uniform float uLutSize;
-    uniform vec3  uLutDomainMin;
-    uniform vec3  uLutDomainMax;
-    uniform int   uLutLogInput;
-
-    const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722);
-
-    // sRGB EOTF (display -> scene linear); inverse of toDisplay().
-    vec3 toLinear(vec3 c){
-        vec3 lo = c / 12.92;
-        vec3 hi = pow((c + 0.055) / 1.055, vec3(2.4));
-        return mix(lo, hi, step(vec3(0.04045), c));
-    }
-
-    // Log-flatten for feeding "log -> display" conversion LUTs from an already
-    // display-referred image. A true camera log curve (F-Log/S-Log) assumes ~14
-    // stops of scene-linear; a display JPEG has ~2.5, so a real log encode either
-    // fogs the shadows or greys the highlights. Instead we use a clean log2
-    // flatten that maps 0->0 and 1->1 (blacks stay black, whites stay white) but
-    // lifts the mid-tones, giving the LUT the low-contrast signal it expects
-    // without the wash-out. LOG_STOPS controls how flat.
-    vec3 linearToFLog(vec3 lin){
-        const float LOG_STOPS = 6.0;
-        float k = exp2(LOG_STOPS) - 1.0;
-        vec3 x = max(lin, vec3(0.0));
-        return log2(1.0 + x * k) / LOG_STOPS;
-    }
-
-    // True sRGB OETF — matches how LUTs (and Photoshop) expect their input, and
-    // exactly inverts the sRGB decode applied to 8-bit source images on load.
-    vec3 toDisplay(vec3 c){
-        c = max(c, vec3(0.0));
-        vec3 lo = c * 12.92;
-        vec3 hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
-        return mix(lo, hi, step(vec3(0.0031308), c));
-    }
-
-    // Fetch an exact LUT lattice point (requires NEAREST filtering on uLUT).
-    vec3 lutFetch(vec3 idx){
-        return texture(uLUT, (idx + 0.5) / uLutSize).rgb;
-    }
-
-    // Tetrahedral interpolation of the 3D LUT — the same method Photoshop /
-    // Resolve use. Avoids the cyan/green cast that GPU trilinear introduces on
-    // film-style LUTs.
-    vec3 lutTetra(vec3 rgb){
-        vec3 pos = clamp(rgb, 0.0, 1.0) * (uLutSize - 1.0);
-        vec3 b = floor(pos);
-        vec3 f = pos - b;
-        vec3 V000 = lutFetch(b);
-        vec3 V111 = lutFetch(b + vec3(1.0));
-        vec3 r;
-        if (f.r > f.g) {
-            if (f.g > f.b) {                 // R > G > B
-                r = (1.0 - f.r) * V000 + (f.r - f.g) * lutFetch(b + vec3(1.0, 0.0, 0.0)) + (f.g - f.b) * lutFetch(b + vec3(1.0, 1.0, 0.0)) + f.b * V111;
-            } else if (f.r > f.b) {          // R > B > G
-                r = (1.0 - f.r) * V000 + (f.r - f.b) * lutFetch(b + vec3(1.0, 0.0, 0.0)) + (f.b - f.g) * lutFetch(b + vec3(1.0, 0.0, 1.0)) + f.g * V111;
-            } else {                         // B > R > G
-                r = (1.0 - f.b) * V000 + (f.b - f.r) * lutFetch(b + vec3(0.0, 0.0, 1.0)) + (f.r - f.g) * lutFetch(b + vec3(1.0, 0.0, 1.0)) + f.g * V111;
-            }
-        } else {
-            if (f.b > f.g) {                 // B > G > R
-                r = (1.0 - f.b) * V000 + (f.b - f.g) * lutFetch(b + vec3(0.0, 0.0, 1.0)) + (f.g - f.r) * lutFetch(b + vec3(0.0, 1.0, 1.0)) + f.r * V111;
-            } else if (f.b > f.r) {          // G > B > R
-                r = (1.0 - f.g) * V000 + (f.g - f.b) * lutFetch(b + vec3(0.0, 1.0, 0.0)) + (f.b - f.r) * lutFetch(b + vec3(0.0, 1.0, 1.0)) + f.r * V111;
-            } else {                         // G > R > B
-                r = (1.0 - f.g) * V000 + (f.g - f.r) * lutFetch(b + vec3(0.0, 1.0, 0.0)) + (f.r - f.b) * lutFetch(b + vec3(1.0, 1.0, 0.0)) + f.b * V111;
-            }
-        }
-        return r;
-    }
-
-    vec3 develop(vec3 lin, vec3 blurLin, vec2 uv){
-        // 1. White balance + exposure in linear light.
-        lin *= uWB;
-        lin *= exp2(uExposure);
-        blurLin *= uWB;
-        blurLin *= exp2(uExposure);
-
-        // 2. Encode to display space for tonal work.
-        vec3 v = toDisplay(lin);
-        float bl = dot(toDisplay(blurLin), LUMA);
-
-        // 3. Contrast (S pivot around mid grey).
-        v = (v - 0.5) * (1.0 + uContrast) + 0.5;
-
-        // 4. Region tone: highlights / shadows / whites / blacks.
-        float L = dot(clamp(v, 0.0, 1.0), LUMA);
-        float hiMask = smoothstep(0.5, 1.0, L);
-        float shMask = smoothstep(0.5, 0.0, L);
-        float whMask = smoothstep(0.7, 1.0, L);
-        float bkMask = smoothstep(0.3, 0.0, L);
-        v += uHighlights * 0.5 * hiMask;
-        v += uShadows    * 0.5 * shMask;
-        v += uWhites     * 0.4 * whMask;
-        v += uBlacks     * 0.4 * bkMask;
-
-        // 5. Texture (fine local contrast) + Clarity (midtone local contrast).
-        float detail = L - bl;
-        float midMask = 1.0 - clamp(abs(L - 0.5) * 2.0, 0.0, 1.0);
-        v += uTexture * detail * 1.4;
-        v += uClarity * detail * midMask * 2.0;
-
-        // 6. Dehaze — pull local contrast harder and lift low areas.
-        if (abs(uDehaze) > 0.001){
-            float d = uDehaze;
-            v += d * detail * 1.5;
-            v -= d * 0.08 * (1.0 - L);
-        }
-
-        v = clamp(v, 0.0, 1.0);
-
-        // 7. Vibrance (weighted) then Saturation (uniform).
-        float lum = dot(v, LUMA);
-        float mx = max(max(v.r, v.g), v.b);
-        float mn = min(min(v.r, v.g), v.b);
-        float curSat = mx - mn;
-        float vibF = 1.0 + uVibrance * (1.0 - curSat);
-        v = clamp(mix(vec3(lum), v, vibF), 0.0, 1.0);
-        lum = dot(v, LUMA);
-        v = clamp(mix(vec3(lum), v, 1.0 + uSaturation), 0.0, 1.0);
-
-        // 8. Vignette (radial) and grain (post effects).
-        if (abs(uVignette) > 0.001){
-            float dd = distance(uv, vec2(0.5)) * 1.41421;
-            v *= clamp(1.0 + uVignette * 0.9 * (dd * dd - 0.25), 0.0, 4.0);
-        }
-        if (uGrain > 0.001){
-            float n = fract(sin(dot(uv, vec2(12.9898, 78.233))) * 43758.5453);
-            v += (n - 0.5) * uGrain * 0.18;
-        }
-        v = clamp(v, 0.0, 1.0);
-
-        // 9. 3D LUT colour grade (tetrahedral, domain-mapped).
-        if (uLutEnabled == 1){
-            vec3 lutIn = clamp(v, 0.0, 1.0);
-            // For "log to display" LUTs, feed the flat log signal they expect.
-            if (uLutLogInput == 1){
-                lutIn = clamp(linearToFLog(toLinear(lutIn)), 0.0, 1.0);
-            }
-            vec3 dom = (lutIn - uLutDomainMin) / max(uLutDomainMax - uLutDomainMin, vec3(1e-5));
-            vec3 graded = lutTetra(clamp(dom, 0.0, 1.0));
-            v = mix(v, graded, uLutAmount);
-        }
-        return clamp(v, 0.0, 1.0);
-    }
-
-    void main(){
-        vec3 lin = texture(uImage, vUv).rgb;
-        vec3 blurLin = texture(uBlur, vUv).rgb;
-        frag = vec4(develop(lin, blurLin, vUv), 1.0);
-    }`;
-
-    function compile(gl, type, src) {
-        const sh = gl.createShader(type);
-        gl.shaderSource(sh, src);
-        gl.compileShader(sh);
-        if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
-            const log = gl.getShaderInfoLog(sh);
-            gl.deleteShader(sh);
-            throw new Error("Shader compile error: " + log);
-        }
-        return sh;
-    }
-
-    function program(gl, vsrc, fsrc) {
-        const p = gl.createProgram();
-        gl.attachShader(p, compile(gl, gl.VERTEX_SHADER, vsrc));
-        gl.attachShader(p, compile(gl, gl.FRAGMENT_SHADER, fsrc));
-        gl.bindAttribLocation(p, 0, "aPos");
-        gl.linkProgram(p);
-        if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
-            throw new Error("Program link error: " + gl.getProgramInfoLog(p));
-        }
-        return p;
-    }
-
-    function Renderer(canvas) {
-        const gl = canvas.getContext("webgl2", { premultipliedAlpha: false, preserveDrawingBuffer: true });
-        if (!gl) throw new Error("WebGL2 is not available in this browser.");
-        if (!gl.getExtension("EXT_color_buffer_float") && !gl.getExtension("EXT_color_buffer_half_float")) {
-            // Float render targets are needed for the blur pass; continue and
-            // hope UNSIGNED_BYTE fallback works, but most modern browsers pass.
-        }
-
-        this.gl = gl;
-        this.canvas = canvas;
-        this.mainProg = program(gl, VERT, MAIN_FRAG);
-        this.blurProg = program(gl, VERT, BLUR_FRAG);
-
-        // Fullscreen triangle.
-        const vbo = gl.createBuffer();
-        gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
-        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
-        const vao = gl.createVertexArray();
-        gl.bindVertexArray(vao);
-        gl.enableVertexAttribArray(0);
-        gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
-        this.vao = vao;
-
-        this.imageTex = null;
-        this.blurTex = null;
-        this.lutTex = null;
-        this.lutSize = 2;
-        this.width = 0;
-        this.height = 0;
-
-        // A 2x2x2 identity 3D LUT kept permanently bound to the uLUT sampler.
-        // Some drivers (ANGLE/SwiftShader) render a draw as black when a used
-        // sampler3D has no complete texture bound — even inside a disabled
-        // branch — so we always keep a valid 3D texture available.
-        this.dummyLut = this._makeIdentityLut3D();
-    }
-
-    Renderer.prototype._makeIdentityLut3D = function () {
-        const gl = this.gl;
-        const n = 2;
-        const d = new Float32Array(n * n * n * 4);
-        let p = 0;
-        for (let b = 0; b < n; b++)
-            for (let g = 0; g < n; g++)
-                for (let r = 0; r < n; r++) {
-                    d[p++] = r; d[p++] = g; d[p++] = b; d[p++] = 1;
-                }
-        const tex = gl.createTexture();
-        gl.bindTexture(gl.TEXTURE_3D, tex);
-        gl.texImage3D(gl.TEXTURE_3D, 0, gl.RGBA16F, n, n, n, 0, gl.RGBA, gl.FLOAT, d);
-        // NEAREST: tetrahedral interpolation fetches exact lattice points itself.
-        gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
-        gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
-        gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
-        gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
-        gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_R, gl.CLAMP_TO_EDGE);
-        return tex;
-    };
-
-    Renderer.prototype._makeFloatTex = function (w, h) {
-        const gl = this.gl;
-        const tex = gl.createTexture();
-        gl.bindTexture(gl.TEXTURE_2D, tex);
-        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, w, h, 0, gl.RGBA, gl.HALF_FLOAT, null);
-        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
-        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
-        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
-        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
-        return tex;
-    };
-
-    // Upload a decoded image ({data:Float32 RGBA linear, width, height}).
-    Renderer.prototype.setImage = function (img) {
-        const gl = this.gl;
-        this.width = img.width;
-        this.height = img.height;
-        this.canvas.width = img.width;
-        this.canvas.height = img.height;
-
-        if (this.imageTex) gl.deleteTexture(this.imageTex);
-        this.imageTex = gl.createTexture();
-        gl.bindTexture(gl.TEXTURE_2D, this.imageTex);
-        // HALF_FLOAT texImage from Float32 source is accepted by WebGL2.
-        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, img.width, img.height, 0, gl.RGBA, gl.FLOAT, img.data);
-        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
-        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
-        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
-        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
-
-        this._buildBlur(img);
-    };
-
-    // Two-pass separable blur of the base linear image into this.blurTex.
-    Renderer.prototype._buildBlur = function (img) {
-        const gl = this.gl;
-        const w = img.width, h = img.height;
-        const texA = this._makeFloatTex(w, h);
-        const texB = this._makeFloatTex(w, h);
-        // Load base into texA.
-        gl.bindTexture(gl.TEXTURE_2D, texA);
-        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, w, h, 0, gl.RGBA, gl.FLOAT, img.data);
-        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
-        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
-        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
-        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
-
-        const fbo = gl.createFramebuffer();
-        gl.useProgram(this.blurProg);
-        gl.bindVertexArray(this.vao);
-        gl.viewport(0, 0, w, h);
-        const uTex = gl.getUniformLocation(this.blurProg, "uTex");
-        const uDir = gl.getUniformLocation(this.blurProg, "uDir");
-
-        // Horizontal: texA -> texB
-        gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
-        gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texB, 0);
-        gl.activeTexture(gl.TEXTURE0);
-        gl.bindTexture(gl.TEXTURE_2D, texA);
-        gl.uniform1i(uTex, 0);
-        gl.uniform2f(uDir, 1.5 / w, 0);
-        gl.drawArrays(gl.TRIANGLES, 0, 3);
-
-        // Vertical: texB -> texA (final blurred result stored in texA)
-        gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texA, 0);
-        gl.bindTexture(gl.TEXTURE_2D, texB);
-        gl.uniform1i(uTex, 0);
-        gl.uniform2f(uDir, 0, 1.5 / h);
-        gl.drawArrays(gl.TRIANGLES, 0, 3);
-
-        gl.bindFramebuffer(gl.FRAMEBUFFER, null);
-        gl.deleteFramebuffer(fbo);
-        gl.deleteTexture(texB);
-        if (this.blurTex) gl.deleteTexture(this.blurTex);
-        this.blurTex = texA;
-    };
-
-    // Upload a parsed LUT ({size, data:Float32 RGB}) or clear it (null).
-    Renderer.prototype.setLUT = function (lut) {
-        const gl = this.gl;
-        if (this.lutTex) { gl.deleteTexture(this.lutTex); this.lutTex = null; }
-        if (!lut) return;
-        const n = lut.size;
-        // Expand RGB -> RGBA for texImage3D.
-        const rgba = new Float32Array(n * n * n * 4);
-        for (let i = 0, j = 0; i < lut.data.length; i += 3, j += 4) {
-            rgba[j] = lut.data[i];
-            rgba[j + 1] = lut.data[i + 1];
-            rgba[j + 2] = lut.data[i + 2];
-            rgba[j + 3] = 1.0;
-        }
-        const tex = gl.createTexture();
-        gl.bindTexture(gl.TEXTURE_3D, tex);
-        gl.texImage3D(gl.TEXTURE_3D, 0, gl.RGBA16F, n, n, n, 0, gl.RGBA, gl.FLOAT, rgba);
-        // NEAREST: tetrahedral interpolation is done in the shader, so we must
-        // read exact lattice values rather than GPU trilinear samples.
-        gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
-        gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
-        gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
-        gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
-        gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_R, gl.CLAMP_TO_EDGE);
-        this.lutTex = tex;
-        this.lutSize = n;
-        this.lutDomainMin = (lut.domainMin && lut.domainMin.length === 3) ? lut.domainMin : [0, 0, 0];
-        this.lutDomainMax = (lut.domainMax && lut.domainMax.length === 3) ? lut.domainMax : [1, 1, 1];
-    };
-
-    // Compute relative white-balance gains from temperature/tint sliders.
-    // baseTemp is the "As Shot" reference already baked into the pixels.
-    function GLRender_kelvinGain(temp, tint, baseTemp) {
-        function warmth(t) {
-            // Map Kelvin to a warm/cool balance on a log scale (neutral at base).
-            return (Math.log(t) - Math.log(baseTemp)) / (Math.log(50000) - Math.log(2000));
-        }
-        const wr = warmth(temp);
-        // Warmer (higher K in ACR) -> boost red, cut blue.
-        let r = 1.0 + wr * 0.9;
-        let b = 1.0 - wr * 0.9;
-        // Tint: positive -> magenta (reduce green), negative -> green.
-        const tn = tint / 150.0;
-        let g = 1.0 - tn * 0.4;
-        r += tn * 0.05; b += tn * 0.05;
-        return [
-            Math.max(0.2, Math.min(4.0, r)),
-            Math.max(0.2, Math.min(4.0, g)),
-            Math.max(0.2, Math.min(4.0, b))
-        ];
-    }
-
-    // Render the image with the given develop parameters.
-    Renderer.prototype.render = function (p) {
-        const gl = this.gl;
-        if (!this.imageTex) return;
-        gl.bindFramebuffer(gl.FRAMEBUFFER, null);
-        gl.viewport(0, 0, this.width, this.height);
-        gl.useProgram(this.mainProg);
-        gl.bindVertexArray(this.vao);
-
-        const u = (n) => gl.getUniformLocation(this.mainProg, n);
-        gl.activeTexture(gl.TEXTURE0);
-        gl.bindTexture(gl.TEXTURE_2D, this.imageTex);
-        gl.uniform1i(u("uImage"), 0);
-        gl.activeTexture(gl.TEXTURE1);
-        gl.bindTexture(gl.TEXTURE_2D, this.blurTex);
-        gl.uniform1i(u("uBlur"), 1);
-
-        const wb = GLRender_kelvinGain(p.temperature, p.tint, p.baseTemp || 5500);
-        gl.uniform3f(u("uWB"), wb[0], wb[1], wb[2]);
-        gl.uniform1f(u("uExposure"), p.exposure);
-        gl.uniform1f(u("uContrast"), p.contrast / 100);
-        gl.uniform1f(u("uHighlights"), p.highlights / 100);
-        gl.uniform1f(u("uShadows"), p.shadows / 100);
-        gl.uniform1f(u("uWhites"), p.whites / 100);
-        gl.uniform1f(u("uBlacks"), p.blacks / 100);
-        gl.uniform1f(u("uTexture"), p.texture / 100);
-        gl.uniform1f(u("uClarity"), p.clarity / 100);
-        gl.uniform1f(u("uDehaze"), p.dehaze / 100);
-        gl.uniform1f(u("uVibrance"), p.vibrance / 100);
-        gl.uniform1f(u("uSaturation"), p.saturation / 100);
-        gl.uniform1f(u("uVignette"), p.vignette / 100);
-        gl.uniform1f(u("uGrain"), p.grain / 100);
-
-        // Always keep a complete 3D texture on the uLUT sampler (see dummyLut).
-        gl.activeTexture(gl.TEXTURE2);
-        gl.uniform1i(u("uLUT"), 2);
-        if (this.lutTex && p.lutEnabled) {
-            gl.bindTexture(gl.TEXTURE_3D, this.lutTex);
-            gl.uniform1i(u("uLutEnabled"), 1);
-            gl.uniform1f(u("uLutAmount"), p.lutAmount != null ? p.lutAmount : 1.0);
-            gl.uniform1f(u("uLutSize"), this.lutSize);
-            gl.uniform1i(u("uLutLogInput"), p.lutLogInput ? 1 : 0);
-            var dmin = this.lutDomainMin || [0, 0, 0], dmax = this.lutDomainMax || [1, 1, 1];
-            gl.uniform3f(u("uLutDomainMin"), dmin[0], dmin[1], dmin[2]);
-            gl.uniform3f(u("uLutDomainMax"), dmax[0], dmax[1], dmax[2]);
-        } else {
-            gl.bindTexture(gl.TEXTURE_3D, this.dummyLut);
-            gl.uniform1i(u("uLutEnabled"), 0);
-            gl.uniform1f(u("uLutSize"), 2.0);
-            gl.uniform1i(u("uLutLogInput"), 0);
-            gl.uniform3f(u("uLutDomainMin"), 0, 0, 0);
-            gl.uniform3f(u("uLutDomainMax"), 1, 1, 1);
-        }
-
-        gl.drawArrays(gl.TRIANGLES, 0, 3);
-        // Ensure the draw is complete so an immediate drawImage()/toBlob() of the
-        // canvas (histogram, export) reads back the freshly rendered frame.
-        gl.finish();
-    };
-
-    return {
-        create: function (canvas) { return new Renderer(canvas); }
-    };
-})();

+ 0 - 82
src/web/Raw Editor/js/lut.js

@@ -1,82 +0,0 @@
-/*
-    lut.js — Adobe/IRIDAS .cube LUT parser for the Raw Editor.
-
-    Parses both 1D and 3D .cube LUTs into a flat RGB Float32Array laid out for
-    upload into a WebGL2 3D texture (R fastest, then G, then B — the .cube spec
-    order). A 1D LUT is expanded into an identity-mapped 3D texture so the WebGL
-    pipeline only ever needs a single sampler.
-*/
-
-const LUTParser = (function () {
-
-    // Parse .cube text -> { size, data: Float32Array(size^3 * 3), title, domainMin, domainMax }
-    function parse(text) {
-        const lines = text.split(/\r?\n/);
-        let size3d = 0, size1d = 0;
-        let domainMin = [0, 0, 0], domainMax = [1, 1, 1];
-        let title = "";
-        const values = [];
-
-        for (let raw of lines) {
-            const line = raw.trim();
-            if (!line || line[0] === "#") continue;
-            const upper = line.toUpperCase();
-            if (upper.startsWith("TITLE")) {
-                const m = line.match(/"([^"]*)"/);
-                title = m ? m[1] : "";
-                continue;
-            }
-            if (upper.startsWith("LUT_3D_SIZE")) { size3d = parseInt(line.split(/\s+/)[1], 10); continue; }
-            if (upper.startsWith("LUT_1D_SIZE")) { size1d = parseInt(line.split(/\s+/)[1], 10); continue; }
-            if (upper.startsWith("DOMAIN_MIN")) { const p = line.split(/\s+/); domainMin = [+p[1], +p[2], +p[3]]; continue; }
-            if (upper.startsWith("DOMAIN_MAX")) { const p = line.split(/\s+/); domainMax = [+p[1], +p[2], +p[3]]; continue; }
-            if (upper.startsWith("LUT_")) continue;
-
-            const parts = line.split(/\s+/).map(Number);
-            if (parts.length >= 3 && parts.every((n) => !isNaN(n))) {
-                values.push(parts[0], parts[1], parts[2]);
-            }
-        }
-
-        if (size3d > 0) {
-            const expected = size3d * size3d * size3d * 3;
-            if (values.length < expected) {
-                throw new Error("Truncated 3D LUT: expected " + expected + " values, got " + values.length);
-            }
-            return { size: size3d, data: new Float32Array(values.slice(0, expected)), title: title, domainMin: domainMin, domainMax: domainMax };
-        }
-
-        if (size1d > 0) {
-            if (values.length < size1d * 3) throw new Error("Truncated 1D LUT");
-            return expand1Dto3D(values, size1d, title, domainMin, domainMax);
-        }
-
-        throw new Error("Not a valid .cube LUT (missing LUT_3D_SIZE / LUT_1D_SIZE).");
-    }
-
-    // Expand a 1D curve LUT into a small 3D texture (per-channel transfer).
-    function expand1Dto3D(values, n1d, title, domainMin, domainMax) {
-        const size = Math.min(n1d, 33);
-        const data = new Float32Array(size * size * size * 3);
-        function curve(ch, t) {
-            const x = t * (n1d - 1);
-            const i0 = Math.floor(x), i1 = Math.min(n1d - 1, i0 + 1);
-            const f = x - i0;
-            const a = values[i0 * 3 + ch], b = values[i1 * 3 + ch];
-            return a + (b - a) * f;
-        }
-        let p = 0;
-        for (let b = 0; b < size; b++) {
-            for (let g = 0; g < size; g++) {
-                for (let r = 0; r < size; r++) {
-                    data[p++] = curve(0, r / (size - 1));
-                    data[p++] = curve(1, g / (size - 1));
-                    data[p++] = curve(2, b / (size - 1));
-                }
-            }
-        }
-        return { size: size, data: data, title: title, domainMin: domainMin, domainMax: domainMax };
-    }
-
-    return { parse: parse };
-})();

+ 0 - 735
src/web/Raw Editor/js/rawdecoder.js

@@ -1,735 +0,0 @@
-/*
-    rawdecoder.js — client side camera RAW decoder for the ArozOS Raw Editor
-
-    Responsibilities:
-      - Detect the container type from the extension / magic bytes.
-      - For ordinary images (jpg/png/webp/tiff) draw them onto a work canvas.
-      - For camera RAW (ARW/DNG/NEF/CR2/ORF/RW2 ... — all TIFF/IFD based)
-        parse the TIFF structure, locate the CFA (Bayer) plane and demosaic it
-        into an RGB image, applying black/white levels and camera / gray-world
-        white balance.
-      - When the raw payload uses a compression we cannot decode, gracefully
-        fall back to the full size JPEG preview that virtually every RAW file
-        embeds, so the user always sees their photo.
-
-    The decoder always resolves to a common structure consumed by the editor:
-
-      {
-        width, height,                // working resolution (long edge capped)
-        data: Float32Array,           // RGBA, scene linear, range ~[0,1]
-        meta: { camera, iso, shutter, aperture, focal, temp, tint, wb:[r,g,b] },
-        source: 'raw-demosaic' | 'embedded-preview' | 'image'
-      }
-
-    Everything downstream (WebGL develop pipeline) treats "data" as linear RGBA.
-*/
-
-const RawDecoder = (function () {
-
-    // Longest edge (px) of the working buffer used for interactive editing and
-    // export. Keeps memory / GPU upload bounded on multi-megapixel sensors.
-    const MAX_WORK_EDGE = 2560;
-
-    const RAW_EXTS = ["arw", "dng", "nef", "cr2", "cr3", "orf", "raf", "rw2", "pef", "srw"];
-
-    function extOf(name) {
-        const i = (name || "").lastIndexOf(".");
-        return i < 0 ? "" : name.substring(i + 1).toLowerCase();
-    }
-
-    // ---- sRGB <-> linear helpers ------------------------------------------
-    function srgbToLinear(c) {
-        return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
-    }
-
-    // Convert an 8bit RGBA ImageData (sRGB) into a linear Float32 RGBA buffer.
-    function imageDataToLinear(imgData) {
-        const src = imgData.data;
-        const out = new Float32Array(src.length);
-        // Small lookup table for the 256 possible 8bit values.
-        const lut = new Float32Array(256);
-        for (let i = 0; i < 256; i++) lut[i] = srgbToLinear(i / 255);
-        for (let i = 0; i < src.length; i += 4) {
-            out[i] = lut[src[i]];
-            out[i + 1] = lut[src[i + 1]];
-            out[i + 2] = lut[src[i + 2]];
-            out[i + 3] = 1.0;
-        }
-        return out;
-    }
-
-    // Draw an ImageBitmap/Image onto a work canvas (capped) and return linear RGBA.
-    function bitmapToWork(bitmap) {
-        let w = bitmap.width, h = bitmap.height;
-        const scale = Math.min(1, MAX_WORK_EDGE / Math.max(w, h));
-        w = Math.max(1, Math.round(w * scale));
-        h = Math.max(1, Math.round(h * scale));
-        const cv = document.createElement("canvas");
-        cv.width = w; cv.height = h;
-        const ctx = cv.getContext("2d");
-        ctx.drawImage(bitmap, 0, 0, w, h);
-        const img = ctx.getImageData(0, 0, w, h);
-        return { width: w, height: h, data: imageDataToLinear(img) };
-    }
-
-    function decodeBlobAsImage(blob) {
-        return new Promise((resolve, reject) => {
-            if (window.createImageBitmap) {
-                createImageBitmap(blob).then((bmp) => {
-                    resolve(bitmapToWork(bmp));
-                }).catch(reject);
-            } else {
-                const url = URL.createObjectURL(blob);
-                const im = new Image();
-                im.onload = () => { URL.revokeObjectURL(url); resolve(bitmapToWork(im)); };
-                im.onerror = (e) => { URL.revokeObjectURL(url); reject(e); };
-                im.src = url;
-            }
-        });
-    }
-
-    // ======================================================================
-    //  TIFF / IFD parsing
-    // ======================================================================
-
-    const TYPE_SIZE = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 6: 1, 7: 1, 8: 2, 9: 4, 10: 8, 11: 4, 12: 8 };
-
-    function parseTIFF(buf) {
-        const dv = new DataView(buf);
-        if (dv.byteLength < 8) throw new Error("not a tiff");
-        const b0 = dv.getUint8(0), b1 = dv.getUint8(1);
-        let little;
-        if (b0 === 0x49 && b1 === 0x49) little = true;        // II
-        else if (b0 === 0x4D && b1 === 0x4D) little = false;  // MM
-        else throw new Error("not a tiff (byte order)");
-        const magic = dv.getUint16(2, little);
-        if (magic !== 42 && magic !== 0x4F52 /*ORF 'RO'*/ && magic !== 0x5352) {
-            // ORF (Olympus) and some others use a non-42 magic; be lenient.
-        }
-        const ifds = [];
-        const visited = {};
-
-        function readValues(dv, type, count, valOffset, entryOffset) {
-            const size = TYPE_SIZE[type] || 1;
-            const total = size * count;
-            let base;
-            if (total <= 4) base = entryOffset; // inline
-            else base = valOffset;
-            if (base + total > dv.byteLength) return null;
-            const vals = [];
-            for (let i = 0; i < count; i++) {
-                const o = base + i * size;
-                switch (type) {
-                    case 1: case 6: case 7: vals.push(dv.getUint8(o)); break;
-                    case 2: vals.push(dv.getUint8(o)); break; // ascii byte
-                    case 3: case 8: vals.push(dv.getUint16(o, little)); break;
-                    case 4: case 9: vals.push(dv.getUint32(o, little)); break;
-                    case 5: vals.push(dv.getUint32(o, little) / (dv.getUint32(o + 4, little) || 1)); break;
-                    case 10: vals.push(dv.getInt32(o, little) / (dv.getInt32(o + 4, little) || 1)); break;
-                    case 11: vals.push(dv.getFloat32(o, little)); break;
-                    case 12: vals.push(dv.getFloat64(o, little)); break;
-                    default: vals.push(dv.getUint8(o));
-                }
-            }
-            return vals;
-        }
-
-        function readIFD(offset) {
-            if (!offset || offset <= 0 || offset + 2 > dv.byteLength) return null;
-            if (visited[offset]) return null;
-            visited[offset] = true;
-            const count = dv.getUint16(offset, little);
-            if (count > 4096) return null; // not a real IFD — bogus offset
-            const tags = {};
-            const subOffsets = [];
-            let p = offset + 2;
-            for (let i = 0; i < count; i++, p += 12) {
-                if (p + 12 > dv.byteLength) break;
-                const tag = dv.getUint16(p, little);
-                const type = dv.getUint16(p + 2, little);
-                const cnt = dv.getUint32(p + 4, little);
-                const valOff = dv.getUint32(p + 8, little);
-                tags[tag] = { type: type, count: cnt, valueOffset: valOff, entryOffset: p + 8 };
-                // Follow SubIFD (0x014A) and the ExifIFD (0x8769) pointers. Do NOT
-                // recurse the MakerNote (0x927C): its bytes are not IFD offsets and
-                // treating them as such can be pathologically slow on real files.
-                if (tag === 0x014A || tag === 0x8769) {
-                    const v = readValues(dv, type, cnt, valOff, p + 8);
-                    if (v) v.forEach((o) => subOffsets.push(o));
-                }
-            }
-            const nextOff = (p + 4 <= dv.byteLength) ? dv.getUint32(p, little) : 0;
-            const ifd = {
-                tags: tags,
-                get: function (tag) {
-                    const t = tags[tag];
-                    if (!t) return null;
-                    return readValues(dv, t.type, t.count, t.valueOffset, t.entryOffset);
-                },
-                raw: tags
-            };
-            ifds.push(ifd);
-            subOffsets.forEach((so) => readIFD(so));
-            return nextOff;
-        }
-
-        let ifdOff = dv.getUint32(4, little);
-        let guard = 0;
-        while (ifdOff && guard++ < 64) {
-            ifdOff = readIFD(ifdOff);
-        }
-        return { dv: dv, little: little, ifds: ifds };
-    }
-
-    // Common TIFF/EXIF/DNG tag ids we care about.
-    const T = {
-        ImageWidth: 0x0100, ImageLength: 0x0101, BitsPerSample: 0x0102,
-        Compression: 0x0103, Photometric: 0x0106, StripOffsets: 0x0111,
-        RowsPerStrip: 0x0116, StripByteCounts: 0x0117, TileWidth: 0x0142,
-        TileLength: 0x0143, TileOffsets: 0x0144, TileByteCounts: 0x0145,
-        JPEGOffset: 0x0201, JPEGLength: 0x0202, Make: 0x010F, Model: 0x0110,
-        CFAPattern: 0x828E, CFAPatternExif: 0xA302, SubfileType: 0x00FE,
-        // EXIF
-        ExposureTime: 0x829A, FNumber: 0x829D, ISO: 0x8827, FocalLength: 0x920A,
-        // DNG
-        BlackLevel: 0xC61A, WhiteLevel: 0xC61D, AsShotNeutral: 0xC628,
-        DNGVersion: 0xC612, CFALayout: 0xC61E, LinearizationTable: 0xC618
-    };
-
-    function asciiOf(vals) {
-        if (!vals) return "";
-        let s = "";
-        for (let i = 0; i < vals.length; i++) {
-            if (vals[i] === 0) break;
-            s += String.fromCharCode(vals[i]);
-        }
-        return s.trim();
-    }
-
-    // ======================================================================
-    //  Embedded JPEG preview extraction (robust fallback)
-    // ======================================================================
-
-    // True only for a real, decodable JPEG (SOI at start, EOI at end). This
-    // rejects lossless-JPEG-compressed CFA raw streams, which also start with
-    // 0xFFD8 but are NOT displayable images.
-    function looksLikeJpeg(u8, off, len) {
-        return off >= 0 && len > 1000 && off + len <= u8.length &&
-            u8[off] === 0xFF && u8[off + 1] === 0xD8 &&
-            u8[off + len - 2] === 0xFF && u8[off + len - 1] === 0xD9;
-    }
-
-    // Locate the largest embedded preview JPEG, using IFD pointers first then a
-    // brute force SOI/EOI scan. CFA / raw IFDs (Photometric 32803) are ignored —
-    // their compressed data starts with 0xFFD8 but is not a viewable image.
-    // Returns { off, len } into the source bytes or null.
-    function findEmbeddedJpegRange(u8, tiff) {
-        let best = null;
-
-        if (tiff) {
-            tiff.ifds.forEach((ifd) => {
-                const photo = ifd.get(T.Photometric);
-                if (photo && photo[0] === 32803) return; // CFA raw — never a preview
-                const off = ifd.get(T.JPEGOffset);
-                const len = ifd.get(T.JPEGLength);
-                if (off && len && looksLikeJpeg(u8, off[0], len[0])) {
-                    if (!best || len[0] > best.len) best = { off: off[0], len: len[0] };
-                }
-                // Some previews are stored as a full strip with Compression 6/7.
-                const comp = ifd.get(T.Compression);
-                if (comp && (comp[0] === 6 || comp[0] === 7 || comp[0] === 99)) {
-                    const so = ifd.get(T.StripOffsets);
-                    const sc = ifd.get(T.StripByteCounts);
-                    if (so && sc && so.length === 1 && looksLikeJpeg(u8, so[0], sc[0])) {
-                        if (!best || sc[0] > best.len) best = { off: so[0], len: sc[0] };
-                    }
-                }
-            });
-        }
-        if (best) return best;
-
-        // Brute force: find the largest FFD8..FFD9 span.
-        let bestStart = -1, bestEnd = -1;
-        for (let i = 0; i + 1 < u8.length; i++) {
-            if (u8[i] === 0xFF && u8[i + 1] === 0xD8 && u8[i + 2] === 0xFF) {
-                for (let j = i + 2; j + 1 < u8.length; j++) {
-                    if (u8[j] === 0xFF && u8[j + 1] === 0xD9) {
-                        if (j - i > bestEnd - bestStart) { bestStart = i; bestEnd = j + 1; }
-                        i = j + 1;
-                        break;
-                    }
-                }
-            }
-        }
-        if (bestStart >= 0 && bestEnd - bestStart > 2000) {
-            return { off: bestStart, len: bestEnd - bestStart + 1 };
-        }
-        return null;
-    }
-
-    function extractEmbeddedJpeg(buf, tiff) {
-        const u8 = new Uint8Array(buf);
-        const r = findEmbeddedJpegRange(u8, tiff);
-        return r ? new Blob([u8.subarray(r.off, r.off + r.len)], { type: "image/jpeg" }) : null;
-    }
-
-    // ======================================================================
-    //  EXIF from a JPEG APP1 segment (covers plain JPEGs, embedded previews,
-    //  and RAWs whose main TIFF structure we could not parse).
-    // ======================================================================
-
-    // jbytes: a Uint8Array starting at a JPEG SOI (0xFFD8). Returns meta or null.
-    function parseJpegExif(jbytes) {
-        if (!jbytes || jbytes.length < 4 || jbytes[0] !== 0xFF || jbytes[1] !== 0xD8) return null;
-        let i = 2;
-        while (i + 4 < jbytes.length) {
-            if (jbytes[i] !== 0xFF) { i++; continue; }
-            const marker = jbytes[i + 1];
-            if (marker === 0xD9 || marker === 0xDA) break; // EOI / start of scan
-            const len = (jbytes[i + 2] << 8) | jbytes[i + 3];
-            if (len < 2) break;
-            if (marker === 0xE1) {
-                const o = i + 4;
-                // "Exif\0\0"
-                if (jbytes[o] === 0x45 && jbytes[o + 1] === 0x78 && jbytes[o + 2] === 0x69 &&
-                    jbytes[o + 3] === 0x66 && jbytes[o + 4] === 0 && jbytes[o + 5] === 0) {
-                    const tiffStart = o + 6;
-                    const tiffLen = (len - 2) - 6;
-                    if (tiffLen > 8 && tiffStart + tiffLen <= jbytes.length) {
-                        try {
-                            const sub = jbytes.slice(tiffStart, tiffStart + tiffLen).buffer;
-                            return readMeta(parseTIFF(sub));
-                        } catch (e) { return null; }
-                    }
-                }
-            }
-            i += 2 + len;
-        }
-        return null;
-    }
-
-    function metaHasExif(m) {
-        return m && (m.iso || m.aperture || m.shutter || m.focal || m.camera);
-    }
-
-    function mergeMeta(primary, secondary) {
-        if (!secondary) return primary;
-        if (!primary) return secondary;
-        const out = Object.assign({}, primary);
-        ["camera", "iso", "shutter", "aperture", "focal"].forEach((k) => {
-            if ((out[k] === "" || out[k] === 0 || out[k] == null) && secondary[k]) out[k] = secondary[k];
-        });
-        if (!out.wb && secondary.wb) out.wb = secondary.wb;
-        if ((!out.temp || out.temp === 5500) && secondary.temp) out.temp = secondary.temp;
-        return out;
-    }
-
-    // Collect every embedded JPEG (IFD-pointed + brute-force SOI/EOI scan).
-    // Sony ARW keeps EXIF in the small thumbnail, not the large preview, so we
-    // must be able to inspect all of them — not just the biggest.
-    function collectJpegRanges(u8, tiff) {
-        const ranges = [];
-        const seen = {};
-        const add = (off, len) => {
-            if (off >= 0 && len > 100 && off + len <= u8.length &&
-                u8[off] === 0xFF && u8[off + 1] === 0xD8 && !seen[off]) {
-                seen[off] = true; ranges.push({ off: off, len: len });
-            }
-        };
-        if (tiff) {
-            tiff.ifds.forEach((ifd) => {
-                const photo = ifd.get(T.Photometric);
-                if (photo && photo[0] === 32803) return; // skip CFA raw stream
-                const off = ifd.get(T.JPEGOffset), len = ifd.get(T.JPEGLength);
-                if (off && len) add(off[0], len[0]);
-                const so = ifd.get(T.StripOffsets), sc = ifd.get(T.StripByteCounts);
-                if (so && sc && so.length === 1) add(so[0], sc[0]);
-            });
-        }
-        // Brute-force SOI/EOI scan, capped so a huge false-positive (e.g. raw
-        // data containing 0xFFD8) can't produce a multi-megabyte junk range.
-        for (let i = 0; i + 2 < u8.length; i++) {
-            if (u8[i] === 0xFF && u8[i + 1] === 0xD8 && u8[i + 2] === 0xFF) {
-                for (let j = i + 2; j + 1 < u8.length && j - i < 4000000; j++) {
-                    if (u8[j] === 0xFF && u8[j + 1] === 0xD9) { add(i, j - i + 1); i = j + 1; break; }
-                }
-            }
-        }
-        return ranges;
-    }
-
-    // Fill gaps in "meta" from EXIF found in any embedded / whole-file JPEG.
-    function enrichMetaFromJpeg(buf, tiff, meta) {
-        if (metaHasExif(meta) && meta.iso && meta.aperture && meta.shutter) return meta;
-        try {
-            const u8 = new Uint8Array(buf);
-            if (u8[0] === 0xFF && u8[1] === 0xD8) {
-                const em0 = parseJpegExif(u8);
-                if (em0) meta = mergeMeta(meta, em0);
-            }
-            const ranges = collectJpegRanges(u8, tiff);
-            for (let k = 0; k < ranges.length; k++) {
-                if (meta.iso && meta.aperture && meta.shutter) break;
-                const em = parseJpegExif(u8.subarray(ranges[k].off, ranges[k].off + ranges[k].len));
-                if (em) meta = mergeMeta(meta, em);
-            }
-            return meta;
-        } catch (e) { return meta; }
-    }
-
-    // ======================================================================
-    //  CFA (Bayer) extraction + demosaic
-    // ======================================================================
-
-    // Read raw CFA samples into a Float32 single-channel plane (values kept in
-    // sensor code range). Supports uncompressed 16/14/12-bit (packed or not)
-    // and Sony ARW2 lossy compression. Returns null when unsupported.
-    function readCFAPlane(buf, tiff, ifd) {
-        const dv = tiff.dv, little = tiff.little;
-        const w = (ifd.get(T.ImageWidth) || [0])[0];
-        const h = (ifd.get(T.ImageLength) || [0])[0];
-        const bps = (ifd.get(T.BitsPerSample) || [16])[0];
-        const comp = (ifd.get(T.Compression) || [1])[0];
-        if (!w || !h || w * h > 80e6) return null;
-
-        const plane = new Float32Array(w * h);
-        const strips = ifd.get(T.StripOffsets);
-        const counts = ifd.get(T.StripByteCounts);
-        const rowsPerStrip = (ifd.get(T.RowsPerStrip) || [h])[0];
-
-        if (comp === 1) {
-            // Uncompressed. Concatenate strips then unpack bit-by-bit.
-            if (!strips) return null;
-            let dstPix = 0;
-            for (let s = 0; s < strips.length; s++) {
-                const off = strips[s];
-                const nRows = Math.min(rowsPerStrip, h - s * rowsPerStrip);
-                const pixInStrip = nRows * w;
-                if (bps === 16) {
-                    for (let i = 0; i < pixInStrip; i++) {
-                        const o = off + i * 2;
-                        if (o + 1 >= dv.byteLength) break;
-                        plane[dstPix++] = dv.getUint16(o, little);
-                    }
-                } else {
-                    // Packed bitstream (12 or 14 bit), MSB first per TIFF spec.
-                    let bitPos = off * 8;
-                    for (let i = 0; i < pixInStrip; i++) {
-                        let v = 0;
-                        for (let b = 0; b < bps; b++) {
-                            const bytePos = bitPos >> 3;
-                            if (bytePos >= dv.byteLength) { v = 0; break; }
-                            const bit = 7 - (bitPos & 7);
-                            v = (v << 1) | ((dv.getUint8(bytePos) >> bit) & 1);
-                            bitPos++;
-                        }
-                        plane[dstPix++] = v;
-                    }
-                }
-            }
-            return { plane: plane, width: w, height: h, maxVal: (1 << bps) - 1 };
-        }
-
-        if (comp === 32767) {
-            // Sony ARW2 lossy compression: 16 pixel blocks, each 128 bits.
-            if (!strips || !counts) return null;
-            if (decodeSonyARW2(dv, strips, counts, w, h, plane)) {
-                return { plane: plane, width: w, height: h, maxVal: 16383 };
-            }
-            return null;
-        }
-
-        return null; // unsupported compression (lossless JPEG etc.)
-    }
-
-    // Sony ARW2 block decoder. Each row is stored in 16 pixel groups; a group
-    // is 16 bytes = 2x max/min (11bit each) + 4bit shift + 14x 7bit deltas.
-    // Reference: dcraw sony_arw2_load_raw. Values are interleaved per Bayer
-    // colour (even/odd columns) but we lay them out linearly which is fine for
-    // a subsequent generic demosaic.
-    function decodeSonyARW2(dv, strips, counts, w, h, plane) {
-        try {
-            // Build a per-row byte offset table from strips.
-            // ARW2 typically uses one strip; bytes-per-row = stripBytes / rows.
-            const totalBytes = counts.reduce((a, b) => a + b, 0);
-            const bytesPerRow = Math.floor(totalBytes / h);
-            if (bytesPerRow < w) return false;
-
-            function bits(bytePos, bitOff, n) {
-                let v = 0;
-                for (let i = 0; i < n; i++) {
-                    const bp = bytePos + ((bitOff + i) >> 3);
-                    if (bp >= dv.byteLength) return v;
-                    const b = 7 - ((bitOff + i) & 7);
-                    v = (v << 1) | ((dv.getUint8(bp) >> b) & 1);
-                }
-                return v;
-            }
-
-            let rowBase = strips[0];
-            for (let row = 0; row < h; row++) {
-                let colByte = rowBase;
-                for (let col = 0; col < w; col += 16) {
-                    // Two interleaved colours -> two passes of 8 pixels.
-                    for (let phase = 0; phase < 2; phase++) {
-                        let bitOff = 0;
-                        const max = bits(colByte, bitOff, 11); bitOff += 11;
-                        const min = bits(colByte, bitOff, 11); bitOff += 11;
-                        const imax = bits(colByte, bitOff, 4); bitOff += 4;
-                        const imin = bits(colByte, bitOff, 4); bitOff += 4;
-                        let sh = 0;
-                        for (let s = max; s < 0x800; s <<= 1) sh++;
-                        for (let i = 0; i < 8; i++) {
-                            let p;
-                            if (i === imax) p = max;
-                            else if (i === imin) p = min;
-                            else {
-                                const d = bits(colByte, bitOff, 7); bitOff += 7;
-                                p = (d << sh) + min;
-                                if (p > 0x7ff) p = 0x7ff;
-                            }
-                            const c = col + i * 2 + phase;
-                            if (c < w) plane[row * w + c] = p << 1;
-                        }
-                        colByte += 16;
-                    }
-                }
-                rowBase += bytesPerRow;
-            }
-            return true;
-        } catch (e) {
-            return false;
-        }
-    }
-
-    // Bilinear demosaic of a single CFA plane into linear RGBA float, applying
-    // black/white level normalisation and camera / gray-world white balance.
-    function demosaic(cfa, pattern, black, white, wbMul) {
-        const w = cfa.width, h = cfa.height, p = cfa.plane;
-        const out = new Float32Array(w * h * 4);
-        const range = Math.max(1, (white - black));
-
-        // pattern: 2x2 colour ids, 0=R 1=G 2=B for (row%2,col%2).
-        function colorAt(y, x) { return pattern[(y & 1) * 2 + (x & 1)]; }
-        function samp(y, x) {
-            if (x < 0) x = 1; if (x >= w) x = w - 2;
-            if (y < 0) y = 1; if (y >= h) y = h - 2;
-            return p[y * w + x];
-        }
-
-        for (let y = 0; y < h; y++) {
-            for (let x = 0; x < w; x++) {
-                const c = colorAt(y, x);
-                let r, g, b;
-                const v = samp(y, x);
-                if (c === 0) { // red site
-                    r = v;
-                    g = (samp(y, x - 1) + samp(y, x + 1) + samp(y - 1, x) + samp(y + 1, x)) * 0.25;
-                    b = (samp(y - 1, x - 1) + samp(y - 1, x + 1) + samp(y + 1, x - 1) + samp(y + 1, x + 1)) * 0.25;
-                } else if (c === 2) { // blue site
-                    b = v;
-                    g = (samp(y, x - 1) + samp(y, x + 1) + samp(y - 1, x) + samp(y + 1, x)) * 0.25;
-                    r = (samp(y - 1, x - 1) + samp(y - 1, x + 1) + samp(y + 1, x - 1) + samp(y + 1, x + 1)) * 0.25;
-                } else { // green site
-                    g = v;
-                    // Neighbours: horizontal & vertical carry the two other colours.
-                    const h1 = samp(y, x - 1), h2 = samp(y, x + 1);
-                    const v1 = samp(y - 1, x), v2 = samp(y + 1, x);
-                    if (colorAt(y, x - 1) === 0) { r = (h1 + h2) * 0.5; b = (v1 + v2) * 0.5; }
-                    else { b = (h1 + h2) * 0.5; r = (v1 + v2) * 0.5; }
-                }
-                const o = (y * w + x) * 4;
-                out[o] = Math.max(0, (r - black) / range) * wbMul[0];
-                out[o + 1] = Math.max(0, (g - black) / range) * wbMul[1];
-                out[o + 2] = Math.max(0, (b - black) / range) * wbMul[2];
-                out[o + 3] = 1.0;
-            }
-        }
-        return { data: out, width: w, height: h };
-    }
-
-    // Downscale a full-res linear RGBA buffer to the working edge cap (box filter).
-    function downscaleLinear(src, w, h) {
-        const scale = Math.min(1, MAX_WORK_EDGE / Math.max(w, h));
-        if (scale >= 1) return { data: src, width: w, height: h };
-        const nw = Math.max(1, Math.round(w * scale));
-        const nh = Math.max(1, Math.round(h * scale));
-        const out = new Float32Array(nw * nh * 4);
-        const sx = w / nw, sy = h / nh;
-        for (let y = 0; y < nh; y++) {
-            const y0 = Math.floor(y * sy), y1 = Math.min(h, Math.floor((y + 1) * sy));
-            for (let x = 0; x < nw; x++) {
-                const x0 = Math.floor(x * sx), x1 = Math.min(w, Math.floor((x + 1) * sx));
-                let r = 0, g = 0, b = 0, n = 0;
-                for (let yy = y0; yy < y1; yy++) {
-                    for (let xx = x0; xx < x1; xx++) {
-                        const o = (yy * w + xx) * 4;
-                        r += src[o]; g += src[o + 1]; b += src[o + 2]; n++;
-                    }
-                }
-                const o = (y * nw + x) * 4;
-                if (n === 0) n = 1;
-                out[o] = r / n; out[o + 1] = g / n; out[o + 2] = b / n; out[o + 3] = 1;
-            }
-        }
-        return { data: out, width: nw, height: nh };
-    }
-
-    // Estimate a gray-world white balance from the demosaiced plane, used when
-    // the file carries no AsShotNeutral (typical for Sony ARW without makernote).
-    function grayWorldMul(cfa, pattern, black) {
-        const w = cfa.width, h = cfa.height, p = cfa.plane;
-        let sr = 0, sg = 0, sb = 0, nr = 0, ng = 0, nb = 0;
-        const step = Math.max(1, Math.floor(Math.min(w, h) / 300));
-        for (let y = 0; y < h; y += step) {
-            for (let x = 0; x < w; x += step) {
-                const v = Math.max(0, p[y * w + x] - black);
-                const c = pattern[(y & 1) * 2 + (x & 1)];
-                if (c === 0) { sr += v; nr++; }
-                else if (c === 2) { sb += v; nb++; }
-                else { sg += v; ng++; }
-            }
-        }
-        const ar = sr / Math.max(1, nr), ag = sg / Math.max(1, ng), ab = sb / Math.max(1, nb);
-        const g = ag || 1;
-        let mr = g / (ar || g), mb = g / (ab || g);
-        // clamp to sane range
-        mr = Math.min(4, Math.max(0.25, mr));
-        mb = Math.min(4, Math.max(0.25, mb));
-        return [mr, 1.0, mb];
-    }
-
-    // Map a DNG CFAPattern (or default) into our 2x2 colour id layout.
-    function resolvePattern(ifd) {
-        const raw = ifd.get(T.CFAPattern) || ifd.get(T.CFAPatternExif);
-        // CFAPattern values: 0=R 1=G 2=B (Exif) preceded by a 2x2 dim header in
-        // some encodings; be defensive and fall back to RGGB.
-        if (raw && raw.length >= 4) {
-            const last4 = raw.slice(raw.length - 4);
-            const ok = last4.every((v) => v >= 0 && v <= 2);
-            if (ok) return last4;
-        }
-        return [0, 1, 1, 2]; // RGGB
-    }
-
-    function readMeta(tiff) {
-        const meta = { camera: "", iso: 0, shutter: 0, aperture: 0, focal: 0, temp: 5500, tint: 0, wb: null };
-        tiff.ifds.forEach((ifd) => {
-            const make = asciiOf(ifd.get(T.Make));
-            const model = asciiOf(ifd.get(T.Model));
-            if (model && !meta.camera) meta.camera = (make ? make + " " : "") + model;
-            const iso = ifd.get(T.ISO); if (iso && !meta.iso) meta.iso = iso[0];
-            const et = ifd.get(T.ExposureTime); if (et && !meta.shutter) meta.shutter = et[0];
-            const fn = ifd.get(T.FNumber); if (fn && !meta.aperture) meta.aperture = fn[0];
-            const fl = ifd.get(T.FocalLength); if (fl && !meta.focal) meta.focal = fl[0];
-            const asn = ifd.get(T.AsShotNeutral);
-            if (asn && asn.length >= 3 && !meta.wb) {
-                // AsShotNeutral is the neutral in camera-native space; multiplier is 1/n.
-                meta.wb = [1 / (asn[0] || 1), 1 / (asn[1] || 1), 1 / (asn[2] || 1)];
-                const g = meta.wb[1] || 1;
-                meta.wb = [meta.wb[0] / g, 1, meta.wb[2] / g];
-            }
-        });
-        return meta;
-    }
-
-    // Attempt a full CFA demosaic. Returns work-sized linear RGBA or null.
-    function tryDemosaic(buf, tiff, meta) {
-        // Find the CFA IFD: Photometric 32803, or the largest raw-looking plane.
-        let cfaIfd = null, bestPix = 0;
-        tiff.ifds.forEach((ifd) => {
-            const photo = ifd.get(T.Photometric);
-            const w = (ifd.get(T.ImageWidth) || [0])[0];
-            const h = (ifd.get(T.ImageLength) || [0])[0];
-            const comp = (ifd.get(T.Compression) || [0])[0];
-            const isCFA = photo && photo[0] === 32803;
-            if ((isCFA || comp === 32767) && w * h > bestPix) { cfaIfd = ifd; bestPix = w * h; }
-        });
-        if (!cfaIfd) return null;
-
-        const cfa = readCFAPlane(buf, tiff, cfaIfd);
-        if (!cfa) return null;
-
-        const pattern = resolvePattern(cfaIfd);
-        let black = (cfaIfd.get(T.BlackLevel) || [0])[0] || 0;
-        let white = (cfaIfd.get(T.WhiteLevel) || [cfa.maxVal])[0] || cfa.maxVal;
-        if (white <= black) white = cfa.maxVal;
-
-        const wb = meta.wb || grayWorldMul(cfa, pattern, black);
-        const dem = demosaic(cfa, pattern, black, white, wb);
-        // A mild highlight normalise so mid-tones land in a sensible range.
-        return downscaleLinear(dem.data, dem.width, dem.height);
-    }
-
-    // ======================================================================
-    //  Public entry point
-    // ======================================================================
-
-    function decode(buf, filename) {
-        return new Promise((resolve, reject) => {
-            const ext = extOf(filename);
-            const blob = new Blob([buf]);
-
-            // Plain images: hand straight to the browser, but still read EXIF
-            // from the JPEG APP1 segment so shooting info is shown.
-            if (["jpg", "jpeg", "png", "webp", "gif", "bmp"].indexOf(ext) >= 0) {
-                let imgMeta = emptyMeta();
-                try { imgMeta = enrichMetaFromJpeg(buf, null, imgMeta); } catch (e) { imgMeta = emptyMeta(); }
-                decodeBlobAsImage(blob).then((work) => {
-                    resolve({ width: work.width, height: work.height, data: work.data, meta: imgMeta, source: "image" });
-                }).catch(reject);
-                return;
-            }
-
-            let tiff = null;
-            try { tiff = parseTIFF(buf); } catch (e) { tiff = null; }
-            let meta = tiff ? readMeta(tiff) : emptyMeta();
-            // Fill any missing EXIF from the embedded JPEG preview — this rescues
-            // shooting info when the RAW's TIFF structure could not be parsed.
-            try { meta = enrichMetaFromJpeg(buf, tiff, meta); } catch (e) { /* keep meta */ }
-
-            // For RAW containers, try to demosaic; otherwise fall back to preview.
-            const isRaw = RAW_EXTS.indexOf(ext) >= 0 || (tiff && ext !== "tif" && ext !== "tiff");
-
-            function finishWithPreview() {
-                const jpeg = tiff ? extractEmbeddedJpeg(buf, tiff) : null;
-                if (jpeg) {
-                    decodeBlobAsImage(jpeg).then((work) => {
-                        resolve({ width: work.width, height: work.height, data: work.data, meta: meta, source: "embedded-preview" });
-                    }).catch(() => tryTiffAsImage());
-                } else {
-                    tryTiffAsImage();
-                }
-            }
-
-            function tryTiffAsImage() {
-                // Last resort: maybe the browser can render it (baseline TIFF/DNG w/ preview).
-                decodeBlobAsImage(blob).then((work) => {
-                    resolve({ width: work.width, height: work.height, data: work.data, meta: meta, source: "image" });
-                }).catch(() => reject(new Error("Unable to decode this file. The RAW format or its compression is not supported and no embedded preview was found.")));
-            }
-
-            if (isRaw && tiff) {
-                let dem = null;
-                try { dem = tryDemosaic(buf, tiff, meta); } catch (e) { dem = null; }
-                if (dem) {
-                    resolve({ width: dem.width, height: dem.height, data: dem.data, meta: meta, source: "raw-demosaic" });
-                    return;
-                }
-                finishWithPreview();
-                return;
-            }
-
-            // tif/tiff or anything else: try as image, then preview.
-            decodeBlobAsImage(blob).then((work) => {
-                resolve({ width: work.width, height: work.height, data: work.data, meta: meta, source: "image" });
-            }).catch(finishWithPreview);
-        });
-    }
-
-    function emptyMeta() {
-        return { camera: "", iso: 0, shutter: 0, aperture: 0, focal: 0, temp: 5500, tint: 0, wb: null };
-    }
-
-    return { decode: decode, MAX_WORK_EDGE: MAX_WORK_EDGE };
-})();