1ascii.im architecture overview#
ascii.im is an ASCII art generator: it converts an AI-generated image into text pixel art using the characters of a prompt as pigments. The architecture is split into four layers so that the interesting part — the conversion kernel — can be reused outside the web app, in a CLI, a worker or a test harness. The system design goal is a kernel with no framework dependency and no UI assumptions.
| Layer | Path | Depends on | Reusable elsewhere |
|---|---|---|---|
| Kernel | src/core | DOM for rasterising only | Yes, no framework imports |
| Providers | src/providers | Kernel types | Yes |
| Server | src/server | Node runtime | Partially (locale, entitlements) |
| App shell | src/app, src/components | Next.js, React | No |
- Only two files in the kernel touch the DOM: src/core/image/raw.ts and src/core/palette/ink.ts. Everything else is pure computation.
- Providers implement one interface and are discovered by a registry; adding one requires no changes to routes or UI.
- Server-side modules handle locale detection, provider access control and quota enforcement.
- ASCII art
- A picture whose pixels are characters: each cell of a grid is filled with a glyph whose visual weight matches the brightness of the source image.
- Ink
- The fraction of a fixed cell that a glyph’s strokes cover, measured by rasterising the character. It is the only quantity used to order glyphs from light to dark.
- Pigment
- One distinct character taken from the prompt and used as a single shade in the artwork. A prompt of five distinct characters yields five pigments.
- Shade ramp
- The ordered list of pigments, from lightest to darkest, that quantisation maps brightness onto.
- Quantisation
- Mapping a continuous brightness onto one of a small number of discrete shade indices, which is what produces the characteristic stepped look of ASCII art.
- Dithering
- Distributing the error introduced by quantisation across neighbouring cells, so the eye perceives intermediate tones that the palette does not actually contain.
Key takeaways
- The kernel has no framework dependency and can run in Node or a CLI.
- Only two kernel files touch the DOM.
- Providers are discovered by a registry, not wired by hand.
2How does the image-to-ASCII pipeline work?#
The ASCII art pipeline has two independent halves that meet once. The pigment half turns the prompt into an ordered shade ramp; the image half turns a raster image into a brightness grid. Quantisation maps every brightness value onto a ramp index, and the renderers then draw that index grid as characters. Each of the conversion steps below is a pure function, which is why the flow is unit-testable end to end.
prompt ──▶ grapheme split ──▶ de-duplicate ──▶ measure ink ──▶ shade ramp (light → dark)
│
image ──▶ area-average resample ──▶ tone adjust ──▶ cutout ──┐
├─▶ quantise + dither ──▶ index grid
ramp ─────┘
│
▼
renderers: text · SVG · canvas/PNG- Extract pigment glyphs from the prompt and de-duplicate them.
- Measure each glyph’s ink and sort ascending to form the ramp.
- Resample the source image to the target column count with a two-pass separable box filter.
- Apply auto levels, contrast, brightness and gamma.
- Optionally remove the background and feather the edge.
- Quantise brightness onto ramp indices, with optional dithering.
- Render the index grid as text, SVG or a canvas snapshot.
Key takeaways
- Pigment and image halves are independent and meet only at quantisation.
- Quantisation works on ramp indices, not on raw ink values.
- Renderers consume the same index grid, so output formats never diverge.
3Ink measurement: how is glyph density computed?#
Ink is the fraction of a fixed cell that a glyph’s stroke covers. The measurement algorithm draws the character into an offscreen canvas at a fixed em size, reads the alpha channel, and divides the covered pixel count by the total. The result is a scalar in roughly 0.03–0.45 that behaves like perceived weight, and it is the only quantity an ASCII art renderer needs in order to order glyphs.
function inkOf(glyph: string, font: string, size = 64): number {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d')!;
ctx.font = `${size}px ${font}`;
ctx.textBaseline = 'alphabetic';
ctx.fillText(glyph, 0, size * 0.8);
const { data } = ctx.getImageData(0, 0, size, size);
let covered = 0;
for (let i = 3; i < data.length; i += 4) {
if (data[i]! > 8) covered += 1;
}
return covered / (size * size);
}- Measurements are cached per (glyph, font) pair, so re-rendering does not re-rasterise.
- When Canvas is unavailable the kernel falls back to a deterministic estimate and sets Palette.measured = false, which the UI surfaces as a warning.
- Ink is used only for ordering. Quantisation distributes levels by ramp index, never by interpolating between ink values.
Key takeaways
- Ink = covered pixels / cell area, measured by real rasterisation.
- Results are cached; the no-Canvas fallback is explicit and flagged.
- Only the ordering matters; levels are indexed by position.
4Script detection and multi-script support#
src/core/script.ts maps code points to a script via Unicode ranges, then picks the dominant script by weighted tally. Each script carries a profile: whether it is bicameral, its default cell aspect, a font stack, an augmentation pool and a de-duplication rule. This is what makes multi-language ASCII art work without a per-language glyph table, and it is also where grapheme clusters are kept intact.
| Script | Case folding | Cell aspect | Segmentation |
|---|---|---|---|
| Latin | Yes (A = a) | ~0.5 | Grapheme |
| Cyrillic | Yes | ~0.55 | Grapheme |
| Greek | Yes | ~0.55 | Grapheme |
| Han (CJK) | No | ~1.0 | Grapheme |
| Kana | No | ~1.0 | Grapheme |
| Hangul | No | ~1.0 | Grapheme |
| Arabic | No | ~0.6 | Grapheme |
| Hebrew | No | ~0.6 | Grapheme |
| Thai | No | ~0.6 | Grapheme cluster (marks) |
| Devanagari | No | ~0.7 | Grapheme cluster (marks) |
- Segmentation uses Intl.Segmenter when available, so Thai and Devanagari combining marks never become standalone pigments.
- Augmentation pools are per script: a French prompt is topped up with Latin glyphs, never with Han characters.
- Font stacks are resolved per script and merged with a CJK fallback and a system monospace tail.
import { detectScript, resolveFontFamily } from '@/core/script';
const script = detectScript('un café très noir'); // 'latin'
const font = resolveFontFamily(script); // script stack + CJK + monospaceKey takeaways
- Script is detected by Unicode range and weighted tally.
- Case folding applies only to bicameral scripts.
- Augmentation and fonts are per script, never cross-script.
5Palette extraction and shade ramp construction#
The palette builder splits the prompt into graphemes, de-duplicates by the script’s key, measures ink for every survivor, and sorts ascending. If the result is shallower than the requested minimum, the script’s augmentation pool is appended until the target depth is reached. This is how an ASCII art palette is built from arbitrary input in a single pass.
- Augmented glyphs are measured with the same metric, so they land in correct positions rather than being appended blindly.
- When measurement is unavailable, augmentation interpolates between the darkest known ink and 1 so ordering stays monotonic.
- A space can be promoted to the lightest shade for cleaner highlights.
- Manual overrides are preserved: once you drag a swatch, the automatic order stops fighting you.
import { createPalette } from '@/core';
const palette = createPalette('一个红苹果', { minLevels: 8, caseFold: true });
// palette.glyphs → ['一','个','红','苹','果', …augmented]
// palette.measured→ true when ink came from real rasterisationKey takeaways
- De-duplication uses the script’s own key.
- Augmentation is measured, not appended blindly.
- Downstream consumers depend on ramp index only.
6Area-average resampling#
Downscaling an image for ASCII art sampling uses a two-pass separable box filter rather than the browser’s built-in scaling. Built-in scaling differs between engines and cannot be asserted in tests; a hand-written box filter is deterministic, and area averaging (rather than point sampling) preserves tonal information that point sampling throws away.
- Horizontal pass then vertical pass — separable, so the cost is linear rather than quadratic in the kernel width.
- Edge pixels are handled by clamping, so borders do not darken.
- Alpha is premultiplied before averaging to avoid halos around semi-transparent edges.
| Strategy | Deterministic | Tone fidelity | Testable |
|---|---|---|---|
| Point sampling | Yes | Poor — aliases detail | Yes |
| Built-in canvas scaling | No — varies by engine | Good | No |
| Two-pass box filter (used) | Yes | Good | Yes |
Key takeaways
- Hand-written separable box filter for determinism.
- Area averaging preserves tone; point sampling does not.
- Alpha is premultiplied to avoid edge halos.
7Quantisation and dithering#
Quantisation maps a continuous brightness to one of N ramp indices using uniform bins over the level count. With small N, uniform bins produce visible banding on smooth gradients; dithering perturbs the decision so neighbouring cells alternate and the eye integrates them into intermediate tones. This is the step that decides how an ASCII art image actually reads at normal viewing distance.
| Algorithm | Error handling | Character | Cost |
|---|---|---|---|
| None | Truncate to nearest bin | Poster-like, flat | Lowest |
| Floyd–Steinberg | Diffuse 7/16, 3/16, 5/16, 1/16 | Organic, smooth | One pass |
| Ordered (Bayer) | Threshold matrix | Regular, retro | Lowest |
(x+1, y) 7/16
(x-1,y+1) 3/16 (x,y+1) 5/16 (x+1,y+1) 1/16Key takeaways
- Uniform bins over ramp index; dithering diffuses the error.
- Floyd–Steinberg for smooth gradients, Bayer for a halftone look.
- Tone adjustment must precede quantisation.
8Output renderers: text, SVG and canvas#
ASCII art rendering always starts from the same shade-index grid, so no output format can silently drift from another. Text emits a character grid, SVG emits positioned <text> elements, and the canvas renderer paints to a bitmap that a minimal PNG encoder serialises.
- SVG emits a list of absolute x offsets per glyph rather than relying on font advance widths, so a file renders identically on machines with different fonts.
- Consecutive identical glyphs are merged into a single <text> node with an x list, which keeps file size down.
- Multi-code-point graphemes (emoji ZWJ sequences) repeat the same x so their components overlap correctly.
- PNG export uses a self-contained encoder in src/server/png.ts — no native dependency, no ImageMagick.
| Renderer | Output | Font dependent | Editable |
|---|---|---|---|
| text | Plain characters | Yes (monospace assumed) | Trivially |
| svg | Vector | No (absolute positioning) | In any vector editor |
| canvas → PNG | Raster | Yes (baked in) | No |
Key takeaways
- One index grid feeds every renderer, so outputs cannot diverge.
- SVG glyph positions are absolute and font-independent.
- PNG export needs no native dependency.
9Image provider adapters#
A provider is an object with a descriptor and a generate function. The descriptor declares credentials, models, aspect support and whether the flow is asynchronous. The registry resolves credentials from server environment variables first, then from session-supplied values, so an ASCII art request never depends on hard-coded keys.
export interface ImageProvider {
descriptor: ProviderDescriptor;
generate(
request: GenerateRequest,
context: {
credentials: ProviderCredentials;
signal?: AbortSignal;
fetchAsDataUrl: (url: string) => Promise<string>;
log?: (message: string) => void;
},
): Promise<GenerateResult>;
}- Create src/providers/<name>.ts implementing ImageProvider.
- Add it to the PROVIDERS array in src/providers/registry.ts.
- Document its environment variables in .env.example.
Routes, the provider dropdown and the credential form all discover providers from the registry, so a new provider needs no changes in the UI or API layer. Asynchronous providers use a shared submit-and-poll helper; Tencent Hunyuan signs requests with TC3-HMAC-SHA256, verified against the vendor’s published test vector.
Key takeaways
- Adding a provider is one file plus one registry entry.
- Remote images are proxied to same-origin data URLs by the server.
- TC3 signing must use the exact Content-Type that is sent.
10HTTP API reference for ascii.im#
Every endpoint returns JSON. The ASCII art API is read-heavy: only /api/generate does real work on the server, while the rest are cheap lookups used by the interface itself.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/providers | Visible provider catalogue with access tier |
| POST | /api/generate | Generate an image and proxy it as a data URL |
| GET | /api/locale | Locale detection result and supported languages |
| GET | /api/auth/providers | Configured sign-in methods and callback URLs |
| GET | /api/auth/session | Current session and quota |
| POST | /api/auth/logout | Clear the session cookie |
| GET | /api/auth/{provider} | Start an OAuth flow |
| GET | /api/auth/{provider}/callback | OAuth redirect target |
{
"provider": "builtin",
"prompt": "a red apple",
"negativePrompt": "text, watermark",
"aspect": "1:1",
"seed": 42,
"credentials": {}
}{
"ok": false,
"error": { "code": "QUOTA_EXCEEDED", "vars": { "total": "3" } }
}| Code | Status | Meaning |
|---|---|---|
| EMPTY_PROMPT | 400 | Prompt is empty |
| PROMPT_TOO_LONG | 400 | Prompt exceeds 1000 characters |
| INVALID_JSON | 400 | Body is not valid JSON |
| UNKNOWN_PROVIDER | 400 / 404 | Unknown id, or a hidden provider |
| MISSING_CREDENTIALS | 400 | Required keys are absent |
| LOGIN_REQUIRED | 401 | Provider tier requires a session |
| QUOTA_EXCEEDED | 402 | No free generations left |
| FORBIDDEN_TIER | 403 | Plan does not include the provider |
| RATE_LIMITED | 429 | Too many requests; see Retry-After |
| PROVIDER_ERROR | 502 | Upstream model rejected the request |
Key takeaways
- Errors are code + vars, never prose.
- Hidden providers return 404, not 403.
- Generation is rate limited per IP by default.
11Authentication, sessions and quota#
Sessions are stateless: base64url(JSON) plus an HMAC-SHA256 signature, compared with a constant-time equality check. Only the user id is stored in the cookie; the user record is re-read on every request, so a plan change takes effect immediately rather than at next login.
| Provider | Token auth | PKCE | Scope |
|---|---|---|---|
| Request body | Yes | openid email profile | |
| Discord | Request body | No | identify email |
| X (Twitter) | HTTP Basic | Required | tweet.read users.read offline.access |
- Callback URLs are derived from APP_BASE_URL: {APP_BASE_URL}/api/auth/{provider}/callback.
- The state parameter and the PKCE verifier live in an HttpOnly signed cookie, which blocks CSRF and code interception.
- Redirect targets are validated to be site-relative, so “//evil.com” and backslash variants fall back to the default page.
- Quota is debited before the upstream call and refunded on any failure.
type AccessDecision =
| { allowed: true; consume: boolean } // consume = debit one free generation
| { allowed: false; reason: 'LOGIN_REQUIRED' | 'QUOTA_EXCEEDED' | 'FORBIDDEN_TIER' };Key takeaways
- Sessions are signed, stateless and verified in constant time.
- State and PKCE verifier never reach JavaScript.
- Quota is refunded whenever the upstream call fails.
12Configuration, internationalisation and deployment#
Configuration is entirely environment-driven, and the i18n architecture follows the same rule: nothing is hard-coded per deployment and nothing is duplicated per language. This section lists every variable group, explains how a language is chosen on the server, and notes what must change when you run more than one instance.
| Group | Variables |
|---|---|
| Site | APP_BASE_URL, DEFAULT_LOCALE, GEOIP_ENDPOINT |
| Auth | AUTH_SECRET, FREE_GENERATIONS, ADMIN_USER_IDS, GOOGLE_*, DISCORD_*, TWITTER_* |
| Provider access | PROVIDER_VISIBILITY, PROVIDER_TIERS, PROVIDER_DEFAULT_* |
| Provider keys | DASHSCOPE_API_KEY, ARK_API_KEY, BIGMODEL_API_KEY, HUNYUAN_SECRET_ID/KEY, QIANFAN_API_KEY, MINIMAX_API_KEY |
| Rate limit | RATE_LIMIT_PER_MIN |
Provider access is expressed entirely in the environment. PROVIDER_VISIBILITY hides a provider from the catalogue and from the API at once; PROVIDER_TIERS assigns free, pro or admin. The built-in offline placeholder is always visible and always free, so there is never a dead end for a first-time visitor.
Internationalisation uses English as the single source of truth: Messages is derived from the English dictionary, so a missing key in any other language fails type checking. Dictionaries are selected on the server and passed down as props, which means the first paint already has the right html lang and dir, and the client bundle does not grow with the number of languages — a locale is imported lazily only when the user switches.
- Detection order: ?lang → cookie → CDN country header → optional GeoIP → Accept-Language → English.
- Private and reserved IP ranges are excluded from geographic detection.
- Arabic switches the document to dir="rtl"; CSS uses logical properties rather than left/right.
Key takeaways
- Provider visibility and tiers are configuration, not code.
- English dictionaries are the compile-time source of truth.
- Shared state (rate limit, user store) must move out of process when scaling.
Read the code, or just use it
The kernel is framework-agnostic — reuse it in a CLI, or start in the studio right away.
Open the studio →