The preview step now asks the same AltTextSuggester the upload screen uses (Gemini Nano through ML Kit on the Play build, none on F-Droid or desktop) to describe the finished drawing, renders a still of it for the model, and fills an empty alt field marked "AI-suggested, edit me", dismissible as on the upload screen. A spinner shows while it works. The user's own text is never replaced, and an unchanged drawing is not described twice when going back and forth between Draw and Preview. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hY7E7VhnGS4ydA5E5Uppo
7.0 KiB
Hand-drawn posts
Draw by hand in the composer and post the drawing as a looping GIF that replays the strokes.
What the user gets
- A Draw button in the short-note composer opens a full-window editor (
DrawingEditorDialog) in two steps:- Draw: the paper fills the window. A floating dock (
DrawingDock) holds a tray of illustrated pens tipped with the current ink (the one in hand springs up), mirror, undo/redo, the inks and a size button with a live stroke preview. A chip in the top bar opens the paper sheet (PaperSheet), which shows real samples of every paper and the user's saved pictures. - Preview: the drawing replays exactly as the posted GIF will (
ReplayPreview, usingDrawingReplay.replayTimeAt). The alt text and "Add to post" live here.
- Draw: the paper fills the window. A floating dock (
- Tools: brush (follows stylus pressure), pencil, spray, eraser, mirror; eight inks; pen size.
- Backgrounds: plain paper, four sticky notes (square, with a glue strip), a notebook page,
grid paper, a dot grid, a chalkboard, or the user's own picture to draw on. Pictures are
kept on the device (
DrawingBackgroundStore) and come back in the row next time, so a user can reuse their own "stationery". - An alt-text field in the preview step, since a picture of handwriting is unreadable to a screen
reader without it. Where the platform has an on-device describer (
AppServices.createAltTextSuggester: Gemini Nano on the Play build), the preview fills it with a description of the finished drawing, marked "AI-suggested" like the upload screen; the user's own words are never overwritten. - Add to post renders the replay GIF and a PNG of the finished drawing, uploads both and puts
the GIF's URL in the note, with an
imetathe uploader could not have built on its own.
Why a GIF
The post has to work in every client, not only in Amethyst. GIF is the one animated format that every
Nostr client and browser plays inline, with no player controls and no buffering. Amethyst decodes it
on Android (GifDecoder) and on desktop (SkiaGifDecoder); animated WebP only animates on Android
here, and an MP4 would show up as a video player. Drawings fit GIF's limits: ink on paper needs far
fewer than 256 colours, and each replay frame changes a small patch of the picture.
What was considered and set aside:
- SVG: Amethyst renders it, but most other clients and many media servers refuse SVG.
- Stroke data inside the event (replay, remix, draw-on-reply): it needs a new kind or tag, which
is a protocol change for the maintainer to approve, and other clients would still need an image.
The editor keeps strokes in a form (
DrawingStroke) that could be serialised later. - Simple Nostr Objects (kind 33331) in
linesmode: one polyline with no pen lifts, no width, no timing, and other clients would show it as a 3D object.
Pieces
| Piece | Module | Notes |
|---|---|---|
DrawingStroke, DrawingPaper, DrawingBackground |
commons | Points as fractions of the canvas width, with timestamps |
DrawingEditorState |
commons | Snapshot state: background, pen, strokes, undo/redo |
DrawingReplay |
commons | Replay timing: cuts pauses (150 ms) and gaps (300 ms), fits ~3.2 s, 0.4 s lead-in, 2.5 s hold |
AnimatedGifEncoder, GifPalette, GifLzw |
commons | Pure Kotlin GIF89a writer. Later frames store only the changed rectangle, with unchanged pixels transparent |
DrawingRenderer (drawDrawing, BackgroundPainter) |
commonsUI | One renderer for the live canvas, every GIF frame and the PNG |
ReplayFrameRenderer |
commonsUI | Replay frames in constant time: finished strokes in a layer that only grows, the background drawn once, one half-drawn stroke on top. Used by the export and the preview |
DrawingExporter |
commonsUI | Renders frames offscreen (ImageBitmap + CanvasDrawScope + readPixels) at 540 px wide |
DrawingBackgroundStore |
commons | Picked pictures as cropped PNGs, one folder per account under the host's drawingBackgroundsDir, newest 12 kept |
PhotoBackgroundLoader |
commonsUI | Coil decode (upright on Android), centre crop to 4:3..2:3, 1080 px wide |
encodeArgbAsPng |
commonsUI | Bitmap.compress on Android, Skia on desktop/iOS |
ShortNotePostViewModel.uploadDrawing |
commonsUI | Uploads both files, builds the GIF's imeta |
Strokes over backgrounds
Strokes are drawn on their own layer above the background (inStrokeLayer, a saveLayer), and the
eraser is BlendMode.Clear in that layer. That is what lets the eraser reveal a photo rather than
paint over it. The live canvas keeps finished strokes in a transparent bitmap that only repaints on
undo, clear or resize, and draws the stroke under the finger on top.
Palette
GifPalette.Builder counts the colours of the finished drawing plus about 12 frames sampled across
the replay, because ink that is erased or painted over never reaches the last frame. When it has at most 255 colours (5-bit buckets), the
palette is exact and nothing is dithered. Over a photo it uses median cut plus a 4x4 Bayer dither.
The dither depends only on a pixel's position and colour, so an unchanged pixel maps to the same
index in every frame and the per-frame patches stay small.
Upload
The composer's batch upload is not used, for three reasons:
- It would re-encode the PNG as JPEG at the default quality.
- On Android it sends the GIF through EXIF stripping, which fails on GIF.
- It measures blurhash and dims from the first frame, which is blank paper.
uploadDrawing instead uploads each file UNCOMPRESSED with stripMetadata = false. It then
writes the GIF's imeta with:
- the dims and the blurhash/thumbhash of the finished drawing;
image= the PNG URL (NIP-94's preview field), for clients that show a still;alt.
A failed PNG upload does not block the post. NIP-95 servers get the GIF as an embedded event, as other uploads do.
Measured on desktop: a two-stroke sticky note replays in 61 frames as a 16 KB GIF (the PNG is 27 KB). A drawing over a 1080 px photo makes a 140 KB GIF (the PNG is 390 KB).
Saved backgrounds
A picture the user draws on is saved, already cropped and scaled, as
<drawingBackgroundsDir>/<account pubkey>/<saved at ms>_<sha256 prefix>.png:
- Location:
filesDir/drawing_backgroundson Android, the desktop data dir on desktop. - One folder per account, so one account's pictures never show in another account's editor on the same device.
- Name: the timestamp orders the row and the hash avoids duplicates. Only the newest 12 are kept.
- Removing: a badge on the selected thumbnail removes the picture.
- Device only: saved pictures are never uploaded unless they end up in a posted drawing.
Not done yet
-
Syncing saved backgrounds across devices (e.g. on the user's Blossom server plus a list event), which needs a NIP decision.
-
Reading
imeta imageas a still preview for GIFs in Amethyst's own feed (it already animates them). -
Stroke data in the event (see above), which needs a NIP decision.
-
Saving an unfinished drawing; closing the editor asks before discarding.