Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b3ca31b
docs: plan version 2
devchaudhary24k Nov 9, 2026
bcdcbde
feat(pipelines): share pipelines as links and count their runs
devchaudhary24k Nov 9, 2026
0dcd91f
feat(built-in-tools): add ready-made tool pages for common jobs
devchaudhary24k Nov 9, 2026
1908e08
feat(nodes): keep a margin around the image in Pad / Extend
devchaudhary24k Nov 9, 2026
acb6b44
feat(images): open HEIC and HEIF photos
devchaudhary24k Nov 9, 2026
fddbfcc
feat(offline): work offline after the first visit
devchaudhary24k Nov 9, 2026
fcb954e
test(offline): wait for pages in the end-to-end tests
devchaudhary24k Nov 9, 2026
0255560
feat(handover): open images and .hexlode files with the installed app
devchaudhary24k Nov 9, 2026
25de4ac
Merge branch 'v2/offline' into feat/version-2
devchaudhary24k Nov 9, 2026
23519e2
docs: list built-in tools among the offline and open-with pages
devchaudhary24k Nov 9, 2026
f6c8648
feat(previews): draw thumbnails of images the browser cannot show
devchaudhary24k Nov 9, 2026
11dc9f9
Merge branch 'v2/heic' into feat/version-2
devchaudhary24k Nov 9, 2026
89dade4
feat(built-in-tools): add HEIC to JPG
devchaudhary24k Nov 9, 2026
7e4a530
feat(background-removal): cut out image subjects on the device
devchaudhary24k Nov 9, 2026
a40a372
docs: record on-device background removal
devchaudhary24k Nov 9, 2026
e12f926
Merge branch 'v2/remove-background' into feat/version-2
devchaudhary24k Nov 9, 2026
48a93ad
test(quick-tools): run Remove background on every input format, HEIC …
devchaudhary24k Nov 9, 2026
78560bf
feat(built-in-tools): add product photos on a white background
devchaudhary24k Nov 9, 2026
90eba0f
fix(security): keep the model folder private and check message senders
devchaudhary24k Nov 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ jobs:
PORT=3000 pnpm start > server.log 2>&1 &
bash scripts/smoke.sh http://localhost:3000 || { cat server.log; exit 1; }

# The production build in Chromium: offline after one visit, shares and file launches.
- run: pnpm exec playwright install --with-deps chromium
- name: End-to-end tests
run: pnpm test:e2e

docker:
name: docker image
runs-on: ubuntu-latest
Expand Down
27 changes: 25 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,19 @@ building branching pipelines.
### Surfaces

**Quick tool**:
A single-purpose page (Convert, Compress, Resize, Crop, Rotate, Strip metadata) that runs a fixed
pipeline.
A single-purpose page (Convert, Compress, Resize, Crop, Rotate, Strip metadata, Remove
background) that runs a fixed pipeline.
_Avoid_: converter, feature page

**Pipeline tool**:
A saved pipeline opened as a quick tool page, without the canvas.
_Avoid_: custom tool, app

**Built-in tool**:
A pipeline tool that ships with Hexlode, on its own page at `/tools/<slug>`, for a job people
search for by name and repeat.
_Avoid_: preset, landing page, recipe

**Studio**:
The desktop editor where users build pipelines on a canvas.
_Avoid_: canvas (as the product name), workspace, editor
Expand Down Expand Up @@ -56,6 +61,10 @@ _Avoid_: autosave, backup
A pipeline exported as a `.hexlode` file.
_Avoid_: recipe file, export

**Pipeline link**:
A link to Hexlode that carries a whole pipeline file, compressed, after its `#`.
_Avoid_: share URL, permalink

### Running

**Run**:
Expand Down Expand Up @@ -104,6 +113,20 @@ _Avoid_: preview image, test image
The predicted work and duration of a run, shown before it starts.
_Avoid_: forecast, quote

**On-device model**:
A machine learning model Hexlode runs in the browser, such as the one Remove background uses. It
downloads once from Hexlode's own server, after the user agrees, and stays in the browser.
_Avoid_: AI, cloud model

**Inference worker**:
The one worker per page that runs on-device models, one image at a time, for every engine and
preview worker, so the page holds a single copy of each model.
_Avoid_: model worker, AI worker

**Cut-out**:
An image whose background Remove background made transparent, leaving its subject.
_Avoid_: matte, sticker, mask (the mask is what the model returns)

### Delivery

**Output node**:
Expand Down
11 changes: 11 additions & 0 deletions DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,22 @@ runs with analytics and error reports off.
| `VITE_SENTRY_DSN` | Sentry DSN. Turns on error reports, logs and tracing in the browser and on the server. |
| `HEXLODE_ENVIRONMENT` | `staging` on the staging application. Leave it unset in production, which reports as `production`. |
| `PORT` | The port the server listens on. Defaults to `3000`; change the domain's port to match. |
| `HEXLODE_MODEL_DIR` | Where the server keeps on-device models it serves under `/models/`. Defaults to a folder in the system's temporary directory. |

The `VITE_*` values are public: they reach every visitor's browser. Never put a secret in a
variable that starts with `VITE_`. A change takes effect on the next deploy or restart. The image
sets `HEXLODE_VERSION` itself; Sentry and PostHog label reports and events with it.

The first request for the background removal model makes the server fetch it once (94 MB) from
its pinned Hugging Face revision, so the container needs outbound HTTPS to `huggingface.co`. The
server checks the file's SHA-256 before keeping and serving it, and browsers never contact Hugging
Face (ADR 0017). The file lives in `HEXLODE_MODEL_DIR`, which a new container starts without; to
skip the fetch after each deploy, mount a volume there in Dokploy (**Advanced → Volumes**), for
example at `/models-cache`, and set `HEXLODE_MODEL_DIR=/models-cache`. The folder must belong to
the user the container runs as (`nonroot`, uid 65532) and must not be writable by other users; the
server refuses any other folder and answers model requests with an error, since it sends what is
in the folder to every visitor. It also checks a kept file's SHA-256 once after each start.

The Sentry organisation, project and auth token are not runtime settings. They are only needed
where the image is built, to upload source maps; see [Readable Sentry stack traces](#readable-sentry-stack-traces).

Expand Down
11 changes: 11 additions & 0 deletions docs/adr/0013-pipeline-links-carry-the-pipeline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Pipeline links carry the whole pipeline

A pipeline link puts the pipeline file, deflated and written in base64url, after the `#` of a link
to `/tools/shared`. Browsers never send the part after `#` to a server, so sharing a pipeline needs
no storage, no accounts and no server code, and Hexlode never holds a copy of anyone's pipeline.
The cost is length: a link stays under 7,800 characters after the `#`, which chat apps, email and
every browser keep intact. Templates make links of a few hundred characters; a pipeline with a
large watermark logo does not fit and is shared as a `.hexlode` file instead. The part after `#`
starts with its own version, apart from the file's, so the packing can change while old links keep
opening, and a link unpacks to at most 2 MB, so a crafted link cannot fill the memory. Once a cloud
mode exists, short links stored on a server can sit beside these without replacing them.
17 changes: 17 additions & 0 deletions docs/adr/0014-built-in-tools-are-pipelines-in-code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Built-in tools are pipelines in code, chosen for real jobs

Built-in tools are pipelines defined in `src/features/built-in-tools/tools.ts` and shown with the
same page as saved pipeline tools, at `/tools/<slug>`. Saved pipelines keep `/tools/<id>` with a
random UUID, so the two never collide, the page renders on the server for built-in slugs only, and
saved pipeline pages ask not to be indexed because their address means nothing outside one
browser. A tool can show the settings of chosen nodes, such as the watermark text, and can name
what must be set before it runs, such as a logo; everything else stays as the pipeline defines it,
and Open in the Studio carries the changed settings over.

The list is short on purpose. A job earns a page only when people search for it by name in large
numbers, repeat it, and Hexlode does all of it on the device better than one quick tool. That rules
out a page per format pair: Convert already does PNG to JPG. The two exceptions, HEIC to JPG and
WebP to JPG, exist because the source format is the problem people search for (phones save HEIC,
websites serve WebP, and many apps open neither), and WebP to JPG also fills transparency with
white instead of black. Tools that follow a platform's rules name the guidance and the date it was
checked on the page, so a reader can see when the numbers might be stale.
58 changes: 58 additions & 0 deletions docs/adr/0015-service-worker-for-offline-use.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# A service worker keeps the app working offline

After one online visit, Hexlode opens and runs without a connection. One service worker at
`/sw.js` does this, registered in production builds only. A Vite plugin
(`src/features/offline/build-plugin.ts`) lists every file of the client build and of `public/` once
the bundle is complete, leaves out source maps and share cards, and bundles the worker
(`src/features/offline/service-worker.ts`) into one classic script with that list and a version
written in. The version is a hash of every listed file's path and contents, so any changed file
changes the worker's bytes and the browser installs the new worker on the next online visit.

On install the worker downloads every listed file into `hexlode-app-<version>`, codecs the visitor
has not used included, and the main pages into `hexlode-pages-<version>`: the home page, the quick
tools, the Studio, the privacy page, the open page and the app shell. It then takes over at once
and deletes the two caches of earlier versions. It never deletes another cache, such as the on-device model's.

Page loads go to the network first, and the worker keeps a copy of each page that loads only this
build's scripts. Offline it returns that copy, the same page with another query, or the app shell:
`/app-shell`, a route with server rendering turned off, so the server renders the root route alone
and the router renders the requested page in the browser. Build files come from the cache. The
worker answers nothing else, so `/api/` (health and auth), `/models/`, server functions, the Sentry
tunnel, PostHog and every other origin reach the network untouched.

The same worker receives photos shared to the installed app on Android at `/share-target`, keeps
them in `hexlode-share` until Strip metadata takes them, and deletes them then.

## Considered options

- **Workbox or vite-plugin-pwa.** Mature, but a new dependency for a worker of a hundred lines,
and both expect a static `index.html` to fall back to. Hexlode renders every page on the server
with its deployment's settings (ADR 0008).
- **TanStack Start's SPA mode for the shell**, which prerenders `/_shell.html` during the build.
Nitro lists its static files before the prerender runs, so the server answered 404 for the shell;
in the dev server the option renders every page as the shell; and the shell carried the build's
settings instead of the deployment's. A route without server rendering gives the same page,
rendered on each request.
- **Cache first for pages.** Faster, but a deploy would show only on the second load. Network first
shows a new deploy on the next online load.
- **Waiting for every tab to close before a new worker takes over**, the default. An installed app
left open for days would keep its old worker. Pages load from the network first either way, so
taking over at once strands no page on an old build.

## Consequences

- The first visit downloads about 20 MB in the background, 16 MB of it codec WebAssembly, including
multi-threaded and SIMD variants a browser may never use.
- `pnpm dev` and the Vitest suites never register a worker. `pnpm test:e2e` checks the production
build offline.
- A page served by a newer deploy than the active worker is not kept, because its scripts are not
in the worker's cache. Offline, the earlier copy opens instead.
- A tab left open across a deploy can fail to load a part of the app it has not loaded yet, as it
could without a worker, because the new worker deletes the old build's files.
- If the worker is missing, as after the site's data was cleared, a share from Android reaches the
server. The server route answers with Strip metadata and reads none of the photos.
- The privacy page says what the copy holds and how to remove it.
- ONNX Runtime's WebAssembly for Remove background (about 41 MB across its two builds) is not
downloaded on install, since it is only needed after the user agrees to the model download
([ADR 0017](./0017-on-device-background-removal.md)). The worker keeps it the first time it is
used, in the same versioned cache, so background removal works offline from then on.
57 changes: 57 additions & 0 deletions docs/adr/0016-heic-decoder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# HEIC is decoded with libheif in WebAssembly and never written

HEIC and HEIF photos, the iPhone default, open wherever other images do. We decode them with
libheif and its libde265 HEVC decoder, compiled to WebAssembly by `libheif-js`, loaded in the
workers the first time a HEIC image needs pixels, like the jSquash codecs
([ADR 0002](./0002-jsquash-wasm-codecs.md)). Hexlode serves the loader (88 KB) and the `.wasm`
(1.46 MB, 490 KB compressed) itself, as files of their own. It never encodes HEIC: a HEIC image
whose pixels changed and that reaches Output, or Convert or Responsive set kept at their own
format, is saved as JPEG, or as PNG when it has transparency, and the run warns. Compress to size
makes it JPEG, since a target size needs a lossy format. A HEIC image whose pixels did not change
keeps its original bytes, and Strip metadata and Set copyright edit its metadata in place.

## Considered options

- **`libheif-js` (chosen).** It follows libheif releases (1.23.5, October 2026), gives raw RGBA
pixels of the primary image, and ships the `.wasm` beside its loader, so the browser compiles it
while it downloads. Its build compiles synchronously unless handed a module, so we compile it
first and instantiate it inside the loader's call.
- **`heic-to`.** The same libheif, also maintained, but it returns canvas blobs or bitmaps and
inlines the WebAssembly as base64 in multi-megabyte bundles.
- **`heic2any`, `elheif`, `@saschazar/wasm-heif`.** Older libheif builds, unmaintained since 2021
to 2024; `heic2any` also decodes on the main thread.
- **`heic-decode`.** A thin wrapper over the inlined `libheif-js` bundle; it adds nothing we need.
- **Safari's own decoder through `createImageBitmap`.** Only Safari has one, and it would make
pixels differ by browser. Left out; it could later skip the download there.
- **Writing HEIC.** It needs an HEVC encoder such as x265 (GPL) and carries the patent question
below for encoding as well. Out of scope.

## Orientation

HEIF stores rotation, mirroring and cropping as `irot`, `imir` and `clap` properties of the image,
and libheif applies them, so decoded pixels are upright and the header reader reports the size
after them. Phones also write the EXIF orientation tag, which HEIF readers ignore. Reading HEIC
metadata sets that tag to 1, changing only its value, so a HEIC item has orientation 1, Rotate's
auto mode and Crop leave the rotation alone, and other formats get an upright tag.

## Metadata

EXIF and XMP live in items of the `meta` box, as in AVIF, and the colour profile in the primary
image's `colr` property. One HEIF module reads both formats; AVIF now carries its colour profile
too. Edits happen in place: removing and shrinking work, adding does not, and the run reports what
could not be kept. The colour profile describes the coded image, so it stays in the file even when
Strip metadata is told to remove it; other formats get it, so colours do not shift.

## Consequences

- **Licence.** libheif and libde265 are LGPL-3.0. They stay separately loaded files, and
`/licenses/jsquash.txt` holds their licence and links to the source of the exact versions. That
source lives upstream on GitHub; if it ever disappears, Hexlode must host it.
- **Patents.** HEVC is covered by patents licensed through pools such as Access Advance and Via LA,
and their terms can cover software decoders a product distributes. Browsers avoid shipping their
own HEVC decoder for this reason. Hexlode sends libde265 to every visitor who opens a HEIC
photo, free and without encoding. This is a risk the maintainer accepts or rejects; removing
HEIC means dropping the decoder and the `heic` format, nothing else depends on them.
- Only the primary image is decoded. Thumbnails, bursts, depth maps and HDR gain maps are not,
and images with more than 8 bits per channel come out at 8.
- Decoding is slow next to other formats, about 70 ms per megapixel, which the estimate counts.
Loading
Loading