After picking an image, a modal opens with cropperjs's drag-handle
crop UI and Rotate left / Rotate right / Reset buttons. Cropping
to just the text region is the single biggest quality win for
OCR on phone photos — strips out logos, lighting transitions, and
background junk that bleed past the confidence filter.
- Lazy-loads cropperjs + its CSS from esm.sh on first use (no
bundling; same pattern as Tesseract.js and CodeMirror).
- Honours EXIF orientation from phone shots (checkOrientation).
- Keyboard: Esc cancels, Enter accepts. Clicking the backdrop
also cancels.
- Output goes through the existing preprocessForOCR pipeline
(downscale to 1600px + Bradley adaptive threshold) before
Tesseract sees it.
Two layers to stop tesseract hallucinating text on phone photos:
1. Bradley adaptive threshold (O(N) via integral images) turns the
downscaled grayscale image into a binary B/W mask. Cuts out
gradients, shadows, and surface texture that tesseract
misreads as ink.
2. After recognise(), filter data.words by confidence >= 60 and
re-assemble lines from kept words. Status line surfaces how
many were dropped so the cost is visible.
Output of preprocess stage is PNG (not JPEG) so the sharp B/W
edges don't get re-blurred.
Phone cameras emit 12+ MP images (~4000 px long edge). Tesseract
on a full-resolution shot was painfully slow — minutes, not
seconds. Downscaling to 1600 px on the long edge before
worker.recognize() makes OCR 4-8× faster with no real accuracy
loss for printed text.
Implementation: createImageBitmap -> draw to a sized HTMLCanvas
-> canvas.toBlob() at JPEG 0.85. Smaller-than-1600 px inputs are
passed through unchanged. Status line briefly shows the
downscale ratio so the timing is interpretable.
With the radagast server flipped to OLLAMA_KEEP_ALIVE=-1, /api/ps
returns expires_at as year 4001 (effectively forever), which the
old code rendered as "Hot: gemma2:9b — unloads in 17500000h". Add
a 7-day threshold to formatRemaining(); anything beyond that just
shows the model name with no expiry suffix.
New "Scan image" button in the input pane header:
- Opens the phone's rear camera (capture=environment) or a desktop
file picker via a hidden <input type="file">.
- On image selected, lazy-imports tesseract.js from esm.sh — no
bundling, no Docker image growth.
- Language follows the "From" dropdown (ISO 639-2 mapping for the
30 currently-supported languages). "Auto-detect" loads the
eng+por+deu+nld pack and lets tesseract pick.
- Progress status renders below the input (engine load → OCR
percentage → "done in Xs").
- Result replaces the editor contents; existing vim bindings,
Shift-H/L nav, Ctrl-Enter translate all keep working.
First scan triggers a ~5 MB Tesseract WASM download plus the chosen
language pack (~3-10 MB). Cached by the browser after.
The vim clipboard integration was unreliable across browsers
(library "+" register broken on Firefox; custom navigator.clipboard
actions worked on Chrome but hit chip prompts and Firefox config
edge cases). The modal dropdown layer added more state than it
saved keystrokes. Pulling both back out.
What remains of the vim work:
- CodeMirror + @replit/codemirror-vim in the input buffer
(i/Esc, hjkl, word/line motions, yy/p via vim's internal
register, undo, /, :)
- Shift-H / Shift-L focus navigation across controls, including
the H/L override inside the editor so it works from any focus
- Replace Vim.map("...", "\"+...", ...) with custom Vim.defineAction
handlers calling navigator.clipboard directly. The library's "+"
register implementation doesn't reliably write on Firefox.
Limitation: only yy/Y/visual-y reach system clipboard; bare
y<motion> still uses vim's internal register.
- Flip dropdown modal: default NORMAL (j/k arrows), `/` or `i`
enters search. Listener moved to document capture phase so closed
dropdowns receive keydown on Firefox.
- y/Y/yy and visual y now route through vim's "+" register, which
copies to the system clipboard via the synchronous document
.execCommand("copy") path — no permission prompt.
- p/P also remapped to "+ register for symmetry; replaces my custom
navigator.clipboard.readText() action. Browser may still show a
permission chip on first paste; granting once makes it silent.
- Source / target / model dropdowns get modal vim-style nav:
default is native typeahead, Esc → normal mode (accent border),
j/k move selectedIndex, i and / return to search, blur resets.
Disabled on coarse-pointer devices.
Vim's default p/P pastes from its internal unnamed register, which
is useless when the text to translate lives in another app's
clipboard. Override both in normal mode to read navigator.clipboard
.readText() and insert at (after) or before the cursor.
Caveats: requires browser clipboard permission (prompted on first
use), HTTPS or localhost. Both already satisfied — finrod is served
over the wildcard TLS cert.
The previous version only handled Shift-H/L when focus was outside
the editor; inside vim normal mode they were viewport top/bottom
and required Esc+Tab to escape. Now Vim.mapCommand overrides H/L
in normal-mode context to call the same moveFocus(±1) helper, so
the shortcut works uniformly regardless of focus position.
- .cm-editor pinned to font-size 1rem so the input matches the
output pane (CodeMirror 6 defaults smaller)
- refreshLoaded(isInitial=true) on first load picks the hot model
as the dropdown default — avoids the 5-15s disk reload on the
first translation. Subsequent refreshes don't override the user's
manual selection.
- document-level Shift-H / Shift-L cycle focus across controls
(source → target → model → editor → button), suppressed when
focus is inside CodeMirror so vim's viewport H/L still work.
- Replace the plain <textarea> with a CodeMirror 6 EditorView that
has @replit/codemirror-vim attached. Loaded from esm.sh, so no
build step — finrod stays no-bundler.
- vim() extension is conditionally omitted on coarse-pointer devices
(phones, tablets) where modal editing without a hardware Esc key
is unusable and IME composition fights the keymap.
- Ctrl-/Cmd-Enter shortcut and the typing-debounce that refreshes
the "Hot:" indicator both migrate to CodeMirror's keymap and
updateListener APIs respectively.
- Editor styled to match the existing .output pane.
CHANGELOG: note under [Unreleased].
- CHANGELOG.md following Keep a Changelog; 0.1.0 entry covers all
features shipped through the loaded-model indicator
- build.sh now also tags the image with the version read from
pyproject.toml, e.g. rg.nl-ams.scw.cloud/valinor/finrod:0.1.0
- README documents the release workflow (edit CHANGELOG, bump
pyproject version, merge, tag vX.Y.Z, build, bump valinor)