claude's first attempt

This commit is contained in:
Samuel Newman
2026-03-11 13:34:14 +02:00
parent 1c2fd5b280
commit c1d92e84ac
16 changed files with 1655 additions and 42 deletions
@@ -0,0 +1,20 @@
Pod::Spec.new do |s|
s.name = 'ExpoBlueskyVideoCompress'
s.version = '1.0.0'
s.summary = 'Hardware-accelerated video compression for Bluesky'
s.description = 'Hardware-accelerated video compression using AVAssetReader/Writer on iOS'
s.author = ''
s.homepage = 'https://github.com/bluesky-social/social-app'
s.platforms = { :ios => '15.1' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end
@@ -0,0 +1,53 @@
import ExpoModulesCore
import AVFoundation
public class ExpoVideoCompressModule: Module {
private var currentCompressor: VideoCompressor?
public func definition() -> ModuleDefinition {
Name("ExpoVideoCompress")
Events("onProgress")
AsyncFunction("probe") { (uri: String) -> [String: Any] in
let url = URL(string: uri) ?? URL(fileURLWithPath: uri)
return try await VideoProber.probe(url: url)
}
AsyncFunction("compress") { (uri: String, options: [String: Any]) -> [String: Any] in
let url = URL(string: uri) ?? URL(fileURLWithPath: uri)
let targetBitrate = options["targetBitrate"] as? Int ?? 3_000_000
let maxSize = options["maxSize"] as? Int ?? 1920
let jobId = options["jobId"] as? Int ?? 0
let compressor = VideoCompressor(
url: url,
targetBitrate: targetBitrate,
maxSize: maxSize,
jobId: jobId,
onProgress: { [weak self] id, progress in
self?.sendEvent("onProgress", [
"id": id,
"progress": progress
])
}
)
self.currentCompressor = compressor
do {
let result = try await compressor.compress()
self.currentCompressor = nil
return result
} catch {
self.currentCompressor = nil
throw error
}
}
Function("cancel") {
self.currentCompressor?.cancel()
self.currentCompressor = nil
}
}
}
@@ -0,0 +1,369 @@
import AVFoundation
class VideoCompressor {
private let url: URL
private let targetBitrate: Int
private let maxSize: Int
private let jobId: Int
private let onProgress: (Int, Double) -> Void
private var isCancelled = false
init(
url: URL,
targetBitrate: Int,
maxSize: Int,
jobId: Int,
onProgress: @escaping (Int, Double) -> Void
) {
self.url = url
self.targetBitrate = targetBitrate
self.maxSize = maxSize
self.jobId = jobId
self.onProgress = onProgress
}
func cancel() {
isCancelled = true
}
func compress() async throws -> [String: Any] {
let asset = AVURLAsset(url: url)
let duration = try await asset.load(.duration)
let totalSeconds = CMTimeGetSeconds(duration)
guard totalSeconds > 0 else {
throw NSError(
domain: "ExpoVideoCompress",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "Invalid video duration"]
)
}
// Load video track
let videoTracks = try await asset.loadTracks(withMediaType: .video)
guard let videoTrack = videoTracks.first else {
throw NSError(
domain: "ExpoVideoCompress",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "No video track found"]
)
}
let naturalSize = try await videoTrack.load(.naturalSize)
let preferredTransform = try await videoTrack.load(.preferredTransform)
// Calculate output dimensions
let outputSize = calculateOutputSize(
naturalSize: naturalSize,
transform: preferredTransform,
maxSize: maxSize
)
// Load audio tracks
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
let hasAudio = !audioTracks.isEmpty
// Audio analysis for passthrough decision
var shouldPassthroughAudio = false
if hasAudio, let audioTrack = audioTracks.first {
shouldPassthroughAudio = try await canPassthroughAudio(audioTrack)
}
// Create output file
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("mp4")
// Set up reader
let reader = try AVAssetReader(asset: asset)
let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)
// Video reader output - request raw frames
let videoReaderSettings: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
]
let videoReaderOutput = AVAssetReaderTrackOutput(
track: videoTrack,
outputSettings: videoReaderSettings
)
videoReaderOutput.alwaysCopiesSampleData = false
guard reader.canAdd(videoReaderOutput) else {
throw NSError(
domain: "ExpoVideoCompress",
code: 3,
userInfo: [NSLocalizedDescriptionKey: "Cannot read video track"]
)
}
reader.add(videoReaderOutput)
// Video writer input
let videoWriterSettings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: outputSize.width,
AVVideoHeightKey: outputSize.height,
AVVideoCompressionPropertiesKey: [
AVVideoAverageBitRateKey: targetBitrate,
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel,
AVVideoMaxKeyFrameIntervalKey: 90, // ~3s at 30fps
AVVideoExpectedSourceFrameRateKey: 30
] as [String: Any]
]
let videoWriterInput = AVAssetWriterInput(
mediaType: .video,
outputSettings: videoWriterSettings
)
videoWriterInput.expectsMediaDataInRealTime = false
// Apply transform for rotation
videoWriterInput.transform = preferredTransform
guard writer.canAdd(videoWriterInput) else {
throw NSError(
domain: "ExpoVideoCompress",
code: 4,
userInfo: [NSLocalizedDescriptionKey: "Cannot write video track"]
)
}
writer.add(videoWriterInput)
// Audio setup
var audioReaderOutput: AVAssetReaderOutput?
var audioWriterInput: AVAssetWriterInput?
if hasAudio, let audioTrack = audioTracks.first {
if shouldPassthroughAudio {
// Passthrough - copy audio as-is
let audioOutput = AVAssetReaderTrackOutput(
track: audioTrack,
outputSettings: nil
)
audioOutput.alwaysCopiesSampleData = false
if reader.canAdd(audioOutput) {
reader.add(audioOutput)
audioReaderOutput = audioOutput
let audioInput = AVAssetWriterInput(
mediaType: .audio,
outputSettings: nil
)
audioInput.expectsMediaDataInRealTime = false
if writer.canAdd(audioInput) {
writer.add(audioInput)
audioWriterInput = audioInput
}
}
} else {
// Re-encode audio to AAC 128kbps
let audioDecoderSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatLinearPCM,
AVSampleRateKey: 44100,
AVNumberOfChannelsKey: 2,
AVLinearPCMBitDepthKey: 16,
AVLinearPCMIsFloatKey: false,
AVLinearPCMIsBigEndianKey: false,
AVLinearPCMIsNonInterleaved: false
]
let audioOutput = AVAssetReaderTrackOutput(
track: audioTrack,
outputSettings: audioDecoderSettings
)
audioOutput.alwaysCopiesSampleData = false
if reader.canAdd(audioOutput) {
reader.add(audioOutput)
audioReaderOutput = audioOutput
let audioEncoderSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 44100,
AVNumberOfChannelsKey: 2,
AVEncoderBitRateKey: 128_000
]
let audioInput = AVAssetWriterInput(
mediaType: .audio,
outputSettings: audioEncoderSettings
)
audioInput.expectsMediaDataInRealTime = false
if writer.canAdd(audioInput) {
writer.add(audioInput)
audioWriterInput = audioInput
}
}
}
}
// Start reading/writing
reader.startReading()
writer.startWriting()
writer.startSession(atSourceTime: .zero)
// Process video and audio concurrently
try await withThrowingTaskGroup(of: Void.self) { group in
// Video processing
group.addTask {
try await self.processTrack(
readerOutput: videoReaderOutput,
writerInput: videoWriterInput,
totalDuration: totalSeconds
)
}
// Audio processing
if let audioOutput = audioReaderOutput, let audioInput = audioWriterInput {
group.addTask {
try await self.processTrack(
readerOutput: audioOutput,
writerInput: audioInput,
totalDuration: nil
)
}
}
try await group.waitForAll()
}
// Check for cancellation
if isCancelled {
writer.cancelWriting()
try? FileManager.default.removeItem(at: outputURL)
throw NSError(
domain: "ExpoVideoCompress",
code: 5,
userInfo: [NSLocalizedDescriptionKey: "Compression cancelled"]
)
}
// Check reader status
if reader.status == .failed {
let error = reader.error ?? NSError(
domain: "ExpoVideoCompress",
code: 6,
userInfo: [NSLocalizedDescriptionKey: "Reader failed"]
)
writer.cancelWriting()
try? FileManager.default.removeItem(at: outputURL)
throw error
}
// Finish writing
await writer.finishWriting()
if writer.status == .failed {
let error = writer.error ?? NSError(
domain: "ExpoVideoCompress",
code: 7,
userInfo: [NSLocalizedDescriptionKey: "Writer failed"]
)
try? FileManager.default.removeItem(at: outputURL)
throw error
}
// Get output file attributes
let attributes = try FileManager.default.attributesOfItem(atPath: outputURL.path)
let fileSize = attributes[.size] as? Int ?? 0
return [
"uri": outputURL.absoluteString,
"size": fileSize,
"mimeType": "video/mp4",
"width": outputSize.width,
"height": outputSize.height,
"duration": totalSeconds
]
}
private func processTrack(
readerOutput: AVAssetReaderOutput,
writerInput: AVAssetWriterInput,
totalDuration: Double?
) async throws {
var lastProgressTime: CFAbsoluteTime = 0
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
writerInput.requestMediaDataWhenReady(on: DispatchQueue(label: "com.bluesky.videocompress")) {
while writerInput.isReadyForMoreMediaData {
if self.isCancelled {
writerInput.markAsFinished()
continuation.resume()
return
}
guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else {
writerInput.markAsFinished()
continuation.resume()
return
}
// Send progress events (throttled to ~10/sec) for video track only
if let totalDuration = totalDuration {
let now = CFAbsoluteTimeGetCurrent()
if now - lastProgressTime >= 0.1 {
lastProgressTime = now
let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
let progress = min(CMTimeGetSeconds(pts) / totalDuration, 1.0)
self.onProgress(self.jobId, progress)
}
}
writerInput.append(sampleBuffer)
}
}
}
}
private func calculateOutputSize(
naturalSize: CGSize,
transform: CGAffineTransform,
maxSize: Int
) -> (width: Int, height: Int) {
// Apply transform to get actual display dimensions
let isRotated = abs(transform.b) == 1.0 && abs(transform.c) == 1.0
let sourceWidth = isRotated ? naturalSize.height : naturalSize.width
let sourceHeight = isRotated ? naturalSize.width : naturalSize.height
let maxDimension = CGFloat(maxSize)
// If already within bounds, keep original size (rounded to even)
if sourceWidth <= maxDimension && sourceHeight <= maxDimension {
return (
width: roundToEven(Int(sourceWidth)),
height: roundToEven(Int(sourceHeight))
)
}
// Scale down maintaining aspect ratio
let scale: CGFloat
if sourceWidth > sourceHeight {
scale = maxDimension / sourceWidth
} else {
scale = maxDimension / sourceHeight
}
return (
width: roundToEven(Int(sourceWidth * scale)),
height: roundToEven(Int(sourceHeight * scale))
)
}
private func roundToEven(_ value: Int) -> Int {
return value % 2 == 0 ? value : value - 1
}
private func canPassthroughAudio(_ audioTrack: AVAssetTrack) async throws -> Bool {
let formatDescriptions = try await audioTrack.load(.formatDescriptions)
guard let formatDesc = formatDescriptions.first else {
return false
}
let mediaSubType = CMFormatDescriptionGetMediaSubType(formatDesc)
// Only passthrough AAC audio
guard mediaSubType == kAudioFormatMPEG4AAC else {
return false
}
// Check bitrate - passthrough if <= 128kbps
let estimatedDataRate = try await audioTrack.load(.estimatedDataRate)
return estimatedDataRate <= 128_000
}
}
@@ -0,0 +1,89 @@
import AVFoundation
struct VideoProber {
static func probe(url: URL) async throws -> [String: Any] {
let asset = AVURLAsset(url: url)
let duration = try await asset.load(.duration)
let tracks = try await asset.loadTracks(withMediaType: .video)
guard let videoTrack = tracks.first else {
throw NSError(
domain: "ExpoVideoCompress",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "No video track found"]
)
}
let naturalSize = try await videoTrack.load(.naturalSize)
let preferredTransform = try await videoTrack.load(.preferredTransform)
let estimatedDataRate = try await videoTrack.load(.estimatedDataRate)
let nominalFrameRate = try await videoTrack.load(.nominalFrameRate)
let formatDescriptions = try await videoTrack.load(.formatDescriptions)
// Determine codec from format descriptions
var codec = "unknown"
if let formatDescription = formatDescriptions.first {
let mediaSubType = CMFormatDescriptionGetMediaSubType(formatDescription)
codec = fourCCToString(mediaSubType)
}
// Detect rotation from transform
let rotation = rotationFromTransform(preferredTransform)
// Apply rotation to get display size
let isRotated = rotation == 90 || rotation == 270
let width = isRotated ? Int(naturalSize.height) : Int(naturalSize.width)
let height = isRotated ? Int(naturalSize.width) : Int(naturalSize.height)
// Check for audio track
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
let hasAudio = !audioTracks.isEmpty
// Get file size
let fileSize: Int
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let size = attributes[.size] as? Int {
fileSize = size
} else {
fileSize = 0
}
// Bitrate: use estimated data rate, or calculate from file size
let durationSeconds = CMTimeGetSeconds(duration)
var bitrate = Int(estimatedDataRate)
if bitrate == 0 && durationSeconds > 0 {
bitrate = Int(Double(fileSize * 8) / durationSeconds)
}
return [
"width": width,
"height": height,
"duration": durationSeconds,
"bitrate": bitrate,
"fileSize": fileSize,
"codec": codec,
"hasAudio": hasAudio,
"frameRate": nominalFrameRate,
"rotation": rotation
]
}
private static func rotationFromTransform(_ transform: CGAffineTransform) -> Int {
let angle = atan2(transform.b, transform.a)
let degrees = Int(round(angle * 180.0 / .pi))
// Normalize to 0, 90, 180, 270
let normalized = ((degrees % 360) + 360) % 360
return normalized
}
private static func fourCCToString(_ code: FourCharCode) -> String {
let chars: [Character] = [
Character(UnicodeScalar((code >> 24) & 0xFF)!),
Character(UnicodeScalar((code >> 16) & 0xFF)!),
Character(UnicodeScalar((code >> 8) & 0xFF)!),
Character(UnicodeScalar(code & 0xFF)!)
]
return String(chars).trimmingCharacters(in: .whitespaces)
}
}