Skip to content

feat: replace 2× supersampling with 1× bitmap fonts and explicit dithering - #80

Open
ghcpuman902 wants to merge 19 commits into
mainfrom
feat/pixel-font-rendering
Open

feat: replace 2× supersampling with 1× bitmap fonts and explicit dithering#80
ghcpuman902 wants to merge 19 commits into
mainfrom
feat/pixel-font-rendering

Conversation

@ghcpuman902

@ghcpuman902 ghcpuman902 commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

Rebased onto main after #78 (palette SSOT, hard-snap device quantizer, image intercept). This PR keeps bitmap fonts + 1× rendering and replaces the overlapping image-dither paths with one device-aware surface.

Render pipeline

Removed / dropped (now on main or redundant):

  • 2× supersampling (getRenderScale, settings.ts, per-recipe supersample)
  • Frame-wide Floyd–Steinberg (renderSettings.dither)
  • Duplicate inline device-image quantizer and getPaletteGrayLevels copy

Kept from this PR:

  • Bitmap font system (v2 format, BitmapText, designer, curated packs)
  • 1× Takumi/Satori rasterize (no sharp downscale)

Unified image dithering (new):

  • Single entry: prepareDeviceImage / prepareDeviceImageFromContext
  • Author picks method: bayer (default), white-noise, floyd-steinberg, atkinson, threshold, snap, none
  • <img> auto-prep + prepareForDevice share the same implementation
  • Grayscale → dither to N levels with Lab L* luminance
  • Color e-ink → Bayer/FS/snap to palette colors (bayerRgbToPalette)
  • <DeviceImage> component documents author intent; album/wikipedia updated

Authoring:

  • responsive-example: dither-* patterns on 1-bit BW; solid Tailwind colors on color screens
  • docs/trmnl/eink-palette-mapping.md maps TRMNL device families ↔ palettes.json

Follow-up fixes (post-rebase):

  • Biome lint cleanup for CI
  • Database setup UX: when only pending migrations remain (e.g. 0021 from main), show Apply pending migrations + pending SQL only — not full “Initialize database”

Expected behaviour

  • Text/icons: crisp (full-frame snap, no FS)
  • Photos: retro Bayer crosshatch by default; FS opt-in for smooth gradients
  • Color panels (Spectra/ACeP): photos dither to palette colors, not gray
  • Local dev after rebase: apply pending migrations from dashboard, then refresh

Test plan

  • pnpm lint / pnpm typecheck / pnpm test (136) / pnpm build
  • /recipes/responsive-example on BW vs color-6a — dither panels vs solid colors
  • /recipes/album, /recipes/wikipedia — photo prep on gray + color palettes
  • /recipes/simple-text, /recipes/bitmap-patterns — BitmapText preview
  • Bitmap font designer export + /api/bitmap-fonts/[packId]
  • Dashboard pending-migration flow (0021) — apply + refresh restores devices

Notes for reviewers

Resolves overlap with #78’s image intercept: same wiring, default algorithm is now Bayer (not FS). Floyd–Steinberg remains available via renderSettings.imageDither: "floyd-steinberg" or prepareForDevice method.

@ghcpuman902 ghcpuman902 self-assigned this Jun 25, 2026
@ghcpuman902

Copy link
Copy Markdown
Collaborator Author

Render pipeline — commit trail

For reviewers tracing the 2× removal vs new dithering:

Commit What changed
6794844 Removes 2× supersampling: deletes lib/recipes/render/settings.ts, drops supersample: true from recipe meta, renders at 1× in rasterize.ts (no sharp downscale), flips BMP applyEdgeSnap default to false, wires opt-in renderSettings.dither into renderDeviceImage.
7fbadfb Adds explicit photo dithering: ditherImageToDataUrl + prepareForDevice hook on album/wikipedia; white-noise dither tuned to palette gray levels (getPaletteGrayLevels). This replaces the old “dither everything at BMP export” approach for embedded images.
37e98f0+ Bitmap font path for crisp 1× text (designer, packs, BitmapText, weather BitmapMarker).

What was removed (the old blanket pipeline):

// lib/recipes/render/settings.ts (deleted)
export const SUPERSAMPLE_SCALE = 2;
// recipes: renderSettings: { supersample: true }
// rasterize: render 2× → sharp.resize(1×) → BMP with edge-snap

What replaced it:

  • Text → pixel font packs at 1× (not downscaled vector)
  • Photos → prepareForDevice dithers source images before they enter JSX
  • Full-frame palette quantization → Floyd-Steinberg only when renderSettings.dither: true (currently unused; default off)

Release: merge to main → release workflow auto-bumps patch, tags v*.*.*, publishes GitHub Release + Docker. No version change in this branch; do not [skip release] unless intentionally holding back a user-visible release.

@ghcpuman902 ghcpuman902 changed the title feat: pixel font rendering, dithering, and recipe text crispness feat: replace 2× supersampling with 1× bitmap fonts and explicit dithering Jun 25, 2026
@ghcpuman902
ghcpuman902 requested a review from rbouteiller June 25, 2026 23:25

@rbouteiller rbouteiller left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems we have some scripts repeated in scripts/lib and lib/bitmap-font, maybe we want an unified surface

Comment thread tmp/geist.v2.json Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file seems not needed and not referenced in the code, should we delete it ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — deleted. It was a one-off trace/debug output, not referenced anywhere.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SHould we commit generated files ? Or have a generation step in readme ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Short answer: yes, commit the packs — but treat them as curated artifacts, not build output.

(I saw you removed generated schema artifacts in #78 in favor of validation at runtime — same instinct applies here, but font packs are different: the trace output is lossy and we hand-tune in the designer before commit.)

The trace pipeline (pnpm generate:fonts) is useful as an assistant to bootstrap a pack, but it is not reliable enough to run unattended or overwrite committed JSON on every build:

  1. Lossy tracing — many “pixel vibe” web fonts do not snap cleanly to a grid; gaps, x-height, and descenders often need manual fixes in the bitmap font designer.
  2. Missing glyphs — symbols and edge-case characters frequently need to be added or redrawn by hand after generation.
  3. Algorithm limits — grid discovery and metrics derivation work for some sources (e.g. Geist Pixel) but not universally; we keep hitting font-specific issues.

So the practical workflow today is: trace → human pass in the designer → commit the tuned JSON. Generation is a starting point, not the source of truth.

This PR updates docs (docs/pixel-font.md §17) and removes generate:fonts from prebuild / dev so a normal pnpm build does not clobber hand-edited packs. Regeneration stays available explicitly via pnpm generate:fonts when adding a new font or re-bootstrapping from source.

Longer term (not blocking this PR): either improve tracing until it is good enough to automate, or drop generation entirely and treat all packs as hand-authored in the designer. Happy to discuss which direction you prefer.

@ghcpuman902

Copy link
Copy Markdown
Collaborator Author

Agreed — the duplicated modules were a mistake from porting trace logic to TS for runtime/tests while leaving .mjs copies for the generator scripts.

This commit removes the duplicates in scripts/lib/{convert-legacy-font,decode-cell-data,metrics-derive,trace-core}.mjs and has the scripts import from lib/bitmap-font/*.ts instead (Node 22 strip-types + a small resolve hook). Script-only I/O stays in scripts/lib/ (trace-glyph-node, discover-pixel-grid, trace-layout).

lib/bitmap-font/ is the single surface for runtime + tests; scripts are thin callers.

@ghcpuman902

ghcpuman902 commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

Sorry, I should have read #78 more carefully before pushing #80; I've now gone through it properly. I agree we should rethink the render path together. #78 reworks the palette/grayscale model my dithering sits on, so let's land #78 first; I'll then rebase #80 on top and reconcile dithering with palette-derived levels.

For future reference:

Dimension TRMNL Framework BYOS Next (this repo)
Authoring Specialized HTML + utility classes (bg--gray-50, image-dither, layout--row) React recipes first; Liquid/HTML for legacy plugins
Default renderer Chrome + plugins.css / plugins.js + Framework Runtime post-processing Takumi/Satori (no browser head) → PNG → sharp palette quantization
Parity lane Same browser stack everywhere REACT_RENDERER=browser or Liquid → Puppeteer (optional, heavier)
Dithering Declarative CSS (image-dither); device preview rewrites tokens by bit depth Server-side (dither-image.ts, device-image.ts) + PreSatori dither-* patterns
Palette truth SCSS tokens with per-bit-depth overrides palettes.json + getPaletteGrayLevels / Lab nearest-color
Layout Fixed Screen → View → Layout hierarchy + CSS variables ScreenProfile logical canvas + React primitives (ScreenCanvas, etc.)
Fonts Framework bundles (NicoClean, BlockKie, …) via CSS Vector via Takumi + curated bitmap font packs (BYOS-specific)

Reference: TRMNL Framework v3.1 Image docs, Structure, Colors.

Addressed review feedback:

  • Removed tmp/geist.v2.json
  • Committed font packs as curated artifacts; documented curation workflow (docs/pixel-font.md §17); stopped auto-regenerating packs on build
  • Unified scripts/lib duplicates into lib/bitmap-font
  • Synced with main (sign-in reliability + layout conflicts resolved)

Rendering alignment proposal:

  • keep palettes.json as single source of truth;
  • dither levels from getPaletteGrayLevels(palette) rather than an independent grayscale knob;
  • fold Fix/remove grayscale parameter #78's palette-gray-levels.ts into palette-colors.ts if it duplicates palettes.json.grays.

Our default path stays Takumi-first (no browser head); palette quantization + dither levels follow the palette the same way Framework image-dither follows device bit depth.

@rbouteiller

Copy link
Copy Markdown
Collaborator
  • keep palettes.json as single source of truth;

Agree

  • dither levels from getPaletteGrayLevels(palette) rather than an independent grayscale knob;

Yes

I thought it was already the case, will fix

Our default path stays Takumi-first (no browser head); palette quantization + dither levels follow the palette the same way Framework image-dither follows device bit depth.

Yes exactly

@ghcpuman902

ghcpuman902 commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

Aligned on palette SSOT and getPaletteGrayLevels — see implementation notes in #78 (routing, FS vs hard quantize).

Scope split:

#78 — foundation

  • Remove Device.grayscale; palette drives levels
  • getPaletteGrayLevels(palette) from palettes.json; fix grayscale routing
  • Default device quantizer: hard quantize to palette (no Floyd–Steinberg on full frame)
  • Device model, preview, log rendering, registry validation, TRMNL X image-size budget

#80 — this PR (rebase after #78)

  • Bitmap / pixel font system (v2, BitmapText, designer, curated packs)
  • Remove 2× supersampling + downscale pipeline
  • Font script dedup, curation docs, review fixes (already pushed)

#80 — image dither (opt-in only, not global FS)
Multi-screen / multi-palette support means the default path must prioritize text and SVG icon sharpness over tonal smoothing. FS on the whole frame works against that.

For embedded photos only, authors choose:

  • pre-render / swap assets per device (conditional classes, separate files), or
  • opt-in server dither via prepareForDevice (Bayer or white-noise — local, CPU-friendly; threshold quantize for 1-bit) — not Floyd–Steinberg on the full Takumi output.

@rbouteiller

Copy link
Copy Markdown
Collaborator

@ghcpuman902 Merged the others PR, there might be some redundancy around image dithering

ghcpuman902 and others added 13 commits July 1, 2026 09:50
- Introduced a new utility function `formatErrorMessage` to standardize error messages across the application.
- Updated `executeSqlStatements` and database utility functions to utilize the new error formatting.
- Revised README to clarify local development setup, including database configuration and initial setup instructions.
- Added new scripts for generating bitmap fonts and benchmarking font performance.
- Updated package.json to include new font generation commands and dependencies.
- Improved no-default-recipe error messaging and device add dialog UX.
- Introduced new font styles in global CSS and updated components to utilize these styles.
- Improved sign-in form with password visibility toggle for better user experience.

Co-authored-by: Cursor <cursoragent@cursor.com>
TRMNL firmware calls /api/setup with MAC only (no Access-Token). Allow
pre-registered devices imported from trmnl.com/devices to complete setup,
add a MAC field when adding devices manually, and log incoming request
headers to system logs for debugging.

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

- Introduced AGENTS.md to provide a comprehensive index for Next.js documentation, emphasizing the importance of consulting the docs before tasks.
- Added a new script command for benchmarking font glyphs in package.json.
- Enhanced bitmap font designer with new utility functions for handling font metrics and improved grid handling.
- Updated bitmap font JSON files to support dynamic width and adjusted metrics for better rendering.
- Introduced new generated bitmap font files for improved design consistency.
- Added new font variable `--font-pixelify-sans` to global CSS for improved typography options.
- Updated `RootLayout` to suppress hydration warnings for better rendering consistency.
- Enhanced `SimpleText` component to utilize `Pixelify Sans` font and added support for new bitmap font data.
- Improved bitmap font designer with additional utility functions and updated editor UI for better glyph management.
- Adjusted metrics and grid handling in bitmap font utilities for enhanced performance and accuracy.
- Removed supersampling settings from multiple recipe definitions to streamline rendering.
- Updated global CSS to ensure proper formatting and consistency across components.
- Enhanced styles in the ResponsiveExample component for better visual presentation.
- Cleaned up unnecessary code and improved readability in bitmap font designer components.
- Added ditherImageToDataUrl function for converting images to dithered data URLs, improving visual quality for grayscale images.
- Integrated dithering functionality into recipe rendering, allowing for dynamic image processing based on device context.
- Updated recipe definitions to include prepareForDevice method for enhanced image handling.
- Enhanced Wikipedia and Album components to utilize dithered images, improving overall presentation.
- Introduced tests for dithering functionality to ensure reliability and performance.
- Added a new module for album city data, including city IDs, labels, timezones, and Wikipedia titles.
- Updated the album component to utilize the new city data, allowing for dynamic city selection and improved presentation.
- Enhanced data fetching logic to retrieve city-specific images and information from Wikipedia.
- Refactored the album component to support optional custom image URLs and improved layout for better user experience.
- Introduced a new utility function for resolving image URLs based on city data and custom inputs.
Apply Biome auto-fixes, align convert-legacy-font tests with defaultCharGap metrics, regenerate font packs, and remove the PR draft notes file.

Co-authored-by: Cursor <cursoragent@cursor.com>
Delete one-off trace debug output; nothing in the repo references it.

Co-authored-by: Cursor <cursoragent@cursor.com>
Remove duplicated scripts/lib/*.mjs modules and import shared logic from
lib/bitmap-font/*.ts via a Node strip-types resolve hook. Extend trace-core
and metrics-derive with script-only helpers previously duplicated in .mjs.

Co-authored-by: Cursor <cursoragent@cursor.com>
Document trace → designer → commit workflow in docs/pixel-font.md. Remove
generate:fonts from prebuild and dev so pnpm build cannot overwrite curated
packs; keep pnpm generate:fonts as an explicit manual assistant step.

Co-authored-by: Cursor <cursoragent@cursor.com>
Rebase onto main (#78 palette SSOT, snap quantizer) and merge the
overlapping image paths into one surface:

- prepareDeviceImage / prepareDeviceImageFromContext (default Bayer)
- Author-selectable methods: bayer, white-noise, floyd-steinberg, etc.
- Color e-ink: bayerRgbToPalette in palette-reduction
- Gray paths use Lab L* luminance (lib/render/luminance.ts)
- RecipeDeviceContext carries levels + colorPalette via buildRecipeDeviceContext
- DeviceImage component; album/wikipedia use unified prep
- responsive-example: dither-* on 1-bit, solid colors on color screens
- Remove frame-wide dither flag; keep 1× rasterize (no supersample)
- Document e-ink palette mapping and update image dither docs

Co-authored-by: Cursor <cursoragent@cursor.com>
@ghcpuman902
ghcpuman902 force-pushed the feat/pixel-font-rendering branch from e95ec83 to c7b3ffb Compare July 1, 2026 08:57
@ghcpuman902

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (#78 merged). Redundancy resolved by merging into one device-aware image surface:

  • prepareDeviceImage + <DeviceImage> / prepareForDevice — author picks method (default Bayer)
  • Auto <img> intercept uses the same code path (no parallel FS module)
  • Color e-ink now dithers to palette colors, not gray-only
  • Dropped: duplicate quantizer, frame dither flag, 2× supersample (re-deleted after rebase)

Full-frame quantize stays hard snap from #78; dithering is image-scoped only.

ghcpuman902 and others added 2 commits July 1, 2026 11:15
Remove leftover unused resolveReactRecipe call, drop unused palette
param from prepareDeviceImageFromContext, prefix intentionally unused
weather/designer destructuring vars, fix redundant JSX fragment, and
organize imports per Biome.

Co-authored-by: Cursor <cursoragent@cursor.com>
When local schema is behind code after a rebase, the setup panel now
surfaces only pending migration SQL and an apply action instead of the
full initialize flow, and the dashboard badge reads setup required.

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

Copy link
Copy Markdown
Collaborator

Have you tested bayer dithering on your trmnl ?
It gives weird results on mine

ghcpuman902 and others added 4 commits July 2, 2026 09:40
… page

- Refactored the handling of device API server URLs to use a new structure, enhancing clarity and maintainability.
- Updated the NotFound component to replace the Next.js Image component with a standard img element, optimizing performance for a static 404 page.
- Adjusted image dimensions for better display on the NotFound page.
- Improved error messaging for device API server URL configuration, ensuring clearer guidance for users.

Co-authored-by: Cursor <cursoragent@cursor.com>
- Updated image preparation to ensure dithering occurs at the physical screen pixel size, preventing resampling artifacts.
- Introduced a new utility function, `toPhysicalImageDimensions`, to convert logical dimensions to physical dimensions based on pixel ratio.
- Adjusted image dither policy to enforce a single dither method, improving clarity and consistency in image rendering.
- Enhanced recipe rendering logic to utilize the new physical dimension calculations, ensuring accurate image display across devices.
- Added tests for the new image dimension utility to validate functionality.

Co-authored-by: Cursor <cursoragent@cursor.com>
- Updated the AppSidebar component to enhance the clarity of LAN IP instructions.
- Refactored HealthChip component to use a new HealthChipProps type for better type management.
- Simplified the getLocalLanIPv4 function by consolidating return statements.
- Adjusted the getConfiguredBaseUrl function for improved readability.
- Enhanced regex in parseDataUrl function to handle whitespace in data URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
- Introduced a new function to resolve bitmap font pack disk paths, ensuring proper validation and clarity.
- Updated the GET request handler to utilize the new path resolution function for improved maintainability.
- Added a check for the BETTER_AUTH_SECRET in the authentication process to enhance security.

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

Copy link
Copy Markdown
Collaborator Author

Have you tested bayer dithering on your trmnl ? It gives weird results on mine

it looks fine on my TRMNL OG 1 bit native
IMG_2869 Large

but somehow all recipe are inverted, still trying to fix that

@rbouteiller

Copy link
Copy Markdown
Collaborator

Ok, my bad, it's working on my TRMNL X and not inverted

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants