Forráskód Böngészése

RAW Editor - LUT Preview update (#272)

* 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

* Add LUT "Log input" mode for log-to-display conversion LUTs

Film-conversion LUTs (F-Log/S-Log/V-Log -> Rec.709, e.g. the Fujifilm
FLog_to_ETERNA LUT) are authored for a flat log signal. Applied straight to a
normal photo they over-contrast and over-saturate, which is why the result
looked much punchier than the same LUT in Photoshop.

Add a "Log input" toggle in the LUT panel: before the 3D lookup the image is
flattened toward a log signal so the LUT reads it the way it was designed to,
giving the intended muted film look instead of a punchy one.

- A true camera-log encode can't work from a display-referred base (a JPEG
  spans ~2.5 stops, F-Log expects ~14 — it fogs shadows or greys highlights),
  so the flatten is a clean log2 curve that keeps blacks black and whites
  white while lifting mid-tones. No wash-out.
- Auto-enables when the LUT name/title looks like a log conversion
  (f-log, s-log, v-log, log-c, ...); still user-toggleable, with an Amount
  slider for fine control.

Verified end-to-end on the real Sony ARW + Fujifilm ETERNA LUT: colours stay
natural and the grade is muted rather than over-saturated.

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 hónapja
szülő
commit
d70e5f7b4c

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

@@ -152,6 +152,11 @@
                                     <span class="lbl">Enabled</span>
                                     <label class="switch"><input type="checkbox" id="lutEnabled" checked><span class="track"></span></label>
                                 </div>
+                                <div class="wb-row toggle-row">
+                                    <span class="lbl">Log input</span>
+                                    <label class="switch"><input type="checkbox" id="lutLogInput"><span class="track"></span></label>
+                                </div>
+                                <div class="lut-hint">Turn on for F-Log / S-Log / V-Log &rarr; display conversion LUTs so they look as intended on a normal photo.</div>
                                 <div class="slider" data-key="lutAmount" data-min="0" data-max="100" data-def="100" data-step="1"><span class="s-name">Amount</span><span class="s-track"><input type="range"></span><input class="s-val" type="number"></div>
                                 <div class="group-reset"><button class="link-btn" id="btnClearLut">Remove LUT</button></div>
                             </div>

+ 13 - 1
src/web/Raw Editor/js/editor.js

@@ -27,6 +27,7 @@
     state.baseTemp = 5500;
     state.treatment = "color";
     state.lutEnabled = true;
+    state.lutLogInput = false;
     // Per-group bypass ("eye") toggles.
     state.groupOn = { light: true, color: true, effects: true, lut: true };
 
@@ -136,7 +137,8 @@
             grain: g.effects ? state.grain : 0,
             // LUT group
             lutEnabled: !!(lut && state.lutEnabled && g.lut),
-            lutAmount: state.lutAmount / 100
+            lutAmount: state.lutAmount / 100,
+            lutLogInput: state.lutLogInput
         };
         if (state.treatment === "bw") { p.saturation = -100; p.vibrance = 0; }
         return p;
@@ -466,6 +468,12 @@
         if (g) { g.classList.remove("bypassed"); }
         state.groupOn.lut = true;
         document.getElementById("lutEnabled").checked = true;
+        // Auto-enable "Log input" for log->display conversion LUTs (F-Log, S-Log,
+        // V-Log, Log-C, C-Log ...), which expect a flat log signal.
+        var tag = ((parsed.title || "") + " " + (displayName || "")).toLowerCase();
+        var isLog = /(^|[^a-z])(f-?log|s-?log|v-?log|log-?c|c-?log|n-?log|d-?log|z-?log|log3|rec709|to_rec|to709|_log|log_)/.test(tag) || tag.indexOf("log") >= 0;
+        state.lutLogInput = isLog;
+        document.getElementById("lutLogInput").checked = isLog;
         document.getElementById("lutInfo").style.display = "block";
         document.getElementById("lutName").textContent = (parsed.title ? parsed.title + "  " : "") + displayName + "  (" + parsed.size + "³)";
         scheduleRender();
@@ -562,6 +570,10 @@
         state.lutEnabled = this.checked;
         scheduleRender();
     });
+    document.getElementById("lutLogInput").addEventListener("change", function () {
+        state.lutLogInput = this.checked;
+        scheduleRender();
+    });
     document.getElementById("btnClearLut").addEventListener("click", function () {
         lut = null;
         renderer.setLUT(null);

+ 30 - 1
src/web/Raw Editor/js/glrender.js

@@ -72,9 +72,31 @@ const GLRender = (function () {
     uniform float uLutSize;
     uniform vec3  uLutDomainMin;
     uniform vec3  uLutDomainMax;
+    uniform int   uLutLogInput;
 
     const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722);
 
+    // sRGB EOTF (display -> scene linear); inverse of toDisplay().
+    vec3 toLinear(vec3 c){
+        vec3 lo = c / 12.92;
+        vec3 hi = pow((c + 0.055) / 1.055, vec3(2.4));
+        return mix(lo, hi, step(vec3(0.04045), c));
+    }
+
+    // Log-flatten for feeding "log -> display" conversion LUTs from an already
+    // display-referred image. A true camera log curve (F-Log/S-Log) assumes ~14
+    // stops of scene-linear; a display JPEG has ~2.5, so a real log encode either
+    // fogs the shadows or greys the highlights. Instead we use a clean log2
+    // flatten that maps 0->0 and 1->1 (blacks stay black, whites stay white) but
+    // lifts the mid-tones, giving the LUT the low-contrast signal it expects
+    // without the wash-out. LOG_STOPS controls how flat.
+    vec3 linearToFLog(vec3 lin){
+        const float LOG_STOPS = 6.0;
+        float k = exp2(LOG_STOPS) - 1.0;
+        vec3 x = max(lin, vec3(0.0));
+        return log2(1.0 + x * k) / LOG_STOPS;
+    }
+
     // True sRGB OETF — matches how LUTs (and Photoshop) expect their input, and
     // exactly inverts the sRGB decode applied to 8-bit source images on load.
     vec3 toDisplay(vec3 c){
@@ -182,7 +204,12 @@ const GLRender = (function () {
 
         // 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 lutIn = clamp(v, 0.0, 1.0);
+            // For "log to display" LUTs, feed the flat log signal they expect.
+            if (uLutLogInput == 1){
+                lutIn = clamp(linearToFLog(toLinear(lutIn)), 0.0, 1.0);
+            }
+            vec3 dom = (lutIn - uLutDomainMin) / max(uLutDomainMax - uLutDomainMin, vec3(1e-5));
             vec3 graded = lutTetra(clamp(dom, 0.0, 1.0));
             v = mix(v, graded, uLutAmount);
         }
@@ -448,6 +475,7 @@ const GLRender = (function () {
             gl.uniform1i(u("uLutEnabled"), 1);
             gl.uniform1f(u("uLutAmount"), p.lutAmount != null ? p.lutAmount : 1.0);
             gl.uniform1f(u("uLutSize"), this.lutSize);
+            gl.uniform1i(u("uLutLogInput"), p.lutLogInput ? 1 : 0);
             var dmin = this.lutDomainMin || [0, 0, 0], dmax = this.lutDomainMax || [1, 1, 1];
             gl.uniform3f(u("uLutDomainMin"), dmin[0], dmin[1], dmin[2]);
             gl.uniform3f(u("uLutDomainMax"), dmax[0], dmax[1], dmax[2]);
@@ -455,6 +483,7 @@ const GLRender = (function () {
             gl.bindTexture(gl.TEXTURE_3D, this.dummyLut);
             gl.uniform1i(u("uLutEnabled"), 0);
             gl.uniform1f(u("uLutSize"), 2.0);
+            gl.uniform1i(u("uLutLogInput"), 0);
             gl.uniform3f(u("uLutDomainMin"), 0, 0, 0);
             gl.uniform3f(u("uLutDomainMax"), 1, 1, 1);
         }