mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
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))
|
||
}
|
||
}
|
||
}
|