Flipghost
Changelog

Every change, written down

Flipghost moves fast enough that the workshop you open next month will not be the one you opened today. This is exactly what moved, and when.

  1. A stroke is not a line

    To build a pressure-sensitive brush in the browser, you have to stop drawing lines and start drawing silhouettes. Here is the math, the code, and the hardware quirks behind it.

    Pick up a stylus, open most drawing tools in a browser, and press harder.

    Nothing happens.

    Not not much. Nothing. The line you are drawing is exactly as wide as it was, and it will stay exactly that wide for its entire length, no matter what you do with your hand. Every animator who has ever picked up a real pencil notices this in about four seconds, and it is the single fastest way to make a drawing tool feel fake.

    The reason is not that pressure is hard to read. It is that the line was never a line.

    One width, for the whole life of the stroke

    Flipghost's canvas runs on Fabric, and Fabric's built-in PencilBrush does the obvious sensible thing: it collects your pointer positions and emits a Path with a strokeWidth.

    Singular. One number. For the entire path.

    That is not Fabric being lazy — it is inherited from the model underneath. A stroked path, in SVG and in Canvas alike, has a stroke width. The width is a property of the whole path, not of any position along it. There is no syntax for "3px here, tapering to 0.5px by the end", because the stroke is applied to the geometry uniformly, as a single operation, after the fact.

    So pressure has nowhere to go. You can sample it beautifully — sixty readings a second, perfectly accurate — and then you arrive at the API and there is one slot for width and you have to put one number in it. Everything you measured collapses to an average.

    The tempting fix, and why it is worse

    The first idea everyone has, ourselves included: chop the stroke into segments. Every few points, start a new path with its own strokeWidth. Enough segments and you have a variable-width line.

    It works, in the sense that a photograph of it looks right. Living with it is another matter. You get seams where segments butt together, and every seam is a place where a semi-transparent brush double-darkens because two paths overlap. You get one canvas object per segment, so a single flick of the wrist becomes forty objects for the renderer to track, for the serialiser to write out, and for undo to carry around. And the joins fight you: each segment gets its own round caps, so the outline pulses subtly wider at every junction, which reads as a lumpy line rather than a tapered one.

    You are essentially trying to fake a shape out of many lines. So draw the shape.

    Draw the outline, not the path

    This is the reframe the whole brush hangs on, and it is worth saying slowly.

    When you drag a real brush across real paper, the mark it leaves is not a line. It is a region — a long thin blob, fat where you leaned in, narrow where you lifted, tapered at both ends. We describe it as a line out of habit, but what is actually on the paper is a filled shape with a silhouette.

    So compute that silhouette directly. perfect-freehand takes your sampled points, each carrying its own pressure, and returns the outline of the resulting mark: a closed polygon that traces all the way up one side of the stroke and back down the other, bulging where pressure was high and pinching where it was low.

    Then fill it. No stroke at all:

    const path = new Path(data, {
      fill: this.color,
      stroke: null,
      strokeWidth: 0,
    })

    That is the punchline of the entire feature. The brush that finally has a variable stroke width is the one with strokeWidth: 0. There is no stroke anywhere in it. It is a filled shape that happens to be long and thin, and its width varies for the same reason a puddle's width varies: because that is the shape it is.

    Smoothing falls out of the same pass for free, incidentally. Once you are computing a silhouette rather than connecting dots, "smooth the line" and "shape the outline" are the same operation, which is why there is no separate smoothing step anywhere in the code.

    Your hardware is lying about pressure

    Now the awkward part. Having built a brush that can express pressure, we went to read some, and found that most devices do not have any.

    The web exposes PointerEvent.pressure, a number from 0 to 1, and it is honest exactly once: when the pointer is a pen. Otherwise:

    • A mouse reports 0.5. Always. Constantly. While any button is held, the pressure is 0.5, because the spec has to return something and half seems fair.

    • A touchscreen frequently reports 0, or a flat 1, depending on the device and the browser. Some report a real value from the contact patch. Most do not.

    So a naive size = pressure * width gives every mouse user a line of precisely one constant width — we would have travelled a long way to reimplement the problem we started with — and gives some touch users a stroke of width zero, which is worse, because it is invisible.

    The rule ends up being explicit about who to trust:

    function readsPressure(e: TPointerEvent) {
      return (e as PointerEvent).pointerType === "pen"
    }

    Only a pen. If it is a pen and it reports something above zero, use it. Otherwise fall back to a flat 0.5 and hand the problem to perfect-freehand's simulatePressure, which infers pressure from velocity instead: fast strokes come out thin, slow strokes come out fat.

    That sounds like a consolation prize and it is not. It is close to what a real brush does — drag a loaded brush quickly and it lays down less ink, precisely because it has less time to. A mouse user gets a line that thins on the fast confident sweeps and thickens through the slow careful curves, which is a decent likeness of intent even though the hardware told us nothing. The device is deciding per stroke, on pointer-down, which world it is in.

    The whole silhouette, every single frame

    One more thing worth knowing, because it violates an instinct.

    A fixed-width line is append-only. New point, new segment, draw it, done. Never touch what is already there.

    A pressure stroke is not. Its outline is not a running total — a point you add now can still reshape the tail behind it, because streamline pulls the rendered stroke toward the pointer path with a lag, and the ends taper relative to where the stroke finishes. The silhouette is a function of the whole point list, so if the list changes, the whole silhouette changes.

    So every pointer move throws the outline away and rebuilds it from all the points collected so far, then fills it onto the top canvas layer. Which sounds ruinous, and simply is not: a stroke is a few hundred points at most, the maths is arithmetic, and it comfortably finishes inside a frame. The expensive-sounding thing was cheap, and the clever incremental thing we did not write would have been wrong.

    The last point of the stroke gets one extra flag — last: true — which is how perfect-freehand knows to close off the tail properly rather than leaving it mid-taper as though your hand were still moving.

    What did not change

    The new brush fires the same path:created event the old one did. The canvas wiring did not need to know about any of this, undo did not need to know, and paths drawn by the old brush still render exactly as they always did, because they are still perfectly valid stroked paths.

    That was deliberate, and it is the part we would do the same way again. The interesting change was one class deep. Everything above it kept its assumptions, including the assumption that what it is storing is a line.

    Which it is not. It never was.

  2. Undo was lying to you

    This post breaks down a subtle but destructive bug in an animation tool's Ctrl+Z feature. It explores how shifting from an isolated, per-frame history model to a global, document-level state not only fixed a glaring undo logic flaw, but unexpectedly improved the application's memory efficiency and user experience through immutable data structures.

    Ctrl+Z is the second thing anyone does in a drawing tool. The first is draw a line they do not like.

    So undo had worked in Flipghost from early on, and we did not think about it much. Then you would delete a frame, press Ctrl+Z to get it back, and watch a stroke disappear from a different frame instead.

    The frame stayed deleted.

    The shape of the bug

    Delete frame 7. Press Ctrl+Z. Frame 7 does not come back — but the last stroke you drew on frame 6 quietly vanishes.

    The interesting part is not that delete had no undo. It is that Ctrl+Z did something anyway. It did not shrug, or beep, or no-op. It confidently reached into a frame you were not looking at and removed work you had not asked it to remove. A missing feature announces itself. This just lied.

    Why we built it that way

    Per-frame history was not an accident. It was an argument, and at the time we thought it was a good one. Our own docs made it in as many words:

    History is kept per frame. Undo on frame 4 only ever affects frame 4, so stepping back to fix something on an earlier frame will not unpick the work you did after it.

    That is a real property, and it comes from watching people actually use a flipbook. Animating is not linear. You draw frame 1, jump to frame 12 to block out where the arm lands, come back to 4, hop to 8, fix 12 again. If undo walked backwards through everything in wall-clock order, then hopping back to frame 4 to fix one line and pressing Ctrl+Z twice could rip out the work you did on frame 12 twenty minutes ago.

    Per-frame history made that impossible. Every frame carried its own little stack:

    histories: Record<string, { past: FrameJSON[]; future: FrameJSON[] }>

    Frame id in, undo stack out. Undo on frame 4 could not touch frame 9 because it could not see frame 9. The isolation was the point.

    What it could not see

    Look at that type for a second and the problem is sitting right there in the first line.

    The history was indexed by frame. Which means it could store anything that happened inside a frame, and nothing that happened to the set of frames. Adding a frame, duplicating one, dragging one along the timeline, deleting one — none of it was recordable, because the thing being changed was the index itself.

    And delete was the worst of them, because delete did not merely fail to record. Delete removed the frame, and removed that frame's history along with it, since the entry was keyed by an id that no longer existed. Then it moved the selection to a neighbouring frame, because you have to be looking at something.

    Now press Ctrl+Z. Undo asks for the history of the current frame. The current frame is the neighbour. The neighbour has a perfectly good stack of its own strokes. Undo pops one.

    Every individual piece did exactly what it said it did. The delete deleted. The cleanup cleaned up. The selection moved somewhere sensible. The undo undid. Composed together, they produced a confident wrong answer, which is the most expensive kind.

    The honest version

    We had not built a broken undo. We had built a working stroke-history and put the word "undo" on it.

    Nobody sitting at a canvas thinks I would like to revert the stroke buffer of the currently selected frame. They think I would like the last thing I did to not have happened. The last thing they did was delete a frame. The gap between those two sentences is the entire bug, and no amount of care inside the per-frame model would have closed it, because the model could not express the sentence the user was thinking.

    The fix, and why it cost less than we expected

    One stack for the whole document. Every entry is the entire state:

    past: Array<{ frames: Frame[]; currentId: string }>

    Draw a stroke, push. Delete a frame, push. Reorder, push. Undo pops one and puts it back.

    The obvious objection is memory. Snapshotting the whole document on every stroke sounds ruinous: a 200-frame animation where each frame carries a base64 PNG thumbnail, copied wholesale, fifty times over? That is hundreds of megabytes of nonsense.

    Except we never copy it. The store was already immutable, because that is what React wants. Every mutation already rebuilt the array and reused every frame object it did not touch:

    frames: s.frames.map((f) =>
      f.id === s.currentId ? { ...f, json, dataUrl } : f
    )

    One new frame object. One hundred and ninety-nine pointers to exactly the objects that were there before. So a snapshot is an array of two hundred pointers — a couple of kilobytes — and the frames inside it are the same frames the live state is using. The only genuinely new bytes in a history step are the one frame that actually changed.

    This is the part worth taking away, and it is not really about undo: the immutability you adopted to keep React honest silently pays for a feature you had not written yet. Structural sharing is not an optimisation you bolt on. It is a thing you either already have or do not.

    The leak we were not looking for

    Then we did the arithmetic on the model we were deleting.

    Fifty steps of history. Per frame. Pruned only when a frame was deleted. So a 200-frame animation that you had drawn on a bit could sit on ten thousand history entries, each holding a full copy of a frame's stroke JSON, and nothing anywhere bounded it except how many frames you had drawn on. The cap felt safe because it was written as 50. It was 50 × however long your animation is.

    The document-level stack is fifty. Total. Across everything. And because the entries share their frames with the live state, fifty steps costs about fifty changed frames' worth of data.

    We went in to fix a correctness bug and came out using less memory. That is not a brag — it is just what tends to happen when a data model stops arguing with the thing it is modelling. The per-frame map was paying, in memory, for a fiction.

    Where undo puts you

    Restoring the frames turns out not to be enough.

    Draw on frame 2. Navigate to frame 40. Press Ctrl+Z. If undo only restores the frame list, the right thing happens — your stroke on frame 2 is gone — and you see absolutely nothing, because you are looking at frame 40. You press it again. And again. You are now three edits deep into a part of your animation you cannot see.

    So the snapshot carries the selection too, and undo puts you back on the frame where the edit happened. Undo a delete and you land on the frame that just reappeared, in its old position, with its drawing intact.

    The trade is that undo can now move you around, which is a genuine cost — you can be reading frame 40 and get teleported. We took it anyway. An edit you cannot see is indistinguishable from a bug, and we had just finished fixing a bug that was exactly that.

    What it still does not cover

    The title, the frame rate, and the canvas size. Those are settings rather than edits — you did not do them so much as choose them, and if you want the old value you can simply choose it again. Putting them in the undo stack would mean Ctrl+Z sometimes changes your frame rate, which is a new small lie to replace the old large one.

    The thing we would tell ourselves

    Per-frame history was not a bad implementation of undo. It was a good implementation of something else, wearing undo's name, and it held up for as long as nobody asked it a question outside its vocabulary. The day someone deleted a frame and reached for Ctrl+Z, it did not fail — it answered anyway.

    We would rather it had beeped.