소스 검색

New webapp RAW Editor (#271)

* Add Raw Editor webapp with RAW decode, develop controls and LUT grading

Introduce a new "Raw Editor" WebApp under src/web that decodes camera RAW
files client-side and develops them with a Camera-Raw style interface.

Decoder (js/rawdecoder.js):
- TIFF/IFD parser covering the common RAW containers (ARW, DNG, NEF, CR2,
  ORF, RW2 ...), little/big endian, with SubIFD/EXIF traversal.
- CFA (Bayer) extraction for uncompressed 12/14/16-bit and Sony ARW2 lossy
  compression, bilinear demosaic with black/white-level normalisation and
  camera (AsShotNeutral) or gray-world white balance.
- Robust embedded full-size JPEG preview fallback (IFD pointers + brute-force
  SOI/EOI scan) so any RAW still renders when its compression is unsupported.
- EXIF metadata (camera, shutter, aperture, ISO, focal length).

Develop pipeline (js/glrender.js): WebGL2 real-time renderer implementing
white balance (temperature/tint), exposure, contrast, highlights, shadows,
whites, blacks, clarity, dehaze, vibrance and saturation, plus an optional
3D LUT grade. A separable Gaussian low-pass drives clarity/dehaze local
contrast. Keeps a valid identity 3D texture bound at all times so drivers
that reject incomplete sampler3D bindings still render.

LUT (js/lut.js): Adobe/IRIDAS .cube parser for 1D and 3D LUTs, uploaded to a
WebGL 3D texture.

UI (index.html, css/style.css, js/editor.js): dark Camera-Raw inspired layout
with RGB histogram, EXIF readout, Basic/LUT tabs, editable sliders, auto white
balance / auto tone, treatment (colour / B&W), file open (ArozOS picker, local
picker and drag & drop) and JPEG export back to storage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151sG7aaGnwCPRt3qvGNxd3

* Hand off developed image to Pixel Studio on Done

Repurpose the Raw Editor's "Done" action into a hand-off: it now writes the
current develop to a temporary JPEG (tmp:/RawEditor, auto-cleared by ArozOS)
and launches Pixel Studio as a float window with that file as its input
(matching Pixel Studio's ao_module_loadInputFiles hash format), then closes
the Raw Editor window.

Falls back to the normal Save dialog when running outside the ArozOS desktop.
The bottom-bar button is relabelled "Open in Pixel Studio".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151sG7aaGnwCPRt3qvGNxd3

* Redesign Raw Editor UI to match the newer Lightroom / Camera Raw layout

Rework the develop panel into the 2024 Lightroom-style layout:
- Thin centred title bar with open / save / reset / fit icons.
- Borderless histogram with shadow/highlight clipping indicators and an
  EXIF readout (ISO, focal, aperture, shutter).
- "Edit" header carrying Auto (auto-tone) and B&W (treatment) pills.
- Profile selector row.
- Collapsible Light / Color / Effects / LUT groups, each with a chevron to
  fold and an eye toggle that bypasses that group's adjustments.
- Lightroom slider rows: label + right-aligned editable value on one line,
  thin track below, with coloured gradient tracks for Temperature (blue->
  yellow), Tint (green->magenta) and Vibrance / Saturation.
- White-balance eyedropper wired to auto white balance.
- Bottom bar with a live filmstrip thumbnail, Fit / 100% zoom, an underlined
  dimension readout and Cancel / Open in Pixel Studio actions.
- Decorative panel-selector strip on the right edge.

Pipeline: add Texture (fine local contrast), Vignette (radial) and Grain
(post noise) controls to the WebGL develop shader to complete the Effects
group, plus per-group bypass handling in the parameter builder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151sG7aaGnwCPRt3qvGNxd3

* Add ArozOS-backed LUT library with a managed folder

LUTs can now be managed through the user's ArozOS storage instead of only
one-off browser uploads:

- On first use the app creates a "user:/RawEditor/LUTs" folder (via the
  file_system newItem endpoint with a CSRF token). Users can drop .cube
  files into it with the Files app.
- The LUT panel scans that folder (listDir) and lists every .cube in a
  "Library" dropdown; picking one reads it back through the media endpoint,
  parses it and applies it live. A refresh button re-scans on demand.
- "Import .cube to library" still loads a local file immediately and now
  also uploads it into the library folder so it persists and reappears in
  the list next time.
- Selecting a LUT re-enables the LUT group (clears any eye bypass); the hint
  line reports how many LUTs are available.

Falls back gracefully to local import when running outside the ArozOS desktop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151sG7aaGnwCPRt3qvGNxd3

* Add scroll-zoom and right-drag pan; remove unused UI

- Scroll wheel now zooms the photo, centred on the cursor, and the current
  zoom level is shown in the status bar. Fit / 100% buttons reset the view.
- Right-mouse (or middle) button drag pans the image; the browser context
  menu is suppressed over the canvas. The view re-fits on window resize.
  The canvas is now transform-based (translate + scale) instead of the old
  fit / 1:1 CSS toggle.
- Removed the Profile dropdown (no camera profiles in this pipeline) and the
  decorative right-edge tool strip, so the panel only shows working controls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151sG7aaGnwCPRt3qvGNxd3

* Pan with left mouse button and fix LUT dropdown overflow

- Change canvas panning from right-drag to left-mouse (and middle) drag, with
  a grab / grabbing cursor over the image. The context menu is no longer
  suppressed.
- Fix the LUT Library dropdown (and any select) overflowing the panel when an
  option label is long: give .mini-select min-width:0 so it can shrink inside
  its flex row instead of stretching to the widest option.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151sG7aaGnwCPRt3qvGNxd3

* Load EXIF from JPEG APP1 so shooting info shows reliably

Some RAW files (and any plain JPEG) did not populate the ISO / focal /
aperture / shutter readout under the histogram. The cause: when the RAW's
outer TIFF structure fails to parse, the image still loads via the brute-force
embedded-JPEG scan, but the metadata comes back empty.

Add a JPEG APP1 EXIF reader (the APP1 payload is itself a TIFF block, so it
reuses the existing TIFF parser) and use it to:
  - read EXIF for plain JPEG images, and
  - fill any missing EXIF fields from the embedded preview for RAW files,
    which rescues shooting info when the main TIFF could not be parsed.

Also harden applyMeta so the EXIF line is written before the window-title
call and a title error can no longer suppress it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151sG7aaGnwCPRt3qvGNxd3

* Match Photoshop LUT rendering: tetrahedral interpolation + true sRGB

The same .cube LUT looked cyan/teal-shifted compared to Photoshop. Two causes,
both fixed:

- Interpolation: the 3D LUT was sampled with GPU trilinear filtering, which
  introduces a cyan/green cast on film-style LUTs. Switch the LUT textures to
  NEAREST and do proper tetrahedral interpolation in the shader — the same
  method Photoshop's Color Lookup and Resolve use.
- Transfer function: encode to display with the true sRGB OETF instead of a
  plain 2.2 gamma. This exactly inverts the sRGB decode applied to 8-bit
  sources, so the values fed into the LUT match what Photoshop feeds it
  (previously the mismatch shifted shadow tones the LUT is sensitive to).

Also honour the LUT's DOMAIN_MIN / DOMAIN_MAX when sampling.

Verified in-browser: an identity LUT now passes through unchanged (exact
tetrahedral), a channel-swap LUT swaps correctly, and a constant LUT maps to
the constant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151sG7aaGnwCPRt3qvGNxd3

* Harden RAW EXIF parsing for real camera files

Improve the chance that ISO / focal / aperture / shutter show for real RAWs
(e.g. Sony ARW), which kept the readout blank:

- Scan every embedded JPEG for APP1 EXIF, not just the largest preview. Sony
  keeps EXIF in the small thumbnail while the full-size preview has none.
- Stop recursing the MakerNote (0x927C) as if its bytes were IFD offsets —
  that could be pathologically slow / derail a clean parse on real files;
  EXIF is reached through the standard ExifIFD (0x8769) pointer.
- Cap IFD entry counts (>4096) to bail out of parsing stray/garbage offsets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151sG7aaGnwCPRt3qvGNxd3

* Fix RAW preview selection: never decode CFA raw as the preview image

On real Sony ARW files the develop view hung and EXIF stayed blank. Root cause:
the CFA raw plane is a lossless-JPEG-compressed stream that also starts with
0xFFD8, and the embedded-preview finder picked it as the "largest JPEG" (31 MB)
and handed it to the browser to decode as an image. The browser cannot decode
lossless JPEG, so decoding stalled and the pipeline never reached the metadata
display.

Fixes:
- Ignore CFA / raw IFDs (Photometric 32803) when hunting for a preview JPEG.
- Only accept a range that is a real JPEG (SOI at the start AND EOI at the end)
  via a looksLikeJpeg() check, so a truncated raw stream can't be chosen.
- Cap the brute-force SOI/EOI scan span so raw bytes containing 0xFFD8 can't
  yield a multi-megabyte junk range.

With this, ARW files pick the correct full-size preview, the image loads, and
ISO / focal / aperture / shutter populate under the histogram.

Verified against a real 25MP Sony ILCE-6700 ARW end-to-end in the browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151sG7aaGnwCPRt3qvGNxd3

---------

Co-authored-by: Claude <noreply@anthropic.com>
Alan Yeung 1 개월 전
부모
커밋
64057ea1e2

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

@@ -0,0 +1,264 @@
+/* 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; }

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

@@ -0,0 +1,19 @@
+<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>

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

@@ -0,0 +1,185 @@
+<!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="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>

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

@@ -0,0 +1,28 @@
+/*
+	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));

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

@@ -0,0 +1,803 @@
+/*
+    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;
+    // 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
+        };
+        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;
+        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("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);
+        }
+    }
+})();

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

@@ -0,0 +1,471 @@
+/*
+    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;
+
+    const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722);
+
+    // 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 dom = (clamp(v, 0.0, 1.0) - 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);
+            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.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); }
+    };
+})();

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

@@ -0,0 +1,82 @@
+/*
+    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 };
+})();

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

@@ -0,0 +1,735 @@
+/*
+    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 };
+})();