Commit Graph

22 Commits

Author SHA1 Message Date
João Pedro Battistella Nadas
a63b11e467 feat(ocr): live camera viewfinder + invert-colours toggle
Four wins:

1. Replace <input type=file capture> with a getUserMedia
   viewfinder modal on mobile. Tapping the shutter goes straight
   to the crop modal — skips the OS "retake/use" confirm step.
   Falls back to file picker if camera permission is denied or
   the API is unavailable. "Pick from file" icon
   (photo_library) inside the camera modal opens the gallery
   on demand.

2. Drop the Reset button — with no default crop box, "reset" is
   just "no box again," which is the starting state anyway.

3. The check (Use this) button is now larger and more prominent
   — wider padding, 24px icon, accent fill.

4. Invert-colours toggle (invert_colors icon) in the crop modal.
   White-on-dark text (signs, menus, screenshots) breaks
   Tesseract because the binariser was made for ink on paper.
   Toggling the button flips the grayscale buffer before
   adaptive thresholding so the binary output ends up
   black-on-white. State plumbed cleanly: openCropper now
   resolves with {blob, invert}; preprocessForOCR takes an
   {invert} option; binarizeAdaptive applies the flip.

Also: drop `capture=environment` from the file <input>. Without
it the file path correctly opens the gallery/file dialog instead
of re-opening the OS camera when the user picks "Pick from file".
2026-06-09 19:27:34 +02:00
João Pedro Battistella Nadas
cc249fb444 ux(ocr): Material Symbols icons, no default crop box, more padding
- Material Symbols Outlined webfont (Google Fonts) — used for the
  FAB camera (photo_camera), and the rotate_left / rotate_right /
  restart_alt / check buttons in the crop modal. Replaces the
  platform-rendered 📷 emoji that was off-centre, and replaces
  text labels on rotate/use buttons. Cancel stays text.
- Cropper: autoCrop=false — no default selection. User drags on
  the image to draw the crop region; if they "Use this" without
  drawing, falls back to the full (possibly rotated) image.
- Modal-body padding bumped from 1.25 rem to 2 rem so the
  bottom-right corner handle sits ~48 px from the screen edge,
  outside Android's edge-swipe back-gesture zone.
- Header copy updated to "Drag on the image to select the text
  region" since there's no longer a default box to convey intent.
2026-06-09 19:18:52 +02:00
João Pedro Battistella Nadas
a4064d0d72 ux(ocr): camera FAB + modal padding for Android edge gestures
- Scan button is now a 📷 floating action button at bottom-right
  of the viewport (camera emoji per explicit request). Replaces
  the small "Scan image" button in the input header.
- Crop modal: inner padding on .modal-body so cropperjs's
  bottom-right corner handle isn't on top of Android's edge-swipe
  back-gesture zone. Backdrop also respects iOS safe-area insets
  (env(safe-area-inset-{right,left,bottom})).
2026-06-09 19:10:22 +02:00
João Pedro Battistella Nadas
74b8877550 feat(ocr): crop + rotate modal before recognition
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.
2026-06-09 19:02:51 +02:00
João Pedro Battistella Nadas
4d4e351860 feat(ocr): adaptive binarisation + per-word confidence filter
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.
2026-06-09 18:54:27 +02:00
João Pedro Battistella Nadas
dcac01e51b perf(ocr): downscale photos to 1600px long edge before recognize
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.
2026-06-09 18:48:40 +02:00
João Pedro Battistella Nadas
6d6b52e10e fix: don't show "unloads in" when keep_alive=-1
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.
2026-06-09 18:47:52 +02:00
João Pedro Battistella Nadas
123ce59595 feat: OCR a camera shot / image into the input via Tesseract.js
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.
2026-06-09 18:42:19 +02:00
d4a033e500 Merge pull request 'v0.2.0: vim bindings, Shift-H/L focus nav, hot-model default' (#3) from feature/vim-bindings into main v0.2.0 2026-06-09 15:22:41 +00:00
João Pedro Battistella Nadas
163f8dda57 release: v0.2.0 2026-06-09 17:22:26 +02:00
João Pedro Battistella Nadas
2d067226b9 revert: drop clipboard yank/paste and modal dropdowns
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
2026-06-09 17:07:01 +02:00
João Pedro Battistella Nadas
73aa54d7bd fix: Firefox clipboard, flip dropdown default to normal mode
- 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.
2026-06-09 16:53:11 +02:00
João Pedro Battistella Nadas
b092047ca6 feat: y/Y system clipboard, modal vim dropdowns
- 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.
2026-06-09 16:47:33 +02:00
João Pedro Battistella Nadas
a058665b5b feat: vim p/P paste from system clipboard
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.
2026-06-09 16:41:52 +02:00
João Pedro Battistella Nadas
876014a7b7 fix: Shift-H/L also exit the editor in vim normal mode
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.
2026-06-09 16:39:05 +02:00
João Pedro Battistella Nadas
9564900909 feat: editor font-size match, hot model default, Shift-H/L nav
- .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.
2026-06-09 16:22:20 +02:00
João Pedro Battistella Nadas
782c7182f2 feat: vim bindings in the input via CodeMirror 6
- 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].
2026-06-09 16:15:36 +02:00
João Pedro Battistella Nadas
f9acdef3fe chore: introduce semver + CHANGELOG.md, cut v0.1.0
- 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)
v0.1.0
2026-06-09 15:55:09 +02:00
João Pedro Battistella Nadas
732982e66c feat: show which model is currently loaded in Ollama
- /api/loaded backend endpoint proxies Ollama /api/ps and returns
  {models: [{name, expires_at}]}
- Status line under the controls shows "Hot: gemma2:9b — unloads in 4m"
  with a glowing orange dot, or "No model loaded" in muted style
- Loaded models in the dropdown get a "● " prefix
- Refreshes on page load, after every translation, and 500ms after the
  user pauses typing in the input box
2026-06-09 15:51:57 +02:00
João Pedro Battistella Nadas
cc92d0ce49 fix: lock against public PyPI so the image builds outside the workstation
uv.lock was originally resolved with the workstation's globally-configured
dexterenergy private artifact registry as the default index, baking that
URL into every `source = { registry = ... }` entry. Inside Docker that
401s.

- pyproject.toml: declare https://pypi.org/simple as the project's
  default uv index (overrideable, but pins intent)
- uv.lock: regenerated with UV_DEFAULT_INDEX=https://pypi.org/simple and
  no UV_INDEX so every source now points at public PyPI

Reproduce: `UV_DEFAULT_INDEX=https://pypi.org/simple UV_INDEX= uv lock`
2026-06-09 15:35:58 +02:00
João Pedro Battistella Nadas
94969a0f46 drop Gitea Actions workflow (no runners on the homelab yet)
Falls back to ./build.sh from the workstation. Re-add the workflow
when Gitea runners are wired up.
2026-06-09 15:26:30 +02:00
João Pedro Battistella Nadas
777c80a0e1 initial commit: FastAPI translator proxying to Ollama on radagast
- app.py: /api/models filtered to gemma+aya, /api/translate streams
  NDJSON from Ollama with stream:true, /healthz for k8s probes
- static/index.html: dark-mode UI, three dropdowns (source/target/model),
  live token streaming with spinner+pulse+timer indicators
- Dockerfile: two-stage uv build, slim Python 3.13 runtime
- .gitea/workflows/build.yml: multi-arch (amd64+arm64) build, push to
  Scaleway registry on push to main
- build.sh: manual fallback if Gitea Actions isn't available
2026-06-09 15:13:48 +02:00