Ollin

Capture

the frame-grab seam: an extension that saves the rendered frame to a PNG when you press S

Run
swift run --package-path Examples Example-Export-Capture
Source
Examples/Export/Capture/Sketch.swift
import AppKit
import Ollin

/// The frame-grab seam: an extension that receives the *rendered* frame and
/// saves it to a PNG, without `draw()` knowing about it.
///
/// `FrameSaver` is a `SketchExtension`. It only asks for the rendered frame
/// while a capture is armed (`wantsRenderedFrame` returns `armed`), so the
/// sketch pays for the grab only on the frame it saves, and the rest of the
/// time it runs full speed. Press **S** to save the current frame; the path is
/// printed and a ring flashes to confirm.
@main
final class Capture: Sketch {
    private let saver = FrameSaver()

    override func setup() {
        extend(saver)
    }

    override func draw() {
        background(Color(white: 0.07))
        // A slow rosette of orbiting dots — just something worth capturing.
        let c = center
        noStroke()
        let count = 12
        for i in 0..<count {
            let a = Double(i) / Double(count) * .tau + time * 0.3
            let r = shortSide * 0.32 * (0.6 + 0.4 * sin(time + Double(i)))
            let p = c + Vector2(angle: a) * r
            fill(CosinePalette.rainbow.color(at: Double(i) / Double(count)))
            drawCircle(center: p, radius: 46 * scale)
        }
    }

    override func keyPressed() {
        if key == "s" || key == "S" { saver.capture() }
    }
}

/// Saves the rendered frame to a PNG when armed, and flashes a ring for a moment
/// afterward to confirm. `wantsRenderedFrame` is `true` only while armed, so the
/// loop grabs the frame just for the save and nothing more.
final class FrameSaver: SketchExtension {
    private var armed = false
    private var flash = 0          // frames left to show the "saved" ring

    /// Arm a one-frame capture: the next rendered frame is saved, then disarms.
    func capture() { armed = true }

    /// Read once per frame. The frame arrives once the GPU has finished it, a
    /// refresh or so later, so a heavy sketch may have asked for a second frame
    /// before the first lands; `frameRendered` keeps only the first.
    var wantsRenderedFrame: Bool { armed }

    func afterDraw(_ sketch: Sketch) {
        guard flash > 0 else { return }
        flash -= 1
        sketch.withState {
            sketch.noFill()
            sketch.stroke(Color(red: 0.95, green: 0.3, blue: 0.3, alpha: Double(flash) / 30))
            sketch.strokeWeight(10 * sketch.scale)
            sketch.drawCircle(sketch.width / 2, sketch.height / 2, min(sketch.width, sketch.height) * 0.46)
        }
    }

    func frameRendered(_ sketch: Sketch, image: CGImage) {
        guard armed else { return }  // a frame asked for before the first arrived
        armed = false                // one frame only
        flash = 30                   // confirm on the frames after (kept out of the saved PNG)

        let url = FileManager.default.temporaryDirectory
            .appendingPathComponent(String(format: "ollin-frame-%05d.png", sketch.frameCount))
        guard let data = NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:]) else { return }
        do {
            try data.write(to: url)
            print("Saved \(image.width)×\(image.height) frame → \(url.path)")
        } catch {
            print("Capture: failed to write \(url.path): \(error)")
        }
    }
}

↑↓ move ↩ open