165 lines
6.2 KiB
Swift
165 lines
6.2 KiB
Swift
|
|
import Foundation
|
|||
|
|
import Photos
|
|||
|
|
import OpenClawKit
|
|||
|
|
import UIKit
|
|||
|
|
|
|||
|
|
final class PhotoLibraryService: PhotosServicing {
|
|||
|
|
// The gateway WebSocket has a max payload size; returning large base64 blobs
|
|||
|
|
// can cause the gateway to close the connection. Keep photo payloads small
|
|||
|
|
// enough to safely fit in a single RPC frame.
|
|||
|
|
//
|
|||
|
|
// This is a transport constraint (not a security policy). If callers need
|
|||
|
|
// full-resolution media, we should switch to an HTTP media handle flow.
|
|||
|
|
private static let maxTotalBase64Chars = 340 * 1024
|
|||
|
|
private static let maxPerPhotoBase64Chars = 300 * 1024
|
|||
|
|
|
|||
|
|
func latest(params: OpenClawPhotosLatestParams) async throws -> OpenClawPhotosLatestPayload {
|
|||
|
|
let status = await Self.ensureAuthorization()
|
|||
|
|
guard status == .authorized || status == .limited else {
|
|||
|
|
throw NSError(domain: "Photos", code: 1, userInfo: [
|
|||
|
|
NSLocalizedDescriptionKey: "PHOTOS_PERMISSION_REQUIRED: grant Photos permission",
|
|||
|
|
])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let limit = max(1, min(params.limit ?? 1, 20))
|
|||
|
|
let fetchOptions = PHFetchOptions()
|
|||
|
|
fetchOptions.fetchLimit = limit
|
|||
|
|
fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
|
|||
|
|
let assets = PHAsset.fetchAssets(with: .image, options: fetchOptions)
|
|||
|
|
|
|||
|
|
var results: [OpenClawPhotoPayload] = []
|
|||
|
|
var remainingBudget = Self.maxTotalBase64Chars
|
|||
|
|
let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
|
|||
|
|
let quality = params.quality.map { max(0.1, min(1.0, $0)) } ?? 0.85
|
|||
|
|
let formatter = ISO8601DateFormatter()
|
|||
|
|
|
|||
|
|
assets.enumerateObjects { asset, _, stop in
|
|||
|
|
if results.count >= limit { stop.pointee = true; return }
|
|||
|
|
if let payload = try? Self.renderAsset(
|
|||
|
|
asset,
|
|||
|
|
maxWidth: maxWidth,
|
|||
|
|
quality: quality,
|
|||
|
|
formatter: formatter)
|
|||
|
|
{
|
|||
|
|
// Keep the entire response under the gateway WS max payload.
|
|||
|
|
if payload.base64.count > remainingBudget {
|
|||
|
|
stop.pointee = true
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
remainingBudget -= payload.base64.count
|
|||
|
|
results.append(payload)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return OpenClawPhotosLatestPayload(photos: results)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static func ensureAuthorization() async -> PHAuthorizationStatus {
|
|||
|
|
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
|||
|
|
PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static func renderAsset(
|
|||
|
|
_ asset: PHAsset,
|
|||
|
|
maxWidth: Int,
|
|||
|
|
quality: Double,
|
|||
|
|
formatter: ISO8601DateFormatter) throws -> OpenClawPhotoPayload
|
|||
|
|
{
|
|||
|
|
let manager = PHImageManager.default()
|
|||
|
|
let options = PHImageRequestOptions()
|
|||
|
|
options.isSynchronous = true
|
|||
|
|
options.isNetworkAccessAllowed = true
|
|||
|
|
options.deliveryMode = .highQualityFormat
|
|||
|
|
|
|||
|
|
let targetSize: CGSize = {
|
|||
|
|
guard maxWidth > 0 else { return PHImageManagerMaximumSize }
|
|||
|
|
let aspect = CGFloat(asset.pixelHeight) / CGFloat(max(1, asset.pixelWidth))
|
|||
|
|
let width = CGFloat(maxWidth)
|
|||
|
|
return CGSize(width: width, height: width * aspect)
|
|||
|
|
}()
|
|||
|
|
|
|||
|
|
var image: UIImage?
|
|||
|
|
manager.requestImage(
|
|||
|
|
for: asset,
|
|||
|
|
targetSize: targetSize,
|
|||
|
|
contentMode: .aspectFit,
|
|||
|
|
options: options)
|
|||
|
|
{ result, _ in
|
|||
|
|
image = result
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
guard let image else {
|
|||
|
|
throw NSError(domain: "Photos", code: 2, userInfo: [
|
|||
|
|
NSLocalizedDescriptionKey: "photo load failed",
|
|||
|
|
])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let (data, finalImage) = try encodeJpegUnderBudget(
|
|||
|
|
image: image,
|
|||
|
|
quality: quality,
|
|||
|
|
maxBase64Chars: maxPerPhotoBase64Chars)
|
|||
|
|
|
|||
|
|
let created = asset.creationDate.map { formatter.string(from: $0) }
|
|||
|
|
return OpenClawPhotoPayload(
|
|||
|
|
format: "jpeg",
|
|||
|
|
base64: data.base64EncodedString(),
|
|||
|
|
width: Int(finalImage.size.width),
|
|||
|
|
height: Int(finalImage.size.height),
|
|||
|
|
createdAt: created)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static func encodeJpegUnderBudget(
|
|||
|
|
image: UIImage,
|
|||
|
|
quality: Double,
|
|||
|
|
maxBase64Chars: Int) throws -> (Data, UIImage)
|
|||
|
|
{
|
|||
|
|
var currentImage = image
|
|||
|
|
var currentQuality = max(0.1, min(1.0, quality))
|
|||
|
|
|
|||
|
|
// Try lowering JPEG quality first, then downscale if needed.
|
|||
|
|
for _ in 0..<10 {
|
|||
|
|
guard let data = currentImage.jpegData(compressionQuality: currentQuality) else {
|
|||
|
|
throw NSError(domain: "Photos", code: 3, userInfo: [
|
|||
|
|
NSLocalizedDescriptionKey: "photo encode failed",
|
|||
|
|
])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let base64Len = ((data.count + 2) / 3) * 4
|
|||
|
|
if base64Len <= maxBase64Chars {
|
|||
|
|
return (data, currentImage)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if currentQuality > 0.35 {
|
|||
|
|
currentQuality = max(0.25, currentQuality - 0.15)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Downscale by ~25% each step once quality is low.
|
|||
|
|
let newWidth = max(240, currentImage.size.width * 0.75)
|
|||
|
|
if newWidth >= currentImage.size.width {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
currentImage = resize(image: currentImage, targetWidth: newWidth)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
throw NSError(domain: "Photos", code: 4, userInfo: [
|
|||
|
|
NSLocalizedDescriptionKey: "photo too large for gateway transport; try smaller maxWidth/quality",
|
|||
|
|
])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static func resize(image: UIImage, targetWidth: CGFloat) -> UIImage {
|
|||
|
|
let size = image.size
|
|||
|
|
if size.width <= 0 || size.height <= 0 || targetWidth <= 0 {
|
|||
|
|
return image
|
|||
|
|
}
|
|||
|
|
let scale = targetWidth / size.width
|
|||
|
|
let targetSize = CGSize(width: targetWidth, height: max(1, size.height * scale))
|
|||
|
|
let format = UIGraphicsImageRendererFormat.default()
|
|||
|
|
format.scale = 1
|
|||
|
|
let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
|
|||
|
|
return renderer.image { _ in
|
|||
|
|
image.draw(in: CGRect(origin: .zero, size: targetSize))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|