Zum Inhalt

Capture / Export Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add high-resolution still (PNG) and offline deterministic video-clip (HEVC/H.264) export to the native Physarum app, with platform-idiomatic save/share (iOS → Photos + Share, macOS → file + Share).

Architecture: A CaptureService fully decoupled from the live RenderCoordinator — it owns its own MTLDevice/Renderer/SimEngine, renders the current preset offline on a background queue (post-FX at the live field resolution, composite upscaled to the chosen export size via the existing cover viewport), and encodes to PNG (still) or AVAssetWriter (video). The live view keeps animating untouched. Export aspect = current player view aspect (reuses the existing fieldSize rule so the export matches what's on screen).

Tech Stack: Swift 5 / SwiftUI multiplatform, Metal (PhysarumEngine), AVFoundation (AVAssetWriter + AVAssetWriterInputPixelBufferAdaptor + CVMetalTextureCache), Photos (PHPhotoLibrary), XcodeGen (project.yml SSOT), XCTest.

Spec: docs/superpowers/specs/2026-06-04-capture-export-design.md


File Structure

Engine (apple/PhysarumEngine/): - Modify Sources/PhysarumEngine/Renderer/Renderer.swift — add public renderOffscreen(into:cover:), target-size-aware encodePNG, public savePNG(_ tex:to:). (~1 small refactor + 1 new method) - Test Tests/PhysarumEngineTests/RendererCaptureTests.swift (new) — render-into-arbitrary-target + PNG of a non-field-size texture.

App (apple/PhysarumApp/): - Create Sources/Capture/CaptureMath.swift — pure functions (captureSlug, captureFilename, exportDimensions, clipFrameCount, clipPresentationTime). - Create Sources/Capture/CaptureConfig.swift — CaptureConfig, VideoCodec, CaptureKind, resolution presets. - Create Sources/Capture/ClipRecorder.swift — ClipRecorder protocol + runClip(...) orchestration loop (testable). - Create Sources/Capture/VideoClipRecorder.swift — real AVFoundation ClipRecorder (AVAssetWriter + adaptor + CVMetalTextureCache). - Create Sources/Capture/CaptureService.swift — @Observable facade: owns Metal objects, runs still/video on a serial queue, reports progress. - Create Sources/Capture/CaptureSaver.swift — platform save/share (#if os Photos vs fileExporter helpers). - Create Sources/Capture/CaptureSheet.swift — the Capture UI (Foto/Video, resolution, duration, codec, action, HUD). - Modify Sources/App/RootView.swift — capture topbar button + sheet presentation + fullscreen control. - Modify project.yml — NSPhotoLibraryAddUsageDescription in info.properties. - Tests: Tests/CaptureMathTests.swift, Tests/ClipRecorderTests.swift.


PART A — Increment 3f: Still Capture

Task 1: Engine — render into an arbitrary target + target-size PNG

Files: - Modify: apple/PhysarumEngine/Sources/PhysarumEngine/Renderer/Renderer.swift (renderOffscreen 258-269, savePNG 273-275, encodePNG 298-316) - Test: apple/PhysarumEngine/Tests/PhysarumEngineTests/RendererCaptureTests.swift (create)

Context: encodeFrame(_:sim:preset:into target: MTLRenderPassDescriptor, cover:) already takes a target descriptor. We expose a texture-target overload and make PNG encoding read the texture's dimensions (currently it reads the renderer's width/height, which only works for finalTex).

  • [ ] Step 1: Write the failing test
// apple/PhysarumEngine/Tests/PhysarumEngineTests/RendererCaptureTests.swift
import XCTest
import Metal
@testable import PhysarumEngine

final class RendererCaptureTests: XCTestCase {
    private func makeRendererAndSim(field: Int = 64) throws -> (Renderer, SimEngine)? {
        guard let device = MTLCreateSystemDefaultDevice() else { return nil }   // headless CI: no GPU → skip
        let lib = ShaderLibrary(device: device)
        let preset = PresetCatalog.fallbackPreset.params
        let r = try Renderer(device: device, library: lib, width: field, height: field)
        let s = try SimEngine(device: device, library: lib, width: field, height: field, preset: preset)
        return (r, s)
    }

    func testRenderOffscreenIntoLargerTargetProducesNonBlankPNG() throws {
        guard let (r, s) = try makeRendererAndSim() else { throw XCTSkip("No Metal device") }
        let preset = PresetCatalog.fallbackPreset.params
        // A target larger than the field (the composite must upscale into it).
        let d = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .bgra8Unorm, width: 256, height: 192, mipmapped: false)
        d.usage = [.renderTarget, .shaderRead]
        d.storageMode = .shared
        let target = try XCTUnwrap(MTLCreateSystemDefaultDevice()!.makeTexture(descriptor: d))
        r.resetFeedback()
        for _ in 0..<30 { s.step(); try r.renderOffscreen(sim: s, preset: preset, into: target, cover: true) }
        let url = FileManager.default.temporaryDirectory.appendingPathComponent("cap-test.png")
        XCTAssertTrue(try r.savePNG(target, to: url))
        let data = try Data(contentsOf: url)
        XCTAssertGreaterThan(data.count, 1000, "PNG of a 256×192 target should be non-trivial")
        try? FileManager.default.removeItem(at: url)
    }
}
  • [ ] Step 2: Run test to verify it fails

Run: cd apple/PhysarumEngine && swift test --filter RendererCaptureTests Expected: FAIL to compile — renderOffscreen(sim:preset:into:cover:) and savePNG(_:to:) don't exist yet.

  • [ ] Step 3: Add the engine API

In Renderer.swift, replace the existing renderOffscreen(sim:preset:) (258-269) with a thin wrapper plus a target overload, and add a public texture savePNG. Change encodePNG to use the texture's dimensions:

// Renders one full-post-FX frame of the current sim into `target`, scaling the
// field into it with the existing cover viewport. Internal scene/bloom/feedback
// stay at the renderer's field resolution; only the final composite is upscaled.
// Blocks until the GPU completes (the caller owns target's lifetime).
public func renderOffscreen(sim: SimEngine, preset: PresetParams,
                            into target: MTLTexture, cover: Bool = true) throws {
    guard let cb = queue.makeCommandBuffer() else { throw ShaderError.compile("no command buffer") }
    let cp = MTLRenderPassDescriptor()
    cp.colorAttachments[0].texture = target
    cp.colorAttachments[0].loadAction = .clear
    cp.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 1)
    cp.colorAttachments[0].storeAction = .store
    encodeFrame(cb, sim: sim, preset: preset, into: cp, cover: cover)
    cb.commit()
    cb.waitUntilCompleted()
    if let err = cb.error { throw ShaderError.compile("render GPU error: \(err)") }
}

// Existing API, now a wrapper over the target overload (no behaviour change).
public func renderOffscreen(sim: SimEngine, preset: PresetParams) throws {
    try renderOffscreen(sim: sim, preset: preset, into: finalTex, cover: true)
}

Change savePNG(to:) (273-275) and add a texture overload:

@discardableResult
public func savePNG(to path: String) throws -> Bool {
    try encodePNG(finalTex, to: URL(fileURLWithPath: path))
}

/// Encode any `.shared` bgra8 texture (e.g. a capture target) to PNG.
@discardableResult
public func savePNG(_ tex: MTLTexture, to url: URL) throws -> Bool {
    try encodePNG(tex, to: url)
}

Change encodePNG (298-316) to use tex.width/tex.height and take a URL:

private func encodePNG(_ tex: MTLTexture, to url: URL) throws -> Bool {
    let w = tex.width, h = tex.height
    let bytesPerRow = w * 4
    var raw = [UInt8](repeating: 0, count: bytesPerRow * h)
    tex.getBytes(&raw, bytesPerRow: bytesPerRow,
                 from: MTLRegionMake2D(0, 0, w, h), mipmapLevel: 0)
    let cs = CGColorSpaceCreateDeviceRGB()
    let bitmapInfo = CGBitmapInfo(
        rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue)
    guard let provider = CGDataProvider(data: Data(raw) as CFData),
          let img = CGImage(width: w, height: h, bitsPerComponent: 8, bitsPerPixel: 32,
                            bytesPerRow: bytesPerRow, space: cs, bitmapInfo: bitmapInfo,
                            provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent)
    else { throw ShaderError.compile("CGImage") }
    guard let dest = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil)
    else { throw ShaderError.compile("png dest") }
    CGImageDestinationAddImage(dest, img, nil)
    return CGImageDestinationFinalize(dest)
}

Note: the old renderPNG(sim:preset:to:) (281-285) still calls savePNG(to:) — unchanged. The cover: true on a square field+square finalTex is the identity, so PresetGallery output is byte-identical.

  • [ ] Step 4: Run tests to verify they pass

Run: cd apple/PhysarumEngine && swift test Expected: all pass (existing 20 + new RendererCaptureTests, or XCTSkip if the test host has no Metal device).

  • [ ] Step 5: Commit
git add apple/PhysarumEngine/Sources/PhysarumEngine/Renderer/Renderer.swift apple/PhysarumEngine/Tests/PhysarumEngineTests/RendererCaptureTests.swift
git commit -m "feat(engine): renderOffscreen into an arbitrary target + target-size PNG encode"

Task 2: Pure capture math (filename, dimensions, frame timing)

Files: - Create: apple/PhysarumApp/Sources/Capture/CaptureMath.swift - Test: apple/PhysarumApp/Tests/CaptureMathTests.swift

  • [ ] Step 1: Write the failing test
// apple/PhysarumApp/Tests/CaptureMathTests.swift
import XCTest
import CoreMedia
@testable import Physarum

final class CaptureMathTests: XCTestCase {
    func testSlugRules() {
        XCTAssertEqual(captureSlug("Mandala Six"), "mandala-six")
        XCTAssertEqual(captureSlug("Ember Veil"), "ember-veil")
        XCTAssertEqual(captureSlug("  Café  Déjà!! "), "cafe-deja")
        XCTAssertEqual(captureSlug("***"), "preset")               // empty → fallback
        XCTAssertEqual(captureSlug("Aurora—Drift"), "aurora-drift") // em-dash → hyphen
    }

    func testFilenameFormat() {
        var c = DateComponents(); c.year = 2026; c.month = 6; c.day = 4; c.hour = 9; c.minute = 7; c.second = 5
        let date = Calendar(identifier: .gregorian).date(from: c)!
        let name = captureFilename(presetName: "Neural Lattice", date: date, ext: "png")
        XCTAssertEqual(name, "physarum-neural-lattice-20260604-090705.png")
    }

    func testExportDimensionsEvenAndOriented() {
        // square
        XCTAssertEqual(exportDimensions(aspectWH: 1, longEdge: 2048).w, 2048)
        XCTAssertEqual(exportDimensions(aspectWH: 1, longEdge: 2048).h, 2048)
        // landscape 16:9 at 1920 long edge → 1920×1080
        let l = exportDimensions(aspectWH: 16.0/9.0, longEdge: 1920)
        XCTAssertEqual(l.w, 1920); XCTAssertEqual(l.h, 1080)
        // portrait 9:16 at 1920 long edge → 1080×1920
        let p = exportDimensions(aspectWH: 9.0/16.0, longEdge: 1920)
        XCTAssertEqual(p.w, 1080); XCTAssertEqual(p.h, 1920)
        // odd → rounded to even
        let odd = exportDimensions(aspectWH: 1.5, longEdge: 999)
        XCTAssertEqual(odd.w % 2, 0); XCTAssertEqual(odd.h % 2, 0)
    }

    func testFrameCountAndPTS() {
        XCTAssertEqual(clipFrameCount(durationSeconds: 10, fps: 60), 600)
        let pts = clipPresentationTime(frameIndex: 30, fps: 60)
        XCTAssertEqual(pts.value, 30); XCTAssertEqual(pts.timescale, 60)
        XCTAssertEqual(CMTimeGetSeconds(pts), 0.5, accuracy: 1e-9)
    }
}
  • [ ] Step 2: Run test to verify it fails

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/CaptureMathTests 2>&1 | tail -20 Expected: FAIL to compile — captureSlug etc. undefined.

  • [ ] Step 3: Write the implementation
// apple/PhysarumApp/Sources/Capture/CaptureMath.swift
import Foundation
import CoreMedia

/// Ports src/lib/filename.ts slug rules: lowercase, strip diacritics,
/// non-alphanumeric runs → single hyphen, trim hyphens, empty → "preset".
func captureSlug(_ input: String) -> String {
    let folded = input.lowercased()
        .folding(options: .diacriticInsensitive, locale: Locale(identifier: "en_US_POSIX"))
    var out = ""
    var lastHyphen = false
    for scalar in folded.unicodeScalars {
        let c = Character(scalar)
        if c.isASCII && (c.isLetter || c.isNumber) {
            out.append(c); lastHyphen = false
        } else if !lastHyphen {
            out.append("-"); lastHyphen = true
        }
    }
    while out.hasPrefix("-") { out.removeFirst() }
    while out.hasSuffix("-") { out.removeLast() }
    return out.isEmpty ? "preset" : out
}

private let timestampFormatter: DateFormatter = {
    let f = DateFormatter()
    f.locale = Locale(identifier: "en_US_POSIX")
    f.dateFormat = "yyyyMMdd-HHmmss"
    return f
}()

/// `physarum-<preset-slug>-<YYYYMMDD-HHmmss>.<ext>`
func captureFilename(presetName: String, date: Date, ext: String) -> String {
    "physarum-\(captureSlug(presetName))-\(timestampFormatter.string(from: date)).\(ext)"
}

/// Even-rounded export dimensions for a width/height aspect at a given long edge.
/// aspectWH >= 1 → width is the long edge; < 1 (portrait) → height is the long edge.
func exportDimensions(aspectWH: Double, longEdge: Int) -> (w: Int, h: Int) {
    func even(_ v: Int) -> Int { max(2, v - (v % 2)) }
    if aspectWH >= 1 {
        return (even(longEdge), even(Int((Double(longEdge) / aspectWH).rounded())))
    } else {
        return (even(Int((Double(longEdge) * aspectWH).rounded())), even(longEdge))
    }
}

func clipFrameCount(durationSeconds: Int, fps: Int) -> Int { max(1, durationSeconds) * max(1, fps) }

func clipPresentationTime(frameIndex: Int, fps: Int) -> CMTime {
    CMTime(value: CMTimeValue(frameIndex), timescale: CMTimeScale(max(1, fps)))
}
  • [ ] Step 4: Run tests to verify they pass

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/CaptureMathTests 2>&1 | tail -20 Expected: TEST SUCCEEDED.

If folding(.diacriticInsensitive) leaves a residual on the em-dash test, adjust the test's expectation — the dash is non-alphanumeric so it becomes a hyphen regardless; the assertion above already expects that.

  • [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/Capture/CaptureMath.swift apple/PhysarumApp/Tests/CaptureMathTests.swift
git commit -m "feat(app): pure capture math (filename slug, export dims, clip timing)"

Task 3: CaptureConfig + CaptureService still path

Files: - Create: apple/PhysarumApp/Sources/Capture/CaptureConfig.swift - Create: apple/PhysarumApp/Sources/Capture/CaptureService.swift

No new unit test (GPU path — verified via build + manual). Keep the file compiling on the macOS test host.

  • [ ] Step 1: Write CaptureConfig
// apple/PhysarumApp/Sources/Capture/CaptureConfig.swift
import Foundation

enum VideoCodec: String, CaseIterable, Identifiable, Equatable {
    case hevc, h264
    var id: String { rawValue }
    var label: String { self == .hevc ? "HEVC (.mov)" : "H.264 (.mp4)" }
    var fileExtension: String { self == .hevc ? "mov" : "mp4" }
}

enum CaptureKind: Equatable { case still; case video(durationSeconds: Int) }

struct CaptureConfig: Equatable {
    var kind: CaptureKind
    var longEdge: Int
    var fps: Int = 60
    var codec: VideoCodec = .hevc

    /// Long-edge presets offered in the UI.
    static let stillSizes = [1080, 2048, 4096]
    static let videoSizes = [1280, 1920, 2560]
    static let videoDurations = [5, 10, 15, 30]
    static let defaultStill = CaptureConfig(kind: .still, longEdge: 2048)
    static let defaultVideo = CaptureConfig(kind: .video(durationSeconds: 10), longEdge: 1920)
}
  • [ ] Step 2: Write CaptureService (still path; video stub added in Part B)
// apple/PhysarumApp/Sources/Capture/CaptureService.swift
import Foundation
import Metal
import PhysarumEngine

@MainActor
@Observable
final class CaptureService {
    enum State: Equatable {
        case idle
        case running(progress: Double)
        case done(url: URL, kind: CaptureKind)
        case failed(String)
    }
    private(set) var state: State = .idle

    private let queue = DispatchQueue(label: "de.jkaindl.physarum.capture", qos: .userInitiated)
    private var cancelled = false

    private let warmupFrames = 180   // feedback warm-up at field resolution (cheap)

    func cancel() { cancelled = true }
    func reset() { state = .idle; cancelled = false }

    /// Render the current preset to a still or clip. `viewWidth/Height` is the live
    /// player size — the export takes that aspect (reuses `fieldSize`) so it matches
    /// what's on screen. Runs off-main; reports progress/result on main.
    func capture(preset: PresetParams, presetName: String,
                 viewWidth: Double, viewHeight: Double, cover: Bool, config: CaptureConfig) {
        cancelled = false
        state = .running(progress: 0)
        let field = fieldSize(viewWidth: viewWidth, viewHeight: viewHeight)
        let aspect = viewWidth / max(1, viewHeight)
        let (w, h) = exportDimensions(aspectWH: aspect, longEdge: config.longEdge)
        queue.async { [weak self] in
            guard let self else { return }
            do {
                let url = try self.run(preset: preset, presetName: presetName,
                                       field: field, exportW: w, exportH: h, cover: cover, config: config)
                Task { @MainActor in
                    if self.cancelled { self.state = .idle }
                    else { self.state = .done(url: url, kind: config.kind) }
                }
            } catch {
                let msg = "\(error)"
                Task { @MainActor in self.state = self.cancelled ? .idle : .failed(msg) }
            }
        }
    }

    // MARK: - Background worker

    private func run(preset: PresetParams, presetName: String, field: FieldSize,
                     exportW: Int, exportH: Int, cover: Bool, config: CaptureConfig) throws -> URL {
        guard let device = MTLCreateSystemDefaultDevice() else { throw CaptureError.noDevice }
        let lib = ShaderLibrary(device: device)
        var p = preset
        p.agentCount = densityScaledAgentCount(preset.agentCount, fieldWidth: field.width, fieldHeight: field.height)
        let renderer = try Renderer(device: device, library: lib, width: field.width, height: field.height)
        let sim = try SimEngine(device: device, library: lib, width: field.width, height: field.height, preset: p)

        // Feedback warm-up at field resolution (renders into the renderer's own finalTex).
        renderer.resetFeedback()
        for _ in 0..<warmupFrames {
            if cancelled { throw CaptureError.cancelled }
            sim.step()
            try renderer.renderOffscreen(sim: sim, preset: p)
        }

        switch config.kind {
        case .still:
            return try captureStill(device: device, renderer: renderer, sim: sim, preset: p,
                                    presetName: presetName, exportW: exportW, exportH: exportH, cover: cover)
        case .video(let seconds):
            return try captureVideo(device: device, renderer: renderer, sim: sim, preset: p,
                                    presetName: presetName, exportW: exportW, exportH: exportH,
                                    cover: cover, seconds: seconds, config: config)
        }
    }

    private func captureStill(device: MTLDevice, renderer: Renderer, sim: SimEngine, preset: PresetParams,
                              presetName: String, exportW: Int, exportH: Int, cover: Bool) throws -> URL {
        let d = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .bgra8Unorm, width: exportW, height: exportH, mipmapped: false)
        d.usage = [.renderTarget, .shaderRead]
        d.storageMode = .shared
        guard let target = device.makeTexture(descriptor: d) else { throw CaptureError.noTexture }
        sim.step()
        try renderer.renderOffscreen(sim: sim, preset: preset, into: target, cover: cover)
        let url = FileManager.default.temporaryDirectory
            .appendingPathComponent(captureFilename(presetName: presetName, date: Date(), ext: "png"))
        guard try renderer.savePNG(target, to: url) else { throw CaptureError.encode }
        return url
    }

    // captureVideo(...) is added in Part B (Task 9).
}

enum CaptureError: Error, CustomStringConvertible {
    case noDevice, noTexture, encode, cancelled, writer(String)
    var description: String {
        switch self {
        case .noDevice: return "No Metal device available."
        case .noTexture: return "Could not allocate the capture texture."
        case .encode: return "Image encoding failed."
        case .cancelled: return "Capture cancelled."
        case .writer(let m): return "Recording failed: \(m)"
        }
    }
}

Date() is used inside the background worker (allowed — this is app code, not a Workflow script). The captureVideo call won't compile yet; add a temporary stub so Part A builds, then replace it in Task 9:

private func captureVideo(device: MTLDevice, renderer: Renderer, sim: SimEngine, preset: PresetParams,
                          presetName: String, exportW: Int, exportH: Int, cover: Bool,
                          seconds: Int, config: CaptureConfig) throws -> URL {
    throw CaptureError.writer("video not implemented yet")   // replaced in Task 9
}

  • [ ] Step 3: Regenerate the Xcode project (new source folder)

Run: cd apple/PhysarumApp && xcodegen generate Expected: project regenerated; signing preserved (team in project.yml).

  • [ ] Step 4: Build to verify it compiles

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -5 Expected: BUILD SUCCEEDED.

  • [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/Capture/CaptureConfig.swift apple/PhysarumApp/Sources/Capture/CaptureService.swift apple/PhysarumApp/Physarum.xcodeproj
git commit -m "feat(app): CaptureService + config (still render path, video stub)"

Task 4: Platform save/share + Photos permission

Files: - Create: apple/PhysarumApp/Sources/Capture/CaptureSaver.swift - Modify: apple/PhysarumApp/project.yml (info.properties)

  • [ ] Step 1: Add the Photos permission string to project.yml

In project.yml, under targets.Physarum.info.properties (currently CFBundleDisplayName, LSApplicationCategoryType, UILaunchScreen), add:

        NSPhotoLibraryAddUsageDescription: "Physarum saves your captured images and clips to your photo library."
  • [ ] Step 2: Write CaptureSaver
// apple/PhysarumApp/Sources/Capture/CaptureSaver.swift
import Foundation
import Photos

enum CaptureSaver {
    /// Save a still/clip file URL to the Photos library (add-only). Requests
    /// permission on first use. Returns true on success.
    static func saveToPhotos(_ url: URL, isVideo: Bool) async -> Bool {
        let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
        guard status == .authorized || status == .limited else { return false }
        return await withCheckedContinuation { cont in
            PHPhotoLibrary.shared().performChanges {
                let req = PHAssetCreationRequest.forAsset()
                let opts = PHAssetResourceCreationOptions()
                opts.originalFilename = url.lastPathComponent
                req.addResource(with: isVideo ? .video : .photo, fileURL: url, options: opts)
            } completionHandler: { ok, _ in cont.resume(returning: ok) }
        }
    }
}
  • [ ] Step 3: Regenerate + build

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -5 Expected: BUILD SUCCEEDED.

  • [ ] Step 4: Verify the plist key landed

Run: cd apple/PhysarumApp && /usr/libexec/PlistBuddy -c "Print :NSPhotoLibraryAddUsageDescription" Resources/Info.plist Expected: prints the usage string (XcodeGen merged it).

  • [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/Capture/CaptureSaver.swift apple/PhysarumApp/project.yml apple/PhysarumApp/Resources/Info.plist apple/PhysarumApp/Physarum.xcodeproj
git commit -m "feat(app): Photos add-only save helper + NSPhotoLibraryAddUsageDescription"

Task 5: Capture UI (sheet + topbar button) — still wired end-to-end

Files: - Create: apple/PhysarumApp/Sources/Capture/CaptureSheet.swift - Modify: apple/PhysarumApp/Sources/App/RootView.swift

  • [ ] Step 1: Write CaptureSheet
// apple/PhysarumApp/Sources/Capture/CaptureSheet.swift
import SwiftUI
import PhysarumEngine

struct CaptureSheet: View {
    let preset: PresetParams
    let presetName: String
    let viewWidth: Double
    let viewHeight: Double
    let cover: Bool
    @Binding var isPresented: Bool

    @State private var service = CaptureService()
    @State private var isVideo = false
    @State private var stillLong = CaptureConfig.defaultStill.longEdge
    @State private var videoLong = CaptureConfig.defaultVideo.longEdge
    @State private var duration = 10
    @State private var codec: VideoCodec = .hevc
    @State private var showShare = false

    private var config: CaptureConfig {
        isVideo
            ? CaptureConfig(kind: .video(durationSeconds: duration), longEdge: videoLong, fps: 60, codec: codec)
            : CaptureConfig(kind: .still, longEdge: stillLong)
    }

    var body: some View {
        VStack(alignment: .leading, spacing: Theme.Space.s4) {
            Picker("", selection: $isVideo) {
                Text("Photo").tag(false); Text("Video").tag(true)
            }.pickerStyle(.segmented)

            switch service.state {
            case .idle:
                form
            case .running(let p):
                hud(progress: p)
            case .done(let url, let kind):
                doneView(url: url, isVideo: kind != .still)
            case .failed(let msg):
                VStack(alignment: .leading, spacing: 8) {
                    Text("Capture failed").font(Theme.Fonts.label)
                    Text(msg).font(.caption).foregroundStyle(Theme.Colors.fgTertiary)
                    Button("Try again") { service.reset() }
                }
            }
        }
        .padding(Theme.Space.s4)
        .frame(minWidth: 280)
        .presentationDetents([.medium])
        .presentationCompactAdaptation(.popover)
    }

    @ViewBuilder private var form: some View {
        if isVideo {
            picker("Resolution", $videoLong, CaptureConfig.videoSizes) { "\($0)p-ish · \($0)" }
            picker("Duration", $duration, CaptureConfig.videoDurations) { "\($0) s" }
            Picker("Codec", selection: $codec) {
                ForEach(VideoCodec.allCases) { Text($0.label).tag($0) }
            }
        } else {
            picker("Resolution", $stillLong, CaptureConfig.stillSizes) { px in
                px >= 3840 ? "4K · \(px)" : "\(px)"
            }
        }
        Button(isVideo ? "Render clip" : "Capture") { start() }
            .buttonStyle(.borderedProminent)
    }

    private func picker<T: Hashable>(_ title: String, _ sel: Binding<T>, _ values: [T],
                                     _ label: @escaping (T) -> String) -> some View {
        Picker(title, selection: sel) { ForEach(values, id: \.self) { Text(label($0)).tag($0) } }
    }

    private func hud(progress: Double) -> some View {
        VStack(spacing: 12) {
            ProgressView(value: progress).progressViewStyle(.linear)
            Text("\(Int(progress * 100)) %").font(.caption).monospacedDigit()
            Button("Cancel", role: .cancel) { service.cancel() }
        }
    }

    @ViewBuilder private func doneView(url: URL, isVideo: Bool) -> some View {
        VStack(alignment: .leading, spacing: 12) {
            Text(isVideo ? "Clip ready" : "Image ready").font(Theme.Fonts.label)
            Text(url.lastPathComponent).font(.caption).foregroundStyle(Theme.Colors.fgTertiary).lineLimit(1)
            HStack {
                #if os(iOS)
                Button("Save to Photos") { Task { _ = await CaptureSaver.saveToPhotos(url, isVideo: isVideo) } }
                #endif
                ShareLink(item: url) { Text("Share") }
                Button("Done") { isPresented = false }
            }
        }
    }

    private func start() {
        service.capture(preset: preset, presetName: presetName,
                        viewWidth: viewWidth, viewHeight: viewHeight, cover: cover, config: config)
    }
}

The macOS "Save…" via .fileExporter can be added later; ShareLink already exposes "Save to Files"/AirDrop on both platforms and is the cross-platform primary. iOS adds the explicit "Save to Photos".

  • [ ] Step 2: Wire into RootView

In RootView.swift, add state (near the other @State, ~line 20):

@State private var showCapture = false

In topbar(...) (after formatMenu, before the fullscreen iconButton), add the capture button:

iconButton("square.and.arrow.up") { showCapture = true }

Add the sheet alongside the other .sheet modifiers (after the showLibrary sheet, ~line 87). Capture needs the live player size — capture it in the GeometryReader via the fullW/fullH already computed; pass them in. Add this modifier inside the GeometryReader chain:

.sheet(isPresented: $showCapture) {
    CaptureSheet(preset: state.edited, presetName: state.selectedPreset.name,
                 viewWidth: Double(fullW), viewHeight: Double(fullH),
                 cover: cover, isPresented: $showCapture)
}

  • [ ] Step 3: Regenerate + build (macOS) + build (iOS sim)

Run:

cd apple/PhysarumApp && xcodegen generate \
  && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -5 \
  && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -5
Expected: BUILD SUCCEEDED (both).

  • [ ] Step 4: Run the app suite

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | tail -15 Expected: TEST SUCCEEDED (existing 50 + CaptureMathTests).

  • [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/Capture/CaptureSheet.swift apple/PhysarumApp/Sources/App/RootView.swift apple/PhysarumApp/Physarum.xcodeproj
git commit -m "feat(app): capture sheet + topbar button (high-res still export, save/share)"

Task 6: Inc 3f gate — sim screenshot verification

Files: none (verification only)

  • [ ] Step 1: Build + install + launch on the iPhone simulator

Run:

xcrun simctl boot 'iPhone 17 Pro' 2>/dev/null; open -a Simulator
cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build 2>&1 | tail -5
APP=$(xcodebuild -scheme Physarum -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -showBuildSettings 2>/dev/null | awk '/BUILT_PRODUCTS_DIR/{d=$3}/FULL_PRODUCT_NAME/{n=$3}END{print d"/"n}')
xcrun simctl install booted "$APP" && xcrun simctl launch booted de.jkaindl.physarum
Expected: app launches; tapping the capture (share) icon opens the Capture sheet.

  • [ ] Step 2: Capture a still in the sim and confirm the file

Drive the capture from the UI (tap capture → Photo → Capture). Then screenshot the sim state: Run: xcrun simctl io booted screenshot /tmp/capture-still-check.png Expected: sheet shows "Image ready" with a physarum-<slug>-…png filename and Share/Save actions.

  • [ ] Step 3: Hand-off note for Johannes (device + visual fidelity)

Document that on-device visual fidelity (export looks like the live view, no thinner/paler) and the iOS "Save to Photos" permission prompt need a real-device check — the still path is GPU + Photos, not unit-tested. (Part A is functionally complete; full visual sign-off in the Verification gate.)

  • [ ] Step 4: Commit (if any tweaks) — otherwise skip.

PART B — Increment 3g: Video Recording

Task 7: Clip orchestration loop + protocol (testable)

Files: - Create: apple/PhysarumApp/Sources/Capture/ClipRecorder.swift - Test: apple/PhysarumApp/Tests/ClipRecorderTests.swift

  • [ ] Step 1: Write the failing test
// apple/PhysarumApp/Tests/ClipRecorderTests.swift
import XCTest
import CoreMedia
@testable import Physarum

final class ClipRecorderTests: XCTestCase {
    final class MockRecorder: ClipRecorder {
        var began = false, finished = false
        var appended: [(Int, CMTime)] = []
        let url = URL(fileURLWithPath: "/tmp/mock.mov")
        func begin() throws { began = true }
        func appendFrame(index: Int, pts: CMTime) throws { appended.append((index, pts)) }
        func finish() async throws -> URL { finished = true; return url }
    }

    func testRunClipAppendsEveryFrameWithMonotonicPTS() async throws {
        let rec = MockRecorder()
        var rendered: [Int] = []
        var progress = 0.0
        let url = try await runClip(frameCount: 5, fps: 60, recorder: rec,
                                    renderFrame: { rendered.append($0) },
                                    isCancelled: { false },
                                    progress: { progress = $0 })
        XCTAssertTrue(rec.began); XCTAssertTrue(rec.finished)
        XCTAssertEqual(rendered, [0, 1, 2, 3, 4])
        XCTAssertEqual(rec.appended.map(\.0), [0, 1, 2, 3, 4])
        XCTAssertEqual(rec.appended.map { $0.1.value }, [0, 1, 2, 3, 4])
        XCTAssertEqual(progress, 1.0, accuracy: 1e-9)
        XCTAssertEqual(url.lastPathComponent, "mock.mov")
    }

    func testRunClipStopsOnCancel() async throws {
        let rec = MockRecorder()
        var rendered = 0
        _ = try await runClip(frameCount: 100, fps: 60, recorder: rec,
                              renderFrame: { _ in rendered += 1 },
                              isCancelled: { rendered >= 3 },   // cancel after 3 renders
                              progress: { _ in })
        XCTAssertLessThanOrEqual(rendered, 4)
        XCTAssertTrue(rec.finished, "finish() must still close the file cleanly")
    }
}
  • [ ] Step 2: Run to verify it fails

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/ClipRecorderTests 2>&1 | tail -20 Expected: FAIL to compile — ClipRecorder / runClip undefined.

  • [ ] Step 3: Write the orchestration
// apple/PhysarumApp/Sources/Capture/ClipRecorder.swift
import Foundation
import CoreMedia

/// Abstracts the video sink so the orchestration loop is testable without AVFoundation.
protocol ClipRecorder {
    func begin() throws
    func appendFrame(index: Int, pts: CMTime) throws
    func finish() async throws -> URL
}

/// Deterministic offline append loop: render frame n, append it at n/fps, report
/// progress. On cancel it stops appending but still finalizes the file cleanly.
func runClip(frameCount: Int, fps: Int, recorder: ClipRecorder,
             renderFrame: (Int) throws -> Void,
             isCancelled: () -> Bool,
             progress: (Double) -> Void) async throws -> URL {
    try recorder.begin()
    for n in 0..<frameCount {
        if isCancelled() { break }
        try renderFrame(n)
        try recorder.appendFrame(index: n, pts: clipPresentationTime(frameIndex: n, fps: fps))
        progress(Double(n + 1) / Double(frameCount))
    }
    return try await recorder.finish()
}
  • [ ] Step 4: Run to verify it passes

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/ClipRecorderTests 2>&1 | tail -20 Expected: TEST SUCCEEDED.

  • [ ] Step 5: Regenerate + commit
cd apple/PhysarumApp && xcodegen generate
git add apple/PhysarumApp/Sources/Capture/ClipRecorder.swift apple/PhysarumApp/Tests/ClipRecorderTests.swift apple/PhysarumApp/Physarum.xcodeproj
git commit -m "feat(app): testable clip orchestration loop + ClipRecorder protocol"

Task 8: AVFoundation video recorder

Files: - Create: apple/PhysarumApp/Sources/Capture/VideoClipRecorder.swift

GPU + AVFoundation — verified via build + manual. The recorder vends a pool pixel buffer, wraps it as a Metal texture, lets the caller render into it, then appends.

  • [ ] Step 1: Write VideoClipRecorder
// apple/PhysarumApp/Sources/Capture/VideoClipRecorder.swift
import Foundation
import AVFoundation
import Metal
import CoreVideo

/// Real ClipRecorder: AVAssetWriter + pixel-buffer-pool + CVMetalTextureCache.
/// The caller renders each frame into the texture handed to `renderInto`.
final class VideoClipRecorder: ClipRecorder {
    private let device: MTLDevice
    private let url: URL
    private let width: Int, height: Int, fps: Int, codec: VideoCodec
    private let renderInto: (MTLTexture) throws -> Void

    private var writer: AVAssetWriter!
    private var input: AVAssetWriterInput!
    private var adaptor: AVAssetWriterInputPixelBufferAdaptor!
    private var textureCache: CVMetalTextureCache!

    init(device: MTLDevice, url: URL, width: Int, height: Int, fps: Int, codec: VideoCodec,
         renderInto: @escaping (MTLTexture) throws -> Void) {
        self.device = device; self.url = url
        self.width = width; self.height = height; self.fps = fps; self.codec = codec
        self.renderInto = renderInto
    }

    func begin() throws {
        try? FileManager.default.removeItem(at: url)
        let fileType: AVFileType = codec == .hevc ? .mov : .mp4
        writer = try AVAssetWriter(outputURL: url, fileType: fileType)

        let codecType: AVVideoCodecType = codec == .hevc ? .hevc : .h264
        let compression: [String: Any] = [
            AVVideoAverageBitRateKey: bitrate(),
            AVVideoMaxKeyFrameIntervalKey: fps * 2,
        ]
        let settings: [String: Any] = [
            AVVideoCodecKey: codecType,
            AVVideoWidthKey: width,
            AVVideoHeightKey: height,
            AVVideoCompressionPropertiesKey: compression,
            AVVideoColorPropertiesKey: [
                AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2,
                AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_709_2,
                AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2,
            ],
        ]
        input = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
        input.expectsMediaDataInRealTime = false
        let pbAttrs: [String: Any] = [
            kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
            kCVPixelBufferMetalCompatibilityKey as String: true,
            kCVPixelBufferIOSurfacePropertiesKey as String: [:],
            kCVPixelBufferWidthKey as String: width,
            kCVPixelBufferHeightKey as String: height,
        ]
        adaptor = AVAssetWriterInputPixelBufferAdaptor(assetWriterInput: input, sourcePixelBufferAttributes: pbAttrs)
        guard writer.canAdd(input) else { throw CaptureError.writer("cannot add input") }
        writer.add(input)

        var cache: CVMetalTextureCache?
        CVMetalTextureCacheCreate(nil, nil, device, nil, &cache)
        guard let cache else { throw CaptureError.writer("no texture cache") }
        textureCache = cache

        guard writer.startWriting() else { throw CaptureError.writer("\(writer.error?.localizedDescription ?? "startWriting")") }
        writer.startSession(atSourceTime: .zero)
    }

    func appendFrame(index: Int, pts: CMTime) throws {
        // Backpressure: offline source — wait until the input drains.
        while !input.isReadyForMoreMediaData {
            if writer.status == .failed { throw CaptureError.writer("\(writer.error?.localizedDescription ?? "failed")") }
            Thread.sleep(forTimeInterval: 0.002)
        }
        try autoreleasepool {
            guard let pool = adaptor.pixelBufferPool else { throw CaptureError.writer("no pool") }
            var pbOut: CVPixelBuffer?
            CVPixelBufferPoolCreatePixelBuffer(nil, pool, &pbOut)
            guard let pb = pbOut else { throw CaptureError.writer("no pixel buffer") }

            var cvTexOut: CVMetalTexture?
            let r = CVMetalTextureCacheCreateTextureFromImage(
                nil, textureCache, pb, nil, .bgra8Unorm, width, height, 0, &cvTexOut)
            guard r == kCVReturnSuccess, let cvTex = cvTexOut,
                  let tex = CVMetalTextureGetTexture(cvTex) else { throw CaptureError.writer("texture from buffer") }

            try renderInto(tex)                 // engine renders + blocks (waitUntilCompleted)
            if !adaptor.append(pb, withPresentationTime: pts) {
                throw CaptureError.writer("\(writer.error?.localizedDescription ?? "append")")
            }
            // cvTex released at autoreleasepool end — GPU is already done (renderInto blocks).
        }
    }

    func finish() async throws -> URL {
        input.markAsFinished()
        await writer.finishWriting()
        if writer.status != .completed { throw CaptureError.writer("\(writer.error?.localizedDescription ?? "finish")") }
        return url
    }

    private func bitrate() -> Int {
        let pixels = width * height
        // ~ HEVC efficient; scale with area. 1080p≈12-16 Mbps HEVC.
        let base = codec == .hevc ? 8.0 : 14.0
        return Int(base * 1_000_000 * Double(pixels) / Double(1920 * 1080))
    }
}

The texture target is BGRA .bgra8Unorm, matching kCVPixelFormatType_32BGRA and the composite pipeline's output format. renderInto calls renderer.renderOffscreen(into: tex, cover:) which blocks (waitUntilCompleted), so the CVMetalTexture is safe to release after append.

  • [ ] Step 2: Regenerate + build

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -5 Expected: BUILD SUCCEEDED.

  • [ ] Step 3: Commit
git add apple/PhysarumApp/Sources/Capture/VideoClipRecorder.swift apple/PhysarumApp/Physarum.xcodeproj
git commit -m "feat(app): AVFoundation video recorder (AVAssetWriter + CVMetalTextureCache)"

Task 9: Wire video into CaptureService

Files: - Modify: apple/PhysarumApp/Sources/Capture/CaptureService.swift (replace the captureVideo stub)

  • [ ] Step 1: Replace the stub with the real implementation
    private func captureVideo(device: MTLDevice, renderer: Renderer, sim: SimEngine, preset: PresetParams,
                              presetName: String, exportW: Int, exportH: Int, cover: Bool,
                              seconds: Int, config: CaptureConfig) throws -> URL {
        let frames = clipFrameCount(durationSeconds: seconds, fps: config.fps)
        let url = FileManager.default.temporaryDirectory
            .appendingPathComponent(captureFilename(presetName: presetName, date: Date(), ext: config.codec.fileExtension))
        let recorder = VideoClipRecorder(
            device: device, url: url, width: exportW, height: exportH, fps: config.fps, codec: config.codec,
            renderInto: { [weak self] tex in
                guard let self else { return }
                sim.step()
                try renderer.renderOffscreen(sim: sim, preset: preset, into: tex, cover: cover)
            })
        // runClip is async; bridge it onto this synchronous background worker.
        let sema = DispatchSemaphore(value: 0)
        var result: Result<URL, Error>!
        Task {
            do {
                let out = try await runClip(
                    frameCount: frames, fps: config.fps, recorder: recorder,
                    renderFrame: { _ in },   // rendering happens inside recorder.appendFrame → renderInto
                    isCancelled: { self.cancelled },
                    progress: { p in Task { @MainActor in
                        if case .running = self.state { self.state = .running(progress: p) }
                    } })
                result = .success(out)
            } catch { result = .failure(error) }
            sema.signal()
        }
        sema.wait()
        switch result! {
        case .success(let u): return u
        case .failure(let e): throw e
        }
    }

The frame render lives inside recorder.appendFrame (via renderInto), so runClip's renderFrame is a no-op here — appendFrame both renders and appends, keeping the pixel-buffer lifetime correct. The progress closure updates the observable state on main.

  • [ ] Step 2: Build (macOS + iOS sim)

Run:

cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -5 \
  && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -5
Expected: BUILD SUCCEEDED (both).

  • [ ] Step 3: Run the full app suite

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | tail -15 Expected: TEST SUCCEEDED (existing 50 + CaptureMathTests + ClipRecorderTests).

  • [ ] Step 4: Commit
git add apple/PhysarumApp/Sources/Capture/CaptureService.swift
git commit -m "feat(app): wire offline video clips into CaptureService"

Task 10: Video save destination + Inc 3g gate

Files: none new (the CaptureSheet already handles video via the shared done view; verify the codec/duration pickers and the share/save work for .mov/.mp4).

  • [ ] Step 1: Verify the sheet exposes video controls

Confirm CaptureSheet (Task 5) shows Resolution + Duration + Codec when isVideo, and the done view's Save to Photos/ShareLink pass isVideo: true for clips. (The doneView(isVideo:) flag is derived from the CaptureKind in .done.)

  • [ ] Step 2: Render a clip on the iPhone simulator

Run:

cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build 2>&1 | tail -5
APP=$(xcodebuild -scheme Physarum -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -showBuildSettings 2>/dev/null | awk '/BUILT_PRODUCTS_DIR/{d=$3}/FULL_PRODUCT_NAME/{n=$3}END{print d"/"n}')
xcrun simctl install booted "$APP" && xcrun simctl launch booted de.jkaindl.physarum
Drive: capture → Video → 5 s → HEVC → Render clip. Watch the progress HUD advance to 100 %, then "Clip ready". Run: xcrun simctl io booted screenshot /tmp/capture-clip-check.png Expected: "Clip ready" with a .mov filename + Save/Share actions; no crash.

  • [ ] Step 3: Deploy to the real iPhone (headless)

Run:

UDID=1D819185-3910-5389-A9BE-C377B8010156
cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination "platform=iOS,id=$UDID" -allowProvisioningUpdates build 2>&1 | tail -5
APP=$(xcodebuild -scheme Physarum -destination "platform=iOS,id=$UDID" -showBuildSettings 2>/dev/null | awk '/BUILT_PRODUCTS_DIR/{d=$3}/FULL_PRODUCT_NAME/{n=$3}END{print d"/"n}')
xcrun devicectl device install app --device "$UDID" "$APP"
Expected: install succeeds. (Johannes launches + records a clip, checks Photos save + playback + visual fidelity.)

  • [ ] Step 4: Commit (if any tweaks) — otherwise skip.

Verification Gate (whole feature)

  • [ ] Engine swift test green (incl. RendererCaptureTests).
  • [ ] App suite green (50 + CaptureMathTests + ClipRecorderTests).
  • [ ] macOS + iOS-sim BUILD SUCCEEDED.
  • [ ] Sim screenshots show still + clip "ready" flows.
  • [ ] Deployed to iPhone JK.
  • [ ] Johannes visual sign-off: export matches the live look (no thinner/paler — [[physarum-preset-resolution-tuning]]); clip plays smoothly with no dropped frames; HEVC + H.264 both play; Photos save prompt + landing works; live view keeps animating during capture; cancel produces no truncated file.

Self-Review (against the spec)

Spec coverage: §3 engine renderOffscreen(into:) → Task 1. §4 CaptureService + pure fns → Tasks 2–3. §5 AVFoundation offline pipeline → Tasks 7–9. §6 UI (sheet/popover, resolution/duration/codec, HUD, fullscreen) → Task 5 (+ fullscreen control noted below). §7 save/permissions → Task 4 (+ video Task 10). §8 testing (pure fns + orchestration mocks + integration) → Tasks 2,7 + gates. §9 scope split 3f/3g → Parts A/B. §10 risks (fidelity, two renderers, lifetime, target storage, threading, even dims) → addressed in Tasks 1,3,8 + notes.

Gap fixed: §6 says the capture button should also appear in the fullscreen tap-reveal cluster. Task 5 adds it to the topbar (hidden in fullscreen). Add to Task 5, Step 2: also place a capture button in exitFullscreenControl's HStack (or add a sibling control in the .overlay(alignment: .bottom)), so capture is reachable in fullscreen. Minimal: extend the bottom overlay to HStack { captureButton; exitFullscreenControl } gated by fsHintShown.

Placeholder scan: none — every step has real code/commands. (The Task 3 captureVideo stub is explicitly a temporary placeholder replaced in Task 9, called out inline.)

Type consistency: CaptureConfig/CaptureKind/VideoCodec consistent across Tasks 3,5,9. ClipRecorder.finish() async throws -> URL matches mock + real + runClip + captureVideo. renderOffscreen(sim:preset:into:cover:) signature consistent Tasks 1,3,8,9. captureFilename(presetName:date:ext:) consistent Tasks 2,3,9. CaptureService.State consistent Tasks 3,5,9.