Переглянути джерело

fix: treat Darwin ps %mem sum as a percent (#304)

* Bump github.com/go-git/go-git/v5 from 5.16.5 to 5.19.1 in /src (#255)

Bumps [github.com/go-git/go-git/v5](https://github.com/go-git/go-git) from 5.16.5 to 5.19.1.
- [Release notes](https://github.com/go-git/go-git/releases)
- [Changelog](https://github.com/go-git/go-git/blob/main/HISTORY.md)
- [Commits](https://github.com/go-git/go-git/compare/v5.16.5...v5.19.1)

---
updated-dependencies:
- dependency-name: github.com/go-git/go-git/v5
  dependency-version: 5.19.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump golang.org/x/image from 0.33.0 to 0.41.0 in /src (#265)

Bumps [golang.org/x/image](https://github.com/golang/image) from 0.33.0 to 0.41.0.
- [Commits](https://github.com/golang/image/compare/v0.33.0...v0.41.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-version: 0.41.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump golang.org/x/net from 0.53.0 to 0.55.0 in /src (#262)

Bumps [golang.org/x/net](https://github.com/golang/net) from 0.53.0 to 0.55.0.
- [Commits](https://github.com/golang/net/compare/v0.53.0...v0.55.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.55.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump golang.org/x/crypto from 0.50.0 to 0.52.0 in /src (#274)

Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.50.0 to 0.52.0.
- [Commits](https://github.com/golang/crypto/compare/v0.50.0...v0.52.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.52.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Toby Chui <tobychui@users.noreply.github.com>

* V3.0.1 (#285)

* Add CalDAV server for iOS Calendar bidirectional sync (#256)

Add CalDAV server for iOS Calendar bidirectional sync (#256)

* Updated CLAUDE.md (#257)

* Updated CLAUDE.md

* Add Reminders WebApp

* Add Reminders WebApp (macOS Reminders-style)

A self-contained ArozOS WebApp that mimics the macOS Reminders app.

Front-end (web/Reminders/index.html), single-page, no framework:
- Sidebar with the smart-list grid — Today, Scheduled, All, Flagged and a
  Completed card — plus custom "My Lists" with colour + SF-style icons and
  live incomplete counts, an Add List action and a light/dark toggle.
- Reminder rows with circle checkboxes, inline title editing, sub-tasks
  (Tab/Shift+Tab to indent), notes, due date/time chips (red when overdue),
  flags, priority (!/!!/!!!) and clickable URLs.
- Smart-view grouping: Today by time-of-day (All-Day/Morning/Afternoon/
  Tonight), Scheduled by date (with an Overdue group), All/Search by list.
- A detail editor popover (On a Day / At a Time, priority, list, URL, flag)
  and a list create/edit modal with colour and icon pickers.
- Native macOS look with full light/dark theming; sticky UI prefs via
  localStorage; responsive off-canvas sidebar on narrow screens.

Back-end AGI scripts (web/Reminders/backend/*.agi) persist a per-user
{ lists, reminders } store to user:/Document/Reminders/data.json via filelib,
following the Calendar app's pattern: init (load + seed default list),
saveReminder/deleteReminder, saveList/deleteList and a full saveData for
re-ordering and clearing completed items.

https://claude.ai/code/session_018JqHkXkuYjj5hz4kjiS1fQ

* Add CalDAV VTODO sync for Reminders and recurring support

Expose the Reminders web-app data as a CalDAV VTODO collection at
/caldav/{username}/reminders/ so iOS Reminders can sync bidirectionally
alongside the existing Calendar (VEVENT) collection. The principal now
advertises a calendar-home-set containing both collections.

Add RRULE recurrence support: events and reminders pass their recurrence
rule through ICS in both directions. The Reminders UI gains a Repeat
selector, shows a repeat indicator, and rolls recurring reminders forward
to their next occurrence on completion (matching iOS behaviour).

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Bump internal_version to 3.0.1

Update internal build version from 3.0.0 to 3.0.1 in src/flags.go to reflect a minor release increment.

* Add shell mode (virtual FS) to web terminal

Introduce an interactive shell mode for the web Terminal. Backend (session.agi): implement shell state, virtual path resolution, many shell commands (pwd, ls, cd, cat, mkdir, touch, rm, cp, mv, echo, whoami, clear, exit, help), tab-completion, prompt building, safe filelib wrappers, and new websocket message types (shellmode, clear, complete, shellPrompt in result). Frontend (index.html): track shellMode/shellPrompt, render shell output differently, handle shell enter/exit and clear messages, send/handle completion requests/responses, adjust input handling (Tab completion and disabling REPL dot-commands while in shell). Add .claude/launch.json to run a local static server for src/web. This enables a secure virtual filesystem shell inside the AGI REPL without host FS access.

* TURN update for Arozcast screen share over Internet (#259)

* Bump github.com/go-git/go-git/v5 from 5.16.5 to 5.19.1 in /src (#255)

Bumps [github.com/go-git/go-git/v5](https://github.com/go-git/go-git) from 5.16.5 to 5.19.1.
- [Release notes](https://github.com/go-git/go-git/releases)
- [Changelog](https://github.com/go-git/go-git/blob/main/HISTORY.md)
- [Commits](https://github.com/go-git/go-git/compare/v5.16.5...v5.19.1)

---
updated-dependencies:
- dependency-name: github.com/go-git/go-git/v5
  dependency-version: 5.19.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Add Reminders WebApp (macOS Reminders-style)

A self-contained ArozOS WebApp that mimics the macOS Reminders app.

Front-end (web/Reminders/index.html), single-page, no framework:
- Sidebar with the smart-list grid — Today, Scheduled, All, Flagged and a
  Completed card — plus custom "My Lists" with colour + SF-style icons and
  live incomplete counts, an Add List action and a light/dark toggle.
- Reminder rows with circle checkboxes, inline title editing, sub-tasks
  (Tab/Shift+Tab to indent), notes, due date/time chips (red when overdue),
  flags, priority (!/!!/!!!) and clickable URLs.
- Smart-view grouping: Today by time-of-day (All-Day/Morning/Afternoon/
  Tonight), Scheduled by date (with an Overdue group), All/Search by list.
- A detail editor popover (On a Day / At a Time, priority, list, URL, flag)
  and a list create/edit modal with colour and icon pickers.
- Native macOS look with full light/dark theming; sticky UI prefs via
  localStorage; responsive off-canvas sidebar on narrow screens.

Back-end AGI scripts (web/Reminders/backend/*.agi) persist a per-user
{ lists, reminders } store to user:/Document/Reminders/data.json via filelib,
following the Calendar app's pattern: init (load + seed default list),
saveReminder/deleteReminder, saveList/deleteList and a full saveData for
re-ordering and clearing completed items.

https://claude.ai/code/session_018JqHkXkuYjj5hz4kjiS1fQ

* Add built-in TURN relay so Arozcast screen share works over the Internet

Arozcast screen share is a direct WebRTC peer-to-peer connection that only
used public STUN servers, so it could not traverse NAT and failed over the
Internet. Add a built-in TURN relay (pion/turn, MIT, pure Go) that both peers
reach through the same ArozOS host, plus an /api/arozcast/iceservers endpoint
that hands the browser STUN + TURN with short-lived HMAC-signed credentials.
The relay is optional/configurable, and operators can override the ICE list
entirely with system/arozcast/iceservers.json to use an external TURN.

- mod/network/turn: pion/turn wrapper + ephemeral credential helpers (tested)
- cast.go: start the relay in ArozcastInit; serve /api/arozcast/iceservers
- main.go: close the relay on shutdown
- flags.go: -arozcast_turn, -arozcast_turn_port, -arozcast_turn_publicip
- screenshare.html / index.html: fetch ICE servers (STUN fallback)
- Arozcast README: endpoint + Internet/TURN operations docs

Media casting (Musicify/Movie/Photo) already worked over the Internet via the
existing relay and is unchanged.

https://claude.ai/code/session_01P68sFxtXuRXfzDnwtpFrnU

* Add TURN-over-TLS and a System Settings toggle for the Arozcast relay

Builds on the built-in Arozcast TURN relay with two operator-facing
enhancements.

TURN-over-TLS (TURNS):
- turn.NewServer now optionally starts a TURNS listener (TLSPort +
  TLSCertFile/TLSKeyFile) alongside the plain UDP/TCP relay, reusing the
  system TLS certificate. Restrictive firewalls that only permit outbound
  TLS (commonly 443) can then relay screen share, since the media rides
  inside a connection indistinguishable from HTTPS.
- Listener setup tracks every opened conn so a later failure cleans up; a
  TURNS failure is non-fatal (logged, plain relay keeps running).
- /api/arozcast/iceservers advertises turns:host:port?transport=tcp when
  the listener is up. New flags: -arozcast_turn_tls (default on, no-op
  without a usable cert) and -arozcast_turn_tls_port (default 5349; set to
  443 for maximum reach).

System Settings toggle & status:
- The relay is now runtime-managed (mutex-guarded start/stop with a
  remembered config) and its on/off state is persisted in the system
  database, overriding the -arozcast_turn flag default across restarts.
- New admin-only endpoints /system/arozcast/turn/{status,setEnabled} via
  the permission router, plus a "Screen Share Relay" page under
  System Settings -> Network & Connection (SystemAO/arozcast/turn.html)
  showing running state, port, TURNS and advertised host.

Tests cover the TURNS listener (self-signed cert handshake) and the
non-fatal missing-cert path. README documents both features.

* Drop the SNI-routing proxy caveat from the TURNS 443 docs

The TURN-over-TLS note suggested fronting port 443 with a TLS/SNI-routing
proxy to share it with the web server. That over-complicated the guidance;
replace it with a plain statement that 5349 is the default and 443 is only
viable when free on the relay's address.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>

* Add single instance support to Movie

* Add cross-tab theme sync and file selector fixes

Broadcast theme changes to other open tabs and improve file selector callback handling. file_explorer.html now writes ao_system_theme to localStorage when toggling theme in standalone mode so other tabs can react. ao_module.js adds ao_module_onThemeChanged, ao_module_toggleSystemTheme and an internal _ao_theme_broadcast to support both VDI (desktop-driven) and standalone/cross-tab theme propagation. Also fixes and documents file selector behavior: allow callback as a string or named function, auto-register a temporary global for anonymous callbacks in VDI mode, resolve string callbacks in non-VDI polling mode, and clean up localStorage handling. Minor typo/comment and whitespace fixes included.

* Add theme listener to desktop

* Added more icons for Code Studio

* Added wip productivity app

* Renewed Text app

* Updated Productivity app

* Text module style update

* Add RWD support to text

* Add Markdown source-mark reveal and image sync

Show Typora-style markdown source marks and allow inline editing of image references. Adds CSS pseudo-elements to reveal heading, emphasis, code, blockquote and link syntax; a settings selector to control reveal mode (active/always/none); and a default syntax setting. Implements updateActiveBlock(), clearDecorations(), decorateBlock(), editable .md-imgsyn labels, syncImgFromSyn()/commitImgSyn(), and helpers (richPlainText, activeImgSyn, caret/block helpers) to keep serialization and word count unchanged. Updates event handlers and key handling to support image-label editing, heading mark editing, backspace behavior at block start, and selection changes. Ensures getContent strips editable image labels before turndown and wires UI state updates throughout.

* Text: add Notion mode with slash commands and document-aware AI (#260)

Adds a toolbar toggle that enables a Notion-style "/" command menu in the
Markdown editor. Typing "/" at a line start (or after a space) opens a
filterable, keyboard-navigable menu to quickly insert blocks (headings,
to-do/bullet/numbered lists, quote, code block, table, divider) and to run
AI actions.

AI actions feed the entire current document in as context and insert the
generated Markdown at the caret, with an inline "Ask AI" prompt for
free-form instructions plus presets (continue writing, summarize,
brainstorm, outline). Generation is relayed through a new Text/ai.agi
backend that calls the aimodel library; the AI endpoint/key are configured
by an admin in System Settings.

A new "Notion & AI" settings section lets users toggle the feature, pick a
model (lazy-loaded from the endpoint) and set the temperature; all
preferences persist alongside the existing editor settings.


Claude-Session: https://claude.ai/code/session_01Xd4TY3ecHMDdG7es1JqbKo

Co-authored-by: Claude <noreply@anthropic.com>

* Revert "Text: add Notion mode with slash commands and document-aware AI (#260)"

This reverts commit be21397ccf060ed4fe403c48fbe3d63d72669965.

* Added advanced code block function to Text

* Add code-language picker and blank-line handling

Introduce a code-block language <select> UI and preserve runs of blank paragraphs through round-trip serialization. Add LANG_LIST and TextHL.languages(), ensure language selectors are appended to <pre> blocks (contenteditable=false and stripped on save), and wire syncLangSelector/ensureLangSelector/clearLangSelectors/onLangSelectChange. Implement renderWithBlankRuns() and expandBlankSentinels() to keep multiple empty paragraphs, switch marked.parse/getContent/renderedHTML to use these, and replace the previous &nbsp; sentinel with a ZWSP sentinel.

Fix code-block editing by rewriting newline/delete logic to operate on the code textContent (insertNewlineInCode, deleteInCode) to avoid DOM fragmentation and caret issues; add protection for key handling when the language <select> has focus. Adjust decoration logic to avoid marking PRE as md-active and update syntax display rules.

Rename/move backend modules under Text/backend (export.agi, filesaver.agi, imgtool.agi) and update ao_module_agirun calls to use the new paths. Add related CSS for the language picker and other small UI adjustments.

* Add link editor and improve code highlighting

Introduce an interactive link editor and inline editable link marks, replacing the previous CSS-only closing a::after pseudo-element with a real .md-linksyn element so link URLs are clickable/editable. Add link dialog UI (overlay, inputs, remove/cancel/OK handlers) and associated logic to insert/edit/remove links, preserve/restore selection, and avoid tearing down marks while edited. Enhance code-block highlighting: switch to relightCode/highlightCode with caret/selection preservation (selectionEndOffsetIn, restoreCaretIn), handle live re-highlighting on edits, language changes, and prevent stuck empty code blocks by exiting them (exitEmptyCodeBlock). Also fix caret placement when clicking below trailing code blocks (escapeTrailingCodeBlockClick), update serialization to strip .md-linksyn, and adjust toolbar/labels for the link action.

* Add support for token access in webdav server

X-Access-Token + X-Aroz-User now can be used to login webdav with autologin token instead of Basic Auth

* Add CNN inference API support

* Rename aimodel to llm and add LLM server pkg

Replace the legacy "aimodel" library with a new "llm" library and introduce a dedicated aiservers/llm package. Key changes:

- Rename library identifier from "aimodel" to "llm" across docs and backend scripts (README, AGI scripts, web backend examples).
- Remove the old agi.aimodel implementation and add agi.llm (new AGI lib) plus agi.llm_test.go.
- Add mod/aiservers/llm package with client, OpenAI/Anthropic adapters and types, and tests (client.go, openai.go, anthropic.go, types.go, and tests).
- Update tests and module wiring to use LLMConfig/LLMPricing/llmDBTable and injectLLMFunctions where appropriate.
- Minor updates to web demo backend/init scripts and API docs to reflect the new requirelib name.

Notes: System Settings UI and routes retaining the original "AI Model" naming; only the requirelib identifier and internal Go types/DB table references were changed. This restructures wire-protocol logic into the new aiservers/llm package for clearer separation of concerns and easier protocol-specific handling.

* Fixed #204

* Add text color change inline

* Add free resize on select tool

* Add Cine Studio, a Premiere-like timeline video editor webapp (#263)

A new dark macOS-style video editor under src/web/Cine Studio with:

- Media bin importing from the ArozOS file system (file selector +
  File Manager drag-drop) and from the local device (uploaded to
  user:/Cine Studio/Media so projects stay portable), with probed
  durations, filmstrip thumbnails and decoded waveform peaks
- Multi-track timeline (video/audio) with drag-move, edge trimming,
  blade splitting, snapping, track visibility/mute toggles, zoomable
  timecode ruler and undo/redo history
- Canvas compositing preview player with per-clip transform, crop
  mode, opacity, color controls (exposure/contrast/saturation/presets)
  and a WebAudio mix bus for synced audio playback
- .cine project format (open/save/save-as via ao_module file selector,
  registered as a file association with its own icon)
- Export pipeline: real-time MediaRecorder render to WebM uploaded to
  the ArozOS storage, with optional server-side MP4 conversion through
  the AGI ffmpeg library when the host has ffmpeg installed

Backend AGI scripts create the per-user app folders and expose the
ffmpeg availability check / conversion / cleanup actions.


Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

Co-authored-by: Claude <noreply@anthropic.com>

* Docker support (#264)

* Updated Video Editor app to have more functionalities (#266)

* Add Cine Studio, a Premiere-like timeline video editor webapp

A new dark macOS-style video editor under src/web/Cine Studio with:

- Media bin importing from the ArozOS file system (file selector +
  File Manager drag-drop) and from the local device (uploaded to
  user:/Cine Studio/Media so projects stay portable), with probed
  durations, filmstrip thumbnails and decoded waveform peaks
- Multi-track timeline (video/audio) with drag-move, edge trimming,
  blade splitting, snapping, track visibility/mute toggles, zoomable
  timecode ruler and undo/redo history
- Canvas compositing preview player with per-clip transform, crop
  mode, opacity, color controls (exposure/contrast/saturation/presets)
  and a WebAudio mix bus for synced audio playback
- .cine project format (open/save/save-as via ao_module file selector,
  registered as a file association with its own icon)
- Export pipeline: real-time MediaRecorder render to WebM uploaded to
  the ArozOS storage, with optional server-side MP4 conversion through
  the AGI ffmpeg library when the host has ffmpeg installed

Backend AGI scripts create the per-user app folders and expose the
ffmpeg availability check / conversion / cleanup actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Remove macOS traffic lights and implement all stubbed Cine Studio features

Top bar: drop the decorative macOS traffic-light buttons.

Effects (new js/effects.js): per-clip effect stacks (Black & White,
Sepia, Invert, Hue Shift, Blur, Pixelate, Mirror, Vignette, Film Grain,
Fade In/Out) applied by the compositor so preview and export match.
Fades also ramp audio-clip volume. Gallery panel with rendered preview
cards plus an inspector Effects tab with amount sliders, add/remove and
an fx badge on timeline clips.

Titles and Elements (new js/titles.js): generated clips with no backing
media - text titles (content, size, color, weight, align, vertical
position, plain/lower-third/caption styles) and solid/gradient color
boards. They render through a cached offscreen frame so transforms,
color controls, effects and transitions all apply. Inserted from the
Titles/Elements panels, the T tool or the T key onto a free video track
(auto-created when needed).

Transitions (new js/transitions.js): Cross Dissolve, Fade and Wipe
stored on the incoming clip with adjustable duration; the previous
clip's last frame is frozen and blended, with a from-black fallback and
a marker on the clip. Applied from the gallery or the inspector
Transition tab.

Also implemented: Filters panel applying color-preset looks, Libraries
panel listing saved .cine projects (new backend/listprojects.js), media
bin list view toggle, and crop/audio toolbar buttons that jump to the
matching inspector section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add preview direct manipulation, auto track creation and .pxs/.asproj import

Preview (new js/previewctl.js): an overlay canvas on the preview lets
the user click a clip to select it, drag it to reposition and pull a
corner handle to scale it - rotation aware, works for video, image,
title and color clips, with a selection outline and handles drawn over
the frame. Timeline drags now listen on the window because Chrome drops
element pointer capture when the clip is reparented across lanes.

Unlimited tracks: dragging a clip past the outermost lanes creates a
new video/audio track on drop, and dropping bin media onto the empty
timeline area below the lanes does the same, in addition to the
existing add-track button.

Pixel Studio import (.pxs): the layered project is flattened in the
browser - raster layers honoring visibility, opacity and blend mode,
text layers re-rendered from their text data - and used as image media.

Audio Studio import (.asproj): prefers an embedded or referenced
rendered mixdown; otherwise decodes the project's track/clip references
(tolerant field names) and mixes them with an OfflineAudioContext into
a WAV. Both importers keep the original virtual path in the project
file and re-composite when a saved project is reopened.

Both extensions join the import filters, the local-device picker and
the module's SupportedExt list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add the most critical everyday editing features to Cine Studio

Safety and workflow:
- Auto-save (20s, localStorage) with crash-recovery prompt on launch,
  cleared once the project is saved to disk (new js/session.js)
- Recent projects list in the project menu (dedup, last 8)

Editing fundamentals:
- Multi-select (shift-click) with group move, group delete, group copy
- Copy / paste at playhead / duplicate clips (Ctrl+C / V / D)
- Ripple delete closing the gap (Shift+Del, context menu)
- Timeline markers: add/remove at playhead (M), flags on the ruler,
  jump to next (Shift+M), saved in the project, act as snap targets
- Snapping on/off toggle (magnet button)
- Clip speed 25-400%: timeline length, element sync, trimming and
  splitting are all speed aware; audio pitch follows rate

Compositing and playback:
- Blend modes (multiply, screen, overlay, add, soft light, difference)
- Flip horizontal / vertical toggles
- JKL shuttle (reverse scrub / pause / forward up to 8x) and loop
  playback toggle; export temporarily disables looping

Output and audio:
- Export current frame as PNG from the export menu
- Detach audio: video clip audio moves to its own audio-track clip
- Audio track solo via the track header context menu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Fix docker package build failure on FreeBSD and other unlisted platforms (#267)

The docker package split its PTY console and host-stats helpers into
build-tagged files that only covered linux/darwin/windows. FreeBSD (built
by the Jenkins release pipeline for amd64 and i386) matched none of them,
so startDockerExecPTY, hostStorageUsage and hostLoadAvg were undefined and
the cross-platform release build failed.

- console_unix.go now builds for freebsd too (creack/pty supports it).
- console_windows.go becomes console_other.go, a catch-all (!linux &&
  !darwin && !freebsd) stub covering windows and any other GOOS.
- host_darwin.go becomes host_bsd.go (darwin || freebsd); the Statfs-based
  storage helper works on both.
- Add host_other.go, a catch-all fallback so the package builds on any
  remaining GOOS.

Also expand the CI cross-compile smoke test to cover the full release
matrix (including freebsd/amd64 and freebsd/386) so a platform-specific
break is caught in CI instead of only in the release build.


Claude-Session: https://claude.ai/code/session_01L4oR1xiTxpcfwYjRgi4RGh

Co-authored-by: Claude <noreply@anthropic.com>

* Added playwright e2e tests for cine studio (#268)

* Add Cine Studio, a Premiere-like timeline video editor webapp

A new dark macOS-style video editor under src/web/Cine Studio with:

- Media bin importing from the ArozOS file system (file selector +
  File Manager drag-drop) and from the local device (uploaded to
  user:/Cine Studio/Media so projects stay portable), with probed
  durations, filmstrip thumbnails and decoded waveform peaks
- Multi-track timeline (video/audio) with drag-move, edge trimming,
  blade splitting, snapping, track visibility/mute toggles, zoomable
  timecode ruler and undo/redo history
- Canvas compositing preview player with per-clip transform, crop
  mode, opacity, color controls (exposure/contrast/saturation/presets)
  and a WebAudio mix bus for synced audio playback
- .cine project format (open/save/save-as via ao_module file selector,
  registered as a file association with its own icon)
- Export pipeline: real-time MediaRecorder render to WebM uploaded to
  the ArozOS storage, with optional server-side MP4 conversion through
  the AGI ffmpeg library when the host has ffmpeg installed

Backend AGI scripts create the per-user app folders and expose the
ffmpeg availability check / conversion / cleanup actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Remove macOS traffic lights and implement all stubbed Cine Studio features

Top bar: drop the decorative macOS traffic-light buttons.

Effects (new js/effects.js): per-clip effect stacks (Black & White,
Sepia, Invert, Hue Shift, Blur, Pixelate, Mirror, Vignette, Film Grain,
Fade In/Out) applied by the compositor so preview and export match.
Fades also ramp audio-clip volume. Gallery panel with rendered preview
cards plus an inspector Effects tab with amount sliders, add/remove and
an fx badge on timeline clips.

Titles and Elements (new js/titles.js): generated clips with no backing
media - text titles (content, size, color, weight, align, vertical
position, plain/lower-third/caption styles) and solid/gradient color
boards. They render through a cached offscreen frame so transforms,
color controls, effects and transitions all apply. Inserted from the
Titles/Elements panels, the T tool or the T key onto a free video track
(auto-created when needed).

Transitions (new js/transitions.js): Cross Dissolve, Fade and Wipe
stored on the incoming clip with adjustable duration; the previous
clip's last frame is frozen and blended, with a from-black fallback and
a marker on the clip. Applied from the gallery or the inspector
Transition tab.

Also implemented: Filters panel applying color-preset looks, Libraries
panel listing saved .cine projects (new backend/listprojects.js), media
bin list view toggle, and crop/audio toolbar buttons that jump to the
matching inspector section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add preview direct manipulation, auto track creation and .pxs/.asproj import

Preview (new js/previewctl.js): an overlay canvas on the preview lets
the user click a clip to select it, drag it to reposition and pull a
corner handle to scale it - rotation aware, works for video, image,
title and color clips, with a selection outline and handles drawn over
the frame. Timeline drags now listen on the window because Chrome drops
element pointer capture when the clip is reparented across lanes.

Unlimited tracks: dragging a clip past the outermost lanes creates a
new video/audio track on drop, and dropping bin media onto the empty
timeline area below the lanes does the same, in addition to the
existing add-track button.

Pixel Studio import (.pxs): the layered project is flattened in the
browser - raster layers honoring visibility, opacity and blend mode,
text layers re-rendered from their text data - and used as image media.

Audio Studio import (.asproj): prefers an embedded or referenced
rendered mixdown; otherwise decodes the project's track/clip references
(tolerant field names) and mixes them with an OfflineAudioContext into
a WAV. Both importers keep the original virtual path in the project
file and re-composite when a saved project is reopened.

Both extensions join the import filters, the local-device picker and
the module's SupportedExt list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add the most critical everyday editing features to Cine Studio

Safety and workflow:
- Auto-save (20s, localStorage) with crash-recovery prompt on launch,
  cleared once the project is saved to disk (new js/session.js)
- Recent projects list in the project menu (dedup, last 8)

Editing fundamentals:
- Multi-select (shift-click) with group move, group delete, group copy
- Copy / paste at playhead / duplicate clips (Ctrl+C / V / D)
- Ripple delete closing the gap (Shift+Del, context menu)
- Timeline markers: add/remove at playhead (M), flags on the ruler,
  jump to next (Shift+M), saved in the project, act as snap targets
- Snapping on/off toggle (magnet button)
- Clip speed 25-400%: timeline length, element sync, trimming and
  splitting are all speed aware; audio pitch follows rate

Compositing and playback:
- Blend modes (multiply, screen, overlay, add, soft light, difference)
- Flip horizontal / vertical toggles
- JKL shuttle (reverse scrub / pause / forward up to 8x) and loop
  playback toggle; export temporarily disables looping

Output and audio:
- Export current frame as PNG from the export menu
- Detach audio: video clip audio moves to its own audio-track clip
- Audio track solo via the track header context menu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add Playwright E2E suite for Cine Studio + GitHub Actions job

Ports the browser-driven tests used to verify Cine Studio into the
codebase under test/e2e/playwright (outside src/ so they never touch
the Go module). The tests drive the real app in headless Chromium,
generating their own media in-page (canvas -> MediaRecorder WebM,
synthesized WAV) and asserting on rendered pixels, so they exercise the
full compositor / playback / export pipeline rather than mocking it.

Structure:
- lib/static-server.js  minimal static server for the ArozOS web root
- lib/harness.js        browser launch + app navigation + ok()/fail();
                        resolves Playwright's own Chromium (no hardcoded
                        path), overridable via PW_CHROMIUM_PATH
- run.js                serves src/web, runs each spec in its own process,
                        exits non-zero on any failure
- specs/functional.js   media probe, playback, edit, round-trip, WebM export
- specs/features.js     effects, titles, transitions, elements, filters
- specs/interaction.js  preview drag/resize, auto tracks, .pxs/.asproj import
- specs/editing.js      multi-select, copy/paste, speed, JKL, markers, autosave

41 assertions across the four specs, all passing locally.

CI: .github/workflows/e2e-playwright.yml installs Node + Playwright
Chromium and runs `npm test`, scoped to changes under the app or the
harness so unrelated commits don't trigger it. Front-end only - runs
independently of the Go build.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Claude/arozos playwright tests for arozos (#269)

* Add full-stack Playwright E2E suite for ArozOS critical paths

Extend the Playwright harness (previously Cine Studio only) with a
'system' suite that drives a real ArozOS server built from src/:

- lib/arozos-server.js boots a disposable isolated instance
  (.instance/: web symlink + private system copy) and bootstraps a
  deterministic admin account at the zero-user state
- lib/system-harness.js shares login (form + API), CSRF, JSON and
  pass/fail helpers; each spec also runs standalone
- specs-system/ covers the critical paths:
  010 sign in / sign out (form render, bad credentials, session
      persistence, gated redirects, logout, retry delay)
  020 desktop shell (task bar, start menu, module list, quick access
      panel identity, UI sign-out)
  030 file explorer (File Manager UI render + full file lifecycle:
      create, rename, copy, move, properties, recycle/restore/clear
      trash, permanent delete, CSRF enforcement)
  040 system settings (settings UI, catalogue groups, admin-only
      modules, host info, permission group listing)
  050 user management + permission control (group CRUD, user CRUD,
      restricted-user module visibility, admin-only endpoint denial,
      administrator group protection)
- run.js orchestrates both suites; npm run test:static / test:system
- CI workflow now builds the Go binary, triggers on src/** changes and
  uploads the server log on failure

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdeQtHTSvBQgUpWfKWvkow

* Add WebApp wave 1 E2E spec and coverage roadmap

- specs-system/060-webapps-core.js: smoke + interaction coverage for
  the highest-importance WebApps (NotepadA, Text, Photo, Music, Video,
  PDF Viewer, Zip File Manager) against the real server, including
  opening a real file in NotepadA via the desktop hash convention
- README: document the ranked 44-WebApp inventory and the wave order
  for upcoming specs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdeQtHTSvBQgUpWfKWvkow

* Add WebApp wave 2 E2E spec (office/productivity)

specs-system/070-webapps-office.js: smoke + interaction coverage for
Code Studio, MDEditor, Calendar, Notes, Memo, Reminders, OfficeViewer
and Dashboard against the real server, including opening a real
Markdown file in MDEditor via the desktop hash convention and probing
Dashboard's live system-info data source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdeQtHTSvBQgUpWfKWvkow

* Add WebApp wave 3 E2E spec (media/creative)

specs-system/080-webapps-media.js: smoke coverage for Musicify, Movie,
Manga, Paint, Pixel Studio, Audio Studio, Camera, Recorder and FFmpeg
Factory against the real server, plus a probe of the storage data path
the media library apps browse. Cine Studio keeps its own deep static
suite and is not repeated here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdeQtHTSvBQgUpWfKWvkow

* Add WebApp wave 4 E2E spec (utilities/dev/network) + close roadmap

specs-system/090-webapps-utilities.js: smoke coverage for Calculator
(incl. a real 7+8=15 calculation through the UI), Clock, Browser,
Speedtest, Web Downloader, Web Builder, SQLite Admin, Terminal,
AGIForge, AIChat, OTPAuth, Productivity, OnScreenKeyboard, Arozcast,
Management Gateway, UnitTest, CronDemo and Serverless.

This completes coverage of all 44 WebApps under src/web across four
waves. README updated to reflect the full map; full suite (13 spec
files: 4 static + 9 system) passes end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdeQtHTSvBQgUpWfKWvkow

* Add deep file-transfer critical-path spec (upload/download/search/share)

specs-system/035-file-transfer.js extends the file-explorer coverage
with the transfer features that move real bytes:
- multipart upload of a real file
- media download with byte-exact round-trip verification
- file search by keyword
- share-link lifecycle: create, list, anonymous direct download via
  /share/download/{uuid}/ (byte-exact) + preview page, then delete and
  confirm the share is revoked

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdeQtHTSvBQgUpWfKWvkow

* Add account password-change critical-path spec

specs-system/055-account.js exercises the My Account flow on a
dedicated throwaway user (keeping the shared admin login intact):
account settings UI render, userinfo identity, wrong-old-password
refusal, successful change, and that afterwards only the new password
signs in. README updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdeQtHTSvBQgUpWfKWvkow

* Add desktop backend API spec (every /system/desktop endpoint)

specs-system/021-desktop-api.js covers the full desktop.go API surface
with the exact request shapes desktop.html uses: host/device details;
user self-info (quota/groups/admin), another user's public info via
?target, and noicon; theme listing + set/get round-trip; preference
set/get/remove; createShortcut + listDesktop metadata; icon location
files set/get/del; and renameShortcut incl. off-desktop rejection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdeQtHTSvBQgUpWfKWvkow

* Add desktop UI shell spec (clock, panels, start menu, search, icons)

specs-system/022-desktop-ui.js drives the real desktop.html: wallpaper
frame mounts, the taskbar clock shows a formatted time, the quick
access panel carries the user identity, the start menu opens with
module entries, its search box narrows the list and reports No Result
for gibberish, the notification bar toggles from the clock, and an
API-seeded shortcut renders as a desktop launch icon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdeQtHTSvBQgUpWfKWvkow

* Add desktop float-window + context-menu spec and update README

specs-system/023-desktop-windows.js drives the desktop window manager
and right-click surfaces using the app's own globals and real clicks:
openModule spawns a float window with a taskbar entry; newFloatWindow
creates addressable windows; focus raises z-order; maximize/restore,
minimize and close all work; and the wallpaper + icon context menus
open with the expected entries.

Desktop coverage is now three specs deep (API 021, UI 022, windows
023) on top of the original 020 shell smoke test. README layout
updated. Full system suite (14 specs) passes together.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdeQtHTSvBQgUpWfKWvkow

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Video Editor Cline Update (#270)

* Add Cine Studio, a Premiere-like timeline video editor webapp

A new dark macOS-style video editor under src/web/Cine Studio with:

- Media bin importing from the ArozOS file system (file selector +
  File Manager drag-drop) and from the local device (uploaded to
  user:/Cine Studio/Media so projects stay portable), with probed
  durations, filmstrip thumbnails and decoded waveform peaks
- Multi-track timeline (video/audio) with drag-move, edge trimming,
  blade splitting, snapping, track visibility/mute toggles, zoomable
  timecode ruler and undo/redo history
- Canvas compositing preview player with per-clip transform, crop
  mode, opacity, color controls (exposure/contrast/saturation/presets)
  and a WebAudio mix bus for synced audio playback
- .cine project format (open/save/save-as via ao_module file selector,
  registered as a file association with its own icon)
- Export pipeline: real-time MediaRecorder render to WebM uploaded to
  the ArozOS storage, with optional server-side MP4 conversion through
  the AGI ffmpeg library when the host has ffmpeg installed

Backend AGI scripts create the per-user app folders and expose the
ffmpeg availability check / conversion / cleanup actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Remove macOS traffic lights and implement all stubbed Cine Studio features

Top bar: drop the decorative macOS traffic-light buttons.

Effects (new js/effects.js): per-clip effect stacks (Black & White,
Sepia, Invert, Hue Shift, Blur, Pixelate, Mirror, Vignette, Film Grain,
Fade In/Out) applied by the compositor so preview and export match.
Fades also ramp audio-clip volume. Gallery panel with rendered preview
cards plus an inspector Effects tab with amount sliders, add/remove and
an fx badge on timeline clips.

Titles and Elements (new js/titles.js): generated clips with no backing
media - text titles (content, size, color, weight, align, vertical
position, plain/lower-third/caption styles) and solid/gradient color
boards. They render through a cached offscreen frame so transforms,
color controls, effects and transitions all apply. Inserted from the
Titles/Elements panels, the T tool or the T key onto a free video track
(auto-created when needed).

Transitions (new js/transitions.js): Cross Dissolve, Fade and Wipe
stored on the incoming clip with adjustable duration; the previous
clip's last frame is frozen and blended, with a from-black fallback and
a marker on the clip. Applied from the gallery or the inspector
Transition tab.

Also implemented: Filters panel applying color-preset looks, Libraries
panel listing saved .cine projects (new backend/listprojects.js), media
bin list view toggle, and crop/audio toolbar buttons that jump to the
matching inspector section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add preview direct manipulation, auto track creation and .pxs/.asproj import

Preview (new js/previewctl.js): an overlay canvas on the preview lets
the user click a clip to select it, drag it to reposition and pull a
corner handle to scale it - rotation aware, works for video, image,
title and color clips, with a selection outline and handles drawn over
the frame. Timeline drags now listen on the window because Chrome drops
element pointer capture when the clip is reparented across lanes.

Unlimited tracks: dragging a clip past the outermost lanes creates a
new video/audio track on drop, and dropping bin media onto the empty
timeline area below the lanes does the same, in addition to the
existing add-track button.

Pixel Studio import (.pxs): the layered project is flattened in the
browser - raster layers honoring visibility, opacity and blend mode,
text layers re-rendered from their text data - and used as image media.

Audio Studio import (.asproj): prefers an embedded or referenced
rendered mixdown; otherwise decodes the project's track/clip references
(tolerant field names) and mixes them with an OfflineAudioContext into
a WAV. Both importers keep the original virtual path in the project
file and re-composite when a saved project is reopened.

Both extensions join the import filters, the local-device picker and
the module's SupportedExt list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add the most critical everyday editing features to Cine Studio

Safety and workflow:
- Auto-save (20s, localStorage) with crash-recovery prompt on launch,
  cleared once the project is saved to disk (new js/session.js)
- Recent projects list in the project menu (dedup, last 8)

Editing fundamentals:
- Multi-select (shift-click) with group move, group delete, group copy
- Copy / paste at playhead / duplicate clips (Ctrl+C / V / D)
- Ripple delete closing the gap (Shift+Del, context menu)
- Timeline markers: add/remove at playhead (M), flags on the ruler,
  jump to next (Shift+M), saved in the project, act as snap targets
- Snapping on/off toggle (magnet button)
- Clip speed 25-400%: timeline length, element sync, trimming and
  splitting are all speed aware; audio pitch follows rate

Compositing and playback:
- Blend modes (multiply, screen, overlay, add, soft light, difference)
- Flip horizontal / vertical toggles
- JKL shuttle (reverse scrub / pause / forward up to 8x) and loop
  playback toggle; export temporarily disables looping

Output and audio:
- Export current frame as PNG from the export menu
- Detach audio: video clip audio moves to its own audio-track clip
- Audio track solo via the track header context menu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add Cine Studio, a Premiere-like timeline video editor webapp

A new dark macOS-style video editor under src/web/Cine Studio with:

- Media bin importing from the ArozOS file system (file selector +
  File Manager drag-drop) and from the local device (uploaded to
  user:/Cine Studio/Media so projects stay portable), with probed
  durations, filmstrip thumbnails and decoded waveform peaks
- Multi-track timeline (video/audio) with drag-move, edge trimming,
  blade splitting, snapping, track visibility/mute toggles, zoomable
  timecode ruler and undo/redo history
- Canvas compositing preview player with per-clip transform, crop
  mode, opacity, color controls (exposure/contrast/saturation/presets)
  and a WebAudio mix bus for synced audio playback
- .cine project format (open/save/save-as via ao_module file selector,
  registered as a file association with its own icon)
- Export pipeline: real-time MediaRecorder render to WebM uploaded to
  the ArozOS storage, with optional server-side MP4 conversion through
  the AGI ffmpeg library when the host has ffmpeg installed

Backend AGI scripts create the per-user app folders and expose the
ffmpeg availability check / conversion / cleanup actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Remove macOS traffic lights and implement all stubbed Cine Studio features

Top bar: drop the decorative macOS traffic-light buttons.

Effects (new js/effects.js): per-clip effect stacks (Black & White,
Sepia, Invert, Hue Shift, Blur, Pixelate, Mirror, Vignette, Film Grain,
Fade In/Out) applied by the compositor so preview and export match.
Fades also ramp audio-clip volume. Gallery panel with rendered preview
cards plus an inspector Effects tab with amount sliders, add/remove and
an fx badge on timeline clips.

Titles and Elements (new js/titles.js): generated clips with no backing
media - text titles (content, size, color, weight, align, vertical
position, plain/lower-third/caption styles) and solid/gradient color
boards. They render through a cached offscreen frame so transforms,
color controls, effects and transitions all apply. Inserted from the
Titles/Elements panels, the T tool or the T key onto a free video track
(auto-created when needed).

Transitions (new js/transitions.js): Cross Dissolve, Fade and Wipe
stored on the incoming clip with adjustable duration; the previous
clip's last frame is frozen and blended, with a from-black fallback and
a marker on the clip. Applied from the gallery or the inspector
Transition tab.

Also implemented: Filters panel applying color-preset looks, Libraries
panel listing saved .cine projects (new backend/listprojects.js), media
bin list view toggle, and crop/audio toolbar buttons that jump to the
matching inspector section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add preview direct manipulation, auto track creation and .pxs/.asproj import

Preview (new js/previewctl.js): an overlay canvas on the preview lets
the user click a clip to select it, drag it to reposition and pull a
corner handle to scale it - rotation aware, works for video, image,
title and color clips, with a selection outline and handles drawn over
the frame. Timeline drags now listen on the window because Chrome drops
element pointer capture when the clip is reparented across lanes.

Unlimited tracks: dragging a clip past the outermost lanes creates a
new video/audio track on drop, and dropping bin media onto the empty
timeline area below the lanes does the same, in addition to the
existing add-track button.

Pixel Studio import (.pxs): the layered project is flattened in the
browser - raster layers honoring visibility, opacity and blend mode,
text layers re-rendered from their text data - and used as image media.

Audio Studio import (.asproj): prefers an embedded or referenced
rendered mixdown; otherwise decodes the project's track/clip references
(tolerant field names) and mixes them with an OfflineAudioContext into
a WAV. Both importers keep the original virtual path in the project
file and re-composite when a saved project is reopened.

Both extensions join the import filters, the local-device picker and
the module's SupportedExt list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add the most critical everyday editing features to Cine Studio

Safety and workflow:
- Auto-save (20s, localStorage) with crash-recovery prompt on launch,
  cleared once the project is saved to disk (new js/session.js)
- Recent projects list in the project menu (dedup, last 8)

Editing fundamentals:
- Multi-select (shift-click) with group move, group delete, group copy
- Copy / paste at playhead / duplicate clips (Ctrl+C / V / D)
- Ripple delete closing the gap (Shift+Del, context menu)
- Timeline markers: add/remove at playhead (M), flags on the ruler,
  jump to next (Shift+M), saved in the project, act as snap targets
- Snapping on/off toggle (magnet button)
- Clip speed 25-400%: timeline length, element sync, trimming and
  splitting are all speed aware; audio pitch follows rate

Compositing and playback:
- Blend modes (multiply, screen, overlay, add, soft light, difference)
- Flip horizontal / vertical toggles
- JKL shuttle (reverse scrub / pause / forward up to 8x) and loop
  playback toggle; export temporarily disables looping

Output and audio:
- Export current frame as PNG from the export menu
- Detach audio: video clip audio moves to its own audio-track clip
- Audio track solo via the track header context menu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add Playwright E2E suite for Cine Studio + GitHub Actions job

Ports the browser-driven tests used to verify Cine Studio into the
codebase under test/e2e/playwright (outside src/ so they never touch
the Go module). The tests drive the real app in headless Chromium,
generating their own media in-page (canvas -> MediaRecorder WebM,
synthesized WAV) and asserting on rendered pixels, so they exercise the
full compositor / playback / export pipeline rather than mocking it.

Structure:
- lib/static-server.js  minimal static server for the ArozOS web root
- lib/harness.js        browser launch + app navigation + ok()/fail();
                        resolves Playwright's own Chromium (no hardcoded
                        path), overridable via PW_CHROMIUM_PATH
- run.js                serves src/web, runs each spec in its own process,
                        exits non-zero on any failure
- specs/functional.js   media probe, playback, edit, round-trip, WebM export
- specs/features.js     effects, titles, transitions, elements, filters
- specs/interaction.js  preview drag/resize, auto tracks, .pxs/.asproj import
- specs/editing.js      multi-select, copy/paste, speed, JKL, markers, autosave

41 assertions across the four specs, all passing locally.

CI: .github/workflows/e2e-playwright.yml installs Node + Playwright
Chromium and runs `npm test`, scoped to changes under the app or the
harness so unrelated commits don't trigger it. Front-end only - runs
independently of the Go build.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>

* 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>

* 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 on…

* Add LoongArch64 (linux/loong64) build support (#287)

* Update github.com/glebarez/go-sqlite to v1.22.1-0.20260317144347-ca37fea19666

* Use fsdb on LoongArch64

* Add linux/loong64 to the Makefile and GitHub Actions workflow

* Update README

* Fix CPU frequency and hardware model detection on LoongArch64 (#288)

* docs: restore 2 dead link(s) via Wayback (#290)

Co-authored-by: mirkosalvato1-ctrl <227404546+mirkosalvato1-ctrl@users.noreply.github.com>

* Fix Windows hardware info when wmic.exe is missing (#289)

Prefer Get-CimInstance for Win32_* queries and keep classic wmic as fallback so System Info and SMART still work on Windows 11 hosts that no longer ship wmic.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: fix a typo RSICV64 => RISC-V 64-bit (#291)

* Bump github.com/pion/dtls/v3 from 3.0.7 to 3.1.4 in /src (#296)

Bumps [github.com/pion/dtls/v3](https://github.com/pion/dtls) from 3.0.7 to 3.1.4.
- [Release notes](https://github.com/pion/dtls/releases)
- [Commits](https://github.com/pion/dtls/compare/v3.0.7...v3.1.4)

---
updated-dependencies:
- dependency-name: github.com/pion/dtls/v3
  dependency-version: 3.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump github.com/pion/stun/v3 from 3.0.1 to 3.1.5 in /src (#295)

Bumps [github.com/pion/stun/v3](https://github.com/pion/stun) from 3.0.1 to 3.1.5.
- [Release notes](https://github.com/pion/stun/releases)
- [Commits](https://github.com/pion/stun/compare/v3.0.1...v3.1.5)

---
updated-dependencies:
- dependency-name: github.com/pion/stun/v3
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump github.com/go-git/go-git/v5 from 5.19.1 to 5.19.2 in /src (#297)

Bumps [github.com/go-git/go-git/v5](https://github.com/go-git/go-git) from 5.19.1 to 5.19.2.
- [Release notes](https://github.com/go-git/go-git/releases)
- [Changelog](https://github.com/go-git/go-git/blob/main/HISTORY.md)
- [Commits](https://github.com/go-git/go-git/compare/v5.19.1...v5.19.2)

---
updated-dependencies:
- dependency-name: github.com/go-git/go-git/v5
  dependency-version: 5.19.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* V3.0.2 (#299)

* Add CalDAV server for iOS Calendar bidirectional sync (#256)

Add CalDAV server for iOS Calendar bidirectional sync (#256)

* Updated CLAUDE.md (#257)

* Updated CLAUDE.md

* Add Reminders WebApp

* Add Reminders WebApp (macOS Reminders-style)

A self-contained ArozOS WebApp that mimics the macOS Reminders app.

Front-end (web/Reminders/index.html), single-page, no framework:
- Sidebar with the smart-list grid — Today, Scheduled, All, Flagged and a
  Completed card — plus custom "My Lists" with colour + SF-style icons and
  live incomplete counts, an Add List action and a light/dark toggle.
- Reminder rows with circle checkboxes, inline title editing, sub-tasks
  (Tab/Shift+Tab to indent), notes, due date/time chips (red when overdue),
  flags, priority (!/!!/!!!) and clickable URLs.
- Smart-view grouping: Today by time-of-day (All-Day/Morning/Afternoon/
  Tonight), Scheduled by date (with an Overdue group), All/Search by list.
- A detail editor popover (On a Day / At a Time, priority, list, URL, flag)
  and a list create/edit modal with colour and icon pickers.
- Native macOS look with full light/dark theming; sticky UI prefs via
  localStorage; responsive off-canvas sidebar on narrow screens.

Back-end AGI scripts (web/Reminders/backend/*.agi) persist a per-user
{ lists, reminders } store to user:/Document/Reminders/data.json via filelib,
following the Calendar app's pattern: init (load + seed default list),
saveReminder/deleteReminder, saveList/deleteList and a full saveData for
re-ordering and clearing completed items.

https://claude.ai/code/session_018JqHkXkuYjj5hz4kjiS1fQ

* Add CalDAV VTODO sync for Reminders and recurring support

Expose the Reminders web-app data as a CalDAV VTODO collection at
/caldav/{username}/reminders/ so iOS Reminders can sync bidirectionally
alongside the existing Calendar (VEVENT) collection. The principal now
advertises a calendar-home-set containing both collections.

Add RRULE recurrence support: events and reminders pass their recurrence
rule through ICS in both directions. The Reminders UI gains a Repeat
selector, shows a repeat indicator, and rolls recurring reminders forward
to their next occurrence on completion (matching iOS behaviour).

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Bump internal_version to 3.0.1

Update internal build version from 3.0.0 to 3.0.1 in src/flags.go to reflect a minor release increment.

* Add shell mode (virtual FS) to web terminal

Introduce an interactive shell mode for the web Terminal. Backend (session.agi): implement shell state, virtual path resolution, many shell commands (pwd, ls, cd, cat, mkdir, touch, rm, cp, mv, echo, whoami, clear, exit, help), tab-completion, prompt building, safe filelib wrappers, and new websocket message types (shellmode, clear, complete, shellPrompt in result). Frontend (index.html): track shellMode/shellPrompt, render shell output differently, handle shell enter/exit and clear messages, send/handle completion requests/responses, adjust input handling (Tab completion and disabling REPL dot-commands while in shell). Add .claude/launch.json to run a local static server for src/web. This enables a secure virtual filesystem shell inside the AGI REPL without host FS access.

* TURN update for Arozcast screen share over Internet (#259)

* Bump github.com/go-git/go-git/v5 from 5.16.5 to 5.19.1 in /src (#255)

Bumps [github.com/go-git/go-git/v5](https://github.com/go-git/go-git) from 5.16.5 to 5.19.1.
- [Release notes](https://github.com/go-git/go-git/releases)
- [Changelog](https://github.com/go-git/go-git/blob/main/HISTORY.md)
- [Commits](https://github.com/go-git/go-git/compare/v5.16.5...v5.19.1)

---
updated-dependencies:
- dependency-name: github.com/go-git/go-git/v5
  dependency-version: 5.19.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Add Reminders WebApp (macOS Reminders-style)

A self-contained ArozOS WebApp that mimics the macOS Reminders app.

Front-end (web/Reminders/index.html), single-page, no framework:
- Sidebar with the smart-list grid — Today, Scheduled, All, Flagged and a
  Completed card — plus custom "My Lists" with colour + SF-style icons and
  live incomplete counts, an Add List action and a light/dark toggle.
- Reminder rows with circle checkboxes, inline title editing, sub-tasks
  (Tab/Shift+Tab to indent), notes, due date/time chips (red when overdue),
  flags, priority (!/!!/!!!) and clickable URLs.
- Smart-view grouping: Today by time-of-day (All-Day/Morning/Afternoon/
  Tonight), Scheduled by date (with an Overdue group), All/Search by list.
- A detail editor popover (On a Day / At a Time, priority, list, URL, flag)
  and a list create/edit modal with colour and icon pickers.
- Native macOS look with full light/dark theming; sticky UI prefs via
  localStorage; responsive off-canvas sidebar on narrow screens.

Back-end AGI scripts (web/Reminders/backend/*.agi) persist a per-user
{ lists, reminders } store to user:/Document/Reminders/data.json via filelib,
following the Calendar app's pattern: init (load + seed default list),
saveReminder/deleteReminder, saveList/deleteList and a full saveData for
re-ordering and clearing completed items.

https://claude.ai/code/session_018JqHkXkuYjj5hz4kjiS1fQ

* Add built-in TURN relay so Arozcast screen share works over the Internet

Arozcast screen share is a direct WebRTC peer-to-peer connection that only
used public STUN servers, so it could not traverse NAT and failed over the
Internet. Add a built-in TURN relay (pion/turn, MIT, pure Go) that both peers
reach through the same ArozOS host, plus an /api/arozcast/iceservers endpoint
that hands the browser STUN + TURN with short-lived HMAC-signed credentials.
The relay is optional/configurable, and operators can override the ICE list
entirely with system/arozcast/iceservers.json to use an external TURN.

- mod/network/turn: pion/turn wrapper + ephemeral credential helpers (tested)
- cast.go: start the relay in ArozcastInit; serve /api/arozcast/iceservers
- main.go: close the relay on shutdown
- flags.go: -arozcast_turn, -arozcast_turn_port, -arozcast_turn_publicip
- screenshare.html / index.html: fetch ICE servers (STUN fallback)
- Arozcast README: endpoint + Internet/TURN operations docs

Media casting (Musicify/Movie/Photo) already worked over the Internet via the
existing relay and is unchanged.

https://claude.ai/code/session_01P68sFxtXuRXfzDnwtpFrnU

* Add TURN-over-TLS and a System Settings toggle for the Arozcast relay

Builds on the built-in Arozcast TURN relay with two operator-facing
enhancements.

TURN-over-TLS (TURNS):
- turn.NewServer now optionally starts a TURNS listener (TLSPort +
  TLSCertFile/TLSKeyFile) alongside the plain UDP/TCP relay, reusing the
  system TLS certificate. Restrictive firewalls that only permit outbound
  TLS (commonly 443) can then relay screen share, since the media rides
  inside a connection indistinguishable from HTTPS.
- Listener setup tracks every opened conn so a later failure cleans up; a
  TURNS failure is non-fatal (logged, plain relay keeps running).
- /api/arozcast/iceservers advertises turns:host:port?transport=tcp when
  the listener is up. New flags: -arozcast_turn_tls (default on, no-op
  without a usable cert) and -arozcast_turn_tls_port (default 5349; set to
  443 for maximum reach).

System Settings toggle & status:
- The relay is now runtime-managed (mutex-guarded start/stop with a
  remembered config) and its on/off state is persisted in the system
  database, overriding the -arozcast_turn flag default across restarts.
- New admin-only endpoints /system/arozcast/turn/{status,setEnabled} via
  the permission router, plus a "Screen Share Relay" page under
  System Settings -> Network & Connection (SystemAO/arozcast/turn.html)
  showing running state, port, TURNS and advertised host.

Tests cover the TURNS listener (self-signed cert handshake) and the
non-fatal missing-cert path. README documents both features.

* Drop the SNI-routing proxy caveat from the TURNS 443 docs

The TURN-over-TLS note suggested fronting port 443 with a TLS/SNI-routing
proxy to share it with the web server. That over-complicated the guidance;
replace it with a plain statement that 5349 is the default and 443 is only
viable when free on the relay's address.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>

* Add single instance support to Movie

* Add cross-tab theme sync and file selector fixes

Broadcast theme changes to other open tabs and improve file selector callback handling. file_explorer.html now writes ao_system_theme to localStorage when toggling theme in standalone mode so other tabs can react. ao_module.js adds ao_module_onThemeChanged, ao_module_toggleSystemTheme and an internal _ao_theme_broadcast to support both VDI (desktop-driven) and standalone/cross-tab theme propagation. Also fixes and documents file selector behavior: allow callback as a string or named function, auto-register a temporary global for anonymous callbacks in VDI mode, resolve string callbacks in non-VDI polling mode, and clean up localStorage handling. Minor typo/comment and whitespace fixes included.

* Add theme listener to desktop

* Added more icons for Code Studio

* Added wip productivity app

* Renewed Text app

* Updated Productivity app

* Text module style update

* Add RWD support to text

* Add Markdown source-mark reveal and image sync

Show Typora-style markdown source marks and allow inline editing of image references. Adds CSS pseudo-elements to reveal heading, emphasis, code, blockquote and link syntax; a settings selector to control reveal mode (active/always/none); and a default syntax setting. Implements updateActiveBlock(), clearDecorations(), decorateBlock(), editable .md-imgsyn labels, syncImgFromSyn()/commitImgSyn(), and helpers (richPlainText, activeImgSyn, caret/block helpers) to keep serialization and word count unchanged. Updates event handlers and key handling to support image-label editing, heading mark editing, backspace behavior at block start, and selection changes. Ensures getContent strips editable image labels before turndown and wires UI state updates throughout.

* Text: add Notion mode with slash commands and document-aware AI (#260)

Adds a toolbar toggle that enables a Notion-style "/" command menu in the
Markdown editor. Typing "/" at a line start (or after a space) opens a
filterable, keyboard-navigable menu to quickly insert blocks (headings,
to-do/bullet/numbered lists, quote, code block, table, divider) and to run
AI actions.

AI actions feed the entire current document in as context and insert the
generated Markdown at the caret, with an inline "Ask AI" prompt for
free-form instructions plus presets (continue writing, summarize,
brainstorm, outline). Generation is relayed through a new Text/ai.agi
backend that calls the aimodel library; the AI endpoint/key are configured
by an admin in System Settings.

A new "Notion & AI" settings section lets users toggle the feature, pick a
model (lazy-loaded from the endpoint) and set the temperature; all
preferences persist alongside the existing editor settings.


Claude-Session: https://claude.ai/code/session_01Xd4TY3ecHMDdG7es1JqbKo

Co-authored-by: Claude <noreply@anthropic.com>

* Revert "Text: add Notion mode with slash commands and document-aware AI (#260)"

This reverts commit be21397ccf060ed4fe403c48fbe3d63d72669965.

* Added advanced code block function to Text

* Add code-language picker and blank-line handling

Introduce a code-block language <select> UI and preserve runs of blank paragraphs through round-trip serialization. Add LANG_LIST and TextHL.languages(), ensure language selectors are appended to <pre> blocks (contenteditable=false and stripped on save), and wire syncLangSelector/ensureLangSelector/clearLangSelectors/onLangSelectChange. Implement renderWithBlankRuns() and expandBlankSentinels() to keep multiple empty paragraphs, switch marked.parse/getContent/renderedHTML to use these, and replace the previous &nbsp; sentinel with a ZWSP sentinel.

Fix code-block editing by rewriting newline/delete logic to operate on the code textContent (insertNewlineInCode, deleteInCode) to avoid DOM fragmentation and caret issues; add protection for key handling when the language <select> has focus. Adjust decoration logic to avoid marking PRE as md-active and update syntax display rules.

Rename/move backend modules under Text/backend (export.agi, filesaver.agi, imgtool.agi) and update ao_module_agirun calls to use the new paths. Add related CSS for the language picker and other small UI adjustments.

* Add link editor and improve code highlighting

Introduce an interactive link editor and inline editable link marks, replacing the previous CSS-only closing a::after pseudo-element with a real .md-linksyn element so link URLs are clickable/editable. Add link dialog UI (overlay, inputs, remove/cancel/OK handlers) and associated logic to insert/edit/remove links, preserve/restore selection, and avoid tearing down marks while edited. Enhance code-block highlighting: switch to relightCode/highlightCode with caret/selection preservation (selectionEndOffsetIn, restoreCaretIn), handle live re-highlighting on edits, language changes, and prevent stuck empty code blocks by exiting them (exitEmptyCodeBlock). Also fix caret placement when clicking below trailing code blocks (escapeTrailingCodeBlockClick), update serialization to strip .md-linksyn, and adjust toolbar/labels for the link action.

* Add support for token access in webdav server

X-Access-Token + X-Aroz-User now can be used to login webdav with autologin token instead of Basic Auth

* Add CNN inference API support

* Rename aimodel to llm and add LLM server pkg

Replace the legacy "aimodel" library with a new "llm" library and introduce a dedicated aiservers/llm package. Key changes:

- Rename library identifier from "aimodel" to "llm" across docs and backend scripts (README, AGI scripts, web backend examples).
- Remove the old agi.aimodel implementation and add agi.llm (new AGI lib) plus agi.llm_test.go.
- Add mod/aiservers/llm package with client, OpenAI/Anthropic adapters and types, and tests (client.go, openai.go, anthropic.go, types.go, and tests).
- Update tests and module wiring to use LLMConfig/LLMPricing/llmDBTable and injectLLMFunctions where appropriate.
- Minor updates to web demo backend/init scripts and API docs to reflect the new requirelib name.

Notes: System Settings UI and routes retaining the original "AI Model" naming; only the requirelib identifier and internal Go types/DB table references were changed. This restructures wire-protocol logic into the new aiservers/llm package for clearer separation of concerns and easier protocol-specific handling.

* Fixed #204

* Add text color change inline

* Add free resize on select tool

* Add Cine Studio, a Premiere-like timeline video editor webapp (#263)

A new dark macOS-style video editor under src/web/Cine Studio with:

- Media bin importing from the ArozOS file system (file selector +
  File Manager drag-drop) and from the local device (uploaded to
  user:/Cine Studio/Media so projects stay portable), with probed
  durations, filmstrip thumbnails and decoded waveform peaks
- Multi-track timeline (video/audio) with drag-move, edge trimming,
  blade splitting, snapping, track visibility/mute toggles, zoomable
  timecode ruler and undo/redo history
- Canvas compositing preview player with per-clip transform, crop
  mode, opacity, color controls (exposure/contrast/saturation/presets)
  and a WebAudio mix bus for synced audio playback
- .cine project format (open/save/save-as via ao_module file selector,
  registered as a file association with its own icon)
- Export pipeline: real-time MediaRecorder render to WebM uploaded to
  the ArozOS storage, with optional server-side MP4 conversion through
  the AGI ffmpeg library when the host has ffmpeg installed

Backend AGI scripts create the per-user app folders and expose the
ffmpeg availability check / conversion / cleanup actions.


Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

Co-authored-by: Claude <noreply@anthropic.com>

* Docker support (#264)

* Updated Video Editor app to have more functionalities (#266)

* Add Cine Studio, a Premiere-like timeline video editor webapp

A new dark macOS-style video editor under src/web/Cine Studio with:

- Media bin importing from the ArozOS file system (file selector +
  File Manager drag-drop) and from the local device (uploaded to
  user:/Cine Studio/Media so projects stay portable), with probed
  durations, filmstrip thumbnails and decoded waveform peaks
- Multi-track timeline (video/audio) with drag-move, edge trimming,
  blade splitting, snapping, track visibility/mute toggles, zoomable
  timecode ruler and undo/redo history
- Canvas compositing preview player with per-clip transform, crop
  mode, opacity, color controls (exposure/contrast/saturation/presets)
  and a WebAudio mix bus for synced audio playback
- .cine project format (open/save/save-as via ao_module file selector,
  registered as a file association with its own icon)
- Export pipeline: real-time MediaRecorder render to WebM uploaded to
  the ArozOS storage, with optional server-side MP4 conversion through
  the AGI ffmpeg library when the host has ffmpeg installed

Backend AGI scripts create the per-user app folders and expose the
ffmpeg availability check / conversion / cleanup actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Remove macOS traffic lights and implement all stubbed Cine Studio features

Top bar: drop the decorative macOS traffic-light buttons.

Effects (new js/effects.js): per-clip effect stacks (Black & White,
Sepia, Invert, Hue Shift, Blur, Pixelate, Mirror, Vignette, Film Grain,
Fade In/Out) applied by the compositor so preview and export match.
Fades also ramp audio-clip volume. Gallery panel with rendered preview
cards plus an inspector Effects tab with amount sliders, add/remove and
an fx badge on timeline clips.

Titles and Elements (new js/titles.js): generated clips with no backing
media - text titles (content, size, color, weight, align, vertical
position, plain/lower-third/caption styles) and solid/gradient color
boards. They render through a cached offscreen frame so transforms,
color controls, effects and transitions all apply. Inserted from the
Titles/Elements panels, the T tool or the T key onto a free video track
(auto-created when needed).

Transitions (new js/transitions.js): Cross Dissolve, Fade and Wipe
stored on the incoming clip with adjustable duration; the previous
clip's last frame is frozen and blended, with a from-black fallback and
a marker on the clip. Applied from the gallery or the inspector
Transition tab.

Also implemented: Filters panel applying color-preset looks, Libraries
panel listing saved .cine projects (new backend/listprojects.js), media
bin list view toggle, and crop/audio toolbar buttons that jump to the
matching inspector section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add preview direct manipulation, auto track creation and .pxs/.asproj import

Preview (new js/previewctl.js): an overlay canvas on the preview lets
the user click a clip to select it, drag it to reposition and pull a
corner handle to scale it - rotation aware, works for video, image,
title and color clips, with a selection outline and handles drawn over
the frame. Timeline drags now listen on the window because Chrome drops
element pointer capture when the clip is reparented across lanes.

Unlimited tracks: dragging a clip past the outermost lanes creates a
new video/audio track on drop, and dropping bin media onto the empty
timeline area below the lanes does the same, in addition to the
existing add-track button.

Pixel Studio import (.pxs): the layered project is flattened in the
browser - raster layers honoring visibility, opacity and blend mode,
text layers re-rendered from their text data - and used as image media.

Audio Studio import (.asproj): prefers an embedded or referenced
rendered mixdown; otherwise decodes the project's track/clip references
(tolerant field names) and mixes them with an OfflineAudioContext into
a WAV. Both importers keep the original virtual path in the project
file and re-composite when a saved project is reopened.

Both extensions join the import filters, the local-device picker and
the module's SupportedExt list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add the most critical everyday editing features to Cine Studio

Safety and workflow:
- Auto-save (20s, localStorage) with crash-recovery prompt on launch,
  cleared once the project is saved to disk (new js/session.js)
- Recent projects list in the project menu (dedup, last 8)

Editing fundamentals:
- Multi-select (shift-click) with group move, group delete, group copy
- Copy / paste at playhead / duplicate clips (Ctrl+C / V / D)
- Ripple delete closing the gap (Shift+Del, context menu)
- Timeline markers: add/remove at playhead (M), flags on the ruler,
  jump to next (Shift+M), saved in the project, act as snap targets
- Snapping on/off toggle (magnet button)
- Clip speed 25-400%: timeline length, element sync, trimming and
  splitting are all speed aware; audio pitch follows rate

Compositing and playback:
- Blend modes (multiply, screen, overlay, add, soft light, difference)
- Flip horizontal / vertical toggles
- JKL shuttle (reverse scrub / pause / forward up to 8x) and loop
  playback toggle; export temporarily disables looping

Output and audio:
- Export current frame as PNG from the export menu
- Detach audio: video clip audio moves to its own audio-track clip
- Audio track solo via the track header context menu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Fix docker package build failure on FreeBSD and other unlisted platforms (#267)

The docker package split its PTY console and host-stats helpers into
build-tagged files that only covered linux/darwin/windows. FreeBSD (built
by the Jenkins release pipeline for amd64 and i386) matched none of them,
so startDockerExecPTY, hostStorageUsage and hostLoadAvg were undefined and
the cross-platform release build failed.

- console_unix.go now builds for freebsd too (creack/pty supports it).
- console_windows.go becomes console_other.go, a catch-all (!linux &&
  !darwin && !freebsd) stub covering windows and any other GOOS.
- host_darwin.go becomes host_bsd.go (darwin || freebsd); the Statfs-based
  storage helper works on both.
- Add host_other.go, a catch-all fallback so the package builds on any
  remaining GOOS.

Also expand the CI cross-compile smoke test to cover the full release
matrix (including freebsd/amd64 and freebsd/386) so a platform-specific
break is caught in CI instead of only in the release build.


Claude-Session: https://claude.ai/code/session_01L4oR1xiTxpcfwYjRgi4RGh

Co-authored-by: Claude <noreply@anthropic.com>

* Added playwright e2e tests for cine studio (#268)

* Add Cine Studio, a Premiere-like timeline video editor webapp

A new dark macOS-style video editor under src/web/Cine Studio with:

- Media bin importing from the ArozOS file system (file selector +
  File Manager drag-drop) and from the local device (uploaded to
  user:/Cine Studio/Media so projects stay portable), with probed
  durations, filmstrip thumbnails and decoded waveform peaks
- Multi-track timeline (video/audio) with drag-move, edge trimming,
  blade splitting, snapping, track visibility/mute toggles, zoomable
  timecode ruler and undo/redo history
- Canvas compositing preview player with per-clip transform, crop
  mode, opacity, color controls (exposure/contrast/saturation/presets)
  and a WebAudio mix bus for synced audio playback
- .cine project format (open/save/save-as via ao_module file selector,
  registered as a file association with its own icon)
- Export pipeline: real-time MediaRecorder render to WebM uploaded to
  the ArozOS storage, with optional server-side MP4 conversion through
  the AGI ffmpeg library when the host has ffmpeg installed

Backend AGI scripts create the per-user app folders and expose the
ffmpeg availability check / conversion / cleanup actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Remove macOS traffic lights and implement all stubbed Cine Studio features

Top bar: drop the decorative macOS traffic-light buttons.

Effects (new js/effects.js): per-clip effect stacks (Black & White,
Sepia, Invert, Hue Shift, Blur, Pixelate, Mirror, Vignette, Film Grain,
Fade In/Out) applied by the compositor so preview and export match.
Fades also ramp audio-clip volume. Gallery panel with rendered preview
cards plus an inspector Effects tab with amount sliders, add/remove and
an fx badge on timeline clips.

Titles and Elements (new js/titles.js): generated clips with no backing
media - text titles (content, size, color, weight, align, vertical
position, plain/lower-third/caption styles) and solid/gradient color
boards. They render through a cached offscreen frame so transforms,
color controls, effects and transitions all apply. Inserted from the
Titles/Elements panels, the T tool or the T key onto a free video track
(auto-created when needed).

Transitions (new js/transitions.js): Cross Dissolve, Fade and Wipe
stored on the incoming clip with adjustable duration; the previous
clip's last frame is frozen and blended, with a from-black fallback and
a marker on the clip. Applied from the gallery or the inspector
Transition tab.

Also implemented: Filters panel applying color-preset looks, Libraries
panel listing saved .cine projects (new backend/listprojects.js), media
bin list view toggle, and crop/audio toolbar buttons that jump to the
matching inspector section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add preview direct manipulation, auto track creation and .pxs/.asproj import

Preview (new js/previewctl.js): an overlay canvas on the preview lets
the user click a clip to select it, drag it to reposition and pull a
corner handle to scale it - rotation aware, works for video, image,
title and color clips, with a selection outline and handles drawn over
the frame. Timeline drags now listen on the window because Chrome drops
element pointer capture when the clip is reparented across lanes.

Unlimited tracks: dragging a clip past the outermost lanes creates a
new video/audio track on drop, and dropping bin media onto the empty
timeline area below the lanes does the same, in addition to the
existing add-track button.

Pixel Studio import (.pxs): the layered project is flattened in the
browser - raster layers honoring visibility, opacity and blend mode,
text layers re-rendered from their text data - and used as image media.

Audio Studio import (.asproj): prefers an embedded or referenced
rendered mixdown; otherwise decodes the project's track/clip references
(tolerant field names) and mixes them with an OfflineAudioContext into
a WAV. Both importers keep the original virtual path in the project
file and re-composite when a saved project is reopened.

Both extensions join the import filters, the local-device picker and
the module's SupportedExt list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add the most critical everyday editing features to Cine Studio

Safety and workflow:
- Auto-save (20s, localStorage) with crash-recovery prompt on launch,
  cleared once the project is saved to disk (new js/session.js)
- Recent projects list in the project menu (dedup, last 8)

Editing fundamentals:
- Multi-select (shift-click) with group move, group delete, group copy
- Copy / paste at playhead / duplicate clips (Ctrl+C / V / D)
- Ripple delete closing the gap (Shift+Del, context menu)
- Timeline markers: add/remove at playhead (M), flags on the ruler,
  jump to next (Shift+M), saved in the project, act as snap targets
- Snapping on/off toggle (magnet button)
- Clip speed 25-400%: timeline length, element sync, trimming and
  splitting are all speed aware; audio pitch follows rate

Compositing and playback:
- Blend modes (multiply, screen, overlay, add, soft light, difference)
- Flip horizontal / vertical toggles
- JKL shuttle (reverse scrub / pause / forward up to 8x) and loop
  playback toggle; export temporarily disables looping

Output and audio:
- Export current frame as PNG from the export menu
- Detach audio: video clip audio moves to its own audio-track clip
- Audio track solo via the track header context menu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add Playwright E2E suite for Cine Studio + GitHub Actions job

Ports the browser-driven tests used to verify Cine Studio into the
codebase under test/e2e/playwright (outside src/ so they never touch
the Go module). The tests drive the real app in headless Chromium,
generating their own media in-page (canvas -> MediaRecorder WebM,
synthesized WAV) and asserting on rendered pixels, so they exercise the
full compositor / playback / export pipeline rather than mocking it.

Structure:
- lib/static-server.js  minimal static server for the ArozOS web root
- lib/harness.js        browser launch + app navigation + ok()/fail();
                        resolves Playwright's own Chromium (no hardcoded
                        path), overridable via PW_CHROMIUM_PATH
- run.js                serves src/web, runs each spec in its own process,
                        exits non-zero on any failure
- specs/functional.js   media probe, playback, edit, round-trip, WebM export
- specs/features.js     effects, titles, transitions, elements, filters
- specs/interaction.js  preview drag/resize, auto tracks, .pxs/.asproj import
- specs/editing.js      multi-select, copy/paste, speed, JKL, markers, autosave

41 assertions across the four specs, all passing locally.

CI: .github/workflows/e2e-playwright.yml installs Node + Playwright
Chromium and runs `npm test`, scoped to changes under the app or the
harness so unrelated commits don't trigger it. Front-end only - runs
independently of the Go build.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Claude/arozos playwright tests for arozos (#269)

* Add full-stack Playwright E2E suite for ArozOS critical paths

Extend the Playwright harness (previously Cine Studio only) with a
'system' suite that drives a real ArozOS server built from src/:

- lib/arozos-server.js boots a disposable isolated instance
  (.instance/: web symlink + private system copy) and bootstraps a
  deterministic admin account at the zero-user state
- lib/system-harness.js shares login (form + API), CSRF, JSON and
  pass/fail helpers; each spec also runs standalone
- specs-system/ covers the critical paths:
  010 sign in / sign out (form render, bad credentials, session
      persistence, gated redirects, logout, retry delay)
  020 desktop shell (task bar, start menu, module list, quick access
      panel identity, UI sign-out)
  030 file explorer (File Manager UI render + full file lifecycle:
      create, rename, copy, move, properties, recycle/restore/clear
      trash, permanent delete, CSRF enforcement)
  040 system settings (settings UI, catalogue groups, admin-only
      modules, host info, permission group listing)
  050 user management + permission control (group CRUD, user CRUD,
      restricted-user module visibility, admin-only endpoint denial,
      administrator group protection)
- run.js orchestrates both suites; npm run test:static / test:system
- CI workflow now builds the Go binary, triggers on src/** changes and
  uploads the server log on failure

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdeQtHTSvBQgUpWfKWvkow

* Add WebApp wave 1 E2E spec and coverage roadmap

- specs-system/060-webapps-core.js: smoke + interaction coverage for
  the highest-importance WebApps (NotepadA, Text, Photo, Music, Video,
  PDF Viewer, Zip File Manager) against the real server, including
  opening a real file in NotepadA via the desktop hash convention
- README: document the ranked 44-WebApp inventory and the wave order
  for upcoming specs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdeQtHTSvBQgUpWfKWvkow

* Add WebApp wave 2 E2E spec (office/productivity)

specs-system/070-webapps-office.js: smoke + interaction coverage for
Code Studio, MDEditor, Calendar, Notes, Memo, Reminders, OfficeViewer
and Dashboard against the real server, including opening a real
Markdown file in MDEditor via the desktop hash convention and probing
Dashboard's live system-info data source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdeQtHTSvBQgUpWfKWvkow

* Add WebApp wave 3 E2E spec (media/creative)

specs-system/080-webapps-media.js: smoke coverage for Musicify, Movie,
Manga, Paint, Pixel Studio, Audio Studio, Camera, Recorder and FFmpeg
Factory against the real server, plus a probe of the storage data path
the media library apps browse. Cine Studio keeps its own deep static
suite and is not repeated here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdeQtHTSvBQgUpWfKWvkow

* Add WebApp wave 4 E2E spec (utilities/dev/network) + close roadmap

specs-system/090-webapps-utilities.js: smoke coverage for Calculator
(incl. a real 7+8=15 calculation through the UI), Clock, Browser,
Speedtest, Web Downloader, Web Builder, SQLite Admin, Terminal,
AGIForge, AIChat, OTPAuth, Productivity, OnScreenKeyboard, Arozcast,
Management Gateway, UnitTest, CronDemo and Serverless.

This completes coverage of all 44 WebApps under src/web across four
waves. README updated to reflect the full map; full suite (13 spec
files: 4 static + 9 system) passes end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdeQtHTSvBQgUpWfKWvkow

* Add deep file-transfer critical-path spec (upload/download/search/share)

specs-system/035-file-transfer.js extends the file-explorer coverage
with the transfer features that move real bytes:
- multipart upload of a real file
- media download with byte-exact round-trip verification
- file search by keyword
- share-link lifecycle: create, list, anonymous direct download via
  /share/download/{uuid}/ (byte-exact) + preview page, then delete and
  confirm the share is revoked

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdeQtHTSvBQgUpWfKWvkow

* Add account password-change critical-path spec

specs-system/055-account.js exercises the My Account flow on a
dedicated throwaway user (keeping the shared admin login intact):
account settings UI render, userinfo identity, wrong-old-password
refusal, successful change, and that afterwards only the new password
signs in. README updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdeQtHTSvBQgUpWfKWvkow

* Add desktop backend API spec (every /system/desktop endpoint)

specs-system/021-desktop-api.js covers the full desktop.go API surface
with the exact request shapes desktop.html uses: host/device details;
user self-info (quota/groups/admin), another user's public info via
?target, and noicon; theme listing + set/get round-trip; preference
set/get/remove; createShortcut + listDesktop metadata; icon location
files set/get/del; and renameShortcut incl. off-desktop rejection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdeQtHTSvBQgUpWfKWvkow

* Add desktop UI shell spec (clock, panels, start menu, search, icons)

specs-system/022-desktop-ui.js drives the real desktop.html: wallpaper
frame mounts, the taskbar clock shows a formatted time, the quick
access panel carries the user identity, the start menu opens with
module entries, its search box narrows the list and reports No Result
for gibberish, the notification bar toggles from the clock, and an
API-seeded shortcut renders as a desktop launch icon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdeQtHTSvBQgUpWfKWvkow

* Add desktop float-window + context-menu spec and update README

specs-system/023-desktop-windows.js drives the desktop window manager
and right-click surfaces using the app's own globals and real clicks:
openModule spawns a float window with a taskbar entry; newFloatWindow
creates addressable windows; focus raises z-order; maximize/restore,
minimize and close all work; and the wallpaper + icon context menus
open with the expected entries.

Desktop coverage is now three specs deep (API 021, UI 022, windows
023) on top of the original 020 shell smoke test. README layout
updated. Full system suite (14 specs) passes together.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdeQtHTSvBQgUpWfKWvkow

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Video Editor Cline Update (#270)

* Add Cine Studio, a Premiere-like timeline video editor webapp

A new dark macOS-style video editor under src/web/Cine Studio with:

- Media bin importing from the ArozOS file system (file selector +
  File Manager drag-drop) and from the local device (uploaded to
  user:/Cine Studio/Media so projects stay portable), with probed
  durations, filmstrip thumbnails and decoded waveform peaks
- Multi-track timeline (video/audio) with drag-move, edge trimming,
  blade splitting, snapping, track visibility/mute toggles, zoomable
  timecode ruler and undo/redo history
- Canvas compositing preview player with per-clip transform, crop
  mode, opacity, color controls (exposure/contrast/saturation/presets)
  and a WebAudio mix bus for synced audio playback
- .cine project format (open/save/save-as via ao_module file selector,
  registered as a file association with its own icon)
- Export pipeline: real-time MediaRecorder render to WebM uploaded to
  the ArozOS storage, with optional server-side MP4 conversion through
  the AGI ffmpeg library when the host has ffmpeg installed

Backend AGI scripts create the per-user app folders and expose the
ffmpeg availability check / conversion / cleanup actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Remove macOS traffic lights and implement all stubbed Cine Studio features

Top bar: drop the decorative macOS traffic-light buttons.

Effects (new js/effects.js): per-clip effect stacks (Black & White,
Sepia, Invert, Hue Shift, Blur, Pixelate, Mirror, Vignette, Film Grain,
Fade In/Out) applied by the compositor so preview and export match.
Fades also ramp audio-clip volume. Gallery panel with rendered preview
cards plus an inspector Effects tab with amount sliders, add/remove and
an fx badge on timeline clips.

Titles and Elements (new js/titles.js): generated clips with no backing
media - text titles (content, size, color, weight, align, vertical
position, plain/lower-third/caption styles) and solid/gradient color
boards. They render through a cached offscreen frame so transforms,
color controls, effects and transitions all apply. Inserted from the
Titles/Elements panels, the T tool or the T key onto a free video track
(auto-created when needed).

Transitions (new js/transitions.js): Cross Dissolve, Fade and Wipe
stored on the incoming clip with adjustable duration; the previous
clip's last frame is frozen and blended, with a from-black fallback and
a marker on the clip. Applied from the gallery or the inspector
Transition tab.

Also implemented: Filters panel applying color-preset looks, Libraries
panel listing saved .cine projects (new backend/listprojects.js), media
bin list view toggle, and crop/audio toolbar buttons that jump to the
matching inspector section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add preview direct manipulation, auto track creation and .pxs/.asproj import

Preview (new js/previewctl.js): an overlay canvas on the preview lets
the user click a clip to select it, drag it to reposition and pull a
corner handle to scale it - rotation aware, works for video, image,
title and color clips, with a selection outline and handles drawn over
the frame. Timeline drags now listen on the window because Chrome drops
element pointer capture when the clip is reparented across lanes.

Unlimited tracks: dragging a clip past the outermost lanes creates a
new video/audio track on drop, and dropping bin media onto the empty
timeline area below the lanes does the same, in addition to the
existing add-track button.

Pixel Studio import (.pxs): the layered project is flattened in the
browser - raster layers honoring visibility, opacity and blend mode,
text layers re-rendered from their text data - and used as image media.

Audio Studio import (.asproj): prefers an embedded or referenced
rendered mixdown; otherwise decodes the project's track/clip references
(tolerant field names) and mixes them with an OfflineAudioContext into
a WAV. Both importers keep the original virtual path in the project
file and re-composite when a saved project is reopened.

Both extensions join the import filters, the local-device picker and
the module's SupportedExt list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add the most critical everyday editing features to Cine Studio

Safety and workflow:
- Auto-save (20s, localStorage) with crash-recovery prompt on launch,
  cleared once the project is saved to disk (new js/session.js)
- Recent projects list in the project menu (dedup, last 8)

Editing fundamentals:
- Multi-select (shift-click) with group move, group delete, group copy
- Copy / paste at playhead / duplicate clips (Ctrl+C / V / D)
- Ripple delete closing the gap (Shift+Del, context menu)
- Timeline markers: add/remove at playhead (M), flags on the ruler,
  jump to next (Shift+M), saved in the project, act as snap targets
- Snapping on/off toggle (magnet button)
- Clip speed 25-400%: timeline length, element sync, trimming and
  splitting are all speed aware; audio pitch follows rate

Compositing and playback:
- Blend modes (multiply, screen, overlay, add, soft light, difference)
- Flip horizontal / vertical toggles
- JKL shuttle (reverse scrub / pause / forward up to 8x) and loop
  playback toggle; export temporarily disables looping

Output and audio:
- Export current frame as PNG from the export menu
- Detach audio: video clip audio moves to its own audio-track clip
- Audio track solo via the track header context menu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add Cine Studio, a Premiere-like timeline video editor webapp

A new dark macOS-style video editor under src/web/Cine Studio with:

- Media bin importing from the ArozOS file system (file selector +
  File Manager drag-drop) and from the local device (uploaded to
  user:/Cine Studio/Media so projects stay portable), with probed
  durations, filmstrip thumbnails and decoded waveform peaks
- Multi-track timeline (video/audio) with drag-move, edge trimming,
  blade splitting, snapping, track visibility/mute toggles, zoomable
  timecode ruler and undo/redo history
- Canvas compositing preview player with per-clip transform, crop
  mode, opacity, color controls (exposure/contrast/saturation/presets)
  and a WebAudio mix bus for synced audio playback
- .cine project format (open/save/save-as via ao_module file selector,
  registered as a file association with its own icon)
- Export pipeline: real-time MediaRecorder render to WebM uploaded to
  the ArozOS storage, with optional server-side MP4 conversion through
  the AGI ffmpeg library when the host has ffmpeg installed

Backend AGI scripts create the per-user app folders and expose the
ffmpeg availability check / conversion / cleanup actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Remove macOS traffic lights and implement all stubbed Cine Studio features

Top bar: drop the decorative macOS traffic-light buttons.

Effects (new js/effects.js): per-clip effect stacks (Black & White,
Sepia, Invert, Hue Shift, Blur, Pixelate, Mirror, Vignette, Film Grain,
Fade In/Out) applied by the compositor so preview and export match.
Fades also ramp audio-clip volume. Gallery panel with rendered preview
cards plus an inspector Effects tab with amount sliders, add/remove and
an fx badge on timeline clips.

Titles and Elements (new js/titles.js): generated clips with no backing
media - text titles (content, size, color, weight, align, vertical
position, plain/lower-third/caption styles) and solid/gradient color
boards. They render through a cached offscreen frame so transforms,
color controls, effects and transitions all apply. Inserted from the
Titles/Elements panels, the T tool or the T key onto a free video track
(auto-created when needed).

Transitions (new js/transitions.js): Cross Dissolve, Fade and Wipe
stored on the incoming clip with adjustable duration; the previous
clip's last frame is frozen and blended, with a from-black fallback and
a marker on the clip. Applied from the gallery or the inspector
Transition tab.

Also implemented: Filters panel applying color-preset looks, Libraries
panel listing saved .cine projects (new backend/listprojects.js), media
bin list view toggle, and crop/audio toolbar buttons that jump to the
matching inspector section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add preview direct manipulation, auto track creation and .pxs/.asproj import

Preview (new js/previewctl.js): an overlay canvas on the preview lets
the user click a clip to select it, drag it to reposition and pull a
corner handle to scale it - rotation aware, works for video, image,
title and color clips, with a selection outline and handles drawn over
the frame. Timeline drags now listen on the window because Chrome drops
element pointer capture when the clip is reparented across lanes.

Unlimited tracks: dragging a clip past the outermost lanes creates a
new video/audio track on drop, and dropping bin media onto the empty
timeline area below the lanes does the same, in addition to the
existing add-track button.

Pixel Studio import (.pxs): the layered project is flattened in the
browser - raster layers honoring visibility, opacity and blend mode,
text layers re-rendered from their text data - and used as image media.

Audio Studio import (.asproj): prefers an embedded or referenced
rendered mixdown; otherwise decodes the project's track/clip references
(tolerant field names) and mixes them with an OfflineAudioContext into
a WAV. Both importers keep the original virtual path in the project
file and re-composite when a saved project is reopened.

Both extensions join the import filters, the local-device picker and
the module's SupportedExt list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add the most critical everyday editing features to Cine Studio

Safety and workflow:
- Auto-save (20s, localStorage) with crash-recovery prompt on launch,
  cleared once the project is saved to disk (new js/session.js)
- Recent projects list in the project menu (dedup, last 8)

Editing fundamentals:
- Multi-select (shift-click) with group move, group delete, group copy
- Copy / paste at playhead / duplicate clips (Ctrl+C / V / D)
- Ripple delete closing the gap (Shift+Del, context menu)
- Timeline markers: add/remove at playhead (M), flags on the ruler,
  jump to next (Shift+M), saved in the project, act as snap targets
- Snapping on/off toggle (magnet button)
- Clip speed 25-400%: timeline length, element sync, trimming and
  splitting are all speed aware; audio pitch follows rate

Compositing and playback:
- Blend modes (multiply, screen, overlay, add, soft light, difference)
- Flip horizontal / vertical toggles
- JKL shuttle (reverse scrub / pause / forward up to 8x) and loop
  playback toggle; export temporarily disables looping

Output and audio:
- Export current frame as PNG from the export menu
- Detach audio: video clip audio moves to its own audio-track clip
- Audio track solo via the track header context menu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSxJni7aDMU5MFoWG2SSa4

* Add Playwright E2E suite for Cine Studio + GitHub Actions job

Ports the browser-driven tests used to verify Cine Studio into the
codebase under test/e2e/playwright (outside src/ so they never touch
the Go module). The tests drive the real app in headless Chromium,
generating their own media in-page (canvas -> MediaRecorder WebM,
synthesized WAV) and asserting on rendered pixels, so they exercise the
full compositor / playback / export pipeline rather than mocking it.

Structure:
- lib/static-server.js  minimal static server for the ArozOS web root
- lib/harness.js        browser launch + app navigation + ok()/fail();
                        resolves Playwright's own Chromium (no hardcoded
                        path), overridable via PW_CHROMIUM_PATH
- run.js                serves src/web, runs each spec in its own process,
                        exits non-zero on any failure
- specs/functional.js   media probe, playback, edit, round-trip, WebM export
- specs/features.js     effects, titles, transitions, elements, filters
- specs/interaction.js  preview drag/resize, auto tracks, .pxs/.asproj import
- specs/editing.js      multi-select, copy/paste, speed, JKL, markers, autosave

41 assertions across the four specs, all passing locally.

CI: .github/workflows/e2e-playwright.yml installs Node + Playwright
Chromium and runs `npm test`, scoped to changes under the app or the
harness so unrelated commits don't trigger it. Front-end only - runs
independently of the Go build.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>

* 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>

* 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 on…

* fix: treat Darwin ps %mem sum as percent, not fraction

GetNumericRAMUsage used phyMem - phyMem*sum, which goes negative
when the process %mem total is ~80 on a 16GB Mac. Convert via /100
and clamp to [0, phyMem].

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Toby Chui <tobychui@users.noreply.github.com>
Co-authored-by: Alan Yeung <yeungbluecat123@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: calico-cat-3333 <113905958+calico-cat-3333@users.noreply.github.com>
Co-authored-by: mirkosalvato1-ctrl <mirkosalvato1@gmail.com>
Co-authored-by: mirkosalvato1-ctrl <227404546+mirkosalvato1-ctrl@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: 白铭骢 (Mingcong Bai) <jeffbai@aosc.io>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tobychui <24617523+tobychui@users.noreply.github.com>
Co-authored-by: stantheman0128 <stantheman0128@users.noreply.github.com>
Po-Han (Stan) Shih · 施博瀚 1 тиждень тому
батько
коміт
93ed113943

+ 31 - 4
src/mod/info/usageinfo/usageinfo.go

@@ -18,10 +18,28 @@ import (
 
 const query_cpuproc_command = "ps -eo pcpu,pid,user,args | sort -k 1 -r | head -10"
 const query_freemem_command = "top -d1 | sed '4q;d' | awk '{print $(NF-1)}'"
+// Sum of per-process %mem (0–100+), not a 0–1 fraction and not free memory.
 const query_freemem_command_darwin = "ps -A -o %mem | awk '{mem += $1} END {print mem}'"
 const query_phymem_command = "sysctl hw.physmem | awk '{print $NF}'"
 const query_phymem_command_darwin = "sysctl hw.memsize | awk '{print $NF}'"
 
+// darwinUsedBytesFromMemPercentSum converts a ps %mem sum into used bytes.
+// The historical formula treated the sum as a 0–1 fraction
+// (phyMem - phyMem*sum), which goes hugely negative when sum ≈ 80.
+func darwinUsedBytesFromMemPercentSum(phyMem int64, sumMemPercent float64) int64 {
+	if phyMem <= 0 {
+		return 0
+	}
+	used := int64(float64(phyMem) * (sumMemPercent / 100.0))
+	if used < 0 {
+		used = 0
+	}
+	if used > phyMem {
+		used = phyMem
+	}
+	return used
+}
+
 // Get CPU Usage in percentage
 func GetCPUUsage() float64 {
 	usage := float64(0)
@@ -248,7 +266,7 @@ func GetNumericRAMUsage() (int64, int64) {
 			return usedRam, totalRam
 		}
 		totalRam = phyMem
-		usedRam = int64(float64(phyMem) - float64(phyMem)*freeMem)
+		usedRam = darwinUsedBytesFromMemPercentSum(phyMem, freeMem)
 		return usedRam, totalRam
 	}
 	return -1, -1
@@ -375,11 +393,20 @@ func GetRAMUsage() (string, string, float64) {
 		phyMemSizeFloat = math.Floor(phyMemSizeFloat)
 		totalRam = strconv.FormatFloat(phyMemSizeFloat, 'f', -1, 64) + "MB"
 
-		usedRAMSizeFloat := float64(phyMemSizeFloat) - float64(phyMemSizeFloat)*(1-(freeMemSizeFloat/100))
-		usedRAMSizeFloat = math.Floor(usedRAMSizeFloat)
+		// freeMemSizeFloat is the sum of %mem, not free memory. Convert via
+		// the shared helper (bytes), then express used in MB.
+		phyBytes := int64(phyMemSizeFloat * 1048576)
+		usedBytes := darwinUsedBytesFromMemPercentSum(phyBytes, freeMemSizeFloat)
+		usedRAMSizeFloat := math.Floor(float64(usedBytes) / 1048576)
 		usedRam = strconv.FormatFloat(usedRAMSizeFloat, 'f', -1, 64) + "MB"
 
-		usedPercentage = freeMemSizeFloat
+		usedPercentage = float64(usedBytes) / float64(phyBytes) * 100
+		if usedPercentage < 0 {
+			usedPercentage = 0
+		}
+		if usedPercentage > 100 {
+			usedPercentage = 100
+		}
 		return usedRam, totalRam, usedPercentage
 	}
 

+ 53 - 0
src/mod/info/usageinfo/usageinfo_test.go

@@ -206,3 +206,56 @@ func TestGetCPUStatsNonLinux(t *testing.T) {
 		t.Error("getCPUStats() should return an error on non-Linux platform")
 	}
 }
+
+// TestDarwinUsedBytesFromMemPercentSum guards the macOS RAM formula.
+// ps -A -o %mem sums percentages (e.g. 82.5), not a 0–1 fraction.
+// The old expression phyMem - phyMem*sum produced usedRam ≈ -1.4e15 on a 16GB Mac.
+func TestDarwinUsedBytesFromMemPercentSum(t *testing.T) {
+	phyMem := int64(17179869184) // 16 GiB
+	sumPercent := 82.5
+
+	oldBroken := int64(float64(phyMem) - float64(phyMem)*sumPercent)
+	if oldBroken >= 0 {
+		t.Fatalf("sanity: old formula should be negative for sum=82.5, got %d", oldBroken)
+	}
+
+	used := darwinUsedBytesFromMemPercentSum(phyMem, sumPercent)
+	if used < 0 {
+		t.Errorf("used=%d want >= 0", used)
+	}
+	if used > phyMem {
+		t.Errorf("used=%d > phyMem=%d", used, phyMem)
+	}
+	want := int64(float64(phyMem) * (sumPercent / 100.0))
+	if used != want {
+		t.Errorf("used=%d want %d", used, want)
+	}
+}
+
+func TestDarwinUsedBytesFromMemPercentSumClamps(t *testing.T) {
+	phyMem := int64(17179869184)
+	over := darwinUsedBytesFromMemPercentSum(phyMem, 250)
+	if over != phyMem {
+		t.Errorf("over-100%% sum: got %d want phyMem %d", over, phyMem)
+	}
+	neg := darwinUsedBytesFromMemPercentSum(phyMem, -10)
+	if neg != 0 {
+		t.Errorf("negative sum: got %d want 0", neg)
+	}
+}
+
+func TestGetNumericRAMUsageDarwin(t *testing.T) {
+	if runtime.GOOS != "darwin" {
+		t.Skip("darwin-only live RAM check")
+	}
+	used, total := GetNumericRAMUsage()
+	if total <= 0 {
+		t.Fatalf("total=%d want > 0", total)
+	}
+	if used < 0 {
+		t.Errorf("used=%d want >= 0", used)
+	}
+	if used > total {
+		t.Errorf("used=%d > total=%d", used, total)
+	}
+}

+ 149 - 0
src/web/Movie/embedded.html

@@ -1114,6 +1114,155 @@ function effectivePlaybackTime() {
     return isTranscodedVideo ? (vid.currentTime + transcodeSeekOffset) : vid.currentTime;
 }
 
+// ── Scrub-bar hover preview (storyboard) ──────────────────────────────────────
+// The server renders one sprite sheet per file holding a downscaled frame every
+// few seconds; we fetch it once and keep it in memory, so hovering the timeline
+// costs nothing. Asking ffmpeg for a frame per hover would be hopeless on a
+// software-decoded H.265 source, which is exactly where previews matter most.
+var storyboard      = null;   // layout from the server + the loaded sheet URL
+var storyboardToken = 0;      // invalidates in-flight loads when the file changes
+var storyboardTimer = null;
+
+function resetStoryboard() {
+    storyboardToken++;
+    storyboard = null;
+    clearTimeout(storyboardTimer);
+    storyboardTimer = null;
+    $('#scrub-preview-thumb').removeClass('ready');
+    hideScrubPreview();
+}
+
+// Building the sheet runs ffmpeg, so hold off briefly and let playback (and any
+// transcode) get established before adding more load.
+function scheduleStoryboardLoad(filepath) {
+    resetStoryboard();
+    if (!filepath) { return; }
+    var token = storyboardToken;
+    storyboardTimer = setTimeout(function () { loadStoryboard(filepath, token); }, 3000);
+}
+
+function loadStoryboard(filepath, token) {
+    var base = STORYBOARD_API + '?file=' + encodeURIComponent(filepath);
+    fetch(base)
+        .then(function (r) { return r.json(); })
+        .then(function (meta) {
+            if (token !== storyboardToken) { return; }   // switched file while loading
+            if (!meta || meta.error || !meta.interval || !meta.tileWidth) { return; }
+            var img = new Image();
+            img.onload = function () {
+                if (token !== storyboardToken) { return; }
+                meta.url   = img.src;
+                storyboard = meta;
+            };
+            img.src = base + '&image=1';
+        })
+        .catch(function () {});   // previews are optional — stay silent on failure
+}
+
+// Whole-file duration, whichever playback mode is active
+function scrubTotalDuration() {
+    if (isTranscodedVideo) { return transcodeDuration; }
+    return vid.duration || 0;
+}
+
+function initScrubPreview() {
+    var $wrap = $('#progress-wrap');
+    $wrap.on('mousemove', function (e) {
+        var total = scrubTotalDuration();
+        var rect  = this.getBoundingClientRect();
+        if (!total || !isFinite(total) || rect.width <= 0) { hideScrubPreview(); return; }
+        var ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
+        showScrubPreview(ratio * total, ratio, rect.width);
+    });
+    $wrap.on('mouseleave', hideScrubPreview);
+}
+
+function showScrubPreview(time, ratio, barWidth) {
+    $('#scrub-preview-time').text(formatTime(time));
+
+    var $thumb = $('#scrub-preview-thumb');
+    var halfWidth;
+    if (storyboard && storyboard.url) {
+        var idx = Math.max(0, Math.min(storyboard.count - 1,
+                                       Math.floor(time / storyboard.interval)));
+        var col = idx % storyboard.cols;
+        var row = Math.floor(idx / storyboard.cols);
+        $thumb.addClass('ready').css({
+            'width':               storyboard.tileWidth + 'px',
+            'height':              storyboard.tileHeight + 'px',
+            'background-image':    'url("' + storyboard.url + '")',
+            'background-position': (-col * storyboard.tileWidth) + 'px '
+                                 + (-row * storyboard.tileHeight) + 'px'
+        });
+        halfWidth = storyboard.tileWidth / 2;
+    } else {
+        // Sheet not ready (or unavailable) — the timestamp alone is still useful
+        $thumb.removeClass('ready');
+        halfWidth = 28;
+    }
+
+    // Keep the popup within the bar rather than letting it hang off an edge
+    var x = Math.max(halfWidth, Math.min(barWidth - halfWidth, ratio * barWidth));
+    $('#scrub-preview').css('left', x + 'px').addClass('active');
+}
+
+function hideScrubPreview() { $('#scrub-preview').removeClass('active'); }
+
+// ── Transcode seek (seek-by-reload) ───────────────────────────────────────────
+// Formats the browser can't decode are streamed through ffmpeg, so "seeking"
+// means restarting the transcode at a new offset. Replacing .src blanks the
+// <video> to black until the first frame of the new segment arrives, which can
+// take seconds. Freeze the last painted frame under a spinner instead, and only
+// reveal the live element once it is genuinely playing again.
+var seekFreezeTimer = null;
+
+function showSeekFreeze() {
+    var canvas = document.getElementById('seek-freeze');
+    if (canvas && vid.videoWidth > 0 && vid.videoHeight > 0) {
+        canvas.width  = vid.videoWidth;
+        canvas.height = vid.videoHeight;
+        try {
+            canvas.getContext('2d').drawImage(vid, 0, 0, canvas.width, canvas.height);
+            $(canvas).show();
+        } catch (e) {
+            // No decodable frame right now (e.g. seeking again mid-buffer) —
+            // leave whatever is already frozen on screen rather than flashing black.
+        }
+    }
+    $('#seek-spinner').addClass('active');
+    clearTimeout(seekFreezeTimer);
+    // Safety net: a stalled transcode must never leave the overlay stuck on
+    seekFreezeTimer = setTimeout(hideSeekFreeze, 30000);
+}
+
+function hideSeekFreeze() {
+    clearTimeout(seekFreezeTimer);
+    seekFreezeTimer = null;
+    $('#seek-freeze').hide();
+    $('#seek-spinner').removeClass('active');
+}
+
+// Restart the transcode stream at `seconds`, holding the last frame until the
+// new segment plays. Clamped to the known duration.
+function transcodeSeekTo(seconds) {
+    if (!currentFile) { return; }
+    var pos = Math.max(0, seconds);
+    if (transcodeDuration > 0) { pos = Math.min(transcodeDuration, pos); }
+
+    showSeekFreeze();
+    setSubtitleSeekFloor(pos);
+    transcodeSeekOffset = pos;
+    vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(currentFile.filepath)
+            + '&start=' + pos.toFixed(3);
+    vid.load();
+    vid.play();
+}
+
+// Current playback position in whole-file terms (transcode streams restart at 0)
+function effectivePlaybackTime() {
+    return isTranscodedVideo ? (vid.currentTime + transcodeSeekOffset) : vid.currentTime;
+}
+
 // ── Video controls ────────────────────────────────────────────────────────────
 function initVideoControls() {
     var $prog  = $('#progress-bar');

+ 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>

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

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

+ 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));

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

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

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

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

+ 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 };
+})();