Overview
ASCII Studio is a
single-page browser tool that takes a picture, a 3D model or whatever
your webcam is pointed at and redraws it as text. Not as a one-shot
filter — as a live render you keep editing. Drag the density slider and
the grid gets finer under your hand. Swap the character ramp from the
detailed set to 01 and the same torus knot turns into a
binary readout. Point it at your face and it does the same thing at
thirty frames a second.
It was built in an afternoon on Replit, which is the whole reason it is worth writing about. A tool like this looks like a weekend of fiddly canvas work, and most of it is. The part that decides whether you ship it or abandon it is a single architectural call you make in the first ten minutes, and if you get that call wrong no amount of agent time rescues it.
What it does: three sources (upload a PNG/JPEG/WebP, import a GLB/OBJ, or start the camera), nine built-in 3D shapes with auto-rotation and drag-to-rotate, five character-ramp presets plus a free-text ramp of your own, live density / brightness / contrast / invert, monochrome or source-colour output, foreground and background colour pickers, three one-click looks, and three exports — TXT, PNG and WebM.
Stack: React + TypeScript on Vite, Three.js for the
model viewer, getUserMedia for the camera,
MediaRecorder for the video export. No backend. Nothing
leaves the tab.
The one decision that matters: every source collapses to pixels
There are three sources. The tempting shape is three renderers — one that walks an uploaded image, one that reads the WebGL canvas, one that samples video frames. It feels natural because the three sources really are different: one is static, one is a scene graph, one is a stream.
It is also the version you throw away. The moment there are three renderers, there are three places where the density slider is interpreted, three ramp lookups, three subtly different ideas of what "contrast" means, and your invert toggle works on images and does nothing to the webcam. Every feature you add after that costs three times as much, and the bug reports are all "it looks different on the camera".
So the brief names one function and forbids the alternative by name:
Everything upstream of that is a source adapter whose only job
is to produce an ImageData at grid resolution. An uploaded
image draws itself into an offscreen canvas. A model renders into an
offscreen WebGL target and gets read back. The webcam draws its current
video frame into the same offscreen canvas every animation frame. Three
adapters, about fifteen lines each, and one converter that never learns
where the pixels came from.
That is what makes "start the camera and every control you were already using keeps working" free instead of a feature.
Density is a cell size, not a character count
The slider says 12px, and that is deliberate. The obvious
alternative is to let the user pick a column count — "render at 120
characters wide" — and it breaks the first time someone resizes the
window, because the characters either stretch or the image stops
filling the frame.
Cell size in pixels is the stable unit. The grid falls out of it:
Resize the window and you get more characters at the same weight, which
is what everyone expects and nobody can articulate. It also means the
number in the corner of the viewport (12px · MONO) is a
real, physical description of the output rather than an abstraction.
And the cell is not square
This is the bug everyone writes at least once. A monospace character cell is roughly twice as tall as it is wide — the exact ratio depends on the font, but it is in the region of 0.5 to 0.6. If you sample the source on a square grid and then print the result into non-square cells, everything comes out stretched vertically by a factor of two. Faces get long. Circles become eggs. It looks almost right, which is why it survives so long.
The fix is to measure the real cell rather than guess it — render one character, take its advance width, take the line height, and derive the sampling box from the ratio:
Write it down in the brief with the number in it. An agent left to its own devices will sample square, because square is what every ASCII tutorial does.
Luminance to a character, in the right order
Per cell: average the pixels, convert to perceptual luminance, apply the adjustments, then index the ramp. The order of those four steps is the whole thing.
Two things go wrong here and both are quiet. If contrast is applied as a plain multiply instead of pivoting around 0.5, it behaves as a second brightness slider and the two controls fight each other. And if invert is applied to the ramp index rather than the luminance, it stops being an inversion the moment the ramp has an uneven number of steps.
The ramp is the art direction
The default ramp is ordered dark to light by ink coverage, not by ASCII value:
A period covers almost nothing. An I covers a lot. The
order is a judgement about how much of the cell each glyph fills in
this font, which is why a ramp copied from a 1990s
aalib table looks muddy in a modern grotesk mono. Ship a
good default, then let people type their own — the free-text ramp field
is three lines of code and it is the single most-played-with control in
the app.
The five presets are five different tools, not five skins:
- Standard Detailed — the long ramp. Photographs, faces, anything with gradients.
- Simple — a short ramp. Reads better at low density and on a phone.
-
Binary —
01. Two steps, so it is effectively a dither, and it is the only preset that makes a 3D model look like a readout instead of a drawing. - Blocks — the Unicode shade blocks. Nearly continuous tone, barely reads as text at all.
- Matrix — katakana and digits. Pure costume, and pure fun, and worth including for exactly that reason.
The 3D side: nine shapes before any file import
The app opens on a rotating torus knot, and that is a product decision rather than a default. A studio that opens on an empty canvas with an upload button asks the visitor to do work before they know whether the thing is any good. A studio that opens already running lets them grab the density slider in the first two seconds.
So there are nine parametric shapes built in — knot, sphere, cube, ring, orbital rings, double helix, diamond, wave surface, icosahedron — all generated in code, none of them an asset to load. They cost nothing to ship and they cover the interesting cases: something with a self- occluding silhouette (the knot), something with flat shaded faces (the cube), something with fine repeated structure that tests the density slider (the helix, the rings).
Imported GLB and OBJ files go through the same viewer. Auto-rotate,
rotation speed, drag to rotate, scroll to zoom. The one thing worth
specifying carefully is the readback: render the scene into an offscreen
render target already sized to the grid, then
readPixels that. Rendering at full resolution and sampling
down afterwards works, and it burns a pile of GPU and memory for an
image you are about to reduce to a few thousand characters.
The built-in shapes also have a software-rendered fallback, so a browser without WebGL still gets a rotating object. Imported models need WebGL and say so plainly instead of failing into a blank frame.
The webcam is an image source with a clock
Once the converter is source-agnostic, live camera input is almost nothing: get a stream, draw the video element into the offscreen canvas each frame, convert, paint. The work is not in the pixels. It is in the permission and shutdown behaviour, and this is where most hobby tools quietly lose people's trust.
-
Never request audio.
getUserMedia({ video: true, audio: false }). Asking for a microphone you do not use lights the recording indicator and turns a fun toy into something the user closes the tab on. -
Gate it behind a button. The Cam tab shows a
Start Camera button and one line of copy. No
getUserMediacall fires on page load, on tab switch, or on any path the user did not explicitly take. -
Stop the tracks on every exit path. Not just the Stop
button — also on switching source, on unmount, and on
pagehide. Calltrack.stop()on every track in the stream and drop the reference. A camera light that stays on after the user has moved to the Image tab is the single worst bug this app can have, and it is invisible in testing because the developer knows what they just clicked. -
Handle the denial properly.
NotAllowedErroris not the same asNotFoundError. And in an embedded preview iframe the permission prompt may not appear at all, which is why there is an Open in New Tab escape hatch next to the retry guidance — a Replit-specific problem worth naming in the brief, because the agent will otherwise test in the preview pane, see the failure, and start rewriting working camera code. - Mirror it. Unmirrored self-view feels wrong to everyone and nobody can say why.
Three exports, three different problems
These look like one feature on the toolbar. They are not.
TXT is the real output
Join the grid with newlines, wrap in a Blob, download. It is the cheapest of the three and the one people actually use, because it is the only export that survives being pasted into a README, a commit message or a terminal.
PNG has to draw, not screenshot
If the on-screen output is DOM text, there is no way to turn it into an
image directly — no html2canvas shortcut that gets the
metrics right. The export path has to render the same grid into a
<canvas> with fillText, at an export
scale independent of the viewport, using the same font, the same cell
metrics and the same foreground and background colours. Which means the
cell geometry has to live in one module that both the screen renderer
and the export renderer call. Two implementations of the grid, and the
PNG is subtly off from the preview forever.
WebM is a stream, and it is conditional
canvas.captureStream(fps) into a
MediaRecorder, collect the chunks, Blob, download. The
thing to specify is the degradation: check
MediaRecorder.isTypeSupported against a list of candidate
mime types, pick the first that passes, and if none do, disable the
button with a tooltip explaining why rather than letting it throw. Codec
support genuinely differs across browsers and this is the one export
that cannot be made universal.
All three are greyed out until there is a frame to export. Obvious in hindsight, and an agent will happily ship three live buttons that download an empty file.
Quick Looks are presets you are allowed to break
Terminal, Ember, Inverted. Each is one click, and each one just writes values into the same controls you were already using — ramp, colours, invert, density. Nothing locks. You click Ember because it gets you close, then you drag the contrast because it was not quite there.
A preset that takes over the UI is a filter. A preset that seeds the UI is a starting point. The difference is a few lines of state management and it is most of what separates a toy from something people come back to.
On the light mode
This went dark studio first, then orange-on-black, and ended up light:
warm white #FDFBF9 background, #CC4400 ink.
Every instinct says ASCII belongs on black. A terminal is black. The
reference images are all black.
The light version is better, and the reason is specific rather than aesthetic. On black, the ASCII art and the UI chrome are competing glowing objects and the art loses. On warm white, the sidebar recedes into paper and the orange character grid is the only saturated thing on the screen. It reads as a print of the art rather than a screenshot of an app.
The one requirement is that the ink stay dark and saturated. Orange at
#CC4400 on #FDFBF9 holds at 12px monospace.
Anything lighter and the fine ramp characters — the periods, the
backticks, the commas — dissolve into the background and the low end of
the tonal range disappears.
The prompt
Paste it whole. The section numbering matters more than it looks — the agent builds in that order, and the order is chosen so that the first thing that works is the thing everything else depends on.
Where it goes wrong
- Everything looks vertically stretched. The cell aspect ratio was ignored and the source is being sampled on a square grid. This is the first bug and it is worth failing the build on.
- The camera works, the image upload does not, or vice versa. Two converters got written. Delete one and route both adapters into the survivor.
- The PNG does not match the preview. The exporter computed its own grid instead of importing the shared cell-geometry module.
- Brightness and contrast fight each other. Contrast is a plain multiply rather than pivoting around 0.5.
- The 3D object renders as a solid block of one character. The scene is lit flat. ASCII has maybe twenty tonal steps to work with; it needs a key and a rim light to find them.
- The camera indicator stays on. Tracks are stopped on the Stop button only. All four exit paths need it.
- The camera never prompts at all. You are testing in an embedded preview pane. Open the deployment in a real tab before you touch the code.
-
Frame rate collapses at low density. A new canvas,
ImageDataor string array is being allocated every frame. At 4px cells that is a lot of garbage per second. -
WebM export throws on Safari.
isTypeSupportedwas never probed and a vp9 mime type was hardcoded.
Recap
-
Collapse every source to
ImageDataand write one converter. Name the forbidden alternative in the brief, because the obvious decomposition is the wrong one. - Density is a cell size. Column counts break on resize.
- Measure the monospace cell. It is about twice as tall as it is wide, and assuming otherwise stretches everything by 2x in a way that looks almost correct.
- Luminance, then contrast around mid-grey, then brightness, then invert, then quantise. Any other order produces controls that interfere with each other.
- Order the ramp by ink coverage in your actual font, then let people replace it. The custom ramp field earns more play than any slider.
- Read the 3D scene back at grid resolution, not at viewport resolution.
- The camera is ten lines of pixels and a page of permission and teardown behaviour. Specify the teardown on all four exit paths, and never ask for audio.
- Share cell geometry between the screen and the PNG exporter, and probe codec support before offering the video one.
- Ship something already running on first paint. Nine generated shapes cost nothing and remove the empty-canvas problem entirely.
- Ask for the converter first, against one static image. It is the only checkpoint in this build you can fail on sight.
If you want more in this shape, there is a 3D ASCII wordmark that animates inside a GitHub README, a liquid-metal webcam filter in Python and OpenCV, and a Replit prompt for faking old CRT hardware in the browser.