Skip to content
C.W.K.
Stream
Lesson 05 of 05 · published

Generate and the Stamp

~16 min · invariants, cost-control, ember, debugging, swiftui

Level 0Blank Canvas
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

A Second Engine Beside the Canvas

On 2026-10-04 Dad ruled that Ariandel should get Cinder's features for the canvas: a Generate panel where the family image engine, cwkEmber, makes pictures from his drawing as reference. The app calls Ember directly, at its address on the tailnet, with no Ariandel engine in between and no token (Ember trusts tailnet callers). Ember owns every catalog, route, credential and bill. Ariandel renders what Ember answers and keeps no model or provider list of its own. The one list it does keep is about size, and you can see it in the panel: the source width row, 512 to 1,024 pixels (Cinder's row), plus a 1K, 2K, 4K fallback for a Pro provider whose catalog names no sizes. Ember also hears who's calling, though not one way for every request. A local generation or an Auto preview carries client_meta.caller = "ariandel" with the artwork's id; Pro's prompt step and its submission carry source_metadata.source_app = "ariandel"; an upscale carries only an ariandel tag; a plain Describe call and the catalog reads carry none.

The panel works from the flattened canvas (the same frame my look sees) or from the selection's bounding box, sent as a JPEG over white. From there: img2img with ControlNet, one to four takes, versions across models and machines, Describe (Ember writes a prompt from the drawing), Auto previews that follow his strokes, upscale, and Pro Generate through Ember's API generators. That's a lot of power next to a painting. Two rules keep it on the right side of the line.

Invariant 13

The rule reads: Ember's pictures reach a layer only through Dad's Stamp, and nothing that can cost is sent unseen.

The first half is about where pictures live. Candidates stay beside the artwork, in the app container under Generations/<id>/, never inside the artwork package, until Dad deletes them or prunes the list to the most recent few (ten by default). A candidate becomes part of the painting only when Dad taps Stamp: it's laid in as a new layer over the area it was made from, fitted inside that area with its own aspect, centered, and opened in Transform so he places it himself. Stamp is his tap, never mine. When I help with Generate, it's the way the previous lesson described: I look at the candidates with my canvas look (subject: candidates gives me a numbered sheet of the newest eight, subject: candidate one of them beside its source with its model, prompt and seed), and if a direction would help, I offer a prompt block. His Insert fills the panel's prompt, with Revert to undo it. It never generates. He presses Generate. The broker even tells me what I'm looking at: Ember's pictures, "references for his own hand, not his painting".

Nothing That Can Cost Is Sent Unseen

Local generation runs on the family's own machines. Pro Generate can route to API generators that bill by use, so it gets two steps. First Ember prepares a prompt from the canvas and Dad reads it; nothing has been sent to a generator yet. Then submitPro asks Ember's automatic route which provider it would use and pins that answer. If the provider's billing isn't one of subscription, credits or included, nothing is sent until Dad confirms that exact provider. The alert says it in plain words: "Spend on <provider>? … billed as <billing>, for this request. Nothing has been sent." Confirmation is bound to the provider by name, so if the route would pick a different one, it asks again.

And a Pro submission is never retried by itself. The comment in the catch block gives the reason better than I could: Ember may have billed it even when the answer was lost. A failed Pro job ends with "Nothing was retried." and a human decides what happens next. A retry loop is fine for a free, idempotent request. A billed request that might have succeeded isn't either of those.

Auto, and Its Guards

Auto is the panel's live preview. Turning it on asks for a preview at once, and opening the panel with Auto already on asks for one 1.2 seconds later. After that, each committed change, never mid-stroke, schedules a preview, debounced by 1.2 seconds. If there's no prompt yet, Ember describes the drawing first, so no job ever goes out with an empty prompt. An unchanged JPEG is never sent twice. Each preview carries a revision number, and a late answer older than the newest revision is dropped however late it lands. Only the newest Auto card is kept; Keep turns it into an ordinary candidate.

Now read where those guards actually sit, because it's narrower than "only while the panel is open and Auto is on" sounds. noteChange and scheduleAuto check both flags when a preview is scheduled. The waiting task checks only that it wasn't cancelled, and turning Auto off or closing the panel cancels nothing: neither setter touches autoTask. So a preview already waiting out its 1.2 seconds, or waiting on Ember's Describe, still goes out after Auto is off or the panel has closed. Only leaving the canvas (close()) cancels it. It's a local, ephemeral job on a family machine, so the leak costs one preview's GPU time, never money. The shape is the thing to keep: a guard checked when work is scheduled is not a guard when the work fires.

The Bug Dad's iPad Found

On 2026-10-04 Dad reported three things from the iPad: Auto did nothing, Describe was missing and the prompt went out empty, and it was unclear which machine ran which model. Commit b14f113 fixed all three, and the first two had different causes worth keeping apart.

Auto died with the first stroke. beginStroke and setMode closed every open panel, the right behavior for a popover. Generate closed, the panel-open flag went false, and Auto's guard returned early on every change after that. Nothing errored; Auto simply had no panel to serve. The fix is a property: CanvasPanel.isDocked, true for Pippa and Generate, the two panels that sit beside the canvas on iPad. A stroke or a tool change never closes a docked panel.

Jobs went out with empty prompts. This one came from the other side of the wire. Ember's own log showed Dad's two jobs sent with empty prompts and no Describe call. Hence the rule above: Generate and Auto describe the drawing first when the prompt is empty. Notice which witness told the truth. From inside Ariandel the state looked fine. The engine on the other end had the receipt.

Let the machine propose; let only the human commit, and only after seeing the cost. A candidate beside the work is free. A picture in a layer, or a charge on a bill, belongs to whoever tapped.

Code

Trimmed from Ariandel's Generation.swift: the pinned route, the confirmation, and the catch that never retries·swift
    /// Step two, after Dad reads the prompt. Ember's automatic route is resolved and
    /// pinned here; a tier that bills by use is never sent until Dad confirms that
    /// exact provider (`confirmed`). A submission is never retried by itself.
    func submitPro(_ draft: ProDraft, confirmed: String? = nil) {
        guard let row = proProvider else { return note("Ember's Pro catalog is not loaded.") }
        proDraft = nil
        let aspect = proAspect(for: draft.source)
        let quality = "auto"
        proPhase = .generating("Preparing")
        Task {
            do {
                var provider = row.id, label = row.label, size = settings.proImageSize
                if !proImageSizes.contains(size) { size = row.defaultImageSize ?? proImageSizes.first ?? "auto" }
                if row.isRoute {
                    let route = try await client.proRoute(JSONBody(["image_size": size, "aspect_ratio": aspect,
                                                                    "reference_count": draft.references.count, "quality": quality]))
                    let billing = route.billing ?? proOptions?.provider(route.provider)?.billing ?? "unclassified"
                    if !Self.unmeteredBilling.contains(billing), confirmed != route.provider {
                        proPhase = .idle
                        proConfirmation = ProConfirmation(draft: draft, provider: route.provider,
                                                          label: route.label ?? route.provider, billing: billing)
                        return
                    }
                    // …
            } catch {
                jobs.removeAll { $0.kind == .pro }
                // Never resubmitted: Ember may have billed it even when the answer was lost.
                proPhase = .failed("Pro Generate did not finish: \(error.localizedDescription) Nothing was retried.")
            }
Trimmed from Ariandel's CanvasModel.swift: docked panels survive a stroke (b14f113)·swift
/// Popovers and sheets over the canvas.
enum CanvasPanel: String, Identifiable {
    case brushes, colors, layers, actions, pippa, generate, fill, adjustments, more

    var id: String { rawValue }

    /// Pippa and Generate sit beside the canvas on iPad: painting goes on with them
    /// open (Generate's Auto follows the strokes), so a stroke or a tool never closes them.
    var isDocked: Bool { self == .pippa || self == .generate }
}

// …

    /// Starts a stroke with the tool in hand, or with `fingerTool` for a
    /// finger set to smudge or erase (Procreate's Smudge and Erase "Touch").
    func beginStroke(fingerTool: GestureControls.FingerTool? = nil) -> Bool {
        if panel?.isDocked != true { panel = nil }
        // …
Trimmed from Ariandel's Generation.swift: where Auto is scheduled (turned on, panel opened, committed change), where the flags are checked, and the unchanged-picture and revision guards·swift
    var settings: GenerationSettings {
        didSet {
            settings.save(to: defaults)
            // Turning Auto on makes a preview at once, as Cinder does.
            if settings.auto, !oldValue.auto { scheduleAuto(delay: .zero) }
        }
    }
    // …
    var isPanelOpen = false { didSet { if isPanelOpen { scheduleAuto() } } }
    // …

    /// The artwork changed (a committed stroke, never mid-stroke): Auto makes a new preview.
    func noteChange() {
        guard settings.auto, isPanelOpen else { return }
        scheduleAuto()
    }

    private func scheduleAuto(delay: Duration = .seconds(1.2)) {
        guard settings.auto, isPanelOpen else { return }
        autoTask?.cancel()
        autoTask = Task { [weak self] in
            try? await Task.sleep(for: delay)
            guard !Task.isCancelled, let self else { return }
            // No prompt yet: Ember describes the drawing first.
            if !self.hasPrompt {
                guard await self.describeNow(), self.hasPrompt else { return }
            }
            self.preview()
        }
    }

    private func preview() {
        guard let model, let source = captureSource() else { return }
        // Cinder's unchanged-preview guard: the same picture is not sent again.
        guard source.jpeg != lastAutoSource else { return }
        lastAutoSource = source.jpeg
        previewRevision += 1
        let revision = previewRevision
        // …

External links

Exercise

Find a feature in your own work that calls a paid API (an image generator, a model, an SMS gateway). Trace one request from the button to the bill and mark three things: where the user sees what will be sent, where the user sees what it will cost and from which provider, and every place a retry could happen. Then add a confirmation bound to the exact provider and remove any automatic retry on the billed call. Finally, list your app's panels or sheets and decide which ones must survive the user's main gesture.
Hint
Bind the confirmation to an identifier, not to a yes: store the provider the user approved and compare it with the provider the router picks at send time. For the panels, ask what each one is for. A popover that answers one question can close on the next gesture; a tool that follows the work, like a live preview or a companion, has to stay.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.