Extract the Start Timecode From a Quicktime Movie
This is a trifle but I was stuck on it for some time, even if you follow all of the documentation there’s a trick to it.
import AVFoundation
import CoreMedia
enum MovieTimecodeExtractionError: Error {
case IntegrityError
}
enum MovieTimecodeExtractionResult {
case Success(CMTime)
case NoTimecodeTrack
case NoTimecodeSamples
}
/// Get the first timecode value from a movie file.
///
/// This function uses AVFoundation and will attempt to load timecode tracks from the
/// given movie. This is appropriate for QuickTime `.mov` and `.mp4`-type files.
///
/// If the movie was succesfully read, the function will either return the time value as a
/// `CMTime` if timecode was found, or `nil` if
/// - The movie contained no timecode track, or
/// - The movie contains a timecode track but that track has no timecode samples
///
func extractStartTimecode(from url: URL) async throws
-> MovieTimecodeExtractionResult
{
let asset = AVURLAsset(url: url)
// all of this is pretty straighforward...
guard
let timecodeTrack = try await asset.loadTracks(withMediaType: .timecode)
.first
else {
return .NoTimecodeTrack
}
let reader = try AVAssetReader(asset: asset)
let output = AVAssetReaderTrackOutput(
track: timecodeTrack,
outputSettings: nil
)
reader.add(output)
guard reader.startReading() else {
throw reader.error!
}
// This is where I got tripped-up...
// What I didn't know was that the `output` here can (and does) return
// valid sampleBuffers that, despite being valid, contain no data...
while let sampleBuffer = output.copyNextSampleBuffer() {
guard
let formatDescription: CMFormatDescription =
CMSampleBufferGetFormatDescription(sampleBuffer),
let blockBuffer: CMBlockBuffer = CMSampleBufferGetDataBuffer(
sampleBuffer
)
else {
// ...so I'd get here, and when these two were nil, I initially assumed
// that this meant the output was exhausted. Not the case!
//
// If these are nil, that just means this sampleBuffer was empty, but
// there are more sampleBuffers and the output will return these from
// copyNextSampleBuffer, and you should only stop iterating the output
// when copyNextSampleBuffer returns nil.
// If we're here, either of these were nil, and so the correct thing
// to do now is to advance to the next sampleBuffer.
continue
}
let frameQuanta = CMTimeCodeFormatDescriptionGetFrameQuanta(
formatDescription
)
var rawData: UnsafeMutablePointer<Int8>? = nil
var length: Int = 0
var totalLength: Int = 0
let error = CMBlockBufferGetDataPointer(
blockBuffer,
atOffset: 0,
lengthAtOffsetOut: &length,
totalLengthOut: &totalLength,
dataPointerOut: &rawData
)
guard error == kCMBlockBufferNoErr else {
throw NSError(domain: NSOSStatusErrorDomain, code: Int(error))
}
guard let tcData = rawData else {
// I'm not sure how we would get here if error was noErr.
throw MovieTimecodeExtractionError.IntegrityError
}
let type = CMFormatDescriptionGetMediaSubType(formatDescription)
let frames: CMTimeValue?
switch type {
case kCMTimeCodeFormatType_TimeCode32:
let fr0 = tcData.withMemoryRebound(
to: UInt32.self,
capacity: 1,
{ CFSwapInt32BigToHost($0.pointee) }
)
frames = CMTimeValue(fr0)
case kCMTimeCodeFormatType_TimeCode64:
let fr0 = tcData.withMemoryRebound(
to: UInt64.self,
capacity: 1,
{ CFSwapInt64BigToHost($0.pointee) }
)
frames = CMTimeValue(fr0)
default:
// IF we're here, `type` is probably one of the
// kCMTimeCodeFormatType_Counter* values, which are not useable to
// this function
frames = nil
}
reader.cancelReading()
return frames.flatMap {
.Success(
CMTime(
value: $0,
timescale: CMTimeScale(frameQuanta)
)
)
} ?? .NoTimecodeSamples
}
// We're here if the `while` never found a timecode databuffer
switch reader.status {
case .completed:
return .NoTimecodeSamples
case .failed:
throw reader.error!
default:
throw MovieTimecodeExtractionError.IntegrityError
}
}