feat(camera): share jpeg transcoder + default maxWidth
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
import AVFoundation
|
||||
import ClawdisIPC
|
||||
import ClawdisKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import OSLog
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
actor CameraCaptureService {
|
||||
enum CameraError: LocalizedError, Sendable {
|
||||
@@ -34,8 +33,9 @@ actor CameraCaptureService {
|
||||
|
||||
func snap(facing: CameraFacing?, maxWidth: Int?, quality: Double?) async throws -> (data: Data, size: CGSize) {
|
||||
let facing = facing ?? .front
|
||||
let maxWidth = maxWidth.flatMap { $0 > 0 ? $0 : nil }
|
||||
let quality = Self.clampQuality(quality)
|
||||
let normalized = Self.normalizeSnap(maxWidth: maxWidth, quality: quality)
|
||||
let maxWidth = normalized.maxWidth
|
||||
let quality = normalized.quality
|
||||
|
||||
try await self.ensureAccess(for: .video)
|
||||
|
||||
@@ -74,7 +74,8 @@ actor CameraCaptureService {
|
||||
output.capturePhoto(with: settings, delegate: PhotoCaptureDelegate(cont))
|
||||
}
|
||||
|
||||
return try Self.reencodeJPEG(imageData: rawData, maxWidth: maxWidth, quality: quality)
|
||||
let res = try JPEGTranscoder.transcodeToJPEG(imageData: rawData, maxWidthPx: maxWidth, quality: quality)
|
||||
return (data: res.data, size: CGSize(width: res.widthPx, height: res.heightPx))
|
||||
}
|
||||
|
||||
func clip(
|
||||
@@ -185,71 +186,19 @@ actor CameraCaptureService {
|
||||
return min(1.0, max(0.05, q))
|
||||
}
|
||||
|
||||
nonisolated static func normalizeSnap(maxWidth: Int?, quality: Double?) -> (maxWidth: Int, quality: Double) {
|
||||
// Default to a reasonable max width to keep downstream payload sizes manageable.
|
||||
// If you need full-res, explicitly request a larger maxWidth.
|
||||
let maxWidth = maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
|
||||
let quality = Self.clampQuality(quality)
|
||||
return (maxWidth: maxWidth, quality: quality)
|
||||
}
|
||||
|
||||
private nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
|
||||
let v = ms ?? 3000
|
||||
return min(15000, max(250, v))
|
||||
}
|
||||
|
||||
private nonisolated static func reencodeJPEG(
|
||||
imageData: Data,
|
||||
maxWidth: Int?,
|
||||
quality: Double) throws -> (data: Data, size: CGSize)
|
||||
{
|
||||
guard let src = CGImageSourceCreateWithData(imageData as CFData, nil),
|
||||
let img = CGImageSourceCreateImageAtIndex(src, 0, nil)
|
||||
else {
|
||||
throw CameraError.captureFailed("Failed to decode captured image")
|
||||
}
|
||||
|
||||
let finalImage: CGImage
|
||||
if let maxWidth, img.width > maxWidth {
|
||||
guard let scaled = self.downscale(image: img, maxWidth: maxWidth) else {
|
||||
throw CameraError.captureFailed("Failed to downscale image")
|
||||
}
|
||||
finalImage = scaled
|
||||
} else {
|
||||
finalImage = img
|
||||
}
|
||||
|
||||
let out = NSMutableData()
|
||||
guard let dest = CGImageDestinationCreateWithData(out, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
throw CameraError.captureFailed("Failed to create JPEG destination")
|
||||
}
|
||||
|
||||
let props = [kCGImageDestinationLossyCompressionQuality: quality] as CFDictionary
|
||||
CGImageDestinationAddImage(dest, finalImage, props)
|
||||
guard CGImageDestinationFinalize(dest) else {
|
||||
throw CameraError.captureFailed("Failed to encode JPEG")
|
||||
}
|
||||
|
||||
return (out as Data, CGSize(width: finalImage.width, height: finalImage.height))
|
||||
}
|
||||
|
||||
private nonisolated static func downscale(image: CGImage, maxWidth: Int) -> CGImage? {
|
||||
guard image.width > 0, image.height > 0 else { return image }
|
||||
guard image.width > maxWidth else { return image }
|
||||
|
||||
let scale = Double(maxWidth) / Double(image.width)
|
||||
let targetW = maxWidth
|
||||
let targetH = max(1, Int((Double(image.height) * scale).rounded()))
|
||||
|
||||
let cs = CGColorSpaceCreateDeviceRGB()
|
||||
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
guard let ctx = CGContext(
|
||||
data: nil,
|
||||
width: targetW,
|
||||
height: targetH,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: cs,
|
||||
bitmapInfo: bitmapInfo)
|
||||
else { return nil }
|
||||
|
||||
ctx.interpolationQuality = .high
|
||||
ctx.draw(image, in: CGRect(x: 0, y: 0, width: targetW, height: targetH))
|
||||
return ctx.makeImage()
|
||||
}
|
||||
|
||||
private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws {
|
||||
let asset = AVAsset(url: inputURL)
|
||||
guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else {
|
||||
|
||||
Reference in New Issue
Block a user