Skip to content
macOSDeep Dive Published Updated 7 min readViews unavailable

AVAssetWriter on macOS: Receivers, Backpressure, and Finalization

Write timed AVFoundation samples with modern receivers, deliberate timeline mapping, bounded backpressure, safe cancellation, and verified output publication.

AVAssetWriter is a single-use container-writing state machine. It accepts one or more configured inputs, maps their timed media into a writing session, interleaves tracks, and finalizes one output file. Calling start() is not proof that the file is complete, and a successful append is not proof that the output can be played. A production writer must coordinate sample timing, input backpressure, per-track completion, writer status, and publication of the destination file.

The current AVFoundation API provides typed input receivers. AVAssetWriterInput.SampleBufferReceiver.append(_:) asynchronously waits until an input can accept a sample buffer. That is a useful built-in backpressure boundary. Older code commonly polls isReadyForMoreMediaData, appends directly on a queue, and marks the input finished; current documentation marks those input APIs deprecated. Use the receiver model where the deployment target allows it, and isolate older compatibility code rather than making both paths race over one input.

Configure the container before accepting samples

Create the writer with a destination URL and a file type that the current system can produce. Add every input before starting. For encoded output, specify settings deliberately and validate them with canApply(outputSettings:forMediaType:) where appropriate. For passthrough, nil output settings mean samples are written without reencoding, subject to container compatibility. Pass a source format hint when it is available and correct; a format hint is not a request to transcode.

The input’s media type and the payload’s format must match. A video receiver does not turn audio samples into video, and an accepted codec is not necessarily valid in every container. Use the actual media type, output settings, source description, and file type together. Check canAdd(_:) before adding, and fail with an actionable configuration error instead of silently dropping the unsupported track.

The writer is single-use. Allocate a fresh writer for a retry or a second destination. Write first to a unique temporary file on the destination volume, then atomically publish it only after the writer reports .completed and any required file validation succeeds. Never truncate a user’s existing final file before the replacement has passed the pipeline.

Establish the timeline from media time

After configuring inputs and obtaining their receivers, call start(). Begin the writing session with startSession(atSourceTime:) before appending media. The chosen source time defines how source timestamps map into the output timeline. For a QuickTime movie, a session starting at source time T maps later samples relative to T; samples before the session start may be written but not displayed, and a later first sample can create an empty edit to preserve synchronization.

Do not substitute wall-clock time for sample presentation timestamps unless the product explicitly defines a live timeline policy. Audio and video tracks may begin at different source times. If preserving A/V sync matters, establish a shared session origin and keep each sample’s original rational CMTime values. Avoid converting timestamps to Double and back on every append. For trimming, define the mapping and test the first and final included samples instead of assuming a duration field rounds exactly to the intended frame.

import AVFoundation

func writeSingleVideoSample(
    _ sample: CMReadySampleBuffer<CMSampleBuffer.DynamicContent>,
    to destination: URL
) async throws {
    let writer = try AVAssetWriter(url: destination, fileType: .mov)
    let input = AVAssetWriterInput(
        mediaType: .video,
        outputSettings: nil,
        sourceFormatHint: sample.formatDescription
    )
    guard writer.canAdd(input) else { throw WriterFailure.cannotAddInput }

    let receiver = writer.inputReceiver(for: input)
    try writer.start()
    writer.startSession(atSourceTime: sample.presentationTimeStamp)
    try await receiver.append(sample)
    receiver.finish()
    await writer.finishWriting()

    guard writer.status == .completed else {
        throw writer.error ?? WriterFailure.incompleteOutput
    }
}

enum WriterFailure: Error {
    case cannotAddInput
    case incompleteOutput
}

This compact example writes one already-ready video sample without reencoding. A real movie pipeline repeatedly reads samples from its source and appends each to the corresponding receiver. The sample should be known to be video and its source format should be accepted by the target container. Production code must also check that the output URL is unique and remove a partial temporary file on failure.

Treat receivers as backpressure, not a queueing license

The async append call suspends until the input is ready. Await it before fetching unlimited more media. If a producer reads much faster than the writer can encode, buffering decoded frames in application memory defeats the receiver’s bounded-flow benefit. A safe transcode connects one or a small bounded number of reader outputs to matching writer receivers and propagates cancellation through both sides.

For live capture, choose a policy for overload: reduce work, drop an allowed frame, or fail the recording visibly. Do not block the capture callback waiting indefinitely for disk or encoder capacity. Distinguish a dropped preview frame from a dropped recording sample; only the latter affects the saved media timeline. If you skip a video frame, retain its timestamp gap unless the product explicitly wants time compression. Audio gaps need their own policy because inserting silence and compressing the timeline have different semantics.

When the output has multiple tracks, each input has its own flow-control point. AVAssetWriter interleaves concurrent track data, but the application still needs to keep inputs advancing in a way that lets the writer make progress. One input that is never finished can keep the writer waiting for ideal interleaving. Finish each receiver only after that track’s last intended sample has been appended. Do not finish the whole writer while producer tasks are still using a receiver.

Output settings and fidelity are explicit decisions

Video settings include codec, dimensions, profile, and possibly color properties. Audio settings include codec, sample rate, channel layout, and bitrate where supported. Check settings against the actual system and file type. A requested width and height do not guarantee aspect-ratio preservation; compute transforms and clean aperture intentionally. A request to convert HDR to SDR requires color-space conversion, not merely selecting an 8-bit pixel format. Apple documents that conversion order matters to avoid banding.

If passing compressed samples through, verify that the chosen container can represent the source codec and its format description. If encoding from pixel buffers, size, pixel format, color attachments, and presentation time must be coherent. If the application generates timestamps, use a CMTime scale appropriate to the source cadence and avoid cumulative rounding. Store metadata only when its provenance and output placement are understood; container metadata, track metadata, and timed metadata are not interchangeable.

Finish and verify the writer state

Call finish() on each receiver when its input is done. Then await finishWriting(). A successful completion operation still requires checking writer.status; inspect writer.error when the status is not completed. Do not announce success on the basis of a file’s existence or nonzero size. Validate the container by reopening it through AVFoundation and checking expected tracks, duration, dimensions, audio format, and a decodable sample at the beginning and end.

Cancellation is not successful completion. On user cancellation, stop upstream producers, cancel the writer, and remove the temporary artifact only after all work that might reference it has unwound. Make cleanup idempotent because writer failure, task cancellation, and source-read failure can arrive close together. Keep a per-job state record with configuration, track counts, first/last timestamps, status, and sanitized errors so an incomplete output can be diagnosed without logging user media.

Avoid subtle synchronization errors

  • Starting at the wrong origin: Starting the session at zero when the first source sample begins later changes how the output timeline is represented. Choose the source-time origin intentionally.
  • Unbounded producer tasks: Launching one append task per sample can create an unbounded task and buffer backlog. Use sequential awaits or an explicitly bounded channel.
  • Premature receiver finish: Finish only after the last successful append for that track; no more data can be appended afterward.
  • Missing input completion: A writer can wait on a track that has no more samples but was never marked finished.
  • False success: Require .completed, check errors, and validate the resulting asset before publishing.
  • Partial destination exposure: Keep incomplete output under a temporary name and make final replacement an explicit commit step.

Measurable acceptance checks

Test a single video input, synchronized audio and video, an empty source, a source whose first timestamp is nonzero, variable-frame-rate samples, a disk-full condition, an unsupported setting, cancellation during backpressure, and failure during finalization. Assert monotonically sensible presentation times per track, bounded queue depth, one terminal result, a completed writer status before publication, and a readable output with the expected duration and tracks. Compare output color and audio synchronization against known fixtures rather than trusting only the writer’s return status.

AVAssetWriter owns encoding and container assembly, but it cannot decide the application’s timeline or recovery policy. Treat receivers as explicit flow-control objects, preserve sample timing deliberately, and publish a file only after finalization and validation. Those rules make export and capture pipelines far easier to reason about under real load.

Related:

Sources:

Comments