mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 22:41:33 +00:00
* feat(apple): complete media playback and uploads * fix(apple): preserve movie picker metadata * fix(apple): harden rendition and picker routing * style(apple): fix macOS media formatting
394 lines
15 KiB
Swift
394 lines
15 KiB
Swift
import AVFoundation
|
|
import Foundation
|
|
import ImageIO
|
|
import OpenClawKit
|
|
import UniformTypeIdentifiers
|
|
|
|
private enum ChatVideoAttachmentReadError: Error {
|
|
case tooLarge
|
|
}
|
|
|
|
#if canImport(AppKit)
|
|
import AppKit
|
|
#elseif canImport(UIKit)
|
|
import UIKit
|
|
#endif
|
|
|
|
extension OpenClawChatViewModel {
|
|
nonisolated static var maxVideoAttachmentBytes: Int {
|
|
20 * 1024 * 1024
|
|
}
|
|
|
|
public func addAttachments(urls: [URL]) {
|
|
self.beginAttachmentStaging()
|
|
Task {
|
|
defer { self.endAttachmentStaging() }
|
|
await self.loadAttachments(urls: urls)
|
|
}
|
|
}
|
|
|
|
func addAttachments(urls: [URL], for session: SessionSnapshot) {
|
|
guard self.isCurrentSession(session) else { return }
|
|
self.beginAttachmentStaging()
|
|
Task {
|
|
defer { self.endAttachmentStaging() }
|
|
await self.loadAttachments(urls: urls, expectedSession: session)
|
|
}
|
|
}
|
|
|
|
public func addImageAttachment(data: Data, fileName: String, mimeType: String) {
|
|
self.beginAttachmentStaging()
|
|
Task {
|
|
defer { self.endAttachmentStaging() }
|
|
await self.addImageAttachment(url: nil, data: data, fileName: fileName, mimeType: mimeType)
|
|
}
|
|
}
|
|
|
|
func addImageAttachment(
|
|
data: Data,
|
|
fileName: String,
|
|
mimeType: String,
|
|
for session: SessionSnapshot) async
|
|
{
|
|
guard self.isCurrentSession(session) else { return }
|
|
self.beginAttachmentStaging()
|
|
defer { self.endAttachmentStaging() }
|
|
await self.addImageAttachment(
|
|
url: nil,
|
|
data: data,
|
|
fileName: fileName,
|
|
mimeType: mimeType,
|
|
expectedSession: session)
|
|
}
|
|
|
|
public func removeAttachment(_ id: OpenClawPendingAttachment.ID) {
|
|
attachments.removeAll { $0.id == id }
|
|
applyDeferredExternalStateIfReady()
|
|
}
|
|
|
|
func restoreEditorAttachments(_ editorAttachments: [OpenClawChatEditorAttachment]?) {
|
|
self.attachments = (editorAttachments ?? []).enumerated().compactMap { index, attachment in
|
|
guard let data = Data(base64Encoded: attachment.data),
|
|
data.count <= Self.maxAttachmentBytes,
|
|
let contentType = UTType(mimeType: attachment.mimeType),
|
|
contentType.conforms(to: .image)
|
|
else { return nil }
|
|
let fileExtension = contentType.preferredFilenameExtension ?? "img"
|
|
return OpenClawPendingAttachment(
|
|
url: nil,
|
|
data: data,
|
|
fileName: "image-\(index + 1).\(fileExtension)",
|
|
mimeType: attachment.mimeType,
|
|
preview: Self.previewImage(data: data))
|
|
}
|
|
}
|
|
|
|
/// True while replacing this model could move an attachment across chats.
|
|
public var isAttachmentOwnerPinned: Bool {
|
|
self.blocksAttachmentOwnerChange
|
|
}
|
|
|
|
var blocksAttachmentOwnerChange: Bool {
|
|
attachmentOwnerIsActive() ||
|
|
isSendingAttachmentDraft ||
|
|
attachmentStagingCount > 0 ||
|
|
!attachments.isEmpty
|
|
}
|
|
|
|
func canCreateSessionForImmediateSwitch() -> Bool {
|
|
guard !self.blocksAttachmentOwnerChange else {
|
|
self.errorText = String(
|
|
localized: "Remove attachments or wait for delivery to resolve before starting a new chat.")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
/// Applies external owner changes once recording or staging releases them.
|
|
public func attachmentOwnerActivityChanged() {
|
|
applyDeferredExternalStateIfReady()
|
|
}
|
|
|
|
/// File reads and image processing suspend before the attachment exists.
|
|
/// Keep their original chat owner pinned until staging succeeds or fails.
|
|
func beginAttachmentStaging() {
|
|
attachmentStagingCount += 1
|
|
}
|
|
|
|
func endAttachmentStaging() {
|
|
precondition(attachmentStagingCount > 0)
|
|
attachmentStagingCount -= 1
|
|
applyDeferredExternalStateIfReady()
|
|
}
|
|
|
|
/// Stages a recorded m4a voice note and removes its temporary file.
|
|
public func addVoiceNoteAttachment(fileURL: URL, durationSeconds: Double) async {
|
|
self.beginAttachmentStaging()
|
|
defer {
|
|
try? FileManager.default.removeItem(at: fileURL)
|
|
self.endAttachmentStaging()
|
|
}
|
|
|
|
let data: Data
|
|
do {
|
|
data = try await Task.detached(priority: .userInitiated) {
|
|
try Data(contentsOf: fileURL)
|
|
}.value
|
|
} catch {
|
|
errorText = String(
|
|
format: String(localized: "Could not attach voice note: %@"),
|
|
error.localizedDescription)
|
|
return
|
|
}
|
|
|
|
guard data.count <= Self.maxAttachmentBytes else {
|
|
errorText = String(localized: "Voice note exceeds the 5 MB attachment limit")
|
|
return
|
|
}
|
|
|
|
let normalizedDuration = durationSeconds.isFinite
|
|
? min(max(0, durationSeconds), OpenClawVoiceNoteRecorder.maximumDurationSeconds)
|
|
: 0
|
|
attachments.append(
|
|
OpenClawPendingAttachment(
|
|
url: nil,
|
|
data: data,
|
|
fileName: fileURL.lastPathComponent,
|
|
mimeType: "audio/mp4",
|
|
preview: nil,
|
|
durationSeconds: normalizedDuration))
|
|
}
|
|
|
|
func loadAttachments(urls: [URL], expectedSession: SessionSnapshot? = nil) async {
|
|
for url in urls {
|
|
guard self.ownsAttachmentSession(expectedSession) else { return }
|
|
let hasSecurityScope = url.startAccessingSecurityScopedResource()
|
|
defer {
|
|
if hasSecurityScope {
|
|
url.stopAccessingSecurityScopedResource()
|
|
}
|
|
}
|
|
do {
|
|
let contentType = UTType(filenameExtension: url.pathExtension) ?? .data
|
|
if Self.isVideoType(contentType) {
|
|
await self.addVideoAttachment(
|
|
url: url,
|
|
fileName: url.lastPathComponent,
|
|
mimeType: contentType.preferredMIMEType ?? "application/octet-stream",
|
|
expectedSession: expectedSession)
|
|
} else if contentType.conforms(to: .image) {
|
|
let data = try await Task.detached { try Data(contentsOf: url) }.value
|
|
await self.addImageAttachment(
|
|
url: url,
|
|
data: data,
|
|
fileName: url.lastPathComponent,
|
|
mimeType: Self.mimeType(for: url) ?? "application/octet-stream",
|
|
expectedSession: expectedSession)
|
|
} else {
|
|
self.errorText = String(localized: "Only image and video attachments are supported right now")
|
|
}
|
|
} catch {
|
|
guard self.ownsAttachmentSession(expectedSession) else { return }
|
|
self.errorText = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
static func mimeType(for url: URL) -> String? {
|
|
let ext = url.pathExtension
|
|
guard !ext.isEmpty else { return nil }
|
|
return (UTType(filenameExtension: ext) ?? .data).preferredMIMEType
|
|
}
|
|
|
|
func addImageAttachment(
|
|
url: URL?,
|
|
data: Data,
|
|
fileName: String,
|
|
mimeType: String,
|
|
expectedSession: SessionSnapshot? = nil) async
|
|
{
|
|
guard self.ownsAttachmentSession(expectedSession) else { return }
|
|
let uti: UTType = {
|
|
if let url {
|
|
return UTType(filenameExtension: url.pathExtension) ?? .data
|
|
}
|
|
return UTType(mimeType: mimeType) ?? .data
|
|
}()
|
|
guard uti.conforms(to: .image) else {
|
|
errorText = String(localized: "Only image attachments are supported right now")
|
|
return
|
|
}
|
|
|
|
let processed: Data
|
|
do {
|
|
processed = try await Task.detached(priority: .userInitiated) {
|
|
try ChatImageProcessor.processForUpload(data: data)
|
|
}.value
|
|
} catch {
|
|
guard self.ownsAttachmentSession(expectedSession) else { return }
|
|
errorText = String(
|
|
format: String(localized: "Could not process %1$@: %2$@"),
|
|
fileName,
|
|
error.localizedDescription)
|
|
return
|
|
}
|
|
|
|
// Image processing runs off actor. Revalidate the draft owner before
|
|
// publishing either the attachment or any session-scoped error state.
|
|
guard self.ownsAttachmentSession(expectedSession) else { return }
|
|
if processed.count > Self.maxAttachmentBytes {
|
|
errorText = String(
|
|
format: String(localized: "Attachment %@ exceeds 5 MB limit after resizing"),
|
|
fileName)
|
|
return
|
|
}
|
|
|
|
let outputFileName: String = {
|
|
let baseName = (fileName as NSString).deletingPathExtension
|
|
return baseName.isEmpty ? "image.jpg" : "\(baseName).jpg"
|
|
}()
|
|
|
|
let preview = Self.previewImage(data: processed)
|
|
attachments.append(
|
|
OpenClawPendingAttachment(
|
|
url: url,
|
|
data: processed,
|
|
fileName: outputFileName,
|
|
mimeType: "image/jpeg",
|
|
preview: preview))
|
|
}
|
|
|
|
func addVideoAttachment(
|
|
url: URL,
|
|
fileName: String,
|
|
mimeType: String,
|
|
expectedSession: SessionSnapshot? = nil) async
|
|
{
|
|
guard self.ownsAttachmentSession(expectedSession) else { return }
|
|
do {
|
|
let data = try await Self.readVideoData(from: url)
|
|
guard self.ownsAttachmentSession(expectedSession) else { return }
|
|
await self.addVideoAttachment(
|
|
data: data,
|
|
fileName: fileName,
|
|
mimeType: mimeType,
|
|
contentType: UTType(filenameExtension: url.pathExtension) ?? .data,
|
|
expectedSession: expectedSession)
|
|
} catch ChatVideoAttachmentReadError.tooLarge {
|
|
guard self.ownsAttachmentSession(expectedSession) else { return }
|
|
self.errorText = String(
|
|
format: String(localized: "Attachment %@ exceeds the 20 MB video limit"),
|
|
fileName)
|
|
} catch {
|
|
guard self.ownsAttachmentSession(expectedSession) else { return }
|
|
self.errorText = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
private func addVideoAttachment(
|
|
data: Data,
|
|
fileName: String,
|
|
mimeType: String,
|
|
contentType: UTType,
|
|
expectedSession: SessionSnapshot?) async
|
|
{
|
|
guard self.ownsAttachmentSession(expectedSession) else { return }
|
|
guard Self.isVideoType(contentType) else {
|
|
self.errorText = String(localized: "Only image and video attachments are supported right now")
|
|
return
|
|
}
|
|
guard data.count <= Self.maxVideoAttachmentBytes else {
|
|
self.errorText = String(
|
|
format: String(localized: "Attachment %@ exceeds the 20 MB video limit"),
|
|
fileName)
|
|
return
|
|
}
|
|
|
|
let normalizedMIME = contentType.preferredMIMEType ?? mimeType
|
|
guard normalizedMIME.lowercased().hasPrefix("video/") else {
|
|
self.errorText = String(localized: "Only image and video attachments are supported right now")
|
|
return
|
|
}
|
|
let thumbnailData = await Self.videoThumbnailData(
|
|
data: data,
|
|
fileExtension: contentType.preferredFilenameExtension ?? "mp4")
|
|
guard self.ownsAttachmentSession(expectedSession) else { return }
|
|
self.attachments.append(OpenClawPendingAttachment(
|
|
url: nil,
|
|
data: data,
|
|
fileName: fileName,
|
|
mimeType: normalizedMIME,
|
|
preview: thumbnailData.flatMap { Self.previewImage(data: $0) }))
|
|
}
|
|
|
|
private func ownsAttachmentSession(_ expectedSession: SessionSnapshot?) -> Bool {
|
|
expectedSession.map(self.isCurrentSession) ?? true
|
|
}
|
|
|
|
static func previewImage(data: Data) -> OpenClawPlatformImage? {
|
|
#if canImport(AppKit)
|
|
NSImage(data: data)
|
|
#elseif canImport(UIKit)
|
|
UIImage(data: data)
|
|
#else
|
|
nil
|
|
#endif
|
|
}
|
|
|
|
private nonisolated static func isVideoType(_ contentType: UTType) -> Bool {
|
|
contentType.conforms(to: .movie) ||
|
|
(contentType.conforms(to: .audiovisualContent) && !contentType.conforms(to: .audio))
|
|
}
|
|
|
|
private nonisolated static func readVideoData(from url: URL) async throws -> Data {
|
|
try await Task.detached(priority: .userInitiated) {
|
|
let maximumBytes = Self.maxVideoAttachmentBytes
|
|
let values = try url.resourceValues(forKeys: [.fileSizeKey])
|
|
if let fileSize = values.fileSize, fileSize > maximumBytes {
|
|
throw ChatVideoAttachmentReadError.tooLarge
|
|
}
|
|
let handle = try FileHandle(forReadingFrom: url)
|
|
defer { try? handle.close() }
|
|
let data = try handle.read(upToCount: maximumBytes + 1) ?? Data()
|
|
guard data.count <= maximumBytes else {
|
|
throw ChatVideoAttachmentReadError.tooLarge
|
|
}
|
|
return data
|
|
}.value
|
|
}
|
|
|
|
private nonisolated static func videoThumbnailData(
|
|
data: Data,
|
|
fileExtension: String) async -> Data?
|
|
{
|
|
await Task.detached(priority: .userInitiated) {
|
|
let fileURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("openclaw-upload-preview-\(UUID().uuidString)")
|
|
.appendingPathExtension(fileExtension)
|
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
|
do {
|
|
try data.write(to: fileURL, options: [.atomic])
|
|
let generator = AVAssetImageGenerator(asset: AVURLAsset(url: fileURL))
|
|
generator.appliesPreferredTrackTransform = true
|
|
generator.maximumSize = CGSize(width: 640, height: 640)
|
|
let image = try await generator.image(at: .zero).image
|
|
let encoded = NSMutableData()
|
|
guard let destination = CGImageDestinationCreateWithData(
|
|
encoded,
|
|
UTType.jpeg.identifier as CFString,
|
|
1,
|
|
nil)
|
|
else { return nil }
|
|
CGImageDestinationAddImage(
|
|
destination,
|
|
image,
|
|
[kCGImageDestinationLossyCompressionQuality: 0.82] as CFDictionary)
|
|
guard CGImageDestinationFinalize(destination) else { return nil }
|
|
return encoded as Data
|
|
} catch {
|
|
return nil
|
|
}
|
|
}.value
|
|
}
|
|
}
|