将 otg_swift OTG 引擎迁入 Core/CameraOTG,并接入旅拍有线传图与历史导入。
恢复 USB 相机连接、边拍边传、三品牌驱动与 SwiftUI 历史导入页,通过适配层对接现有本地上传与 OSS 管道。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -87,5 +87,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {}
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
ConnectionManager.shared.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
//
|
||||
// CanonCameraDriver.swift
|
||||
// otg_swift
|
||||
//
|
||||
// Created by hanqiu on 2026/7/2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageCaptureCore
|
||||
|
||||
/// 佳能相机 Driver:ImageCaptureCore 枚举 SD 卡历史照片并下载。
|
||||
/// 边拍边传由 `CanonRemoteCaptureService` + EOS GetEvent 负责,与 catalog 扫描分离。
|
||||
final class CanonCameraDriver: CameraDriver {
|
||||
private let device: ICCameraDevice
|
||||
let platform: CameraPlatform = .canon
|
||||
let deviceInfo: CameraDeviceInfo
|
||||
|
||||
init(device: ICCameraDevice, deviceInfo: CameraDeviceInfo) {
|
||||
self.device = device
|
||||
self.deviceInfo = deviceInfo
|
||||
OTGLog.info(.canon, "initialized, device=\(deviceInfo.name)")
|
||||
}
|
||||
|
||||
func connect() async throws {
|
||||
OTGLog.debug(.canon, "connect called")
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
OTGLog.info(.canon, "driver released, device=\(deviceInfo.name)")
|
||||
}
|
||||
|
||||
func listObjects() async throws -> [CameraObject] {
|
||||
let mediaFilesCount = device.mediaFiles?.count ?? 0
|
||||
let contentsCount = device.contents?.count ?? 0
|
||||
let files = ICCameraItemScanner.collectImageFiles(from: device)
|
||||
OTGLog.info(.canon, "listObjects: mediaFiles=\(mediaFilesCount), contents=\(contentsCount), images=\(files.count)")
|
||||
|
||||
let results = await ICCameraItemScanner.cameraObjects(from: files)
|
||||
.sorted { $0.capturedAt > $1.capturedAt }
|
||||
|
||||
OTGLog.info(.canon, "listObjects: image count=\(results.count)")
|
||||
return results
|
||||
}
|
||||
|
||||
func requestThumbnailData(for object: CameraObject, maxPixelSize: Int) async -> Data? {
|
||||
await ICCameraThumbnailLoader.loadThumbnailData(for: object, from: device, maxPixelSize: maxPixelSize)
|
||||
}
|
||||
|
||||
func downloadObject(_ object: CameraObject, to directory: URL) async throws -> URL {
|
||||
guard let file = ICCameraItemScanner.findFile(matching: object, in: device) else {
|
||||
OTGLog.error(.canon, "downloadObject file not found: \(object.filename)")
|
||||
throw CameraError.deviceNotFound
|
||||
}
|
||||
|
||||
let url = try await ICCameraFileDownloader.download(file, to: directory)
|
||||
OTGLog.info(.canon, "downloadObject saved: \(url.lastPathComponent)")
|
||||
return url
|
||||
}
|
||||
}
|
||||
82
suixinkan/Core/CameraOTG/Brands/Canon/CanonPTPCommands.swift
Normal file
82
suixinkan/Core/CameraOTG/Brands/Canon/CanonPTPCommands.swift
Normal file
@ -0,0 +1,82 @@
|
||||
//
|
||||
// CanonPTPCommands.swift
|
||||
// otg_swift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 佳能 EOS 扩展 PTP 命令码与 GetEvent 传图事件码。
|
||||
enum CanonPTPCommand {
|
||||
/// 标准 GetObjectInfo
|
||||
static let getObjectInfo: UInt16 = PTPStandardCommand.getObjectInfo
|
||||
/// 标准 GetObject
|
||||
static let getObject: UInt16 = PTPStandardCommand.getObject
|
||||
/// EOS GetObject (0x9104)
|
||||
static let eosGetObject: UInt16 = 0x9104
|
||||
/// EOS GetObjectInfoEx (0x9109)
|
||||
static let eosGetObjectInfoEx: UInt16 = 0x9109
|
||||
/// EOS SetRemoteMode (0x9114)
|
||||
static let setRemoteMode: UInt16 = 0x9114
|
||||
/// EOS SetEventMode (0x9115)
|
||||
static let setEventMode: UInt16 = 0x9115
|
||||
/// EOS GetEvent (0x9116)
|
||||
static let getEvent: UInt16 = 0x9116
|
||||
/// EOS TransferComplete (0x9117)
|
||||
static let transferComplete: UInt16 = 0x9117
|
||||
/// EOS PCHDDCapacity (0x911A)
|
||||
static let pchddCapacity: UInt16 = 0x911A
|
||||
/// EOS KeepDeviceOn (0x911D)
|
||||
static let keepDeviceOn: UInt16 = 0x911D
|
||||
/// 标准 ObjectAdded 事件
|
||||
static let objectAdded: UInt16 = PTPStandardCommand.objectAdded
|
||||
/// 标准 RequestObjectTransfer 事件
|
||||
static let requestObjectTransfer: UInt16 = PTPStandardCommand.requestObjectTransfer
|
||||
/// EOS ObjectAddedEx (0xC181)
|
||||
static let eosObjectAddedEx: UInt16 = 0xC181
|
||||
/// EOS ObjectInfoChangedEx (0xC187)
|
||||
static let eosObjectInfoChangedEx: UInt16 = 0xC187
|
||||
/// EOS ObjectContentChanged (0xC188)
|
||||
static let eosObjectContentChanged: UInt16 = 0xC188
|
||||
/// EOS RequestObjectTransfer (0xC186)
|
||||
static let eosRequestObjectTransfer: UInt16 = 0xC186
|
||||
/// EOS RequestObjectTransferDT (0xC190)
|
||||
static let eosRequestObjectTransferDT: UInt16 = 0xC190
|
||||
/// EOS RequestObjectTransferTS (0xC1A2)
|
||||
static let eosRequestObjectTransferTS: UInt16 = 0xC1A2
|
||||
/// EOS RequestObjectTransferEx (0xC1A7),R8 等新机型快门后传图事件
|
||||
static let eosRequestObjectTransferEx: UInt16 = 0xC1A7
|
||||
/// EOS DevicePropChanged (0xC189)
|
||||
static let eosDevicePropChanged: UInt16 = 0xC189
|
||||
/// EOS PropValueDescChanged (0xC18A)
|
||||
static let eosPropValueDescChanged: UInt16 = 0xC18A
|
||||
/// SetRemoteMode 参数:启用
|
||||
static let remoteModeEnabled: UInt32 = 1
|
||||
/// SetEventMode 参数:启用
|
||||
static let eventModeEnabled: UInt32 = 1
|
||||
}
|
||||
|
||||
/// libgphoto2 `ptp-pack.c` 中 Canon EOS 事件记录字段偏移(相对单条 record 起点)。
|
||||
enum CanonEOSEventOffset {
|
||||
static let recordCode = 0x04
|
||||
static let objectHandle = 0x08
|
||||
static let transferObjectSize = 0x14
|
||||
static let transferFilename = 0x1C
|
||||
static let addedStorageID = 0x0C
|
||||
static let addedObjectSize = 0x1C
|
||||
static let addedFilename = 0x28
|
||||
}
|
||||
|
||||
/// GetEvent 解析出的单条佳能 EOS 事件。
|
||||
struct CanonEOSEvent {
|
||||
let code: UInt32
|
||||
let storageID: UInt32?
|
||||
let objectHandle: UInt32?
|
||||
let filename: String?
|
||||
let objectSize: UInt32?
|
||||
}
|
||||
|
||||
/// Canon 远程会话初始化结果。
|
||||
struct CanonRemoteSessionResult {
|
||||
let success: Bool
|
||||
let nextTransactionID: UInt32
|
||||
}
|
||||
369
suixinkan/Core/CameraOTG/Brands/Canon/CanonPTPHelper.swift
Normal file
369
suixinkan/Core/CameraOTG/Brands/Canon/CanonPTPHelper.swift
Normal file
@ -0,0 +1,369 @@
|
||||
//
|
||||
// CanonPTPHelper.swift
|
||||
// otg_swift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageCaptureCore
|
||||
|
||||
/// 佳能 EOS PTP:远程会话、GetEvent 解析、Probe 与 GetObject 下载。
|
||||
enum CanonPTPHelper {
|
||||
|
||||
/// 会话打开且 catalog 就绪后进入 Canon 远程拍摄模式。
|
||||
static func initializeRemoteSession(
|
||||
on camera: ICCameraDevice,
|
||||
startingTransactionID: UInt32
|
||||
) async -> CanonRemoteSessionResult {
|
||||
var transactionID = startingTransactionID
|
||||
|
||||
OTGLog.info(.canon, "Canon EOS remote session initializing")
|
||||
|
||||
let remoteMode = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.setRemoteMode,
|
||||
transactionID: &transactionID,
|
||||
parameters: [CanonPTPCommand.remoteModeEnabled],
|
||||
label: "EOS SetRemoteMode"
|
||||
)
|
||||
guard remoteMode.success else {
|
||||
OTGLog.error(.canon, "SetRemoteMode failed")
|
||||
return CanonRemoteSessionResult(success: false, nextTransactionID: transactionID)
|
||||
}
|
||||
|
||||
let eventMode = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.setEventMode,
|
||||
transactionID: &transactionID,
|
||||
parameters: [CanonPTPCommand.eventModeEnabled],
|
||||
label: "EOS SetEventMode"
|
||||
)
|
||||
guard eventMode.success else {
|
||||
OTGLog.error(.canon, "SetEventMode failed")
|
||||
return CanonRemoteSessionResult(success: false, nextTransactionID: transactionID)
|
||||
}
|
||||
|
||||
_ = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.pchddCapacity,
|
||||
transactionID: &transactionID,
|
||||
parameters: [0x0FFF_FFF8, 0x1000, 1],
|
||||
label: "EOS PCHDDCapacity"
|
||||
)
|
||||
|
||||
_ = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.keepDeviceOn,
|
||||
transactionID: &transactionID,
|
||||
parameters: [1],
|
||||
label: "EOS KeepDeviceOn"
|
||||
)
|
||||
|
||||
OTGLog.info(.canon, "Canon EOS remote session ready, transactionID=\(transactionID)")
|
||||
return CanonRemoteSessionResult(success: true, nextTransactionID: transactionID)
|
||||
}
|
||||
|
||||
/// 轮询 GetEvent 并解析 EOS 事件列表。
|
||||
static func pollEvents(
|
||||
on camera: ICCameraDevice,
|
||||
transactionID: inout UInt32
|
||||
) async -> [CanonEOSEvent] {
|
||||
let result = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.getEvent,
|
||||
transactionID: &transactionID,
|
||||
parameters: [],
|
||||
label: "EOS GetEvent"
|
||||
)
|
||||
guard result.success, !result.inData.isEmpty else { return [] }
|
||||
|
||||
let events = parseEOSEvents(result.inData)
|
||||
if events.isEmpty, result.inData.count > 8 {
|
||||
let hex = result.inData.prefix(64).map { String(format: "%02X", $0) }.joined(separator: " ")
|
||||
OTGLog.debug(.canon, "GetEvent inData=\(result.inData.count)B, no events, raw=\(hex)")
|
||||
}
|
||||
for event in events where isTransferEvent(event) {
|
||||
OTGLog.info(
|
||||
.canon,
|
||||
"transfer event 0x\(String(format: "%04X", event.code)) handle=0x\(String(format: "%08X", event.objectHandle ?? 0)) file=\(event.filename ?? "?") size=\(event.objectSize ?? 0)"
|
||||
)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/// 解析 GetEvent 返回的 EOS 事件(布局参考 libgphoto2 `ptp_unpack_EOS_events`)。
|
||||
static func parseEOSEvents(_ data: Data) -> [CanonEOSEvent] {
|
||||
var events: [CanonEOSEvent] = []
|
||||
var offset = 0
|
||||
|
||||
while offset + 8 <= data.count {
|
||||
let recordLen = Int(PTPHelper.readUInt32LE(data, offset: offset))
|
||||
let code = PTPHelper.readUInt32LE(data, offset: offset + CanonEOSEventOffset.recordCode)
|
||||
|
||||
if recordLen == 8, code == 0 { break }
|
||||
guard recordLen >= 8, offset + recordLen <= data.count else { break }
|
||||
|
||||
events.append(
|
||||
parseSingleEOSEvent(
|
||||
data: data,
|
||||
recordOffset: offset,
|
||||
recordLen: recordLen,
|
||||
code: code
|
||||
)
|
||||
)
|
||||
|
||||
offset += recordLen
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
/// 是否为应触发下载的传图事件。
|
||||
static func isTransferEvent(_ event: CanonEOSEvent) -> Bool {
|
||||
guard let handle = event.objectHandle, handle != 0 else { return false }
|
||||
switch event.code {
|
||||
case UInt32(CanonPTPCommand.eosRequestObjectTransfer),
|
||||
UInt32(CanonPTPCommand.eosRequestObjectTransferTS),
|
||||
UInt32(CanonPTPCommand.eosRequestObjectTransferDT),
|
||||
UInt32(CanonPTPCommand.eosRequestObjectTransferEx),
|
||||
UInt32(CanonPTPCommand.eosObjectAddedEx),
|
||||
UInt32(CanonPTPCommand.eosObjectInfoChangedEx),
|
||||
UInt32(CanonPTPCommand.eosObjectContentChanged),
|
||||
UInt32(CanonPTPCommand.objectAdded),
|
||||
UInt32(CanonPTPCommand.requestObjectTransfer):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取对象元数据(文件名、压缩大小)。
|
||||
static func probeObject(
|
||||
handle: UInt32,
|
||||
storageID: UInt32?,
|
||||
on camera: ICCameraDevice,
|
||||
transactionID: inout UInt32
|
||||
) async -> (filename: String, size: UInt32)? {
|
||||
if let storageID, storageID != 0 {
|
||||
let infoResult = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.eosGetObjectInfoEx,
|
||||
transactionID: &transactionID,
|
||||
parameters: [storageID, handle, 0],
|
||||
label: "EOS GetObjectInfoEx(0x\(String(format: "%08X", handle)))"
|
||||
)
|
||||
if infoResult.success,
|
||||
let size = PTPHelper.parseObjectCompressedSize(infoResult.inData), size > 0 {
|
||||
let filename = PTPHelper.parseObjectInfoFilename(infoResult.inData) ?? defaultFilename()
|
||||
return (filename, size)
|
||||
}
|
||||
}
|
||||
|
||||
let infoResult = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.getObjectInfo,
|
||||
transactionID: &transactionID,
|
||||
parameters: [handle],
|
||||
label: "GetObjectInfo(0x\(String(format: "%08X", handle)))"
|
||||
)
|
||||
guard infoResult.success,
|
||||
let size = PTPHelper.parseObjectCompressedSize(infoResult.inData), size > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let filename = PTPHelper.parseObjectInfoFilename(infoResult.inData) ?? defaultFilename()
|
||||
return (filename, size)
|
||||
}
|
||||
|
||||
/// 下载对象二进制;先试标准 GetObject,再试 EOS GetObject。
|
||||
static func downloadObject(
|
||||
handle: UInt32,
|
||||
storageID: UInt32?,
|
||||
preferredFilename: String?,
|
||||
on camera: ICCameraDevice,
|
||||
transactionID: inout UInt32
|
||||
) async -> (filename: String, data: Data)? {
|
||||
var filename = preferredFilename ?? defaultFilename()
|
||||
|
||||
if preferredFilename == nil,
|
||||
let probe = await probeObject(
|
||||
handle: handle,
|
||||
storageID: storageID,
|
||||
on: camera,
|
||||
transactionID: &transactionID
|
||||
) {
|
||||
filename = probe.filename
|
||||
}
|
||||
|
||||
let standardResult = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.getObject,
|
||||
transactionID: &transactionID,
|
||||
parameters: [handle],
|
||||
label: "GetObject(0x\(String(format: "%08X", handle)))"
|
||||
)
|
||||
if standardResult.success, PTPHelper.isLikelyImageData(standardResult.inData) {
|
||||
return (filename, standardResult.inData)
|
||||
}
|
||||
|
||||
if let storageID, storageID != 0 {
|
||||
let eosResult = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.eosGetObject,
|
||||
transactionID: &transactionID,
|
||||
parameters: [storageID, handle, 0],
|
||||
label: "EOS GetObject(0x\(String(format: "%08X", handle)))"
|
||||
)
|
||||
if eosResult.success, PTPHelper.isLikelyImageData(eosResult.inData) {
|
||||
return (filename, eosResult.inData)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 传图完成后 ACK,避免后续事件卡住。
|
||||
static func acknowledgeTransfer(
|
||||
handle: UInt32,
|
||||
on camera: ICCameraDevice,
|
||||
transactionID: inout UInt32
|
||||
) async {
|
||||
_ = await sendCommand(
|
||||
on: camera,
|
||||
code: CanonPTPCommand.transferComplete,
|
||||
transactionID: &transactionID,
|
||||
parameters: [handle],
|
||||
label: "EOS TransferComplete(0x\(String(format: "%08X", handle)))"
|
||||
)
|
||||
}
|
||||
|
||||
/// 从被动 PTP Event Container 提取 object handle(ObjectAdded 等)。
|
||||
static func parsePTPEventHandle(_ eventData: Data) -> UInt32? {
|
||||
guard let container = try? PTPHelper.parseContainer(eventData) else { return nil }
|
||||
switch container.code {
|
||||
case CanonPTPCommand.objectAdded, CanonPTPCommand.requestObjectTransfer:
|
||||
return container.params.first
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func objectSignature(filename: String, size: UInt32) -> String {
|
||||
PTPHelper.objectSignature(filename: filename, size: size)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static func parseSingleEOSEvent(
|
||||
data: Data,
|
||||
recordOffset: Int,
|
||||
recordLen: Int,
|
||||
code: UInt32
|
||||
) -> CanonEOSEvent {
|
||||
func u32(at absoluteOffset: Int) -> UInt32? {
|
||||
guard absoluteOffset + 4 <= recordOffset + recordLen, absoluteOffset + 4 <= data.count else { return nil }
|
||||
return PTPHelper.readUInt32LE(data, offset: absoluteOffset)
|
||||
}
|
||||
|
||||
func string(at absoluteOffset: Int) -> String? {
|
||||
guard absoluteOffset < recordOffset + recordLen else { return nil }
|
||||
guard let parsed = PTPHelper.parsePTPString(data, offset: absoluteOffset) else { return nil }
|
||||
let sanitized = PTPHelper.sanitizeFilename(parsed.string)
|
||||
return sanitized.isEmpty ? nil : sanitized
|
||||
}
|
||||
|
||||
switch code {
|
||||
case UInt32(CanonPTPCommand.eosRequestObjectTransfer),
|
||||
UInt32(CanonPTPCommand.eosRequestObjectTransferTS),
|
||||
UInt32(CanonPTPCommand.eosRequestObjectTransferDT),
|
||||
UInt32(CanonPTPCommand.eosRequestObjectTransferEx):
|
||||
guard let handle = u32(at: recordOffset + CanonEOSEventOffset.objectHandle), handle != 0 else {
|
||||
return CanonEOSEvent(code: code, storageID: nil, objectHandle: nil, filename: nil, objectSize: nil)
|
||||
}
|
||||
return CanonEOSEvent(
|
||||
code: code,
|
||||
storageID: nil,
|
||||
objectHandle: handle,
|
||||
filename: string(at: recordOffset + CanonEOSEventOffset.transferFilename),
|
||||
objectSize: u32(at: recordOffset + CanonEOSEventOffset.transferObjectSize)
|
||||
)
|
||||
|
||||
case UInt32(CanonPTPCommand.eosObjectAddedEx),
|
||||
UInt32(CanonPTPCommand.eosObjectInfoChangedEx):
|
||||
guard let handle = u32(at: recordOffset + CanonEOSEventOffset.objectHandle), handle != 0 else {
|
||||
return CanonEOSEvent(code: code, storageID: nil, objectHandle: nil, filename: nil, objectSize: nil)
|
||||
}
|
||||
return CanonEOSEvent(
|
||||
code: code,
|
||||
storageID: u32(at: recordOffset + CanonEOSEventOffset.addedStorageID),
|
||||
objectHandle: handle,
|
||||
filename: string(at: recordOffset + CanonEOSEventOffset.addedFilename),
|
||||
objectSize: u32(at: recordOffset + CanonEOSEventOffset.addedObjectSize)
|
||||
)
|
||||
|
||||
case UInt32(CanonPTPCommand.eosObjectContentChanged):
|
||||
guard let handle = u32(at: recordOffset + CanonEOSEventOffset.objectHandle), handle != 0 else {
|
||||
return CanonEOSEvent(code: code, storageID: nil, objectHandle: nil, filename: nil, objectSize: nil)
|
||||
}
|
||||
return CanonEOSEvent(code: code, storageID: nil, objectHandle: handle, filename: nil, objectSize: nil)
|
||||
|
||||
case UInt32(CanonPTPCommand.objectAdded),
|
||||
UInt32(CanonPTPCommand.requestObjectTransfer):
|
||||
guard let handle = u32(at: recordOffset + CanonEOSEventOffset.objectHandle), handle != 0 else {
|
||||
return CanonEOSEvent(code: code, storageID: nil, objectHandle: nil, filename: nil, objectSize: nil)
|
||||
}
|
||||
return CanonEOSEvent(code: code, storageID: nil, objectHandle: handle, filename: nil, objectSize: nil)
|
||||
|
||||
default:
|
||||
if isNonTransferNoiseCode(code) {
|
||||
return CanonEOSEvent(code: code, storageID: nil, objectHandle: nil, filename: nil, objectSize: nil)
|
||||
}
|
||||
let handle = u32(at: recordOffset + CanonEOSEventOffset.objectHandle)
|
||||
if handle != nil {
|
||||
OTGLog.debug(
|
||||
.canon,
|
||||
"EOS event 0x\(String(format: "%04X", code)) len=\(recordLen) handle=0x\(String(format: "%08X", handle ?? 0))"
|
||||
)
|
||||
}
|
||||
return CanonEOSEvent(
|
||||
code: code,
|
||||
storageID: u32(at: recordOffset + CanonEOSEventOffset.addedStorageID),
|
||||
objectHandle: handle,
|
||||
filename: string(at: recordOffset + CanonEOSEventOffset.addedFilename)
|
||||
?? string(at: recordOffset + CanonEOSEventOffset.transferFilename),
|
||||
objectSize: u32(at: recordOffset + CanonEOSEventOffset.addedObjectSize)
|
||||
?? u32(at: recordOffset + CanonEOSEventOffset.transferObjectSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func isNonTransferNoiseCode(_ code: UInt32) -> Bool {
|
||||
switch code {
|
||||
case UInt32(CanonPTPCommand.eosDevicePropChanged),
|
||||
UInt32(CanonPTPCommand.eosPropValueDescChanged),
|
||||
0xC18B, 0xC185, 0xC19D, 0xC1F6, 0xC1A4:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func defaultFilename() -> String {
|
||||
PTPHelper.sanitizeFilename("IMG_\(Int(Date().timeIntervalSince1970)).JPG")
|
||||
}
|
||||
|
||||
private static func sendCommand(
|
||||
on camera: ICCameraDevice,
|
||||
code: UInt16,
|
||||
transactionID: inout UInt32,
|
||||
parameters: [UInt32],
|
||||
label: String
|
||||
) async -> PTPHelper.CommandResult {
|
||||
await PTPHelper.sendCommand(
|
||||
on: camera,
|
||||
code: code,
|
||||
transactionID: &transactionID,
|
||||
parameters: parameters,
|
||||
label: label
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,448 @@
|
||||
//
|
||||
// CanonRemoteCaptureService.swift
|
||||
// otg_swift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageCaptureCore
|
||||
|
||||
/// 佳能 EOS 边拍边传:GetEvent 轮询 + PTP 下载 + TransferComplete + 写入相册。
|
||||
final class CanonRemoteCaptureService {
|
||||
|
||||
private weak var camera: ICCameraDevice?
|
||||
private var transactionID: UInt32 = 1
|
||||
private(set) var isReady = false
|
||||
|
||||
private weak var photoSink: OTGPhotoSink?
|
||||
/// 下载成功时回调,供 ConnectionManager 转发给 ViewModel。
|
||||
var onShotSaved: ((SavedOTGPhoto) -> Void)?
|
||||
|
||||
private var eventPollTimer: Timer?
|
||||
private var catalogPollTimer: Timer?
|
||||
private var isDownloading = false
|
||||
private var isDrainingQueue = false
|
||||
private var isPollingGetEvent = false
|
||||
|
||||
private var lastDownloadedSignature: String?
|
||||
private var knownDownloadedHandles: Set<UInt32> = []
|
||||
private var knownCatalogIDs: Set<String> = []
|
||||
private var catalogBaselineEstablished = false
|
||||
private var savedShotCount = 0
|
||||
|
||||
private var downloadQueue: [PendingDownload] = []
|
||||
|
||||
private struct PendingDownload {
|
||||
let handle: UInt32
|
||||
let storageID: UInt32?
|
||||
let filename: String?
|
||||
let objectSize: UInt32?
|
||||
let reason: String
|
||||
}
|
||||
|
||||
private let eventPollInterval: TimeInterval = 0.2
|
||||
private let catalogPollInterval: TimeInterval = 1.5
|
||||
private var pollTickCount = 0
|
||||
private var catalogScanCount = 0
|
||||
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// catalog 就绪且远程会话初始化成功后启动。
|
||||
func start(
|
||||
camera: ICCameraDevice,
|
||||
startingTransactionID: UInt32,
|
||||
photoSink: OTGPhotoSink?
|
||||
) {
|
||||
stop()
|
||||
|
||||
self.camera = camera
|
||||
self.transactionID = startingTransactionID
|
||||
self.photoSink = photoSink
|
||||
self.isReady = true
|
||||
self.lastDownloadedSignature = nil
|
||||
self.knownDownloadedHandles = []
|
||||
self.knownCatalogIDs = []
|
||||
self.catalogBaselineEstablished = false
|
||||
self.savedShotCount = 0
|
||||
self.downloadQueue = []
|
||||
self.pollTickCount = 0
|
||||
self.isPollingGetEvent = false
|
||||
|
||||
establishCatalogBaseline(from: camera)
|
||||
|
||||
OTGLog.info(.canon, "Remote capture started, transactionID=\(startingTransactionID)")
|
||||
startEventPolling()
|
||||
startCatalogPolling()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
isReady = false
|
||||
if Thread.isMainThread {
|
||||
stopEventPolling()
|
||||
stopCatalogPolling()
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
stopEventPolling()
|
||||
stopCatalogPolling()
|
||||
}
|
||||
}
|
||||
isDownloading = false
|
||||
isDrainingQueue = false
|
||||
isPollingGetEvent = false
|
||||
downloadQueue = []
|
||||
knownCatalogIDs = []
|
||||
catalogBaselineEstablished = false
|
||||
photoSink = nil
|
||||
camera = nil
|
||||
}
|
||||
|
||||
// MARK: - Catalog Fallback
|
||||
|
||||
/// ImageCaptureCore 在 catalog 就绪后新增的文件(仅保存到 SD 卡时 GetEvent 可能无传图事件)。
|
||||
func handleCatalogItemsAdded(_ items: [ICCameraItem]) {
|
||||
guard isReady, catalogBaselineEstablished else { return }
|
||||
|
||||
for item in items {
|
||||
guard let file = item as? ICCameraFile else { continue }
|
||||
Task { await downloadCatalogFileIfNew(file, reason: "didAdd") }
|
||||
}
|
||||
}
|
||||
|
||||
private func establishCatalogBaseline(from camera: ICCameraDevice) {
|
||||
var known = Set<String>()
|
||||
var newDuringConnect = 0
|
||||
|
||||
for file in ICCameraItemScanner.collectImageFiles(from: camera) {
|
||||
if file.wasAddedAfterContentCatalogCompleted {
|
||||
newDuringConnect += 1
|
||||
Task { await downloadCatalogFileIfNew(file, reason: "baseline-new") }
|
||||
} else {
|
||||
known.insert(ICCameraItemScanner.cameraObjectID(for: file))
|
||||
}
|
||||
}
|
||||
|
||||
knownCatalogIDs = known
|
||||
catalogBaselineEstablished = true
|
||||
OTGLog.info(.canon, "Catalog baseline: known=\(known.count), newDuringConnect=\(newDuringConnect)")
|
||||
}
|
||||
|
||||
private func scanCatalogForNewFiles() async {
|
||||
guard isReady, catalogBaselineEstablished, let camera else { return }
|
||||
|
||||
let files = ICCameraItemScanner.collectImageFiles(from: camera)
|
||||
catalogScanCount += 1
|
||||
if catalogScanCount == 1 || catalogScanCount % 10 == 0 {
|
||||
OTGLog.debug(.canon, "Catalog scan #\(catalogScanCount): files=\(files.count), known=\(knownCatalogIDs.count)")
|
||||
}
|
||||
|
||||
for file in files {
|
||||
await downloadCatalogFileIfNew(file, reason: "catalog-poll")
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadCatalogFileIfNew(_ file: ICCameraFile, reason: String) async {
|
||||
guard isReady else { return }
|
||||
|
||||
let objectID = ICCameraItemScanner.cameraObjectID(for: file)
|
||||
guard !knownCatalogIDs.contains(objectID) else { return }
|
||||
knownCatalogIDs.insert(objectID)
|
||||
|
||||
if isDownloading {
|
||||
knownCatalogIDs.remove(objectID)
|
||||
return
|
||||
}
|
||||
|
||||
isDownloading = true
|
||||
defer { isDownloading = false }
|
||||
|
||||
let filename = file.name ?? UUID().uuidString
|
||||
if objectID == lastDownloadedSignature {
|
||||
return
|
||||
}
|
||||
|
||||
OTGLog.info(.canon, "\(reason): downloading \(filename) id=\(objectID)")
|
||||
|
||||
do {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("canon-live", isDirectory: true)
|
||||
let url = try await ICCameraFileDownloader.download(file, to: tempDir)
|
||||
let data = try Data(contentsOf: url)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
|
||||
lastDownloadedSignature = objectID
|
||||
|
||||
guard let saved = await saveToAlbum(data: data, filename: filename, reason: reason) else {
|
||||
knownCatalogIDs.remove(objectID)
|
||||
return
|
||||
}
|
||||
|
||||
savedShotCount += 1
|
||||
OTGLog.info(.canon, "Live shot saved (#\(savedShotCount)): \(filename), bytes=\(data.count)")
|
||||
onShotSaved?(saved)
|
||||
} catch {
|
||||
knownCatalogIDs.remove(objectID)
|
||||
OTGLog.error(.canon, "\(reason): catalog download failed \(filename): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PTP Events
|
||||
|
||||
/// 处理 ImageCaptureCore 被动 PTP 事件(ObjectAdded 等)。
|
||||
func handlePTPEvent(_ eventData: Data) {
|
||||
guard isReady else { return }
|
||||
|
||||
let hex = eventData.prefix(16).map { String(format: "%02X", $0) }.joined(separator: " ")
|
||||
OTGLog.info(.canon, "PTP event received: \(hex) (\(eventData.count)B)")
|
||||
|
||||
if let handle = CanonPTPHelper.parsePTPEventHandle(eventData), handle != 0 {
|
||||
enqueueDownload(
|
||||
handle: handle,
|
||||
storageID: nil,
|
||||
filename: nil,
|
||||
objectSize: nil,
|
||||
reason: "PTPEvent"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - GetEvent Polling
|
||||
|
||||
private func startEventPolling() {
|
||||
let schedule = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.stopEventPolling()
|
||||
OTGLog.info(.canon, "GetEvent polling started (interval=\(self.eventPollInterval)s)")
|
||||
|
||||
let timer = Timer(timeInterval: self.eventPollInterval, repeats: true) { [weak self] _ in
|
||||
guard let self, self.isReady else { return }
|
||||
Task { await self.pollCanonEvents() }
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
self.eventPollTimer = timer
|
||||
}
|
||||
|
||||
if Thread.isMainThread {
|
||||
schedule()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: schedule)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopEventPolling() {
|
||||
eventPollTimer?.invalidate()
|
||||
eventPollTimer = nil
|
||||
}
|
||||
|
||||
private func startCatalogPolling() {
|
||||
let schedule = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.stopCatalogPolling()
|
||||
OTGLog.info(.canon, "Catalog polling started (interval=\(self.catalogPollInterval)s)")
|
||||
|
||||
let timer = Timer(timeInterval: self.catalogPollInterval, repeats: true) { [weak self] _ in
|
||||
guard let self, self.isReady else { return }
|
||||
Task { await self.scanCatalogForNewFiles() }
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
self.catalogPollTimer = timer
|
||||
}
|
||||
|
||||
if Thread.isMainThread {
|
||||
schedule()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: schedule)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopCatalogPolling() {
|
||||
catalogPollTimer?.invalidate()
|
||||
catalogPollTimer = nil
|
||||
}
|
||||
|
||||
private func pollCanonEvents() async {
|
||||
guard isReady, let camera else { return }
|
||||
guard !isDownloading else { return }
|
||||
guard !isPollingGetEvent else { return }
|
||||
|
||||
isPollingGetEvent = true
|
||||
defer { isPollingGetEvent = false }
|
||||
|
||||
pollTickCount += 1
|
||||
|
||||
var localTransactionID = transactionID
|
||||
let events = await CanonPTPHelper.pollEvents(on: camera, transactionID: &localTransactionID)
|
||||
transactionID = localTransactionID
|
||||
|
||||
if pollTickCount == 1 || pollTickCount % 25 == 0 {
|
||||
OTGLog.debug(.canon, "GetEvent poll #\(pollTickCount), parsedEvents=\(events.count)")
|
||||
}
|
||||
|
||||
for event in events where CanonPTPHelper.isTransferEvent(event) {
|
||||
guard let handle = event.objectHandle else { continue }
|
||||
enqueueDownload(
|
||||
handle: handle,
|
||||
storageID: event.storageID,
|
||||
filename: event.filename,
|
||||
objectSize: event.objectSize,
|
||||
reason: "GetEvent-0x\(String(format: "%04X", event.code))"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Download Queue
|
||||
|
||||
private func enqueueDownload(
|
||||
handle: UInt32,
|
||||
storageID: UInt32?,
|
||||
filename: String?,
|
||||
objectSize: UInt32?,
|
||||
reason: String
|
||||
) {
|
||||
if knownDownloadedHandles.contains(handle) { return }
|
||||
|
||||
downloadQueue.append(
|
||||
PendingDownload(
|
||||
handle: handle,
|
||||
storageID: storageID,
|
||||
filename: filename,
|
||||
objectSize: objectSize,
|
||||
reason: reason
|
||||
)
|
||||
)
|
||||
OTGLog.info(
|
||||
.canon,
|
||||
"\(reason): enqueued handle=0x\(String(format: "%08X", handle)) file=\(filename ?? "?") queue=\(downloadQueue.count)"
|
||||
)
|
||||
startDrainIfNeeded()
|
||||
}
|
||||
|
||||
private func startDrainIfNeeded() {
|
||||
guard !isDrainingQueue, !downloadQueue.isEmpty else { return }
|
||||
isDrainingQueue = true
|
||||
Task { await drainDownloadQueue() }
|
||||
}
|
||||
|
||||
private func drainDownloadQueue() async {
|
||||
defer {
|
||||
isDrainingQueue = false
|
||||
if !downloadQueue.isEmpty {
|
||||
startDrainIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
while !downloadQueue.isEmpty {
|
||||
let item = downloadQueue.removeFirst()
|
||||
_ = await downloadAndSave(item: item, maxAttempts: 6)
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func downloadAndSave(item: PendingDownload, maxAttempts: Int) async -> Bool {
|
||||
guard isReady, let camera else { return false }
|
||||
|
||||
if knownDownloadedHandles.contains(item.handle) { return false }
|
||||
|
||||
if isDownloading {
|
||||
downloadQueue.insert(item, at: 0)
|
||||
return false
|
||||
}
|
||||
|
||||
isDownloading = true
|
||||
defer { isDownloading = false }
|
||||
|
||||
var localTransactionID = transactionID
|
||||
|
||||
for attempt in 1...maxAttempts {
|
||||
if attempt > 1 {
|
||||
try? await Task.sleep(nanoseconds: UInt64(attempt) * 300_000_000)
|
||||
}
|
||||
|
||||
var filename = item.filename
|
||||
var resolvedSize = item.objectSize ?? 0
|
||||
|
||||
if filename == nil || resolvedSize == 0 {
|
||||
if let probe = await CanonPTPHelper.probeObject(
|
||||
handle: item.handle,
|
||||
storageID: item.storageID,
|
||||
on: camera,
|
||||
transactionID: &localTransactionID
|
||||
) {
|
||||
if filename == nil { filename = probe.filename }
|
||||
if resolvedSize == 0 { resolvedSize = probe.size }
|
||||
} else if filename == nil, resolvedSize == 0 {
|
||||
if attempt == maxAttempts {
|
||||
OTGLog.warning(.canon, "\(item.reason): object not readable handle=0x\(String(format: "%08X", item.handle))")
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedFilename = filename ?? PTPHelper.sanitizeFilename("IMG_\(item.handle).JPG")
|
||||
let signature = resolvedSize > 0
|
||||
? CanonPTPHelper.objectSignature(filename: resolvedFilename, size: resolvedSize)
|
||||
: "\(resolvedFilename)_handle_\(item.handle)"
|
||||
|
||||
if signature == lastDownloadedSignature {
|
||||
knownDownloadedHandles.insert(item.handle)
|
||||
return false
|
||||
}
|
||||
|
||||
guard let result = await CanonPTPHelper.downloadObject(
|
||||
handle: item.handle,
|
||||
storageID: item.storageID,
|
||||
preferredFilename: filename,
|
||||
on: camera,
|
||||
transactionID: &localTransactionID
|
||||
) else {
|
||||
if attempt == maxAttempts {
|
||||
OTGLog.error(.canon, "\(item.reason): download failed handle=0x\(String(format: "%08X", item.handle))")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
transactionID = localTransactionID
|
||||
|
||||
await CanonPTPHelper.acknowledgeTransfer(
|
||||
handle: item.handle,
|
||||
on: camera,
|
||||
transactionID: &localTransactionID
|
||||
)
|
||||
transactionID = localTransactionID
|
||||
|
||||
lastDownloadedSignature = signature
|
||||
knownDownloadedHandles.insert(item.handle)
|
||||
|
||||
guard let saved = await saveToAlbum(data: result.data, filename: result.filename, reason: item.reason) else {
|
||||
return false
|
||||
}
|
||||
|
||||
savedShotCount += 1
|
||||
OTGLog.info(.canon, "Live shot saved (#\(savedShotCount)): \(result.filename), bytes=\(result.data.count)")
|
||||
onShotSaved?(saved)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func saveToAlbum(data: Data, filename: String, reason: String) async -> SavedOTGPhoto? {
|
||||
guard let photoSink else {
|
||||
OTGLog.warning(.canon, "\(reason): live transfer sink not configured, skip save")
|
||||
return nil
|
||||
}
|
||||
|
||||
let objectID = ICCameraItemScanner.cameraObjectID(filename: filename, fileSize: Int64(data.count))
|
||||
do {
|
||||
return try await photoSink.saveLiveShot(
|
||||
data: data,
|
||||
filename: filename,
|
||||
capturedAt: Date(),
|
||||
cameraObjectID: objectID
|
||||
)
|
||||
} catch {
|
||||
OTGLog.error(.canon, "\(reason): save failed: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
//
|
||||
// NikonCameraDriver.swift
|
||||
// otg_swift
|
||||
//
|
||||
// Created by hanqiu on 2026/7/2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageCaptureCore
|
||||
|
||||
/// 尼康相机 Driver:ImageCaptureCore 枚举 SD 卡历史照片并下载(含 NEF/JPEG 等)。
|
||||
final class NikonCameraDriver: CameraDriver {
|
||||
private let device: ICCameraDevice
|
||||
let platform: CameraPlatform = .nikon
|
||||
let deviceInfo: CameraDeviceInfo
|
||||
|
||||
init(device: ICCameraDevice, deviceInfo: CameraDeviceInfo) {
|
||||
self.device = device
|
||||
self.deviceInfo = deviceInfo
|
||||
OTGLog.info(.nikon, "initialized, device=\(deviceInfo.name)")
|
||||
}
|
||||
|
||||
func connect() async throws {
|
||||
OTGLog.debug(.nikon, "connect called")
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
OTGLog.info(.nikon, "driver released, device=\(deviceInfo.name)")
|
||||
}
|
||||
|
||||
func listObjects() async throws -> [CameraObject] {
|
||||
let mediaFilesCount = device.mediaFiles?.count ?? 0
|
||||
let contentsCount = device.contents?.count ?? 0
|
||||
let files = ICCameraItemScanner.collectImageFiles(from: device)
|
||||
OTGLog.info(.nikon, "listObjects: mediaFiles=\(mediaFilesCount), contents=\(contentsCount), images=\(files.count)")
|
||||
|
||||
let results = await ICCameraItemScanner.cameraObjects(from: files)
|
||||
.sorted { $0.capturedAt > $1.capturedAt }
|
||||
|
||||
OTGLog.info(.nikon, "listObjects: image count=\(results.count)")
|
||||
return results
|
||||
}
|
||||
|
||||
func requestThumbnailData(for object: CameraObject, maxPixelSize: Int) async -> Data? {
|
||||
await ICCameraThumbnailLoader.loadThumbnailData(for: object, from: device, maxPixelSize: maxPixelSize)
|
||||
}
|
||||
|
||||
func downloadObject(_ object: CameraObject, to directory: URL) async throws -> URL {
|
||||
guard let file = ICCameraItemScanner.findFile(matching: object, in: device) else {
|
||||
OTGLog.error(.nikon, "downloadObject file not found: \(object.filename)")
|
||||
throw CameraError.deviceNotFound
|
||||
}
|
||||
|
||||
let url = try await ICCameraFileDownloader.download(file, to: directory)
|
||||
OTGLog.info(.nikon, "downloadObject saved: \(url.lastPathComponent)")
|
||||
return url
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,199 @@
|
||||
//
|
||||
// NikonCatalogLiveCaptureService.swift
|
||||
// otg_swift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageCaptureCore
|
||||
|
||||
/// 尼康边拍边传:监听 ImageCaptureCore catalog 新增文件并下载写入相册。
|
||||
/// Z6 系列等在快门后会触发 `didAdd`,无需 Nikon 专用 PTP 协议。
|
||||
final class NikonCatalogLiveCaptureService {
|
||||
|
||||
private weak var camera: ICCameraDevice?
|
||||
private(set) var isReady = false
|
||||
|
||||
private weak var photoSink: OTGPhotoSink?
|
||||
var onShotSaved: ((SavedOTGPhoto) -> Void)?
|
||||
|
||||
private var catalogPollTimer: Timer?
|
||||
private var isDownloading = false
|
||||
|
||||
private var lastDownloadedSignature: String?
|
||||
private var knownCatalogIDs: Set<String> = []
|
||||
private var catalogBaselineEstablished = false
|
||||
private var savedShotCount = 0
|
||||
|
||||
private let catalogPollInterval: TimeInterval = 1.5
|
||||
private var catalogScanCount = 0
|
||||
|
||||
func start(camera: ICCameraDevice, photoSink: OTGPhotoSink?) {
|
||||
stop()
|
||||
|
||||
self.camera = camera
|
||||
self.photoSink = photoSink
|
||||
self.isReady = true
|
||||
self.lastDownloadedSignature = nil
|
||||
self.knownCatalogIDs = []
|
||||
self.catalogBaselineEstablished = false
|
||||
self.savedShotCount = 0
|
||||
self.catalogScanCount = 0
|
||||
|
||||
establishCatalogBaseline(from: camera)
|
||||
|
||||
OTGLog.info(.nikon, "Catalog live capture started")
|
||||
startCatalogPolling()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
isReady = false
|
||||
if Thread.isMainThread {
|
||||
stopCatalogPolling()
|
||||
} else {
|
||||
DispatchQueue.main.sync { stopCatalogPolling() }
|
||||
}
|
||||
isDownloading = false
|
||||
knownCatalogIDs = []
|
||||
catalogBaselineEstablished = false
|
||||
photoSink = nil
|
||||
camera = nil
|
||||
}
|
||||
|
||||
// MARK: - Catalog
|
||||
|
||||
func handleCatalogItemsAdded(_ items: [ICCameraItem]) {
|
||||
guard isReady, catalogBaselineEstablished else { return }
|
||||
|
||||
for item in items {
|
||||
guard let file = item as? ICCameraFile else { continue }
|
||||
Task { await downloadCatalogFileIfNew(file, reason: "didAdd") }
|
||||
}
|
||||
}
|
||||
|
||||
private func establishCatalogBaseline(from camera: ICCameraDevice) {
|
||||
var known = Set<String>()
|
||||
var newDuringConnect = 0
|
||||
|
||||
for file in ICCameraItemScanner.collectImageFiles(from: camera) {
|
||||
if file.wasAddedAfterContentCatalogCompleted {
|
||||
newDuringConnect += 1
|
||||
Task { await downloadCatalogFileIfNew(file, reason: "baseline-new") }
|
||||
} else {
|
||||
known.insert(ICCameraItemScanner.cameraObjectID(for: file))
|
||||
}
|
||||
}
|
||||
|
||||
knownCatalogIDs = known
|
||||
catalogBaselineEstablished = true
|
||||
OTGLog.info(.nikon, "Catalog baseline: known=\(known.count), newDuringConnect=\(newDuringConnect)")
|
||||
}
|
||||
|
||||
private func scanCatalogForNewFiles() async {
|
||||
guard isReady, catalogBaselineEstablished, let camera else { return }
|
||||
|
||||
let files = ICCameraItemScanner.collectImageFiles(from: camera)
|
||||
catalogScanCount += 1
|
||||
if catalogScanCount == 1 || catalogScanCount % 10 == 0 {
|
||||
OTGLog.debug(.nikon, "Catalog scan #\(catalogScanCount): files=\(files.count), known=\(knownCatalogIDs.count)")
|
||||
}
|
||||
|
||||
for file in files {
|
||||
await downloadCatalogFileIfNew(file, reason: "catalog-poll")
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadCatalogFileIfNew(_ file: ICCameraFile, reason: String) async {
|
||||
guard isReady else { return }
|
||||
|
||||
let objectID = ICCameraItemScanner.cameraObjectID(for: file)
|
||||
guard !knownCatalogIDs.contains(objectID) else { return }
|
||||
knownCatalogIDs.insert(objectID)
|
||||
|
||||
if isDownloading {
|
||||
knownCatalogIDs.remove(objectID)
|
||||
return
|
||||
}
|
||||
|
||||
isDownloading = true
|
||||
defer { isDownloading = false }
|
||||
|
||||
let filename = file.name ?? UUID().uuidString
|
||||
if objectID == lastDownloadedSignature {
|
||||
return
|
||||
}
|
||||
|
||||
OTGLog.info(.nikon, "\(reason): downloading \(filename) id=\(objectID)")
|
||||
|
||||
do {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("nikon-live", isDirectory: true)
|
||||
let url = try await ICCameraFileDownloader.download(file, to: tempDir)
|
||||
let data = try Data(contentsOf: url)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
|
||||
lastDownloadedSignature = objectID
|
||||
|
||||
guard let saved = await saveToAlbum(data: data, filename: filename, reason: reason) else {
|
||||
knownCatalogIDs.remove(objectID)
|
||||
return
|
||||
}
|
||||
|
||||
savedShotCount += 1
|
||||
OTGLog.info(.nikon, "Live shot saved (#\(savedShotCount)): \(filename), bytes=\(data.count)")
|
||||
onShotSaved?(saved)
|
||||
} catch {
|
||||
knownCatalogIDs.remove(objectID)
|
||||
OTGLog.error(.nikon, "\(reason): catalog download failed \(filename): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Polling
|
||||
|
||||
private func startCatalogPolling() {
|
||||
let schedule = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.stopCatalogPolling()
|
||||
OTGLog.info(.nikon, "Catalog polling started (interval=\(self.catalogPollInterval)s)")
|
||||
|
||||
let timer = Timer(timeInterval: self.catalogPollInterval, repeats: true) { [weak self] _ in
|
||||
guard let self, self.isReady else { return }
|
||||
Task { await self.scanCatalogForNewFiles() }
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
self.catalogPollTimer = timer
|
||||
}
|
||||
|
||||
if Thread.isMainThread {
|
||||
schedule()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: schedule)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopCatalogPolling() {
|
||||
catalogPollTimer?.invalidate()
|
||||
catalogPollTimer = nil
|
||||
}
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
private func saveToAlbum(data: Data, filename: String, reason: String) async -> SavedOTGPhoto? {
|
||||
guard let photoSink else {
|
||||
OTGLog.warning(.nikon, "\(reason): live transfer sink not configured, skip save")
|
||||
return nil
|
||||
}
|
||||
|
||||
let objectID = ICCameraItemScanner.cameraObjectID(filename: filename, fileSize: Int64(data.count))
|
||||
do {
|
||||
return try await photoSink.saveLiveShot(
|
||||
data: data,
|
||||
filename: filename,
|
||||
capturedAt: Date(),
|
||||
cameraObjectID: objectID
|
||||
)
|
||||
} catch {
|
||||
OTGLog.error(.nikon, "\(reason): save failed: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
61
suixinkan/Core/CameraOTG/Brands/Sony/SonyCameraDriver.swift
Normal file
61
suixinkan/Core/CameraOTG/Brands/Sony/SonyCameraDriver.swift
Normal file
@ -0,0 +1,61 @@
|
||||
//
|
||||
// SonyCameraDriver.swift
|
||||
// otg_swift
|
||||
//
|
||||
// Created by hanqiu on 2026/7/2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageCaptureCore
|
||||
|
||||
/// 索尼相机 **MTP 历史导入** 路径的 Driver 实现。
|
||||
/// PC Remote 边拍边传由 `SonyRemoteCaptureService` + PTP 负责,与本 Driver 的 catalog 扫描分离。
|
||||
/// 索尼在 PC Remote 模式下 catalog 常为空,需提示用户切换 MTP(见 AGENTS.md)。
|
||||
final class SonyCameraDriver: CameraDriver {
|
||||
private let device: ICCameraDevice
|
||||
let platform: CameraPlatform = .sony
|
||||
let deviceInfo: CameraDeviceInfo
|
||||
|
||||
init(device: ICCameraDevice, deviceInfo: CameraDeviceInfo) {
|
||||
self.device = device
|
||||
self.deviceInfo = deviceInfo
|
||||
OTGLog.info(.sony, "initialized, device=\(deviceInfo.name)")
|
||||
}
|
||||
|
||||
func connect() async throws {
|
||||
OTGLog.debug(.sony, "connect called")
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
OTGLog.info(.sony, "driver released, device=\(deviceInfo.name)")
|
||||
}
|
||||
|
||||
/// 扫描 `mediaFiles` / `contents` 树;PC Remote 模式下可能返回空列表。
|
||||
func listObjects() async throws -> [CameraObject] {
|
||||
let mediaFilesCount = device.mediaFiles?.count ?? 0
|
||||
let contentsCount = device.contents?.count ?? 0
|
||||
let files = ICCameraItemScanner.collectImageFiles(from: device)
|
||||
OTGLog.info(.sony, "listObjects: mediaFiles=\(mediaFilesCount), contents=\(contentsCount), images=\(files.count)")
|
||||
|
||||
let results = await ICCameraItemScanner.cameraObjects(from: files)
|
||||
.sorted { $0.capturedAt > $1.capturedAt }
|
||||
|
||||
OTGLog.info(.sony, "listObjects: image count=\(results.count)")
|
||||
return results
|
||||
}
|
||||
|
||||
func requestThumbnailData(for object: CameraObject, maxPixelSize: Int) async -> Data? {
|
||||
await ICCameraThumbnailLoader.loadThumbnailData(for: object, from: device, maxPixelSize: maxPixelSize)
|
||||
}
|
||||
|
||||
func downloadObject(_ object: CameraObject, to directory: URL) async throws -> URL {
|
||||
guard let file = ICCameraItemScanner.findFile(matching: object, in: device) else {
|
||||
OTGLog.error(.sony, "downloadObject file not found: \(object.filename)")
|
||||
throw CameraError.deviceNotFound
|
||||
}
|
||||
|
||||
let url = try await ICCameraFileDownloader.download(file, to: directory)
|
||||
OTGLog.info(.sony, "downloadObject saved: \(url.lastPathComponent)")
|
||||
return url
|
||||
}
|
||||
}
|
||||
51
suixinkan/Core/CameraOTG/Brands/Sony/SonyPTPCommands.swift
Normal file
51
suixinkan/Core/CameraOTG/Brands/Sony/SonyPTPCommands.swift
Normal file
@ -0,0 +1,51 @@
|
||||
//
|
||||
// SonyPTPCommands.swift
|
||||
// otg_swift
|
||||
//
|
||||
// Created by hanqiu on 2026/7/3.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 索尼 SDIO / SDIE 扩展 PTP 命令码、属性码与事件码常量。
|
||||
enum SonyPTPCommand {
|
||||
// MARK: - SDIO 命令
|
||||
|
||||
/// SDIO Connect (0x9201),握手阶段多次调用,参数区分阶段
|
||||
static let sdioConnect: UInt16 = 0x9201
|
||||
/// GetExtDeviceInfo (0x9202),获取扩展设备能力
|
||||
static let sdioGetExtDeviceInfo: UInt16 = 0x9202
|
||||
/// SetExtDevicePropValue (0x9205),写入单个扩展属性
|
||||
static let sdioSetExtDevicePropValue: UInt16 = 0x9205
|
||||
/// GetAllExtDevicePropInfo (0x9209),读取全部或 diff 扩展属性
|
||||
static let sdioGetAllExtDevicePropInfo: UInt16 = 0x9209
|
||||
|
||||
// MARK: - 扩展设备属性
|
||||
|
||||
/// Position_Key_Setting (0xD25A),设为 PC Remote 模式
|
||||
static let positionKeySetting: UInt16 = 0xD25A
|
||||
/// Still_Image_Save_Destination (0xD222),保存目标含 Host PC
|
||||
static let stillImageSaveDestination: UInt16 = 0xD222
|
||||
/// Still_Image_Trans_Size (0xD268),传输尺寸(0xFFFFFFFF = 原图)
|
||||
static let stillImageTransSize: UInt16 = 0xD268
|
||||
/// Shooting_File_Info (0xD215);值 > 0x8000 表示 PC Remote 缓冲区可能有新片
|
||||
static let shootingFileInfo: UInt16 = 0xD215
|
||||
|
||||
/// PC Remote 虚拟对象句柄,GetObjectInfo/GetObject 用于 Probe 与下载
|
||||
static let shotObjectHandle: UInt32 = 0xFFFF_C001
|
||||
/// 静态影像保存到 Host PC 的属性枚举值
|
||||
static let saveDestinationHostPC: UInt16 = 1
|
||||
/// D215 超过此阈值视为 shot buffer 就绪
|
||||
static let shotBufferReadyThreshold: Int64 = 0x8000
|
||||
|
||||
// MARK: - PTP Events (Sony SDIE)
|
||||
|
||||
/// ObjectAdded (0xC201),虚拟对象已加入设备侧 catalog
|
||||
static let sdieObjectAdded: UInt16 = 0xC201
|
||||
/// ObjectRemoved (0xC202),对象从 catalog 移除
|
||||
static let sdieObjectRemoved: UInt16 = 0xC202
|
||||
/// DevicePropChanged (0xC203);ZV-E10 快门后常见,需配合 D215/Probe
|
||||
static let sdieDevicePropChanged: UInt16 = 0xC203
|
||||
/// CapturedEvent (0xC206),拍摄完成事件
|
||||
static let sdieCapturedEvent: UInt16 = 0xC206
|
||||
}
|
||||
422
suixinkan/Core/CameraOTG/Brands/Sony/SonyPTPHelper.swift
Normal file
422
suixinkan/Core/CameraOTG/Brands/Sony/SonyPTPHelper.swift
Normal file
@ -0,0 +1,422 @@
|
||||
//
|
||||
// SonyPTPHelper.swift
|
||||
// otg_swift
|
||||
//
|
||||
// Created by hanqiu on 2026/7/3.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageCaptureCore
|
||||
|
||||
/// 索尼 PC Remote(SDIO)握手、属性解析与边拍边传 Probe 的 PTP 封装。
|
||||
enum SonyPTPHelper {
|
||||
|
||||
/// SDIO 握手结果;`nextTransactionID` 供后续 PTP 命令递增使用。
|
||||
struct RemoteSessionResult {
|
||||
let success: Bool
|
||||
let nextTransactionID: UInt32
|
||||
}
|
||||
|
||||
private static func sendCommand(
|
||||
on camera: ICCameraDevice,
|
||||
code: UInt16,
|
||||
transactionID: inout UInt32,
|
||||
parameters: [UInt32],
|
||||
outData: Data?,
|
||||
label: String
|
||||
) async -> PTPHelper.CommandResult {
|
||||
await PTPHelper.sendCommand(
|
||||
on: camera,
|
||||
code: code,
|
||||
transactionID: &transactionID,
|
||||
parameters: parameters,
|
||||
outData: outData,
|
||||
label: label
|
||||
)
|
||||
}
|
||||
|
||||
private static func setDeviceProperty(
|
||||
on camera: ICCameraDevice,
|
||||
propertyCode: UInt16,
|
||||
value: UInt16,
|
||||
transactionID: inout UInt32,
|
||||
label: String
|
||||
) async -> Bool {
|
||||
var payload = Data()
|
||||
PTPHelper.appendUInt16LE(value, to: &payload)
|
||||
|
||||
return await sendCommand(
|
||||
on: camera,
|
||||
code: SonyPTPCommand.sdioSetExtDevicePropValue,
|
||||
transactionID: &transactionID,
|
||||
parameters: [UInt32(propertyCode), 1],
|
||||
outData: payload,
|
||||
label: label
|
||||
).success
|
||||
}
|
||||
|
||||
private static func setDevicePropertyUInt32(
|
||||
on camera: ICCameraDevice,
|
||||
propertyCode: UInt16,
|
||||
value: UInt32,
|
||||
transactionID: inout UInt32,
|
||||
label: String
|
||||
) async -> Bool {
|
||||
var payload = Data()
|
||||
PTPHelper.appendUInt32LE(value, to: &payload)
|
||||
|
||||
return await sendCommand(
|
||||
on: camera,
|
||||
code: SonyPTPCommand.sdioSetExtDevicePropValue,
|
||||
transactionID: &transactionID,
|
||||
parameters: [UInt32(propertyCode), 1],
|
||||
outData: payload,
|
||||
label: label
|
||||
).success
|
||||
}
|
||||
|
||||
/// 配置 PC Remote 保存目标与传输尺寸(D25A / D222 / D268)。
|
||||
private static func configureRemoteCapture(
|
||||
on camera: ICCameraDevice,
|
||||
transactionID: inout UInt32
|
||||
) async -> Bool {
|
||||
guard await setDeviceProperty(
|
||||
on: camera,
|
||||
propertyCode: SonyPTPCommand.positionKeySetting,
|
||||
value: 1,
|
||||
transactionID: &transactionID,
|
||||
label: "Position_Key_Setting=PC Remote"
|
||||
) else { return false }
|
||||
|
||||
guard await setDeviceProperty(
|
||||
on: camera,
|
||||
propertyCode: SonyPTPCommand.stillImageSaveDestination,
|
||||
value: SonyPTPCommand.saveDestinationHostPC,
|
||||
transactionID: &transactionID,
|
||||
label: "Still_Image_Save_Destination=Host PC"
|
||||
) else { return false }
|
||||
|
||||
return await setDevicePropertyUInt32(
|
||||
on: camera,
|
||||
propertyCode: SonyPTPCommand.stillImageTransSize,
|
||||
value: 0xFFFF_FFFF,
|
||||
transactionID: &transactionID,
|
||||
label: "Still_Image_Trans_Size=Full"
|
||||
)
|
||||
}
|
||||
|
||||
/// 完整 SDIO 握手:Connect×3 → GetExtDeviceInfo → GetAllExtDevicePropInfo → 远程拍摄属性。
|
||||
static func initializeRemoteSession(
|
||||
on camera: ICCameraDevice,
|
||||
startingTransactionID: UInt32
|
||||
) async -> RemoteSessionResult {
|
||||
var transactionID = startingTransactionID
|
||||
|
||||
OTGLog.info(.sony, "SDIO handshake starting (phase 1: steps 1-5)")
|
||||
|
||||
// Phase 1:标准 SDIO 连接序列(与 libgphoto2 / my_otg 一致)
|
||||
let steps: [(UInt16, [UInt32], String)] = [
|
||||
(SonyPTPCommand.sdioConnect, [1, 0, 0], "SDIO Connect(1)"),
|
||||
(SonyPTPCommand.sdioConnect, [2, 0, 0], "SDIO Connect(2)"),
|
||||
(SonyPTPCommand.sdioGetExtDeviceInfo, [0x012C, 1], "SDIO GetExtDeviceInfo"),
|
||||
(SonyPTPCommand.sdioConnect, [3, 0, 0], "SDIO Connect(3)"),
|
||||
(SonyPTPCommand.sdioGetAllExtDevicePropInfo, [1], "SDIO GetAllExtDevicePropInfo"),
|
||||
]
|
||||
|
||||
for (code, parameters, label) in steps {
|
||||
let result = await sendCommand(
|
||||
on: camera,
|
||||
code: code,
|
||||
transactionID: &transactionID,
|
||||
parameters: parameters,
|
||||
outData: nil,
|
||||
label: label
|
||||
)
|
||||
guard result.success else {
|
||||
OTGLog.error(.sony, "SDIO failed at: \(label)")
|
||||
return RemoteSessionResult(success: false, nextTransactionID: transactionID)
|
||||
}
|
||||
}
|
||||
|
||||
OTGLog.info(.sony, "SDIO phase 1 OK, nextTransactionID=\(transactionID)")
|
||||
|
||||
let configured = await configureRemoteCapture(on: camera, transactionID: &transactionID)
|
||||
if configured {
|
||||
OTGLog.info(.sony, "SDIO handshake completed, nextTransactionID=\(transactionID)")
|
||||
} else {
|
||||
OTGLog.error(.sony, "SDIO handshake failed at remote capture configuration")
|
||||
}
|
||||
|
||||
return RemoteSessionResult(success: configured, nextTransactionID: transactionID)
|
||||
}
|
||||
|
||||
// MARK: - PTP Events
|
||||
|
||||
static func describeEvent(_ eventData: Data) -> String {
|
||||
guard let code = PTPHelper.parseEventCode(from: eventData) else {
|
||||
return "unknown, hex=\(PTPHelper.hexString(eventData.prefix(16)))"
|
||||
}
|
||||
|
||||
let name: String
|
||||
switch code {
|
||||
case SonyPTPCommand.sdieObjectAdded:
|
||||
name = "ObjectAdded"
|
||||
case SonyPTPCommand.sdieObjectRemoved:
|
||||
name = "ObjectRemoved"
|
||||
case SonyPTPCommand.sdieDevicePropChanged:
|
||||
name = "DevicePropChanged"
|
||||
case SonyPTPCommand.sdieCapturedEvent:
|
||||
name = "CapturedEvent"
|
||||
default:
|
||||
name = "Other"
|
||||
}
|
||||
|
||||
if let container = try? PTPHelper.parseContainer(eventData), !container.params.isEmpty {
|
||||
let params = container.params
|
||||
.map { String(format: "0x%08X", $0) }
|
||||
.joined(separator: ", ")
|
||||
return "\(name) (0x\(String(format: "%04X", code))), params=[\(params)]"
|
||||
}
|
||||
|
||||
return "\(name) (0x\(String(format: "%04X", code)))"
|
||||
}
|
||||
|
||||
// MARK: - Shot detection
|
||||
|
||||
/// Probe 成功时返回的待下载片信息(尚未 GetObject)。
|
||||
struct PendingShot {
|
||||
let filename: String
|
||||
let size: UInt32
|
||||
let handle: UInt32
|
||||
}
|
||||
|
||||
/// 对虚拟句柄发 GetObjectInfo,解析文件名与 CompressedSize。
|
||||
static func probeShotObject(
|
||||
on camera: ICCameraDevice,
|
||||
handle: UInt32 = SonyPTPCommand.shotObjectHandle,
|
||||
transactionID: inout UInt32
|
||||
) async -> PendingShot? {
|
||||
let infoResult = await sendCommand(
|
||||
on: camera,
|
||||
code: PTPStandardCommand.getObjectInfo,
|
||||
transactionID: &transactionID,
|
||||
parameters: [handle],
|
||||
outData: nil,
|
||||
label: "ProbeObjectInfo(0x\(String(format: "%08X", handle)))"
|
||||
)
|
||||
guard infoResult.success else { return nil }
|
||||
|
||||
guard let size = PTPHelper.parseObjectCompressedSize(infoResult.inData), size > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let filename = PTPHelper.parseObjectInfoFilename(infoResult.inData)
|
||||
?? "DSC_\(Int(Date().timeIntervalSince1970)).JPG"
|
||||
|
||||
return PendingShot(filename: filename, size: size, handle: handle)
|
||||
}
|
||||
|
||||
/// GetObject 下载结果。
|
||||
struct DownloadedShot {
|
||||
let filename: String
|
||||
let data: Data
|
||||
let handle: UInt32
|
||||
}
|
||||
|
||||
/// 对虚拟句柄发 GetObject,返回 JPEG/RAW 二进制;Probe 后可 `skipObjectInfo` 省略重复 GetObjectInfo。
|
||||
static func downloadShotObject(
|
||||
on camera: ICCameraDevice,
|
||||
handle: UInt32 = SonyPTPCommand.shotObjectHandle,
|
||||
transactionID: inout UInt32,
|
||||
knownFilename: String? = nil,
|
||||
skipObjectInfo: Bool = false
|
||||
) async -> DownloadedShot? {
|
||||
var filename = PTPHelper.sanitizeFilename(
|
||||
knownFilename ?? "DSC_\(Int(Date().timeIntervalSince1970)).JPG"
|
||||
)
|
||||
|
||||
if !skipObjectInfo {
|
||||
let infoResult = await sendCommand(
|
||||
on: camera,
|
||||
code: PTPStandardCommand.getObjectInfo,
|
||||
transactionID: &transactionID,
|
||||
parameters: [handle],
|
||||
outData: nil,
|
||||
label: "GetObjectInfo(0x\(String(format: "%08X", handle)))"
|
||||
)
|
||||
if infoResult.success, let parsedName = PTPHelper.parseObjectInfoFilename(infoResult.inData) {
|
||||
filename = parsedName
|
||||
}
|
||||
}
|
||||
|
||||
let objectResult = await sendCommand(
|
||||
on: camera,
|
||||
code: PTPStandardCommand.getObject,
|
||||
transactionID: &transactionID,
|
||||
parameters: [handle],
|
||||
outData: nil,
|
||||
label: "GetObject(0x\(String(format: "%08X", handle)))"
|
||||
)
|
||||
guard objectResult.success, PTPHelper.isLikelyImageData(objectResult.inData) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return DownloadedShot(filename: filename, data: objectResult.inData, handle: handle)
|
||||
}
|
||||
|
||||
/// 读取 SDIO 扩展属性;`onlyDiff=true` 仅返回自上次读取以来变化的属性。
|
||||
static func fetchExtDeviceProperties(
|
||||
on camera: ICCameraDevice,
|
||||
transactionID: inout UInt32,
|
||||
onlyDiff: Bool = true
|
||||
) async -> [UInt16: Int64] {
|
||||
let result = await sendCommand(
|
||||
on: camera,
|
||||
code: SonyPTPCommand.sdioGetAllExtDevicePropInfo,
|
||||
transactionID: &transactionID,
|
||||
parameters: [onlyDiff ? 1 : 0],
|
||||
outData: nil,
|
||||
label: "GetAllExtDevicePropInfo(\(onlyDiff ? "diff" : "all"))"
|
||||
)
|
||||
guard result.success else { return [:] }
|
||||
return parseExtDeviceProperties(result.inData)
|
||||
}
|
||||
|
||||
/// 解析 GetAllExtDevicePropInfo 返回的二进制属性列表。
|
||||
static func parseExtDeviceProperties(_ data: Data) -> [UInt16: Int64] {
|
||||
guard data.count >= 8 else { return [:] }
|
||||
|
||||
var properties: [UInt16: Int64] = [:]
|
||||
var offset = 8 // 跳过响应头
|
||||
|
||||
while offset + 6 <= data.count {
|
||||
let propertyCode = PTPHelper.readUInt16LE(data, offset: offset)
|
||||
offset += 2
|
||||
let dataType = PTPHelper.readUInt16LE(data, offset: offset)
|
||||
offset += 2
|
||||
offset += 2 // GetSet 等标志位,当前不解析
|
||||
|
||||
guard skipVariableValue(dataType: dataType, data: data, offset: &offset) else { break }
|
||||
guard let currentValue = readVariableValue(dataType: dataType, data: data, offset: &offset) else { break }
|
||||
properties[propertyCode] = currentValue
|
||||
|
||||
guard offset < data.count else { break }
|
||||
let formFlag = data[offset]
|
||||
offset += 1
|
||||
// formFlag: 0=无表单, 1=Range, 2=Enumeration
|
||||
guard skipFormPayload(formFlag: formFlag, dataType: dataType, data: data, offset: &offset) else { break }
|
||||
}
|
||||
|
||||
return properties
|
||||
}
|
||||
|
||||
/// D215(Shooting_File_Info)大于阈值表示缓冲区可能有完整新片。
|
||||
static func isShotBufferReady(_ shootingFileInfo: Int64?) -> Bool {
|
||||
guard let shootingFileInfo else { return false }
|
||||
return shootingFileInfo > SonyPTPCommand.shotBufferReadyThreshold
|
||||
}
|
||||
|
||||
static func shotObjectSignature(filename: String, size: UInt32) -> String {
|
||||
PTPHelper.objectSignature(filename: filename, size: size)
|
||||
}
|
||||
|
||||
/// PTP 设备属性值的数据类型码(用于 parseExtDeviceProperties)。
|
||||
private enum PTPDataType: UInt16 {
|
||||
/// 有符号 8 位整数
|
||||
case int8 = 0x0001
|
||||
/// 无符号 8 位整数
|
||||
case uint8 = 0x0002
|
||||
/// 有符号 16 位整数
|
||||
case int16 = 0x0003
|
||||
/// 无符号 16 位整数
|
||||
case uint16 = 0x0004
|
||||
/// 有符号 32 位整数
|
||||
case int32 = 0x0005
|
||||
/// 无符号 32 位整数
|
||||
case uint32 = 0x0006
|
||||
/// 有符号 64 位整数(当前仅跳过 8 字节)
|
||||
case int64 = 0x0007
|
||||
/// 无符号 64 位整数(当前仅跳过 8 字节)
|
||||
case uint64 = 0x0008
|
||||
/// UTF-16LE 字符串;解析时跳过不参与数值 map
|
||||
case string = 0xFFFF
|
||||
}
|
||||
|
||||
private static func readVariableValue(dataType: UInt16, data: Data, offset: inout Int) -> Int64? {
|
||||
guard let type = PTPDataType(rawValue: dataType) else { return nil }
|
||||
|
||||
switch type {
|
||||
case .int8:
|
||||
guard offset < data.count else { return nil }
|
||||
let value = Int64(Int8(bitPattern: data[offset]))
|
||||
offset += 1
|
||||
return value
|
||||
case .uint8:
|
||||
guard offset < data.count else { return nil }
|
||||
let value = Int64(data[offset])
|
||||
offset += 1
|
||||
return value
|
||||
case .int16:
|
||||
guard offset + 1 < data.count else { return nil }
|
||||
let value = Int64(Int16(bitPattern: PTPHelper.readUInt16LE(data, offset: offset)))
|
||||
offset += 2
|
||||
return value
|
||||
case .uint16:
|
||||
guard offset + 1 < data.count else { return nil }
|
||||
let value = Int64(PTPHelper.readUInt16LE(data, offset: offset))
|
||||
offset += 2
|
||||
return value
|
||||
case .int32:
|
||||
guard offset + 3 < data.count else { return nil }
|
||||
let value = Int64(Int32(bitPattern: PTPHelper.readUInt32LE(data, offset: offset)))
|
||||
offset += 4
|
||||
return value
|
||||
case .uint32:
|
||||
guard offset + 3 < data.count else { return nil }
|
||||
let value = Int64(PTPHelper.readUInt32LE(data, offset: offset))
|
||||
offset += 4
|
||||
return value
|
||||
case .int64, .uint64:
|
||||
guard offset + 7 < data.count else { return nil }
|
||||
offset += 8
|
||||
return 0
|
||||
case .string:
|
||||
guard offset < data.count else { return nil }
|
||||
let length = Int(data[offset])
|
||||
offset += 1 + length * 2
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private static func skipVariableValue(dataType: UInt16, data: Data, offset: inout Int) -> Bool {
|
||||
readVariableValue(dataType: dataType, data: data, offset: &offset) != nil
|
||||
}
|
||||
|
||||
private static func skipFormPayload(formFlag: UInt8, dataType: UInt16, data: Data, offset: inout Int) -> Bool {
|
||||
switch formFlag {
|
||||
case 0:
|
||||
return true
|
||||
case 1:
|
||||
for _ in 0..<3 {
|
||||
guard skipVariableValue(dataType: dataType, data: data, offset: &offset) else { return false }
|
||||
}
|
||||
return true
|
||||
case 2:
|
||||
guard offset + 1 < data.count else { return false }
|
||||
var enumCount = Int(PTPHelper.readUInt16LE(data, offset: offset))
|
||||
offset += 2
|
||||
for _ in 0..<enumCount {
|
||||
guard skipVariableValue(dataType: dataType, data: data, offset: &offset) else { return false }
|
||||
}
|
||||
guard offset + 1 < data.count else { return false }
|
||||
enumCount = Int(PTPHelper.readUInt16LE(data, offset: offset))
|
||||
offset += 2
|
||||
for _ in 0..<enumCount {
|
||||
guard skipVariableValue(dataType: dataType, data: data, offset: &offset) else { return false }
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,331 @@
|
||||
//
|
||||
// SonyRemoteCaptureService.swift
|
||||
// otg_swift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageCaptureCore
|
||||
|
||||
/// Sony PC Remote 边拍边传:检测(Step 5)+ GetObject 下载入库(Step 6)。
|
||||
final class SonyRemoteCaptureService {
|
||||
|
||||
private weak var camera: ICCameraDevice?
|
||||
private var transactionID: UInt32 = 1
|
||||
private(set) var isReady = false
|
||||
|
||||
private weak var photoSink: OTGPhotoSink?
|
||||
/// 下载成功时回调,供 ConnectionManager 转发给 ViewModel。
|
||||
var onShotSaved: ((SavedOTGPhoto) -> Void)?
|
||||
|
||||
private var pollTimer: Timer?
|
||||
private var propertyCheckTask: Task<Void, Never>?
|
||||
private var isEvaluatingShotBuffer = false
|
||||
private var propertyCheckCoalesceScheduled = false
|
||||
private var isDownloading = false
|
||||
|
||||
private var lastDownloadedShotSignature: String?
|
||||
private var detectedShotCount = 0
|
||||
private var savedShotCount = 0
|
||||
|
||||
private let idlePollInterval: TimeInterval = 1.0
|
||||
/// 检测到新片后加快 D215 轮询,提高连拍响应
|
||||
private let activePollInterval: TimeInterval = 0.25
|
||||
/// C203 等事件后略延迟再读属性,等待相机写入缓冲区
|
||||
private let propertyCheckDelay: TimeInterval = 0.12
|
||||
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// 在 SDIO 握手成功后启动;`photoSink` 为边拍边传落盘入口,nil 时只检测不保存。
|
||||
func start(
|
||||
camera: ICCameraDevice,
|
||||
startingTransactionID: UInt32,
|
||||
photoSink: OTGPhotoSink?
|
||||
) {
|
||||
stop()
|
||||
|
||||
self.camera = camera
|
||||
self.transactionID = startingTransactionID
|
||||
self.photoSink = photoSink
|
||||
self.isReady = true
|
||||
self.lastDownloadedShotSignature = nil
|
||||
self.detectedShotCount = 0
|
||||
self.savedShotCount = 0
|
||||
|
||||
OTGLog.info(.sony, "Remote capture service started, transactionID=\(startingTransactionID)")
|
||||
startD215Polling()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
isReady = false
|
||||
stopD215Polling()
|
||||
propertyCheckTask?.cancel()
|
||||
propertyCheckTask = nil
|
||||
isEvaluatingShotBuffer = false
|
||||
propertyCheckCoalesceScheduled = false
|
||||
isDownloading = false
|
||||
photoSink = nil
|
||||
camera = nil
|
||||
}
|
||||
|
||||
// MARK: - PTP Events
|
||||
|
||||
/// 处理 Sony SDIE 事件:C201/C206 强信号,C203 需配合 D215/Probe。
|
||||
func handlePTPEvent(_ eventData: Data) {
|
||||
guard isReady else { return }
|
||||
|
||||
guard let code = PTPHelper.parseEventCode(from: eventData) else { return }
|
||||
|
||||
switch code {
|
||||
case SonyPTPCommand.sdieObjectAdded:
|
||||
let handle = (try? PTPHelper.parseContainer(eventData))?.params.first
|
||||
?? SonyPTPCommand.shotObjectHandle
|
||||
OTGLog.info(.sony, "Shot signal: ObjectAdded handle=0x\(String(format: "%08X", handle))")
|
||||
schedulePropertyBasedShotCheck(reason: "ObjectAdded")
|
||||
|
||||
case SonyPTPCommand.sdieCapturedEvent:
|
||||
OTGLog.info(.sony, "Shot signal: CapturedEvent")
|
||||
schedulePropertyBasedShotCheck(reason: "CapturedEvent")
|
||||
|
||||
case SonyPTPCommand.sdieDevicePropChanged:
|
||||
OTGLog.debug(.sony, "PTP event: DevicePropChanged (0xC203)")
|
||||
schedulePropertyBasedShotCheck(reason: "DevicePropChanged")
|
||||
|
||||
case SonyPTPCommand.sdieObjectRemoved:
|
||||
OTGLog.debug(.sony, "PTP event: ObjectRemoved (0xC202)")
|
||||
|
||||
default:
|
||||
OTGLog.debug(.sony, "PTP event: \(SonyPTPHelper.describeEvent(eventData))")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - D215 Polling
|
||||
|
||||
private func startD215Polling() {
|
||||
stopD215Polling()
|
||||
OTGLog.info(.sony, "D215 polling started (idle=\(idlePollInterval)s)")
|
||||
schedulePollTimer(interval: idlePollInterval)
|
||||
}
|
||||
|
||||
private func stopD215Polling() {
|
||||
pollTimer?.invalidate()
|
||||
pollTimer = nil
|
||||
}
|
||||
|
||||
private func schedulePollTimer(interval: TimeInterval) {
|
||||
stopD215Polling()
|
||||
pollTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
self?.schedulePropertyBasedShotCheck(reason: "D215Poll", delay: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshPollInterval() {
|
||||
guard isReady else { return }
|
||||
let interval = savedShotCount > 0 ? activePollInterval : idlePollInterval
|
||||
if pollTimer?.timeInterval != interval {
|
||||
schedulePollTimer(interval: interval)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Property check + Probe + Download
|
||||
|
||||
private func schedulePropertyBasedShotCheck(reason: String, delay: TimeInterval? = nil) {
|
||||
// 正在评估时合并后续请求,避免并发 PTP 风暴
|
||||
if isEvaluatingShotBuffer {
|
||||
propertyCheckCoalesceScheduled = true
|
||||
return
|
||||
}
|
||||
|
||||
propertyCheckTask?.cancel()
|
||||
let wait = delay ?? propertyCheckDelay
|
||||
propertyCheckTask = Task { [weak self] in
|
||||
if wait > 0 {
|
||||
try? await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000))
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
await self?.runPropertyBasedShotCheck(reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
private func runPropertyBasedShotCheck(reason: String) async {
|
||||
if isEvaluatingShotBuffer {
|
||||
propertyCheckCoalesceScheduled = true
|
||||
return
|
||||
}
|
||||
|
||||
isEvaluatingShotBuffer = true
|
||||
defer {
|
||||
isEvaluatingShotBuffer = false
|
||||
if propertyCheckCoalesceScheduled {
|
||||
propertyCheckCoalesceScheduled = false
|
||||
schedulePropertyBasedShotCheck(reason: "Coalesced", delay: 0.08)
|
||||
}
|
||||
}
|
||||
|
||||
await evaluateShotBuffer(reason: reason)
|
||||
}
|
||||
|
||||
/// 读 D215 → 必要时 Probe → GetObject 下载入库。
|
||||
private func evaluateShotBuffer(reason: String) async {
|
||||
guard isReady, let camera else { return }
|
||||
|
||||
var localTransactionID = transactionID
|
||||
var properties = await SonyPTPHelper.fetchExtDeviceProperties(
|
||||
on: camera,
|
||||
transactionID: &localTransactionID,
|
||||
onlyDiff: true
|
||||
)
|
||||
transactionID = localTransactionID
|
||||
|
||||
var shootingFileInfo = properties[SonyPTPCommand.shootingFileInfo]
|
||||
|
||||
if shootingFileInfo == nil, shouldFallbackFullPropertyFetch(for: reason) {
|
||||
properties = await SonyPTPHelper.fetchExtDeviceProperties(
|
||||
on: camera,
|
||||
transactionID: &localTransactionID,
|
||||
onlyDiff: false
|
||||
)
|
||||
transactionID = localTransactionID
|
||||
shootingFileInfo = properties[SonyPTPCommand.shootingFileInfo]
|
||||
OTGLog.debug(.sony, "\(reason): diff had no D215, fetched full property set")
|
||||
}
|
||||
|
||||
if let shootingFileInfo {
|
||||
OTGLog.debug(
|
||||
.sony,
|
||||
"\(reason): D215=0x\(String(format: "%04X", shootingFileInfo))"
|
||||
)
|
||||
}
|
||||
|
||||
if SonyPTPHelper.isShotBufferReady(shootingFileInfo) {
|
||||
_ = await attemptFetchPendingShot(reason: "\(reason)-D215")
|
||||
refreshPollInterval()
|
||||
return
|
||||
}
|
||||
|
||||
// ZV-E10 等机型快门可能只发 C203 且 D215 未及时更新,仍尝试 Probe
|
||||
if shouldProbeWithoutD215(for: reason) {
|
||||
_ = await attemptFetchPendingShot(reason: "\(reason)-Probe")
|
||||
}
|
||||
|
||||
refreshPollInterval()
|
||||
}
|
||||
|
||||
/// Probe → 去重 → GetObject → 写入相册目录与 Core Data。
|
||||
@discardableResult
|
||||
private func attemptFetchPendingShot(reason: String) async -> Bool {
|
||||
guard isReady, let camera else { return false }
|
||||
|
||||
var localTransactionID = transactionID
|
||||
guard let probe = await SonyPTPHelper.probeShotObject(
|
||||
on: camera,
|
||||
transactionID: &localTransactionID
|
||||
) else {
|
||||
return false
|
||||
}
|
||||
transactionID = localTransactionID
|
||||
|
||||
let signature = SonyPTPHelper.shotObjectSignature(
|
||||
filename: probe.filename,
|
||||
size: probe.size
|
||||
)
|
||||
|
||||
if signature == lastDownloadedShotSignature {
|
||||
OTGLog.debug(.sony, "\(reason): already downloaded signature=\(signature)")
|
||||
return false
|
||||
}
|
||||
|
||||
detectedShotCount += 1
|
||||
OTGLog.info(
|
||||
.sony,
|
||||
"New shot detected (#\(detectedShotCount)): \(probe.filename), size=\(probe.size), reason=\(reason)"
|
||||
)
|
||||
|
||||
guard await downloadAndSave(probe: probe, reason: reason) else {
|
||||
return false
|
||||
}
|
||||
|
||||
lastDownloadedShotSignature = signature
|
||||
savedShotCount += 1
|
||||
return true
|
||||
}
|
||||
|
||||
/// 串行下载,失败时最多重试 3 次(连拍缓冲区可能尚未写完)。
|
||||
private func downloadAndSave(probe: SonyPTPHelper.PendingShot, reason: String) async -> Bool {
|
||||
guard isReady, let camera else { return false }
|
||||
|
||||
guard photoSink != nil else {
|
||||
OTGLog.warning(.sony, "\(reason): live transfer sink not configured, skip save")
|
||||
return false
|
||||
}
|
||||
|
||||
if isDownloading {
|
||||
OTGLog.debug(.sony, "\(reason): download already in progress, skip")
|
||||
return false
|
||||
}
|
||||
|
||||
isDownloading = true
|
||||
defer { isDownloading = false }
|
||||
|
||||
var localTransactionID = transactionID
|
||||
|
||||
for attempt in 1...3 {
|
||||
if attempt > 1 {
|
||||
try? await Task.sleep(nanoseconds: UInt64(attempt) * 350_000_000)
|
||||
}
|
||||
|
||||
guard let downloaded = await SonyPTPHelper.downloadShotObject(
|
||||
on: camera,
|
||||
handle: probe.handle,
|
||||
transactionID: &localTransactionID,
|
||||
knownFilename: probe.filename,
|
||||
skipObjectInfo: true
|
||||
) else {
|
||||
if attempt == 3 {
|
||||
OTGLog.error(.sony, "\(reason): GetObject failed after \(attempt) attempts")
|
||||
}
|
||||
continue
|
||||
}
|
||||
transactionID = localTransactionID
|
||||
|
||||
guard let photoSink else { return false }
|
||||
let objectID = ICCameraItemScanner.cameraObjectID(
|
||||
filename: downloaded.filename,
|
||||
fileSize: Int64(downloaded.data.count)
|
||||
)
|
||||
do {
|
||||
let saved = try await photoSink.saveLiveShot(
|
||||
data: downloaded.data,
|
||||
filename: downloaded.filename,
|
||||
capturedAt: Date(),
|
||||
cameraObjectID: objectID
|
||||
)
|
||||
OTGLog.info(
|
||||
.sony,
|
||||
"Live shot saved (#\(savedShotCount + 1)): \(downloaded.filename), bytes=\(downloaded.data.count)"
|
||||
)
|
||||
onShotSaved?(saved)
|
||||
return true
|
||||
} catch {
|
||||
OTGLog.error(.sony, "\(reason): save failed: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func shouldFallbackFullPropertyFetch(for reason: String) -> Bool {
|
||||
reason.contains("DevicePropChanged")
|
||||
|| reason.hasPrefix("D215Poll")
|
||||
|| reason.hasPrefix("Coalesced")
|
||||
}
|
||||
|
||||
private func shouldProbeWithoutD215(for reason: String) -> Bool {
|
||||
reason.contains("DevicePropChanged")
|
||||
|| reason.hasPrefix("D215Poll")
|
||||
|| reason.hasPrefix("Coalesced")
|
||||
|| reason.contains("ObjectAdded")
|
||||
|| reason.contains("CapturedEvent")
|
||||
}
|
||||
}
|
||||
43
suixinkan/Core/CameraOTG/Bridge/OTGPhotoSink.swift
Normal file
43
suixinkan/Core/CameraOTG/Bridge/OTGPhotoSink.swift
Normal file
@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
|
||||
/// 边拍边传与历史导入落盘后的照片信息。
|
||||
struct SavedOTGPhoto: Equatable {
|
||||
let assetID: String
|
||||
let relativeLocalPath: String
|
||||
let filename: String
|
||||
let capturedAt: Date
|
||||
}
|
||||
|
||||
/// 边拍边传目标相册上下文,由旅拍有线传图页在 start 前注入 ConnectionManager。
|
||||
struct OTGLiveTransferContext: Equatable {
|
||||
let albumId: Int
|
||||
let accountKey: String
|
||||
weak var photoSink: OTGPhotoSink?
|
||||
|
||||
static func == (lhs: OTGLiveTransferContext, rhs: OTGLiveTransferContext) -> Bool {
|
||||
lhs.albumId == rhs.albumId && lhs.accountKey == rhs.accountKey
|
||||
}
|
||||
}
|
||||
|
||||
/// OTG 照片落盘协议:品牌 LiveCapture 与历史导入均通过此接口写入业务存储。
|
||||
@MainActor
|
||||
protocol OTGPhotoSink: AnyObject {
|
||||
/// 保存边拍边传下载的图像数据。
|
||||
func saveLiveShot(
|
||||
data: Data,
|
||||
filename: String,
|
||||
capturedAt: Date,
|
||||
cameraObjectID: String
|
||||
) async throws -> SavedOTGPhoto
|
||||
|
||||
/// 保存历史导入已下载到本地的文件。
|
||||
func saveDownloadedFile(
|
||||
at url: URL,
|
||||
filename: String,
|
||||
capturedAt: Date,
|
||||
cameraObjectID: String
|
||||
) async throws -> SavedOTGPhoto
|
||||
|
||||
/// 当前相册已导入的相机对象 ID,用于历史导入去重。
|
||||
func existingCameraObjectIDs() async -> Set<String>
|
||||
}
|
||||
55
suixinkan/Core/CameraOTG/CameraOTG.md
Normal file
55
suixinkan/Core/CameraOTG/CameraOTG.md
Normal file
@ -0,0 +1,55 @@
|
||||
# CameraOTG
|
||||
|
||||
## 模块职责
|
||||
|
||||
提供 USB OTG 相机有线连接能力,支持 Sony / Canon / Nikon 三品牌:
|
||||
|
||||
- USB 设备发现、授权、Session 管理与设备缓存重连
|
||||
- 边拍边传(Sony PTP、Canon EOS GetEvent、Nikon catalog 轮询)
|
||||
- 相机历史照片枚举与下载(供旅拍相册历史导入页使用)
|
||||
|
||||
不包含 OSS 上传与后端 API;上传由 `Features/TravelAlbum` 的 `WiredCameraTransferPipeline` 负责。
|
||||
|
||||
## 核心类型
|
||||
|
||||
| 类型 | 职责 |
|
||||
| --- | --- |
|
||||
| `ConnectionManager` | 单例,ImageCaptureCore 设备发现与会话编排 |
|
||||
| `ConnectionState` | 连接状态机 |
|
||||
| `CameraDriver` | 品牌无关的枚举/下载协议 |
|
||||
| `CameraFactory` / `PlatformDetector` | 品牌识别与 Driver 创建 |
|
||||
| `OTGPhotoSink` | 照片落盘协议,由旅拍模块实现 |
|
||||
| `OTGPhotoFileWriter` | scoped 本地文件写入 |
|
||||
|
||||
## 品牌实现
|
||||
|
||||
| 品牌 | 边拍边传 | 历史导入 |
|
||||
| --- | --- | --- |
|
||||
| Sony | `SonyRemoteCaptureService` + SDIO PTP | `SonyCameraDriver`(需 MTP 模式) |
|
||||
| Canon | `CanonRemoteCaptureService` + EOS GetEvent | `CanonCameraDriver` |
|
||||
| Nikon | `NikonCatalogLiveCaptureService` + catalog 监听 | `NikonCameraDriver` |
|
||||
|
||||
## 连接流程
|
||||
|
||||
1. 有线传图页 `start` → `ConnectionManager.configureLiveTransfer` + `start`
|
||||
2. `ICDeviceBrowser` 发现相机 → 申请 contents/control 授权 → `requestOpenSession`
|
||||
3. `PlatformDetector` 识别品牌 → `CameraFactory.makeDriver`
|
||||
4. Sony 立即初始化 SDIO 远程会话;Canon/Nikon 边拍边传在 content catalog 就绪后启动
|
||||
5. 页面离开 `detach`:仅解绑 delegate,保留设备缓存;App 终止时 `disconnect`
|
||||
|
||||
## 与旅拍相册集成
|
||||
|
||||
- `OTGLiveTransferCoordinator` 实现 `ConnectionManagerDelegate` 与 `TravelAlbumOTGPhotoSink`
|
||||
- 边拍边传落盘 → `WiredCameraTransferPipeline.ingestDownloadedAsset` → OSS 上传(边拍边传模式)
|
||||
- 历史导入由 `CameraHistoryImportView` 独立 Sheet 呈现
|
||||
|
||||
## 依赖
|
||||
|
||||
- `ImageCaptureCore`(系统框架)
|
||||
- `PTPUtilities` 通用 PTP 容器编解码
|
||||
|
||||
## 注意事项
|
||||
|
||||
- Sony 历史导入需 MTP 模式;边拍边传使用 PC Remote 模式(见 `SonyMTPHelpContent`)
|
||||
- Canon 远程模式在 catalog 就绪后初始化,避免打断 SD 卡枚举
|
||||
- 不使用 ExternalAccessory / MFi,仅 ImageCaptureCore
|
||||
16
suixinkan/Core/CameraOTG/Core/CameraDeviceInfo.swift
Normal file
16
suixinkan/Core/CameraOTG/Core/CameraDeviceInfo.swift
Normal file
@ -0,0 +1,16 @@
|
||||
//
|
||||
// CameraDeviceInfo.swift
|
||||
// otg_swift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// ImageCaptureCore 设备信息的轻量快照,供 PlatformDetector 与 Driver 使用。
|
||||
struct CameraDeviceInfo {
|
||||
let name: String
|
||||
let manufacturer: String?
|
||||
let model: String?
|
||||
let serialNumber: String?
|
||||
let usbVendorID: Int?
|
||||
let usbProductID: Int?
|
||||
}
|
||||
32
suixinkan/Core/CameraOTG/Core/CameraDriver.swift
Normal file
32
suixinkan/Core/CameraOTG/Core/CameraDriver.swift
Normal file
@ -0,0 +1,32 @@
|
||||
//
|
||||
// CameraDriver.swift
|
||||
// otg_swift
|
||||
//
|
||||
// Created by hanqiu on 2026/7/2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 任意品牌相机 Driver 的统一接口。
|
||||
/// ViewModel 只依赖此协议,不引用具体品牌实现。
|
||||
protocol CameraDriver: AnyObject {
|
||||
var platform: CameraPlatform { get }
|
||||
var deviceInfo: CameraDeviceInfo { get }
|
||||
|
||||
/// Session 已由 ConnectionManager 打开;Driver 内通常无需再次连接。
|
||||
func connect() async throws
|
||||
func disconnect()
|
||||
/// 枚举相机内可导入的历史照片(MTP/PTP 目录扫描)。
|
||||
func listObjects() async throws -> [CameraObject]
|
||||
func requestThumbnailData(for object: CameraObject, maxPixelSize: Int) async -> Data?
|
||||
/// 下载单张对象到指定目录,返回本地文件 URL。
|
||||
func downloadObject(_ object: CameraObject, to directory: URL) async throws -> URL
|
||||
}
|
||||
|
||||
/// 与品牌无关的相机文件描述,由 ICCameraFile 映射而来。
|
||||
struct CameraObject: Equatable {
|
||||
let id: String
|
||||
let filename: String
|
||||
let fileSize: Int64
|
||||
let capturedAt: Date
|
||||
}
|
||||
29
suixinkan/Core/CameraOTG/Core/CameraError.swift
Normal file
29
suixinkan/Core/CameraOTG/Core/CameraError.swift
Normal file
@ -0,0 +1,29 @@
|
||||
//
|
||||
// CameraError.swift
|
||||
// otg_swift
|
||||
//
|
||||
// Created by hanqiu on 2026/7/2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 相机连接与业务层共用的错误类型。
|
||||
enum CameraError: LocalizedError {
|
||||
/// 品牌 Driver 中该能力尚未实现(如尼康 downloadObject)
|
||||
case notImplemented
|
||||
/// PlatformDetector 返回 unknown,无法创建 Driver
|
||||
case unsupportedPlatform
|
||||
/// listObjects / download 时在设备树中找不到对应 ICCameraFile
|
||||
case deviceNotFound
|
||||
/// Session、授权或 PTP 失败;`String` 为具体原因
|
||||
case connectionFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notImplemented: return "功能尚未实现"
|
||||
case .unsupportedPlatform: return "不支持的相机品牌"
|
||||
case .deviceNotFound: return "未找到相机"
|
||||
case .connectionFailed(let msg): return "连接失败:\(msg)"
|
||||
}
|
||||
}
|
||||
}
|
||||
39
suixinkan/Core/CameraOTG/Core/CameraFactory.swift
Normal file
39
suixinkan/Core/CameraOTG/Core/CameraFactory.swift
Normal file
@ -0,0 +1,39 @@
|
||||
//
|
||||
// CameraFactory.swift
|
||||
// otg_swift
|
||||
//
|
||||
// Created by hanqiu on 2026/7/2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageCaptureCore
|
||||
|
||||
/// 根据 PlatformDetector 结果创建对应品牌的 CameraDriver 实例。
|
||||
enum CameraFactory {
|
||||
/// 创建品牌 Driver;`unknown` 平台返回 nil。
|
||||
static func makeDriver(
|
||||
platform: CameraPlatform,
|
||||
device: ICCameraDevice,
|
||||
deviceInfo: CameraDeviceInfo
|
||||
) -> CameraDriver? {
|
||||
OTGLog.info(.factory, "creating driver for platform=\(platform.rawValue), device=\(deviceInfo.name)")
|
||||
|
||||
switch platform {
|
||||
case .nikon:
|
||||
let driver = NikonCameraDriver(device: device, deviceInfo: deviceInfo)
|
||||
OTGLog.info(.factory, "created NikonCameraDriver")
|
||||
return driver
|
||||
case .canon:
|
||||
let driver = CanonCameraDriver(device: device, deviceInfo: deviceInfo)
|
||||
OTGLog.info(.factory, "created CanonCameraDriver")
|
||||
return driver
|
||||
case .sony:
|
||||
let driver = SonyCameraDriver(device: device, deviceInfo: deviceInfo)
|
||||
OTGLog.info(.factory, "created SonyCameraDriver")
|
||||
return driver
|
||||
case .unknown:
|
||||
OTGLog.error(.factory, "refused to create driver for unknown platform")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
20
suixinkan/Core/CameraOTG/Core/CameraPlatform.swift
Normal file
20
suixinkan/Core/CameraOTG/Core/CameraPlatform.swift
Normal file
@ -0,0 +1,20 @@
|
||||
//
|
||||
// CameraPlatform.swift
|
||||
// otg_swift
|
||||
//
|
||||
// Created by hanqiu on 2026/7/2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 相机品牌枚举,由 PlatformDetector 输出、CameraFactory 消费。
|
||||
enum CameraPlatform: String, CaseIterable {
|
||||
/// 尼康;当前 Driver 为占位实现
|
||||
case nikon
|
||||
/// 佳能;MTP 历史导入已接入
|
||||
case canon
|
||||
/// 索尼;MTP 导入与 PC Remote 边拍边传分路径实现
|
||||
case sony
|
||||
/// VID 与型号字符串均无法识别;不创建 Driver
|
||||
case unknown
|
||||
}
|
||||
646
suixinkan/Core/CameraOTG/Core/ConnectionManager.swift
Normal file
646
suixinkan/Core/CameraOTG/Core/ConnectionManager.swift
Normal file
@ -0,0 +1,646 @@
|
||||
//
|
||||
// ConnectionManager.swift
|
||||
// otg_swift
|
||||
//
|
||||
// Created by hanqiu on 2026/7/2.
|
||||
//
|
||||
|
||||
import ImageCaptureCore
|
||||
|
||||
/// ConnectionManager 向 ViewModel 上报连接生命周期与内容目录就绪事件。
|
||||
protocol ConnectionManagerDelegate: AnyObject {
|
||||
func connectionManager(_ manager: ConnectionManager, didUpdate state: ConnectionState)
|
||||
func connectionManager(_ manager: ConnectionManager, didCreate driver: CameraDriver)
|
||||
func connectionManager(_ manager: ConnectionManager, didFail error: Error)
|
||||
func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: CameraDriver)
|
||||
func connectionManagerDidSuggestReplug(_ manager: ConnectionManager)
|
||||
/// 边拍边传:单张照片已下载并写入目标相册。
|
||||
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot photo: SavedOTGPhoto, albumId: Int)
|
||||
}
|
||||
|
||||
extension ConnectionManagerDelegate {
|
||||
func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: CameraDriver) {}
|
||||
func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) {}
|
||||
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot photo: SavedOTGPhoto, albumId: Int) {}
|
||||
}
|
||||
|
||||
/// 单例:ImageCaptureCore 设备发现、授权、Session、Driver 创建与边拍边传编排。
|
||||
/// 持有 `ICDeviceBrowser` 与缓存的 `ICCameraDevice`,View 层通过 Delegate 订阅状态。
|
||||
final class ConnectionManager: NSObject {
|
||||
|
||||
static let shared = ConnectionManager()
|
||||
|
||||
weak var delegate: ConnectionManagerDelegate?
|
||||
|
||||
private(set) var state: ConnectionState = .idle {
|
||||
didSet {
|
||||
if oldValue != state {
|
||||
OTGLog.info(.connection, "state changed: \(oldValue) -> \(state)")
|
||||
}
|
||||
notify { self.delegate?.connectionManager(self, didUpdate: self.state) }
|
||||
}
|
||||
}
|
||||
|
||||
private(set) var currentDriver: CameraDriver?
|
||||
private(set) var isContentCatalogReady = false
|
||||
private var browser: ICDeviceBrowser?
|
||||
/// First `didAdd` caches the device; kept after session close until USB unplug (`didRemove`).
|
||||
private var cachedDevice: ICCameraDevice?
|
||||
private var isClosingSession = false
|
||||
private var pendingStartAfterClose = false
|
||||
private var searchRescanWorkItem: DispatchWorkItem?
|
||||
private var searchReplugHintWorkItem: DispatchWorkItem?
|
||||
private var sonyRemoteCapture: SonyRemoteCaptureService?
|
||||
private var canonRemoteCapture: CanonRemoteCaptureService?
|
||||
private var nikonLiveCapture: NikonCatalogLiveCaptureService?
|
||||
/// 边拍边传目标上下文;由有线传图 ViewModel 在 start 前设置。
|
||||
private var liveTransferContext: OTGLiveTransferContext?
|
||||
|
||||
private let searchRescanDelay: TimeInterval = 1.5
|
||||
/// 长时间搜不到设备时提示用户重新插拔
|
||||
private let searchReplugHintDelay: TimeInterval = 4.5
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
/// 设置边拍边传目标相册;传 nil 清除。
|
||||
func configureLiveTransfer(context: OTGLiveTransferContext?) {
|
||||
liveTransferContext = context
|
||||
OTGLog.debug(.connection, "live transfer album=\(context.map { String($0.albumId) } ?? "nil")")
|
||||
}
|
||||
|
||||
/// Entry point when OTG UI appears: reconnect cached device or start discovery.
|
||||
func start() {
|
||||
OTGLog.info(.connection, "start requested, state=\(state)")
|
||||
|
||||
if case .connected = state {
|
||||
return
|
||||
}
|
||||
|
||||
if isClosingSession {
|
||||
OTGLog.info(.connection, "start deferred, session is closing")
|
||||
pendingStartAfterClose = true
|
||||
return
|
||||
}
|
||||
|
||||
if let cached = cachedDevice {
|
||||
reconnect(to: cached)
|
||||
return
|
||||
}
|
||||
|
||||
startSearching()
|
||||
}
|
||||
|
||||
func unbindDelegate() {
|
||||
delegate = nil
|
||||
cancelSearchTimers()
|
||||
}
|
||||
|
||||
/// 断开 Session 并释放 Driver;缓存设备保留至 USB 拔出。
|
||||
func disconnect() {
|
||||
guard let device = activeDevice else {
|
||||
state = .disconnected
|
||||
return
|
||||
}
|
||||
|
||||
guard !isClosingSession else {
|
||||
OTGLog.info(.connection, "disconnect ignored, session already closing")
|
||||
return
|
||||
}
|
||||
|
||||
OTGLog.info(.connection, "disconnect requested")
|
||||
isClosingSession = true
|
||||
isContentCatalogReady = false
|
||||
cancelSearchTimers()
|
||||
sonyRemoteCapture?.stop()
|
||||
sonyRemoteCapture = nil
|
||||
canonRemoteCapture?.stop()
|
||||
canonRemoteCapture = nil
|
||||
nikonLiveCapture?.stop()
|
||||
nikonLiveCapture = nil
|
||||
liveTransferContext = nil
|
||||
currentDriver?.disconnect()
|
||||
currentDriver = nil
|
||||
device.delegate = self
|
||||
device.requestCloseSession()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var activeDevice: ICCameraDevice? {
|
||||
cachedDevice
|
||||
}
|
||||
|
||||
private func notify(_ block: @escaping () -> Void) {
|
||||
if Thread.isMainThread {
|
||||
block()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: block)
|
||||
}
|
||||
}
|
||||
|
||||
private func startSearching() {
|
||||
OTGLog.info(.connection, "start searching for cameras")
|
||||
cancelSearchTimers()
|
||||
state = .searching
|
||||
|
||||
if browser == nil {
|
||||
launchBrowser()
|
||||
}
|
||||
|
||||
scheduleSearchMonitoring()
|
||||
}
|
||||
|
||||
private func launchBrowser() {
|
||||
let browser = ICDeviceBrowser()
|
||||
browser.delegate = self
|
||||
// iOS 15.2+:同时浏览 Camera 与 Local USB,提高 Sony/Canon 直连发现率
|
||||
if #available(iOS 15.2, *) {
|
||||
let mask = ICDeviceTypeMask(
|
||||
rawValue: ICDeviceTypeMask.camera.rawValue | ICDeviceLocationTypeMask.local.rawValue
|
||||
) ?? .camera
|
||||
browser.browsedDeviceTypeMask = mask
|
||||
} else {
|
||||
browser.browsedDeviceTypeMask = .camera
|
||||
}
|
||||
browser.start()
|
||||
self.browser = browser
|
||||
OTGLog.debug(.connection, "browser launched")
|
||||
}
|
||||
|
||||
private func reconnect(to camera: ICCameraDevice) {
|
||||
guard !isClosingSession else {
|
||||
OTGLog.warning(.connection, "reconnect ignored, session is closing")
|
||||
return
|
||||
}
|
||||
|
||||
if case .connected = state {
|
||||
return
|
||||
}
|
||||
|
||||
OTGLog.info(.connection, "reconnecting to cached device: \(camera.name ?? "nil")")
|
||||
cancelSearchTimers()
|
||||
cachedDevice = camera
|
||||
state = .connecting(deviceName: camera.name ?? "Unknown Camera")
|
||||
requestAuthorizations { [weak self] in
|
||||
self?.openSession(for: camera, context: "cached device")
|
||||
}
|
||||
}
|
||||
|
||||
/// 先申请 contents 再申请 control;PTP 传图必须获得 control 授权。
|
||||
private func requestAuthorizations(then completion: @escaping () -> Void) {
|
||||
if browser == nil {
|
||||
launchBrowser()
|
||||
}
|
||||
|
||||
guard let browser else {
|
||||
OTGLog.error(.connection, "authorization skipped, browser unavailable")
|
||||
completion()
|
||||
return
|
||||
}
|
||||
|
||||
browser.requestContentsAuthorization { [weak self] contentsStatus in
|
||||
guard let self else { return }
|
||||
|
||||
let contentsGranted = contentsStatus == .authorized
|
||||
OTGLog.info(.connection, "contents authorization: \(contentsStatus.rawValue)")
|
||||
|
||||
guard contentsGranted else {
|
||||
DispatchQueue.main.async {
|
||||
OTGLog.error(.connection, "camera contents authorization denied")
|
||||
self.state = .failed(message: "未获得相机内容访问权限")
|
||||
self.delegate?.connectionManager(
|
||||
self,
|
||||
didFail: CameraError.connectionFailed("未获得相机内容访问权限")
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
browser.requestControlAuthorization { [weak self] controlStatus in
|
||||
guard let self else { return }
|
||||
|
||||
let controlGranted = controlStatus == .authorized
|
||||
OTGLog.info(.connection, "control authorization: \(controlStatus.rawValue)")
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard controlGranted else {
|
||||
OTGLog.error(.connection, "camera control authorization denied, PTP requires control access")
|
||||
self.state = .failed(message: "未获得相机控制权限,无法使用 PTP 传图")
|
||||
self.delegate?.connectionManager(
|
||||
self,
|
||||
didFail: CameraError.connectionFailed(
|
||||
"请在系统弹窗中允许控制外接相机(PTP 传图需要此权限)"
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
completion()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openSession(for camera: ICCameraDevice, context: String) {
|
||||
camera.delegate = self
|
||||
camera.requestOpenSession()
|
||||
OTGLog.debug(.connection, "requestOpenSession sent (\(context))")
|
||||
}
|
||||
|
||||
private func scheduleSearchMonitoring() {
|
||||
cancelSearchTimers()
|
||||
|
||||
let rescan = DispatchWorkItem { [weak self] in
|
||||
guard let self, self.state == .searching, self.cachedDevice == nil else { return }
|
||||
self.rescanBrowser()
|
||||
}
|
||||
|
||||
let replugHint = DispatchWorkItem { [weak self] in
|
||||
guard let self, self.state == .searching, self.cachedDevice == nil else { return }
|
||||
OTGLog.warning(.connection, "search timed out, suggesting replug")
|
||||
self.notify { self.delegate?.connectionManagerDidSuggestReplug(self) }
|
||||
self.scheduleSearchMonitoring()
|
||||
}
|
||||
|
||||
searchRescanWorkItem = rescan
|
||||
searchReplugHintWorkItem = replugHint
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + searchRescanDelay, execute: rescan)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + searchReplugHintDelay, execute: replugHint)
|
||||
}
|
||||
|
||||
private func rescanBrowser() {
|
||||
OTGLog.info(.connection, "rescanning: restarting browser")
|
||||
browser?.stop()
|
||||
browser?.delegate = nil
|
||||
browser = nil
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
guard let self, self.state == .searching, self.cachedDevice == nil else { return }
|
||||
self.launchBrowser()
|
||||
self.scheduleSearchMonitoring()
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelSearchTimers() {
|
||||
searchRescanWorkItem?.cancel()
|
||||
searchRescanWorkItem = nil
|
||||
searchReplugHintWorkItem?.cancel()
|
||||
searchReplugHintWorkItem = nil
|
||||
}
|
||||
|
||||
private func clearDeviceCache() {
|
||||
sonyRemoteCapture?.stop()
|
||||
sonyRemoteCapture = nil
|
||||
canonRemoteCapture?.stop()
|
||||
canonRemoteCapture = nil
|
||||
nikonLiveCapture?.stop()
|
||||
nikonLiveCapture = nil
|
||||
cachedDevice = nil
|
||||
currentDriver?.disconnect()
|
||||
currentDriver = nil
|
||||
isContentCatalogReady = false
|
||||
isClosingSession = false
|
||||
}
|
||||
}
|
||||
|
||||
extension ConnectionManager: ICDeviceBrowserDelegate {
|
||||
|
||||
func deviceBrowser(_ browser: ICDeviceBrowser, didAdd device: ICDevice, moreComing: Bool) {
|
||||
OTGLog.info(.connection, "device added: name=\(device.name ?? "nil"), type=\(device.type.rawValue), moreComing=\(moreComing)")
|
||||
guard let camera = device as? ICCameraDevice else {
|
||||
OTGLog.warning(.connection, "ignored non-camera device: \(device.name ?? "nil")")
|
||||
return
|
||||
}
|
||||
handleDiscoveredCamera(camera)
|
||||
}
|
||||
|
||||
func deviceBrowser(_ browser: ICDeviceBrowser, didRemove device: ICDevice, moreGoing: Bool) {
|
||||
OTGLog.info(.connection, "device removed: name=\(device.name ?? "nil"), uuid=\(device.uuidString ?? "nil"), moreGoing=\(moreGoing)")
|
||||
guard device.uuidString == cachedDevice?.uuidString else { return }
|
||||
cancelSearchTimers()
|
||||
clearDeviceCache()
|
||||
state = .idle
|
||||
}
|
||||
}
|
||||
|
||||
private extension ConnectionManager {
|
||||
|
||||
func handleDiscoveredCamera(_ camera: ICCameraDevice) {
|
||||
ICCameraDeviceDebugLogger.logNonEmptyProperties(of: camera, context: "discovered")
|
||||
|
||||
guard !isClosingSession else {
|
||||
OTGLog.warning(.connection, "camera ignored while session is closing")
|
||||
return
|
||||
}
|
||||
|
||||
if let cached = cachedDevice, cached.uuidString == camera.uuidString {
|
||||
if case .connected = state { return }
|
||||
reconnect(to: cached)
|
||||
return
|
||||
}
|
||||
|
||||
guard cachedDevice == nil else {
|
||||
OTGLog.warning(.connection, "camera ignored, already have cached device: \(cachedDevice?.name ?? "nil")")
|
||||
return
|
||||
}
|
||||
|
||||
OTGLog.info(.connection, "discovered camera: name=\(camera.name ?? "nil"), uuid=\(camera.uuidString ?? "nil")")
|
||||
cancelSearchTimers()
|
||||
cachedDevice = camera
|
||||
state = .connecting(deviceName: camera.name ?? "Unknown Camera")
|
||||
requestAuthorizations { [weak self] in
|
||||
self?.openSession(for: camera, context: "discovered")
|
||||
}
|
||||
}
|
||||
|
||||
func buildDeviceInfo(from camera: ICCameraDevice) -> CameraDeviceInfo {
|
||||
let vendorID = Int(camera.usbVendorID)
|
||||
let productID = Int(camera.usbProductID)
|
||||
let info = CameraDeviceInfo(
|
||||
name: camera.name ?? "Unknown Camera",
|
||||
manufacturer: camera.productKind,
|
||||
model: camera.name,
|
||||
serialNumber: camera.uuidString,
|
||||
usbVendorID: vendorID != 0 ? vendorID : nil,
|
||||
usbProductID: productID != 0 ? productID : nil
|
||||
)
|
||||
OTGLog.debug(
|
||||
.connection,
|
||||
"device info: \(info.name), manufacturer=\(info.manufacturer ?? "nil"), usbVendorID=\(info.usbVendorID.map { String(format: "0x%04X", $0) } ?? "nil"), serial=\(info.serialNumber ?? "nil")"
|
||||
)
|
||||
return info
|
||||
}
|
||||
|
||||
/// 检测品牌、创建 Driver 并进入 connected 状态。
|
||||
func createDriver(for camera: ICCameraDevice) {
|
||||
let info = buildDeviceInfo(from: camera)
|
||||
let platform = PlatformDetector.detect(from: info)
|
||||
|
||||
guard platform != .unknown else {
|
||||
OTGLog.error(.connection, "unsupported platform for device: \(info.name)")
|
||||
state = .failed(message: "不支持的相机品牌")
|
||||
delegate?.connectionManager(self, didFail: CameraError.unsupportedPlatform)
|
||||
return
|
||||
}
|
||||
|
||||
guard let driver = CameraFactory.makeDriver(
|
||||
platform: platform,
|
||||
device: camera,
|
||||
deviceInfo: info
|
||||
) else {
|
||||
OTGLog.error(.connection, "failed to create driver for platform: \(platform.rawValue)")
|
||||
state = .failed(message: "无法创建相机驱动")
|
||||
return
|
||||
}
|
||||
|
||||
currentDriver = driver
|
||||
state = .connected(platform: platform, deviceName: info.name)
|
||||
OTGLog.info(.connection, "driver ready: \(platform.rawValue), device=\(info.name)")
|
||||
delegate?.connectionManager(self, didCreate: driver)
|
||||
}
|
||||
|
||||
/// Session 打开后按品牌配置 PTP:索尼 SDIO + 边拍边传;佳能仅 Probe(远程模式等 catalog 就绪后启动)。
|
||||
private func configurePTPAfterSession(_ camera: ICCameraDevice) {
|
||||
let platform = PlatformDetector.detect(from: buildDeviceInfo(from: camera))
|
||||
|
||||
Task {
|
||||
// 等待 Session 稳定后再发 PTP,避免首包超时
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
|
||||
switch platform {
|
||||
case .canon:
|
||||
await runGetDeviceInfoProbe(on: camera)
|
||||
|
||||
case .sony:
|
||||
var transactionID: UInt32 = 1
|
||||
let result = await SonyPTPHelper.initializeRemoteSession(
|
||||
on: camera,
|
||||
startingTransactionID: transactionID
|
||||
)
|
||||
if result.success {
|
||||
let service = SonyRemoteCaptureService()
|
||||
service.onShotSaved = makeLiveShotSavedHandler()
|
||||
sonyRemoteCapture = service
|
||||
service.start(
|
||||
camera: camera,
|
||||
startingTransactionID: result.nextTransactionID,
|
||||
photoSink: liveTransferContext?.photoSink
|
||||
)
|
||||
OTGLog.info(.sony, "Sony remote session ready")
|
||||
} else {
|
||||
OTGLog.error(.sony, "Sony remote session setup failed")
|
||||
}
|
||||
|
||||
default:
|
||||
await runGetDeviceInfoProbe(on: camera)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runGetDeviceInfoProbe(on camera: ICCameraDevice) async {
|
||||
OTGLog.info(.ptp, "PTP probe starting...")
|
||||
|
||||
var transactionID: UInt32 = 1
|
||||
let result = await PTPHelper.sendCommand(
|
||||
on: camera,
|
||||
code: PTPStandardCommand.getDeviceInfo,
|
||||
transactionID: &transactionID,
|
||||
label: "GetDeviceInfo(probe)"
|
||||
)
|
||||
|
||||
if result.success {
|
||||
OTGLog.info(.ptp, "PTP probe passed, deviceInfo=\(result.inData.count) bytes")
|
||||
} else {
|
||||
OTGLog.error(.ptp, "PTP probe failed")
|
||||
}
|
||||
}
|
||||
|
||||
/// catalog 就绪后再初始化 Canon 远程模式,避免 SetRemoteMode 打断 SD 卡枚举。
|
||||
private func startCanonLiveTransferIfNeeded(for camera: ICCameraDevice) {
|
||||
guard canonRemoteCapture == nil else { return }
|
||||
guard PlatformDetector.detect(from: buildDeviceInfo(from: camera)) == .canon else { return }
|
||||
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
|
||||
var transactionID: UInt32 = 1
|
||||
let result = await CanonPTPHelper.initializeRemoteSession(
|
||||
on: camera,
|
||||
startingTransactionID: transactionID
|
||||
)
|
||||
guard result.success else {
|
||||
OTGLog.error(.canon, "Canon remote session setup failed")
|
||||
return
|
||||
}
|
||||
|
||||
let service = CanonRemoteCaptureService()
|
||||
service.onShotSaved = makeLiveShotSavedHandler()
|
||||
canonRemoteCapture = service
|
||||
service.start(
|
||||
camera: camera,
|
||||
startingTransactionID: result.nextTransactionID,
|
||||
photoSink: liveTransferContext?.photoSink
|
||||
)
|
||||
OTGLog.info(.canon, "Canon remote capture ready")
|
||||
}
|
||||
}
|
||||
|
||||
/// catalog 就绪后启动尼康边拍边传(didAdd + catalog 轮询,无需 PTP 远程会话)。
|
||||
private func startNikonLiveTransferIfNeeded(for camera: ICCameraDevice) {
|
||||
guard nikonLiveCapture == nil else { return }
|
||||
guard PlatformDetector.detect(from: buildDeviceInfo(from: camera)) == .nikon else { return }
|
||||
|
||||
let service = NikonCatalogLiveCaptureService()
|
||||
service.onShotSaved = makeLiveShotSavedHandler()
|
||||
nikonLiveCapture = service
|
||||
service.start(camera: camera, photoSink: liveTransferContext?.photoSink)
|
||||
OTGLog.info(.nikon, "Nikon catalog live capture ready")
|
||||
}
|
||||
|
||||
private func makeLiveShotSavedHandler() -> (SavedOTGPhoto) -> Void {
|
||||
{ [weak self] photo in
|
||||
guard let self, let albumId = self.liveTransferContext?.albumId else { return }
|
||||
self.notify {
|
||||
self.delegate?.connectionManager(
|
||||
self,
|
||||
didSaveLiveShot: photo,
|
||||
albumId: albumId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConnectionManager: ICCameraDeviceDelegate {
|
||||
|
||||
func device(_ device: ICDevice, didOpenSessionWithError error: (any Error)?) {
|
||||
guard device.uuidString == cachedDevice?.uuidString else { return }
|
||||
|
||||
if let error {
|
||||
OTGLog.error(.connection, "open session failed: \(error.localizedDescription)")
|
||||
state = .failed(message: error.localizedDescription)
|
||||
delegate?.connectionManager(self, didFail: error)
|
||||
return
|
||||
}
|
||||
|
||||
OTGLog.info(.connection, "session opened: \(device.name ?? "nil")")
|
||||
guard let camera = device as? ICCameraDevice else { return }
|
||||
cachedDevice = camera
|
||||
ICCameraDeviceDebugLogger.logNonEmptyProperties(of: camera, context: "session-opened")
|
||||
configurePTPAfterSession(camera)
|
||||
createDriver(for: camera)
|
||||
}
|
||||
|
||||
func device(_ device: ICDevice, didCloseSessionWithError error: (any Error)?) {
|
||||
guard device.uuidString == cachedDevice?.uuidString else { return }
|
||||
|
||||
if let error {
|
||||
OTGLog.warning(.connection, "session closed with error: \(error.localizedDescription)")
|
||||
} else {
|
||||
OTGLog.info(.connection, "session closed: \(device.name ?? "nil")")
|
||||
}
|
||||
|
||||
isClosingSession = false
|
||||
sonyRemoteCapture?.stop()
|
||||
sonyRemoteCapture = nil
|
||||
canonRemoteCapture?.stop()
|
||||
canonRemoteCapture = nil
|
||||
nikonLiveCapture?.stop()
|
||||
nikonLiveCapture = nil
|
||||
currentDriver = nil
|
||||
isContentCatalogReady = false
|
||||
if let camera = device as? ICCameraDevice {
|
||||
cachedDevice = camera
|
||||
}
|
||||
state = .disconnected
|
||||
|
||||
if pendingStartAfterClose {
|
||||
pendingStartAfterClose = false
|
||||
OTGLog.info(.connection, "session close finished, resuming start")
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
func cameraDevice(_ camera: ICCameraDevice, didAdd items: [ICCameraItem]) {
|
||||
OTGLog.debug(.connection, "camera items added: \(items.count)")
|
||||
canonRemoteCapture?.handleCatalogItemsAdded(items)
|
||||
nikonLiveCapture?.handleCatalogItemsAdded(items)
|
||||
}
|
||||
|
||||
func cameraDevice(_ camera: ICCameraDevice, didRemove items: [ICCameraItem]) {
|
||||
OTGLog.debug(.connection, "camera items removed: \(items.count)")
|
||||
}
|
||||
|
||||
func cameraDevice(
|
||||
_ camera: ICCameraDevice,
|
||||
didReceiveThumbnail thumbnail: CGImage?,
|
||||
for item: ICCameraItem,
|
||||
error: (any Error)?
|
||||
) {
|
||||
if let error {
|
||||
OTGLog.warning(.connection, "thumbnail failed for \(item.name ?? "nil"): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func cameraDevice(
|
||||
_ camera: ICCameraDevice,
|
||||
didReceiveMetadata metadata: [AnyHashable: Any]?,
|
||||
for item: ICCameraItem,
|
||||
error: (any Error)?
|
||||
) {
|
||||
if let error {
|
||||
OTGLog.warning(.connection, "metadata failed for \(item.name ?? "nil"): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func cameraDevice(_ camera: ICCameraDevice, didRenameItems items: [ICCameraItem]) {
|
||||
OTGLog.debug(.connection, "camera items renamed: \(items.count)")
|
||||
}
|
||||
|
||||
func cameraDeviceDidChangeCapability(_ camera: ICCameraDevice) {
|
||||
OTGLog.debug(.connection, "camera capability changed: \(camera.name ?? "nil")")
|
||||
}
|
||||
|
||||
func cameraDevice(_ camera: ICCameraDevice, didReceivePTPEvent event: Data) {
|
||||
guard cachedDevice?.uuidString == camera.uuidString else { return }
|
||||
sonyRemoteCapture?.handlePTPEvent(event)
|
||||
canonRemoteCapture?.handlePTPEvent(event)
|
||||
}
|
||||
|
||||
/// ImageCaptureCore 内容目录就绪后可安全调用 `listObjects`(MTP 历史导入)。
|
||||
func deviceDidBecomeReady(withCompleteContentCatalog camera: ICCameraDevice) {
|
||||
guard camera.uuidString == cachedDevice?.uuidString else {
|
||||
OTGLog.warning(.connection, "content catalog ready ignored, unknown camera: \(camera.name ?? "nil")")
|
||||
return
|
||||
}
|
||||
|
||||
OTGLog.info(.connection, "camera ready with content catalog: \(camera.name ?? "nil")")
|
||||
ICCameraDeviceDebugLogger.logNonEmptyProperties(of: camera, context: "content-catalog-ready")
|
||||
isContentCatalogReady = true
|
||||
|
||||
guard let driver = currentDriver else {
|
||||
OTGLog.warning(.connection, "content catalog ready but no driver available")
|
||||
return
|
||||
}
|
||||
|
||||
notify { self.delegate?.connectionManager(self, contentCatalogDidBecomeReady: driver) }
|
||||
startCanonLiveTransferIfNeeded(for: camera)
|
||||
startNikonLiveTransferIfNeeded(for: camera)
|
||||
}
|
||||
|
||||
func cameraDeviceDidRemoveAccessRestriction(_ device: ICDevice) {
|
||||
OTGLog.info(.connection, "access restriction removed: \(device.name ?? "nil")")
|
||||
}
|
||||
|
||||
func cameraDeviceDidEnableAccessRestriction(_ device: ICDevice) {
|
||||
OTGLog.warning(.connection, "access restriction enabled: \(device.name ?? "nil")")
|
||||
}
|
||||
|
||||
func didRemove(_ device: ICDevice) {
|
||||
OTGLog.info(.connection, "device removed delegate callback: \(device.name ?? "nil")")
|
||||
}
|
||||
}
|
||||
58
suixinkan/Core/CameraOTG/Core/ConnectionState.swift
Normal file
58
suixinkan/Core/CameraOTG/Core/ConnectionState.swift
Normal file
@ -0,0 +1,58 @@
|
||||
//
|
||||
// ConnectionState.swift
|
||||
// otg_swift
|
||||
//
|
||||
// Created by hanqiu on 2026/7/2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// OTG 连接流程的状态机,供 ViewModel 绑定 `displayText` 展示给用户。
|
||||
enum ConnectionState: Equatable {
|
||||
/// 初始态,尚未开始搜索或已完全复位
|
||||
case idle
|
||||
/// 正在通过 ICDeviceBrowser 发现 USB 相机
|
||||
case searching
|
||||
/// 已发现设备,正在打开 Session 或等待系统授权
|
||||
case connecting(deviceName: String)
|
||||
/// Session 已打开且 Driver 已创建;`platform` 用于品牌相关 UI 提示
|
||||
case connected(platform: CameraPlatform, deviceName: String)
|
||||
/// 用户主动断开或 Session 已关闭,设备仍可能插在 USB 上
|
||||
case disconnected
|
||||
/// 授权被拒、不支持品牌或 Session 打开失败;`message` 为可读错误说明
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
extension ConnectionState: CustomStringConvertible {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .idle: return "idle"
|
||||
case .searching: return "searching"
|
||||
case .connecting(let name): return "connecting(\(name))"
|
||||
case .connected(let platform, let name):
|
||||
return "connected(\(platform.rawValue), \(name))"
|
||||
case .disconnected: return "disconnected"
|
||||
case .failed(let message): return "failed(\(message))"
|
||||
}
|
||||
}
|
||||
|
||||
var displayText: String {
|
||||
switch self {
|
||||
case .idle: return "等待开始"
|
||||
case .searching: return "正在搜索相机…"
|
||||
case .connecting(let name): return "正在连接 \(name)…"
|
||||
case .connected(let platform, let name):
|
||||
return "已连接 \(platformLabel(platform)) · \(name)"
|
||||
case .disconnected: return "已断开连接"
|
||||
case .failed(let msg): return "连接失败:\(msg)"
|
||||
}
|
||||
}
|
||||
private func platformLabel(_ platform: CameraPlatform) -> String {
|
||||
switch platform {
|
||||
case .nikon: return "尼康"
|
||||
case .canon: return "佳能"
|
||||
case .sony: return "索尼"
|
||||
case .unknown: return "未知"
|
||||
}
|
||||
}
|
||||
}
|
||||
107
suixinkan/Core/CameraOTG/Core/ICCameraDevice+DebugLog.swift
Normal file
107
suixinkan/Core/CameraOTG/Core/ICCameraDevice+DebugLog.swift
Normal file
@ -0,0 +1,107 @@
|
||||
//
|
||||
// ICCameraDevice+DebugLog.swift
|
||||
// otg_swift
|
||||
//
|
||||
|
||||
import CoreGraphics
|
||||
import ImageCaptureCore
|
||||
|
||||
/// 调试工具:打印 ICCameraDevice 非空属性,辅助 PlatformDetector 与 catalog 为空问题排查。
|
||||
enum ICCameraDeviceDebugLogger {
|
||||
|
||||
/// 打印 ICCameraDevice 在 iOS 上可访问的非空属性,便于调试 PlatformDetector 与连接问题。
|
||||
static func logNonEmptyProperties(of camera: ICCameraDevice, context: String) {
|
||||
var lines: [String] = []
|
||||
lines.append("=== ICCameraDevice [\(context)] ===")
|
||||
|
||||
// MARK: ICDevice
|
||||
append("type", String(describing: camera.type.rawValue), to: &lines)
|
||||
append("name", camera.name, to: &lines)
|
||||
append("productKind", camera.productKind, to: &lines)
|
||||
append("transportType", camera.transportType, to: &lines)
|
||||
append("uuidString", camera.uuidString, to: &lines)
|
||||
append("systemSymbolName", camera.systemSymbolName, to: &lines)
|
||||
|
||||
appendIfTrue("hasOpenSession", camera.hasOpenSession, to: &lines)
|
||||
appendCapabilities(camera.capabilities, to: &lines)
|
||||
appendUserData(camera.userData, to: &lines)
|
||||
appendIcon(camera.icon, to: &lines)
|
||||
|
||||
appendUSBID("usbVendorID", Int(camera.usbVendorID), to: &lines)
|
||||
appendUSBID("usbProductID", Int(camera.usbProductID), to: &lines)
|
||||
appendUSBID("usbLocationID", Int(camera.usbLocationID), to: &lines)
|
||||
|
||||
// MARK: ICCameraDevice
|
||||
if camera.contentCatalogPercentCompleted > 0 {
|
||||
lines.append("contentCatalogPercentCompleted=\(camera.contentCatalogPercentCompleted)")
|
||||
}
|
||||
|
||||
appendItems("contents", camera.contents, to: &lines)
|
||||
appendItems("mediaFiles", camera.mediaFiles, to: &lines)
|
||||
|
||||
appendIfTrue("ejectable", camera.isEjectable, to: &lines)
|
||||
appendIfTrue("locked", camera.isLocked, to: &lines)
|
||||
appendIfTrue("accessRestrictedAppleDevice", camera.isAccessRestrictedAppleDevice, to: &lines)
|
||||
appendIfTrue("iCloudPhotosEnabled", camera.iCloudPhotosEnabled, to: &lines)
|
||||
appendIfTrue("tetheredCaptureEnabled", camera.tetheredCaptureEnabled, to: &lines)
|
||||
|
||||
if #available(iOS 14.0, *) {
|
||||
lines.append("mediaPresentation=\(camera.mediaPresentation.rawValue)")
|
||||
}
|
||||
|
||||
if camera.batteryLevelAvailable {
|
||||
lines.append("batteryLevelAvailable=true")
|
||||
lines.append("batteryLevel=\(camera.batteryLevel)")
|
||||
}
|
||||
|
||||
if camera.ptpEventHandler != nil {
|
||||
lines.append("ptpEventHandler=set")
|
||||
}
|
||||
|
||||
lines.append("=== end ICCameraDevice dump ===")
|
||||
|
||||
for line in lines {
|
||||
OTGLog.debug(.connection, line)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func append(_ key: String, _ value: String?, to lines: inout [String]) {
|
||||
guard let value, !value.isEmpty else { return }
|
||||
lines.append("\(key)=\(value)")
|
||||
}
|
||||
|
||||
private static func appendIfTrue(_ key: String, _ value: Bool, to lines: inout [String]) {
|
||||
guard value else { return }
|
||||
lines.append("\(key)=true")
|
||||
}
|
||||
|
||||
private static func appendUSBID(_ key: String, _ value: Int, to lines: inout [String]) {
|
||||
guard value != 0 else { return }
|
||||
lines.append("\(key)=0x\(String(format: "%04X", value)) (\(value))")
|
||||
}
|
||||
|
||||
private static func appendCapabilities(_ capabilities: [String], to lines: inout [String]) {
|
||||
guard !capabilities.isEmpty else { return }
|
||||
lines.append("capabilities=[\(capabilities.joined(separator: ", "))]")
|
||||
}
|
||||
|
||||
private static func appendUserData(_ userData: NSMutableDictionary?, to lines: inout [String]) {
|
||||
guard let userData, !userData.allKeys.isEmpty else { return }
|
||||
lines.append("userData=\(userData)")
|
||||
}
|
||||
|
||||
private static func appendIcon(_ icon: CGImage?, to lines: inout [String]) {
|
||||
guard let icon else { return }
|
||||
lines.append("icon=present(\(icon.width)x\(icon.height))")
|
||||
}
|
||||
|
||||
private static func appendItems(_ key: String, _ items: [ICCameraItem]?, to lines: inout [String]) {
|
||||
guard let items, !items.isEmpty else { return }
|
||||
let names = items.prefix(5).map { $0.name ?? "unnamed" }.joined(separator: ", ")
|
||||
let suffix = items.count > 5 ? ", ..." : ""
|
||||
lines.append("\(key).count=\(items.count)")
|
||||
lines.append("\(key).preview=[\(names)\(suffix)]")
|
||||
}
|
||||
}
|
||||
46
suixinkan/Core/CameraOTG/Core/ICCameraFileDownloader.swift
Normal file
46
suixinkan/Core/CameraOTG/Core/ICCameraFileDownloader.swift
Normal file
@ -0,0 +1,46 @@
|
||||
//
|
||||
// ICCameraFileDownloader.swift
|
||||
// otg_swift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageCaptureCore
|
||||
|
||||
/// 封装 `ICCameraFile.requestDownload`,将相机文件异步保存到本地目录。
|
||||
enum ICCameraFileDownloader {
|
||||
|
||||
/// 下载到 `directory`,同名文件会被覆盖(`ICDownloadOption.overwrite`)。
|
||||
static func download(_ file: ICCameraFile, to directory: URL) async throws -> URL {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
|
||||
let filename = file.name ?? UUID().uuidString
|
||||
let destinationURL = directory.appendingPathComponent(filename)
|
||||
|
||||
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
||||
try FileManager.default.removeItem(at: destinationURL)
|
||||
}
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let options: [ICDownloadOption: Any] = [
|
||||
.downloadsDirectoryURL: directory,
|
||||
.savedFilename: filename,
|
||||
.overwrite: true
|
||||
]
|
||||
|
||||
file.requestDownload(options: options) { savedFilename, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
|
||||
let resolvedFilename = savedFilename ?? filename
|
||||
let resolvedURL = directory.appendingPathComponent(resolvedFilename)
|
||||
guard FileManager.default.fileExists(atPath: resolvedURL.path) else {
|
||||
continuation.resume(throwing: CameraError.connectionFailed("下载文件未找到"))
|
||||
return
|
||||
}
|
||||
continuation.resume(returning: resolvedURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
139
suixinkan/Core/CameraOTG/Core/ICCameraItem+CameraObjects.swift
Normal file
139
suixinkan/Core/CameraOTG/Core/ICCameraItem+CameraObjects.swift
Normal file
@ -0,0 +1,139 @@
|
||||
//
|
||||
// ICCameraItem+CameraObjects.swift
|
||||
// otg_swift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageCaptureCore
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// 从 `ICCameraDevice` 设备树收集图片文件,并映射为通用 `CameraObject`。
|
||||
enum ICCameraItemScanner {
|
||||
|
||||
/// 多策略收集:`mediaFiles` → `contents` 递归 → `filesOfType(public.image)` 兜底。
|
||||
static func collectImageFiles(from device: ICCameraDevice, supplementalItems: [ICCameraItem] = []) -> [ICCameraFile] {
|
||||
var files = collectImageFilesFromDeviceTree(device)
|
||||
|
||||
if files.isEmpty, !supplementalItems.isEmpty {
|
||||
files = collectImageFiles(from: supplementalItems)
|
||||
OTGLog.info(.connection, "collectImageFiles: using supplemental items, count=\(files.count)")
|
||||
}
|
||||
|
||||
// 部分机型 contents 为空但 filesOfType 仍可枚举
|
||||
if files.isEmpty {
|
||||
files = collectImageFiles(fromFilesOfType: device)
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
private static func collectImageFilesFromDeviceTree(_ device: ICCameraDevice) -> [ICCameraFile] {
|
||||
let mediaFiles = (device.mediaFiles ?? []).compactMap { $0 as? ICCameraFile }
|
||||
if !mediaFiles.isEmpty {
|
||||
return mediaFiles.filter { isImageFilename($0.name) }
|
||||
}
|
||||
return collectImageFiles(from: device.contents ?? [])
|
||||
}
|
||||
|
||||
private static func collectImageFiles(fromFilesOfType device: ICCameraDevice) -> [ICCameraFile] {
|
||||
guard let names = device.files(ofType: UTType.image.identifier), !names.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
OTGLog.info(.connection, "filesOfType(public.image) count=\(names.count), preview=\(names.prefix(3))")
|
||||
|
||||
let treeFiles = collectImageFiles(from: device.contents ?? [])
|
||||
if !treeFiles.isEmpty {
|
||||
return treeFiles.filter { file in
|
||||
guard let name = file.name else { return false }
|
||||
return names.contains(name) || names.contains(where: { ($0 as NSString).lastPathComponent == name })
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/// 递归遍历文件夹,收集扩展名匹配的图片文件。
|
||||
static func collectImageFiles(from items: [ICCameraItem]) -> [ICCameraFile] {
|
||||
var results: [ICCameraFile] = []
|
||||
for item in items {
|
||||
if let file = item as? ICCameraFile, isImageFilename(file.name) {
|
||||
results.append(file)
|
||||
} else if let folder = item as? ICCameraFolder {
|
||||
results.append(contentsOf: collectImageFiles(from: folder.contents ?? []))
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/// 按 `CameraObject.id`(文件名+大小)反查 ICCameraFile。
|
||||
static func findFile(matching object: CameraObject, in device: ICCameraDevice) -> ICCameraFile? {
|
||||
collectImageFiles(from: device).first { cameraObjectID(for: $0) == object.id }
|
||||
}
|
||||
|
||||
static func cameraObject(from file: ICCameraFile) -> CameraObject? {
|
||||
guard let filename = file.name, !filename.isEmpty, isImageFilename(filename) else { return nil }
|
||||
let fileSize = Int64(file.fileSize)
|
||||
let capturedAt = file.exifCreationDate ?? file.fileCreationDate ?? file.creationDate ?? .distantPast
|
||||
return CameraObject(
|
||||
id: cameraObjectID(for: file),
|
||||
filename: filename,
|
||||
fileSize: fileSize,
|
||||
capturedAt: capturedAt
|
||||
)
|
||||
}
|
||||
|
||||
/// 并发请求 metadata,补全 catalog 枚举阶段缺失的拍摄时间。
|
||||
static func cameraObjects(from files: [ICCameraFile]) async -> [CameraObject] {
|
||||
await withTaskGroup(of: (Int, CameraObject?).self) { group in
|
||||
for (index, file) in files.enumerated() {
|
||||
group.addTask {
|
||||
(index, await cameraObject(from: file))
|
||||
}
|
||||
}
|
||||
|
||||
var indexed = Array<CameraObject?>(repeating: nil, count: files.count)
|
||||
for await (index, object) in group {
|
||||
indexed[index] = object
|
||||
}
|
||||
return indexed.compactMap { $0 }
|
||||
}
|
||||
}
|
||||
|
||||
static func cameraObject(from file: ICCameraFile) async -> CameraObject? {
|
||||
guard let filename = file.name, !filename.isEmpty, isImageFilename(filename) else { return nil }
|
||||
let fileSize = Int64(file.fileSize)
|
||||
let capturedAt = await ICCameraMetadataLoader.captureDate(from: file)
|
||||
return CameraObject(
|
||||
id: cameraObjectID(for: file),
|
||||
filename: filename,
|
||||
fileSize: fileSize,
|
||||
capturedAt: capturedAt
|
||||
)
|
||||
}
|
||||
|
||||
/// 稳定 ID:文件名 + 文件大小(同目录重名不同尺寸可区分)。
|
||||
static func cameraObjectID(for file: ICCameraFile) -> String {
|
||||
cameraObjectID(filename: file.name ?? "unknown", fileSize: Int64(file.fileSize))
|
||||
}
|
||||
|
||||
static func cameraObjectID(filename: String, fileSize: Int64) -> String {
|
||||
"\(filename)|\(fileSize)"
|
||||
}
|
||||
|
||||
/// 从相册本地文件路径解析 dedup 键,与 `CameraObject.id` 格式一致。
|
||||
static func cameraObjectID(localPath: String) -> String? {
|
||||
guard !localPath.isEmpty,
|
||||
FileManager.default.fileExists(atPath: localPath) else { return nil }
|
||||
let filename = (localPath as NSString).lastPathComponent
|
||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: localPath),
|
||||
let size = attrs[.size] as? Int64 else { return nil }
|
||||
return cameraObjectID(filename: filename, fileSize: size)
|
||||
}
|
||||
|
||||
static func isImageFilename(_ name: String?) -> Bool {
|
||||
guard let name else { return false }
|
||||
let ext = (name as NSString).pathExtension.lowercased()
|
||||
return ["jpg", "jpeg", "heic", "png", "arw", "cr2", "cr3", "nef", "raf"].contains(ext)
|
||||
}
|
||||
}
|
||||
133
suixinkan/Core/CameraOTG/Core/ICCameraMetadataLoader.swift
Normal file
133
suixinkan/Core/CameraOTG/Core/ICCameraMetadataLoader.swift
Normal file
@ -0,0 +1,133 @@
|
||||
//
|
||||
// ICCameraMetadataLoader.swift
|
||||
// otg_swift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageCaptureCore
|
||||
import ImageIO
|
||||
|
||||
/// 从 `ICCameraFile` 读取拍摄时间;catalog 枚举阶段 EXIF 日期通常尚未加载,需主动 request metadata。
|
||||
enum ICCameraMetadataLoader {
|
||||
|
||||
static func captureDate(from file: ICCameraFile) async -> Date {
|
||||
if let date = immediateCaptureDate(from: file) {
|
||||
return date
|
||||
}
|
||||
|
||||
if let metadata = await requestMetadata(from: file) {
|
||||
if let date = parseCaptureDate(from: metadata) {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
if let date = immediateCaptureDate(from: file) {
|
||||
return date
|
||||
}
|
||||
|
||||
if let date = parseCaptureDate(fromFilename: file.name ?? "") {
|
||||
return date
|
||||
}
|
||||
|
||||
return .distantPast
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static func immediateCaptureDate(from file: ICCameraFile) -> Date? {
|
||||
file.exifCreationDate ?? file.fileCreationDate ?? file.creationDate
|
||||
}
|
||||
|
||||
private static func requestMetadata(from file: ICCameraFile) async -> [AnyHashable: Any]? {
|
||||
await withCheckedContinuation { continuation in
|
||||
file.requestMetadataDictionary(options: nil) { metadata, error in
|
||||
if let error {
|
||||
OTGLog.debug(
|
||||
.connection,
|
||||
"metadata failed for \(file.name ?? "nil"): \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
continuation.resume(returning: metadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseCaptureDate(from metadata: [AnyHashable: Any]) -> Date? {
|
||||
if let exif = metadata[kCGImagePropertyExifDictionary as String] as? [String: Any],
|
||||
let date = parseEXIFDateString(exif[kCGImagePropertyExifDateTimeOriginal as String] as? String) {
|
||||
return date
|
||||
}
|
||||
|
||||
if let tiff = metadata[kCGImagePropertyTIFFDictionary as String] as? [String: Any],
|
||||
let date = parseEXIFDateString(tiff[kCGImagePropertyTIFFDateTime as String] as? String) {
|
||||
return date
|
||||
}
|
||||
|
||||
if let date = parseEXIFDateString(metadata[kCGImagePropertyExifDateTimeOriginal as String] as? String) {
|
||||
return date
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func parseEXIFDateString(_ value: String?) -> Date? {
|
||||
guard let value, !value.isEmpty else { return nil }
|
||||
return exifDateFormatter.date(from: value)
|
||||
}
|
||||
|
||||
/// 部分机身文件名含 `YYYYMMDD_HHMMSS` 或 `IMG_YYYYMMDD_HHMMSS`。
|
||||
private static func parseCaptureDate(fromFilename filename: String) -> Date? {
|
||||
let stem = (filename as NSString).deletingPathExtension
|
||||
let patterns = [
|
||||
(#"(\d{8})_(\d{6})"#, ["yyyyMMdd", "HHmmss"]),
|
||||
(#"IMG_(\d{8})_(\d{6})"#, ["yyyyMMdd", "HHmmss"])
|
||||
]
|
||||
|
||||
for (pattern, formats) in patterns {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern),
|
||||
let match = regex.firstMatch(in: stem, range: NSRange(stem.startIndex..., in: stem)),
|
||||
match.numberOfRanges == 3,
|
||||
let dateRange = Range(match.range(at: 1), in: stem),
|
||||
let timeRange = Range(match.range(at: 2), in: stem) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let datePart = String(stem[dateRange])
|
||||
let timePart = String(stem[timeRange])
|
||||
guard let date = filenameDateFormatter.date(from: datePart),
|
||||
let time = filenameTimeFormatter.date(from: timePart) else {
|
||||
continue
|
||||
}
|
||||
|
||||
var components = Calendar.current.dateComponents([.year, .month, .day], from: date)
|
||||
let timeComponents = Calendar.current.dateComponents([.hour, .minute, .second], from: time)
|
||||
components.hour = timeComponents.hour
|
||||
components.minute = timeComponents.minute
|
||||
components.second = timeComponents.second
|
||||
return Calendar.current.date(from: components)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static let exifDateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy:MM:dd HH:mm:ss"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let filenameDateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyyMMdd"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let filenameTimeFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "HHmmss"
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
40
suixinkan/Core/CameraOTG/Core/ICCameraThumbnailLoader.swift
Normal file
40
suixinkan/Core/CameraOTG/Core/ICCameraThumbnailLoader.swift
Normal file
@ -0,0 +1,40 @@
|
||||
//
|
||||
// ICCameraThumbnailLoader.swift
|
||||
// otg_swift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageCaptureCore
|
||||
|
||||
/// 通过 ImageCaptureCore 请求相机内文件的缩略图 Data。
|
||||
enum ICCameraThumbnailLoader {
|
||||
|
||||
static func loadThumbnailData(
|
||||
for object: CameraObject,
|
||||
from device: ICCameraDevice,
|
||||
maxPixelSize: Int
|
||||
) async -> Data? {
|
||||
guard let file = ICCameraItemScanner.findFile(matching: object, in: device) else {
|
||||
return nil
|
||||
}
|
||||
return await loadThumbnailData(from: file, maxPixelSize: maxPixelSize)
|
||||
}
|
||||
|
||||
/// `maxPixelSize` 控制缩略图最长边像素,用于导入网格展示。
|
||||
static func loadThumbnailData(from file: ICCameraFile, maxPixelSize: Int) async -> Data? {
|
||||
await withCheckedContinuation { continuation in
|
||||
let options: [ICCameraItemThumbnailOption: Any] = [
|
||||
ICCameraItemThumbnailOption.imageSourceThumbnailMaxPixelSize: maxPixelSize
|
||||
]
|
||||
|
||||
file.requestThumbnailData(options: options) { data, error in
|
||||
if let error {
|
||||
OTGLog.debug(.connection, "thumbnail failed for \(file.name ?? "nil"): \(error.localizedDescription)")
|
||||
continuation.resume(returning: nil)
|
||||
return
|
||||
}
|
||||
continuation.resume(returning: data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
112
suixinkan/Core/CameraOTG/Core/OTGLog.swift
Normal file
112
suixinkan/Core/CameraOTG/Core/OTGLog.swift
Normal file
@ -0,0 +1,112 @@
|
||||
//
|
||||
// OTGLog.swift
|
||||
// otg_swift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// OTG 统一日志,Xcode Console 过滤关键字:`[OTG]`
|
||||
enum OTGLog {
|
||||
|
||||
/// Console 过滤前缀
|
||||
static let filterPrefix = "[OTG]"
|
||||
|
||||
/// 日志级别,映射 os.Logger 与控制台输出
|
||||
enum Level: String {
|
||||
/// 调试细节,仅 DEBUG 构建打印到 Xcode Console
|
||||
case debug = "DEBUG"
|
||||
/// 正常流程节点(连接、PTP 成功等)
|
||||
case info = "INFO"
|
||||
/// 可恢复异常或降级路径
|
||||
case warning = "WARN"
|
||||
/// 失败路径,需用户或开发者关注
|
||||
case error = "ERROR"
|
||||
}
|
||||
|
||||
/// 日志分类,对应 Console 中 `[Category]` 段
|
||||
enum Category: String {
|
||||
/// ConnectionManager、ICCamera 扫描与 Session
|
||||
case connection = "Connection"
|
||||
/// 通用 PTP 命令发送与解析
|
||||
case ptp = "PTP"
|
||||
/// PlatformDetector 品牌识别
|
||||
case detector = "Detector"
|
||||
/// CameraFactory Driver 创建
|
||||
case factory = "Factory"
|
||||
/// NikonCameraDriver
|
||||
case nikon = "Nikon"
|
||||
/// CanonCameraDriver
|
||||
case canon = "Canon"
|
||||
/// Sony Driver、SDIO 与边拍边传
|
||||
case sony = "Sony"
|
||||
}
|
||||
|
||||
private static let osLog = Logger(
|
||||
subsystem: Bundle.main.bundleIdentifier ?? "otg_swift",
|
||||
category: "OTG"
|
||||
)
|
||||
|
||||
static func debug(
|
||||
_ category: Category,
|
||||
_ message: @autoclosure () -> String,
|
||||
file: String = #file,
|
||||
line: Int = #line
|
||||
) {
|
||||
log(.debug, category: category, message: message(), file: file, line: line)
|
||||
}
|
||||
|
||||
static func info(
|
||||
_ category: Category,
|
||||
_ message: @autoclosure () -> String,
|
||||
file: String = #file,
|
||||
line: Int = #line
|
||||
) {
|
||||
log(.info, category: category, message: message(), file: file, line: line)
|
||||
}
|
||||
|
||||
static func warning(
|
||||
_ category: Category,
|
||||
_ message: @autoclosure () -> String,
|
||||
file: String = #file,
|
||||
line: Int = #line
|
||||
) {
|
||||
log(.warning, category: category, message: message(), file: file, line: line)
|
||||
}
|
||||
|
||||
static func error(
|
||||
_ category: Category,
|
||||
_ message: @autoclosure () -> String,
|
||||
file: String = #file,
|
||||
line: Int = #line
|
||||
) {
|
||||
log(.error, category: category, message: message(), file: file, line: line)
|
||||
}
|
||||
|
||||
private static func log(
|
||||
_ level: Level,
|
||||
category: Category,
|
||||
message: String,
|
||||
file: String,
|
||||
line: Int
|
||||
) {
|
||||
let fileName = (file as NSString).lastPathComponent
|
||||
let formatted = "\(filterPrefix)[\(level.rawValue)][\(category.rawValue)] \(message) (\(fileName):\(line))"
|
||||
|
||||
#if DEBUG
|
||||
// DEBUG 仅用 print,避免与 osLog 在 Xcode Console 重复输出
|
||||
print(formatted)
|
||||
#else
|
||||
switch level {
|
||||
case .debug:
|
||||
osLog.debug("\(formatted)")
|
||||
case .info:
|
||||
osLog.info("\(formatted)")
|
||||
case .warning:
|
||||
osLog.warning("\(formatted)")
|
||||
case .error:
|
||||
osLog.error("\(formatted)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
111
suixinkan/Core/CameraOTG/Core/PlatformDetector.swift
Normal file
111
suixinkan/Core/CameraOTG/Core/PlatformDetector.swift
Normal file
@ -0,0 +1,111 @@
|
||||
//
|
||||
// PlatformDetector.swift
|
||||
// otg_swift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 根据 USB Vendor ID 与设备名字符串识别相机品牌。
|
||||
enum PlatformDetector {
|
||||
|
||||
private struct Rule {
|
||||
let platform: CameraPlatform
|
||||
let keywords: [String]
|
||||
}
|
||||
|
||||
/// USB Vendor ID → 品牌(最可靠,优先于型号字符串匹配)
|
||||
private static let vendorIDMap: [Int: CameraPlatform] = [
|
||||
0x054C: .sony, // Sony
|
||||
0x04A9: .canon, // Canon
|
||||
0x04B0: .nikon // Nikon
|
||||
]
|
||||
|
||||
/// 品牌词 + 常见 USB 设备名型号特征(VID 不可用时的兜底)
|
||||
private static let rules: [Rule] = [
|
||||
Rule(platform: .nikon, keywords: [
|
||||
"nikon",
|
||||
"dsc-n",
|
||||
"coolpix",
|
||||
"z fc",
|
||||
"z 6", "z 7", "z 8", "z 9",
|
||||
"z6", "z7", "z8", "z9",
|
||||
"d3", "d4", "d5", "d6", "d7", "d8", "d850", "d750"
|
||||
]),
|
||||
Rule(platform: .canon, keywords: [
|
||||
"canon",
|
||||
"eos ",
|
||||
"eos-",
|
||||
"powershot",
|
||||
"ixus",
|
||||
"eos m", "eos r", "eos 5d", "eos 6d", "eos 7d", "eos 90d"
|
||||
]),
|
||||
Rule(platform: .sony, keywords: [
|
||||
"sony",
|
||||
"alpha",
|
||||
"zv-",
|
||||
"zv e",
|
||||
"ilce-",
|
||||
"ilca-",
|
||||
"dsc-",
|
||||
"nex-",
|
||||
"fx3",
|
||||
"fx30",
|
||||
"hdr-",
|
||||
"a7", "a9", "a1"
|
||||
])
|
||||
]
|
||||
|
||||
static func detect(from info: CameraDeviceInfo) -> CameraPlatform {
|
||||
// VID 最可靠:厂商 ID 由 USB 描述符提供,优于型号字符串模糊匹配
|
||||
if let vendorID = info.usbVendorID, vendorID != 0,
|
||||
let platform = vendorIDMap[vendorID] {
|
||||
OTGLog.info(
|
||||
.detector,
|
||||
"detected platform=\(platform.rawValue), matched=usbVendorID:0x\(String(format: "%04X", vendorID)), device=\"\(info.name)\""
|
||||
)
|
||||
return platform
|
||||
}
|
||||
|
||||
let text = normalizedText(from: info)
|
||||
|
||||
for rule in rules {
|
||||
if let keyword = rule.keywords.first(where: { matches(text, keyword: $0) }) {
|
||||
OTGLog.info(
|
||||
.detector,
|
||||
"detected platform=\(rule.platform.rawValue), matched=\"\(keyword)\", text=\"\(text)\""
|
||||
)
|
||||
return rule.platform
|
||||
}
|
||||
}
|
||||
|
||||
let vidHint = info.usbVendorID.map { "usbVendorID=0x\(String(format: "%04X", $0))" } ?? "usbVendorID=nil"
|
||||
OTGLog.warning(.detector, "unknown platform, \(vidHint), text=\"\(text)\"")
|
||||
return .unknown
|
||||
}
|
||||
|
||||
private static func normalizedText(from info: CameraDeviceInfo) -> String {
|
||||
[info.manufacturer, info.model, info.name]
|
||||
.compactMap { $0?.lowercased() }
|
||||
.joined(separator: " ")
|
||||
.replacingOccurrences(of: "_", with: " ")
|
||||
.replacingOccurrences(of: " ", with: " ")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func matches(_ text: String, keyword: String) -> Bool {
|
||||
let key = keyword.lowercased()
|
||||
guard !key.isEmpty else { return false }
|
||||
|
||||
if text.contains(key) {
|
||||
return true
|
||||
}
|
||||
|
||||
let compactText = text.replacingOccurrences(of: " ", with: "")
|
||||
.replacingOccurrences(of: "-", with: "")
|
||||
let compactKey = key.replacingOccurrences(of: " ", with: "")
|
||||
.replacingOccurrences(of: "-", with: "")
|
||||
|
||||
// 兼容 "ZV-E10" vs "ZVE10" 等空格/连字符差异
|
||||
return !compactKey.isEmpty && compactText.contains(compactKey)
|
||||
}
|
||||
}
|
||||
318
suixinkan/Core/CameraOTG/PTP/PTPUtilities.swift
Normal file
318
suixinkan/Core/CameraOTG/PTP/PTPUtilities.swift
Normal file
@ -0,0 +1,318 @@
|
||||
//
|
||||
// PTPUtilities.swift
|
||||
// otg_swift
|
||||
//
|
||||
// Created by hanqiu on 2026/7/3.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageCaptureCore
|
||||
|
||||
/// PTP Container 类型字段(ISO 15740)。
|
||||
enum PTPContainerType: UInt16 {
|
||||
/// 主机发出的操作命令包
|
||||
case command = 0x0001
|
||||
/// 伴随命令的数据阶段(out/in Data)
|
||||
case data = 0x0002
|
||||
/// 设备对命令的响应包
|
||||
case response = 0x0003
|
||||
/// 设备主动上报的事件包(如 Sony SDIE)
|
||||
case event = 0x0004
|
||||
}
|
||||
|
||||
/// 解析后的 PTP 容器:命令、数据、响应或事件包。
|
||||
struct PTPContainer {
|
||||
let length: UInt32
|
||||
let type: UInt16
|
||||
let code: UInt16
|
||||
let transactionID: UInt32
|
||||
let params: [UInt32]
|
||||
}
|
||||
|
||||
/// PTP Container 解析失败原因。
|
||||
enum PTPParseError: Error {
|
||||
/// 数据长度不足 12 字节(Container 最小头)
|
||||
case tooShort
|
||||
/// length 字段与 buffer 实际长度不一致
|
||||
case invalidLength
|
||||
}
|
||||
|
||||
/// 标准 PTP 操作码与响应码(ISO 15740)。
|
||||
enum PTPStandardCommand {
|
||||
/// GetDeviceInfo (0x1001),用于 PTP 管线 Probe
|
||||
static let getDeviceInfo: UInt16 = 0x1001
|
||||
/// GetObjectInfo (0x1008),Probe 虚拟对象元数据
|
||||
static let getObjectInfo: UInt16 = 0x1008
|
||||
/// GetObject (0x1009),下载对象二进制(Step 6)
|
||||
static let getObject: UInt16 = 0x1009
|
||||
/// ObjectAdded 事件 (0x4002)
|
||||
static let objectAdded: UInt16 = 0x4002
|
||||
/// RequestObjectTransfer 事件 (0x4009)
|
||||
static let requestObjectTransfer: UInt16 = 0x4009
|
||||
/// Response OK (0x2001),命令成功响应码
|
||||
static let responseOK: UInt16 = 0x2001
|
||||
}
|
||||
|
||||
/// PTP 包构建、解析与经 ImageCaptureCore 发送的通用工具。
|
||||
enum PTPHelper {
|
||||
|
||||
// MARK: - Build
|
||||
|
||||
/// 构建 PTP Command Container(小端序,length = 12 + 4×参数个数)。
|
||||
static func buildCommand(
|
||||
code: UInt16,
|
||||
transactionID: UInt32,
|
||||
parameters: [UInt32] = []
|
||||
) -> Data {
|
||||
let length = UInt32(12 + parameters.count * 4)
|
||||
var data = Data()
|
||||
appendUInt32LE(length, to: &data)
|
||||
appendUInt16LE(PTPContainerType.command.rawValue, to: &data)
|
||||
appendUInt16LE(code, to: &data)
|
||||
appendUInt32LE(transactionID, to: &data)
|
||||
for parameter in parameters {
|
||||
appendUInt32LE(parameter, to: &data)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// MARK: - Parse
|
||||
|
||||
/// 从二进制数据解析 PTP Container;参数区按 4 字节对齐读取。
|
||||
static func parseContainer(_ data: Data) throws -> PTPContainer {
|
||||
guard data.count >= 12 else { throw PTPParseError.tooShort }
|
||||
|
||||
let length = readUInt32LE(data, offset: 0)
|
||||
guard length >= 12, data.count >= Int(length) else {
|
||||
throw PTPParseError.invalidLength
|
||||
}
|
||||
|
||||
let type = readUInt16LE(data, offset: 4)
|
||||
let code = readUInt16LE(data, offset: 6)
|
||||
let transactionID = readUInt32LE(data, offset: 8)
|
||||
|
||||
var params: [UInt32] = []
|
||||
var offset = 12
|
||||
while offset + 4 <= Int(length) {
|
||||
params.append(readUInt32LE(data, offset: offset))
|
||||
offset += 4
|
||||
}
|
||||
|
||||
return PTPContainer(
|
||||
length: length,
|
||||
type: type,
|
||||
code: code,
|
||||
transactionID: transactionID,
|
||||
params: params
|
||||
)
|
||||
}
|
||||
|
||||
/// 从 PTP Event Container 提取事件码(如 Sony 0xC203)。
|
||||
static func parseEventCode(from eventData: Data) -> UInt16? {
|
||||
try? parseContainer(eventData).code
|
||||
}
|
||||
|
||||
static func isResponseOK(_ response: Data) -> Bool {
|
||||
guard !response.isEmpty else { return true }
|
||||
guard let parsed = try? parseContainer(response) else { return false }
|
||||
return parsed.code == PTPStandardCommand.responseOK
|
||||
}
|
||||
|
||||
static func hexString(_ data: Data) -> String {
|
||||
data.map { String(format: "%02X", $0) }.joined(separator: " ")
|
||||
}
|
||||
|
||||
/// 同一文件名+大小视为同一张待传照片,用于边拍边传去重。
|
||||
static func objectSignature(filename: String, size: UInt32) -> String {
|
||||
"\(filename)_\(size)"
|
||||
}
|
||||
|
||||
/// ObjectInfo 固定布局:offset 8 为 CompressedSize(UInt32 LE)。
|
||||
static func parseObjectCompressedSize(_ data: Data) -> UInt32? {
|
||||
guard data.count >= 12 else { return nil }
|
||||
return readUInt32LE(data, offset: 8)
|
||||
}
|
||||
|
||||
/// 从 ObjectInfo 解析文件名;优先 PTP 字符串格式,失败则扫描 UTF-16LE。
|
||||
static func parseObjectInfoFilename(_ data: Data) -> String? {
|
||||
guard data.count >= 53 else { return nil }
|
||||
|
||||
if let filename = parsePTPString(data, offset: 52)?.string, !filename.isEmpty {
|
||||
return sanitizeFilename(filename)
|
||||
}
|
||||
|
||||
var offset = 52
|
||||
var codeUnits: [UInt16] = []
|
||||
while offset + 1 < data.count {
|
||||
let unit = readUInt16LE(data, offset: offset)
|
||||
if unit == 0 { break }
|
||||
codeUnits.append(unit)
|
||||
offset += 2
|
||||
}
|
||||
|
||||
guard !codeUnits.isEmpty else { return nil }
|
||||
let raw = String(utf16CodeUnits: codeUnits, count: codeUnits.count)
|
||||
return sanitizeFilename(raw)
|
||||
}
|
||||
|
||||
/// PTP 字符串:1 字节字符数 + UTF-16LE 字符序列(无 NUL 终止)。
|
||||
static func parsePTPString(_ data: Data, offset start: Int) -> (string: String, nextOffset: Int)? {
|
||||
guard start < data.count else { return nil }
|
||||
let charCount = Int(data[start])
|
||||
var offset = start + 1
|
||||
guard charCount > 0, offset + charCount * 2 <= data.count else { return nil }
|
||||
|
||||
var codeUnits: [UInt16] = []
|
||||
codeUnits.reserveCapacity(charCount)
|
||||
for index in 0..<charCount {
|
||||
let unit = readUInt16LE(data, offset: offset + index * 2)
|
||||
// 部分机身(如 ZV-E10)在 charCount 内含 NUL,需跳过
|
||||
if unit != 0 {
|
||||
codeUnits.append(unit)
|
||||
}
|
||||
}
|
||||
offset += charCount * 2
|
||||
|
||||
guard !codeUnits.isEmpty else { return nil }
|
||||
let string = String(utf16CodeUnits: codeUnits, count: codeUnits.count)
|
||||
return (string, offset)
|
||||
}
|
||||
|
||||
/// 去除 PTP 字符串残留的控制字符与非法路径字符。
|
||||
static func sanitizeFilename(_ name: String) -> String {
|
||||
let withoutNulls = name.replacingOccurrences(of: "\0", with: "")
|
||||
let trimmed = withoutNulls.trimmingCharacters(in: .whitespacesAndNewlines.union(.controlCharacters))
|
||||
let invalid = CharacterSet(charactersIn: "/\\:?%*|\"<>")
|
||||
let cleaned = trimmed.components(separatedBy: invalid).joined(separator: "_")
|
||||
return cleaned.isEmpty ? "photo.jpg" : cleaned
|
||||
}
|
||||
|
||||
/// 粗略判断 inData 是否为 JPEG/PNG 或足够大的二进制块。
|
||||
static func isLikelyImageData(_ data: Data) -> Bool {
|
||||
guard data.count >= 3 else { return false }
|
||||
if data[0] == 0xFF, data[1] == 0xD8, data[2] == 0xFF { return true }
|
||||
if data.count >= 4, data[0] == 0x89, data[1] == 0x50, data[2] == 0x4E, data[3] == 0x47 { return true }
|
||||
return data.count > 1024
|
||||
}
|
||||
|
||||
// MARK: - Send
|
||||
|
||||
struct CommandResult {
|
||||
let success: Bool
|
||||
let response: Data
|
||||
let inData: Data
|
||||
}
|
||||
|
||||
/// 经 `requestSendPTPCommand` 发送 PTP 命令;调用方传入并递增 `transactionID`。
|
||||
/// 必须在主线程发起(ImageCaptureCore 要求),completion 内 resume continuation。
|
||||
static func sendCommand(
|
||||
on camera: ICCameraDevice,
|
||||
code: UInt16,
|
||||
transactionID: inout UInt32,
|
||||
parameters: [UInt32] = [],
|
||||
outData: Data? = nil,
|
||||
label: String
|
||||
) async -> CommandResult {
|
||||
let currentID = transactionID
|
||||
transactionID += 1
|
||||
|
||||
let commandData = buildCommand(
|
||||
code: code,
|
||||
transactionID: currentID,
|
||||
parameters: parameters
|
||||
)
|
||||
|
||||
OTGLog.info(.ptp, "PTP command sending: \(label), bytes=\(commandData.count)")
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
let finish: (CommandResult) -> Void = { result in
|
||||
// continuation 必须在主线程 resume,避免 UI/IC 线程问题
|
||||
if Thread.isMainThread {
|
||||
continuation.resume(returning: result)
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sendPTP = {
|
||||
camera.requestSendPTPCommand(
|
||||
commandData,
|
||||
outData: outData,
|
||||
completion: { inData, response, error in
|
||||
OTGLog.info(
|
||||
.ptp,
|
||||
"PTP callback received: \(label), inData=\(inData.count), response=\(response.count)"
|
||||
)
|
||||
|
||||
if let error {
|
||||
OTGLog.error(.ptp, "PTP \(label) failed: \(error.localizedDescription)")
|
||||
finish(CommandResult(success: false, response: response, inData: inData))
|
||||
return
|
||||
}
|
||||
|
||||
var ok = true
|
||||
if !response.isEmpty {
|
||||
if let parsed = try? parseContainer(response) {
|
||||
// 非 0x2001 ResponseOK 视为失败
|
||||
if parsed.code != PTPStandardCommand.responseOK {
|
||||
ok = false
|
||||
OTGLog.warning(
|
||||
.ptp,
|
||||
"PTP \(label) response code=0x\(String(format: "%04X", parsed.code))"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ok = false
|
||||
OTGLog.warning(.ptp, "PTP \(label) response parse failed")
|
||||
}
|
||||
}
|
||||
|
||||
if ok {
|
||||
OTGLog.info(.ptp, "PTP \(label) OK, inData=\(inData.count) bytes")
|
||||
}
|
||||
|
||||
finish(CommandResult(success: ok, response: response, inData: inData))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ImageCaptureCore PTP API 要求从主线程调用
|
||||
if Thread.isMainThread {
|
||||
sendPTP()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: sendPTP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Little Endian
|
||||
|
||||
static func appendUInt16LE(_ value: UInt16, to data: inout Data) {
|
||||
var littleEndian = value.littleEndian
|
||||
withUnsafeBytes(of: &littleEndian) { raw in
|
||||
data.append(contentsOf: raw)
|
||||
}
|
||||
}
|
||||
|
||||
static func appendUInt32LE(_ value: UInt32, to data: inout Data) {
|
||||
var littleEndian = value.littleEndian
|
||||
withUnsafeBytes(of: &littleEndian) { raw in
|
||||
data.append(contentsOf: raw)
|
||||
}
|
||||
}
|
||||
|
||||
static func readUInt16LE(_ data: Data, offset: Int) -> UInt16 {
|
||||
UInt16(data[offset])
|
||||
| (UInt16(data[offset + 1]) << 8)
|
||||
}
|
||||
|
||||
static func readUInt32LE(_ data: Data, offset: Int) -> UInt32 {
|
||||
UInt32(data[offset])
|
||||
| (UInt32(data[offset + 1]) << 8)
|
||||
| (UInt32(data[offset + 2]) << 16)
|
||||
| (UInt32(data[offset + 3]) << 24)
|
||||
}
|
||||
}
|
||||
|
||||
100
suixinkan/Core/CameraOTG/Storage/OTGPhotoFileWriter.swift
Normal file
100
suixinkan/Core/CameraOTG/Storage/OTGPhotoFileWriter.swift
Normal file
@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
|
||||
/// OTG 照片文件写入工具,按账号与相册 scoped 目录落盘(不依赖旅拍模块)。
|
||||
enum OTGPhotoFileWriter {
|
||||
private static let rootFolderName = "CameraDownloads"
|
||||
|
||||
/// Documents 根目录。
|
||||
static var documentsDirectory: URL {
|
||||
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
}
|
||||
|
||||
/// 当前账号与相册的原图目录。
|
||||
static func originalsDirectory(accountKey: String, albumID: Int) -> URL {
|
||||
let account = sanitizePathComponent(accountKey, fallback: "guest")
|
||||
let directory = documentsDirectory
|
||||
.appendingPathComponent(rootFolderName, isDirectory: true)
|
||||
.appendingPathComponent(account, isDirectory: true)
|
||||
.appendingPathComponent("\(max(albumID, 0))", isDirectory: true)
|
||||
.appendingPathComponent("originals", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
|
||||
/// 将绝对路径转为相对 Documents 的路径。
|
||||
static func relativePath(for url: URL) -> String {
|
||||
let standardizedURL = url.standardizedFileURL
|
||||
let documentsPath = documentsDirectory.standardizedFileURL.path
|
||||
let filePath = standardizedURL.path
|
||||
if filePath.hasPrefix(documentsPath + "/") {
|
||||
return String(filePath.dropFirst(documentsPath.count + 1))
|
||||
}
|
||||
let privateDocumentsPath = "/private" + documentsPath
|
||||
if filePath.hasPrefix(privateDocumentsPath + "/") {
|
||||
return String(filePath.dropFirst(privateDocumentsPath.count + 1))
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
/// 在 scoped 原图目录分配不冲突的文件 URL。
|
||||
static func uniqueFileURL(filename: String, accountKey: String, albumID: Int) throws -> URL {
|
||||
let directory = originalsDirectory(accountKey: accountKey, albumID: albumID)
|
||||
let sanitized = PTPHelper.sanitizeFilename(filename)
|
||||
let baseURL = directory.appendingPathComponent(sanitized)
|
||||
if !FileManager.default.fileExists(atPath: baseURL.path) {
|
||||
return baseURL
|
||||
}
|
||||
|
||||
let stem = (sanitized as NSString).deletingPathExtension
|
||||
let ext = (sanitized as NSString).pathExtension
|
||||
var counter = 1
|
||||
while counter < 10_000 {
|
||||
let suffix = ext.isEmpty ? "\(stem)_\(counter)" : "\(stem)_\(counter).\(ext)"
|
||||
let candidate = directory.appendingPathComponent(suffix)
|
||||
if !FileManager.default.fileExists(atPath: candidate.path) {
|
||||
return candidate
|
||||
}
|
||||
counter += 1
|
||||
}
|
||||
throw CameraError.connectionFailed("无法分配唯一文件名")
|
||||
}
|
||||
|
||||
/// 原子写入图像数据。
|
||||
static func writeImage(
|
||||
_ data: Data,
|
||||
filename: String,
|
||||
accountKey: String,
|
||||
albumID: Int
|
||||
) throws -> URL {
|
||||
let url = try uniqueFileURL(filename: filename, accountKey: accountKey, albumID: albumID)
|
||||
try data.write(to: url, options: .atomic)
|
||||
return url
|
||||
}
|
||||
|
||||
/// 将已下载文件移动到 scoped 原图目录。
|
||||
static func moveDownloadedFile(
|
||||
from source: URL,
|
||||
filename: String,
|
||||
accountKey: String,
|
||||
albumID: Int
|
||||
) throws -> URL {
|
||||
let destination = try uniqueFileURL(filename: filename, accountKey: accountKey, albumID: albumID)
|
||||
if FileManager.default.fileExists(atPath: destination.path) {
|
||||
try FileManager.default.removeItem(at: destination)
|
||||
}
|
||||
try FileManager.default.moveItem(at: source, to: destination)
|
||||
return destination
|
||||
}
|
||||
|
||||
private static func sanitizePathComponent(_ value: String, fallback: String) -> String {
|
||||
let trimmed = value
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: ":", with: "_")
|
||||
let ascii = trimmed.unicodeScalars.filter { scalar in
|
||||
scalar.isASCII && (CharacterSet.alphanumerics.contains(scalar) || scalar == "_" || scalar == "-")
|
||||
}
|
||||
let component = String(String.UnicodeScalarView(ascii))
|
||||
return component.isEmpty ? fallback : component
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
/// 有线传图页展示的相机连接状态(UI 层使用,OTG 连接能力已移除)。
|
||||
/// 有线传图页展示的相机连接状态(UI 层使用)。
|
||||
enum CameraConnectionState: Equatable {
|
||||
case disconnected
|
||||
case searching
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
/// 单条索尼 MTP 帮助说明段落。
|
||||
struct SonyMTPHelpSection: Equatable, Identifiable {
|
||||
var id: String { title }
|
||||
let title: String
|
||||
let body: String
|
||||
}
|
||||
|
||||
/// 索尼历史照片导入需 MTP 模式的说明文案。
|
||||
enum SonyMTPHelpContent {
|
||||
static let navigationTitle = "索尼 MTP 导入说明"
|
||||
|
||||
static let introduction = """
|
||||
当前可能处于「电脑遥控(PC Remote)」等 USB 模式,App 无法读取 SD 卡历史照片。\
|
||||
若要使用「历史导入」浏览相机内已有照片,请先将相机切换为 MTP 模式。
|
||||
"""
|
||||
|
||||
static let sections: [SonyMTPHelpSection] = [
|
||||
SonyMTPHelpSection(
|
||||
title: "1. USB 连接模式",
|
||||
body: "进入相机菜单:设置 → USB 连接 → 选择「MTP」或「自动」(优先 MTP)。"
|
||||
),
|
||||
SonyMTPHelpSection(
|
||||
title: "2. 关闭遥控连接",
|
||||
body: "关闭:网络 → 电脑遥控、智能手机连接。避免相机仍处于 PC Remote 传图状态。"
|
||||
),
|
||||
SonyMTPHelpSection(
|
||||
title: "3. 保存目的地",
|
||||
body: "静态影像保存目的地勿选「仅电脑」;建议「仅拍摄装置」或「电脑+拍摄装置」。"
|
||||
),
|
||||
SonyMTPHelpSection(
|
||||
title: "4. 重新连接",
|
||||
body: "修改设置后,拔插 USB 线,回到 App 重新连接相机,再点击「历史导入」。"
|
||||
),
|
||||
SonyMTPHelpSection(
|
||||
title: "5. 说明",
|
||||
body: "MTP 连接期间机身可能无法按快门。边拍边传请使用 PC Remote 模式,历史导入请使用 MTP 模式。"
|
||||
)
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
/// 将 OTG 引擎连接状态映射为有线传图页展示状态。
|
||||
extension ConnectionState {
|
||||
/// 转换为旅拍有线传图 UI 使用的连接状态。
|
||||
var cameraConnectionState: CameraConnectionState {
|
||||
switch self {
|
||||
case .idle, .disconnected:
|
||||
return .disconnected
|
||||
case .searching:
|
||||
return .searching
|
||||
case .connecting:
|
||||
return .connecting
|
||||
case .connected(_, let deviceName):
|
||||
return .connected(deviceName: deviceName)
|
||||
case .failed(let message):
|
||||
return .error(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
import Foundation
|
||||
|
||||
/// OTG 连接与旅拍有线传图管道之间的协调器。
|
||||
@MainActor
|
||||
final class OTGLiveTransferCoordinator: NSObject, ConnectionManagerDelegate {
|
||||
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
|
||||
var onContentCatalogReady: (() -> Void)?
|
||||
var onLiveShotSaved: ((SavedOTGPhoto) -> Void)?
|
||||
var onError: ((String) -> Void)?
|
||||
var onSuggestReplug: (() -> Void)?
|
||||
|
||||
private(set) var currentDriver: (any CameraDriver)?
|
||||
private(set) var isContentCatalogReady = false
|
||||
|
||||
private let connectionManager: ConnectionManager
|
||||
private let pipeline: WiredCameraTransferPipeline
|
||||
private var photoSink: TravelAlbumOTGPhotoSink?
|
||||
private var liveContext: OTGLiveTransferContext?
|
||||
|
||||
/// 初始化协调器。
|
||||
init(
|
||||
connectionManager: ConnectionManager = .shared,
|
||||
pipeline: WiredCameraTransferPipeline
|
||||
) {
|
||||
self.connectionManager = connectionManager
|
||||
self.pipeline = pipeline
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// 配置边拍边传上下文并绑定 ConnectionManager。
|
||||
func start(
|
||||
albumId: Int,
|
||||
accountKey: String,
|
||||
autoUploadProvider: @escaping () -> Bool,
|
||||
existingObjectIDsProvider: @escaping () async -> Set<String>
|
||||
) {
|
||||
let sink = TravelAlbumOTGPhotoSink(
|
||||
albumId: albumId,
|
||||
accountKey: accountKey,
|
||||
pipeline: pipeline,
|
||||
existingObjectIDsProvider: existingObjectIDsProvider,
|
||||
autoUploadProvider: autoUploadProvider
|
||||
)
|
||||
photoSink = sink
|
||||
liveContext = OTGLiveTransferContext(albumId: albumId, accountKey: accountKey, photoSink: sink)
|
||||
|
||||
connectionManager.configureLiveTransfer(context: liveContext)
|
||||
connectionManager.delegate = self
|
||||
syncFromManager()
|
||||
connectionManager.start()
|
||||
}
|
||||
|
||||
/// 解绑 UI 回调,保持设备缓存。
|
||||
func detach() {
|
||||
connectionManager.unbindDelegate()
|
||||
connectionManager.delegate = nil
|
||||
}
|
||||
|
||||
/// 主动断开相机连接。
|
||||
func disconnect() {
|
||||
connectionManager.disconnect()
|
||||
liveContext = nil
|
||||
photoSink = nil
|
||||
}
|
||||
|
||||
/// 重新搜索 USB 相机。
|
||||
func restartSearch() {
|
||||
connectionManager.start()
|
||||
syncFromManager()
|
||||
}
|
||||
|
||||
/// 当前连接状态(旅拍 UI 格式)。
|
||||
var connectionState: CameraConnectionState {
|
||||
connectionManager.state.cameraConnectionState
|
||||
}
|
||||
|
||||
/// 创建历史导入 ViewModel。
|
||||
func makeHistoryImportViewModel(
|
||||
albumId: Int,
|
||||
accountKey: String,
|
||||
onImported: @escaping () -> Void
|
||||
) -> CameraHistoryImportViewModel? {
|
||||
guard let driver = currentDriver, isContentCatalogReady, let photoSink else { return nil }
|
||||
return CameraHistoryImportViewModel(
|
||||
albumId: albumId,
|
||||
accountKey: accountKey,
|
||||
driver: driver,
|
||||
photoSink: photoSink,
|
||||
onImported: onImported
|
||||
)
|
||||
}
|
||||
|
||||
private func syncFromManager() {
|
||||
currentDriver = connectionManager.currentDriver
|
||||
isContentCatalogReady = connectionManager.isContentCatalogReady
|
||||
onConnectionStateChange?(connectionManager.state.cameraConnectionState)
|
||||
if isContentCatalogReady {
|
||||
onContentCatalogReady?()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ConnectionManagerDelegate
|
||||
|
||||
func connectionManager(_ manager: ConnectionManager, didUpdate state: ConnectionState) {
|
||||
onConnectionStateChange?(state.cameraConnectionState)
|
||||
}
|
||||
|
||||
func connectionManager(_ manager: ConnectionManager, didCreate driver: any CameraDriver) {
|
||||
currentDriver = driver
|
||||
}
|
||||
|
||||
func connectionManager(_ manager: ConnectionManager, didFail error: any Error) {
|
||||
onError?(error.localizedDescription)
|
||||
}
|
||||
|
||||
func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: any CameraDriver) {
|
||||
currentDriver = driver
|
||||
isContentCatalogReady = true
|
||||
onContentCatalogReady?()
|
||||
}
|
||||
|
||||
func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) {
|
||||
onSuggestReplug?()
|
||||
}
|
||||
|
||||
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot photo: SavedOTGPhoto, albumId: Int) {
|
||||
onLiveShotSaved?(photo)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
import Foundation
|
||||
|
||||
/// 旅拍相册 OTG 照片落盘实现:写入 scoped 本地目录并通知传输管道。
|
||||
@MainActor
|
||||
final class TravelAlbumOTGPhotoSink: OTGPhotoSink {
|
||||
private let albumId: Int
|
||||
private let accountKey: String
|
||||
private let pipeline: WiredCameraTransferPipeline
|
||||
private let existingObjectIDsProvider: () async -> Set<String>
|
||||
private let autoUploadProvider: () -> Bool
|
||||
|
||||
/// 初始化旅拍相册 OTG 落盘实现。
|
||||
init(
|
||||
albumId: Int,
|
||||
accountKey: String,
|
||||
pipeline: WiredCameraTransferPipeline,
|
||||
existingObjectIDsProvider: @escaping () async -> Set<String>,
|
||||
autoUploadProvider: @escaping () -> Bool
|
||||
) {
|
||||
self.albumId = albumId
|
||||
self.accountKey = accountKey
|
||||
self.pipeline = pipeline
|
||||
self.existingObjectIDsProvider = existingObjectIDsProvider
|
||||
self.autoUploadProvider = autoUploadProvider
|
||||
}
|
||||
|
||||
/// 保存边拍边传图像数据。
|
||||
func saveLiveShot(
|
||||
data: Data,
|
||||
filename: String,
|
||||
capturedAt: Date,
|
||||
cameraObjectID: String
|
||||
) async throws -> SavedOTGPhoto {
|
||||
let fileURL = try OTGPhotoFileWriter.writeImage(
|
||||
data,
|
||||
filename: filename,
|
||||
accountKey: accountKey,
|
||||
albumID: albumId
|
||||
)
|
||||
return ingestSavedFile(
|
||||
fileURL: fileURL,
|
||||
filename: filename,
|
||||
capturedAt: capturedAt,
|
||||
cameraObjectID: cameraObjectID
|
||||
)
|
||||
}
|
||||
|
||||
/// 保存历史导入已下载文件。
|
||||
func saveDownloadedFile(
|
||||
at url: URL,
|
||||
filename: String,
|
||||
capturedAt: Date,
|
||||
cameraObjectID: String
|
||||
) async throws -> SavedOTGPhoto {
|
||||
let fileURL = try OTGPhotoFileWriter.moveDownloadedFile(
|
||||
from: url,
|
||||
filename: filename,
|
||||
accountKey: accountKey,
|
||||
albumID: albumId
|
||||
)
|
||||
return ingestSavedFile(
|
||||
fileURL: fileURL,
|
||||
filename: filename,
|
||||
capturedAt: capturedAt,
|
||||
cameraObjectID: cameraObjectID
|
||||
)
|
||||
}
|
||||
|
||||
/// 返回当前相册已导入的相机对象 ID。
|
||||
func existingCameraObjectIDs() async -> Set<String> {
|
||||
await existingObjectIDsProvider()
|
||||
}
|
||||
|
||||
private func ingestSavedFile(
|
||||
fileURL: URL,
|
||||
filename: String,
|
||||
capturedAt: Date,
|
||||
cameraObjectID: String
|
||||
) -> SavedOTGPhoto {
|
||||
let relativePath = OTGPhotoFileWriter.relativePath(for: fileURL)
|
||||
let autoUpload = autoUploadProvider()
|
||||
pipeline.ingestDownloadedAsset(
|
||||
assetID: cameraObjectID,
|
||||
filename: filename,
|
||||
localPath: relativePath,
|
||||
capturedAt: capturedAt,
|
||||
autoUpload: autoUpload
|
||||
)
|
||||
return SavedOTGPhoto(
|
||||
assetID: cameraObjectID,
|
||||
relativeLocalPath: relativePath,
|
||||
filename: filename,
|
||||
capturedAt: capturedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -142,6 +142,96 @@ final class WiredCameraTransferPipeline {
|
||||
lastNotifiedProgressByAssetID.removeValue(forKey: assetID)
|
||||
}
|
||||
|
||||
/// 将相机已下载到本地的照片纳入传输管道。
|
||||
func ingestDownloadedAsset(
|
||||
assetID: String,
|
||||
filename: String,
|
||||
localPath: String,
|
||||
capturedAt: Date?,
|
||||
autoUpload: Bool
|
||||
) {
|
||||
guard !excludedAssetIDs.contains(assetID) else { return }
|
||||
|
||||
let capturedAtString = capturedAt.map { CameraTransferTask.capturedAtString(from: $0) }
|
||||
?? CameraTransferTask.capturedAtString(forLocalPath: localPath)
|
||||
|
||||
if let index = tasks.firstIndex(where: { $0.assetID == assetID }) {
|
||||
tasks[index].localPath = localPath
|
||||
if tasks[index].capturedAt?.isEmpty != false {
|
||||
tasks[index].capturedAt = capturedAtString
|
||||
}
|
||||
if tasks[index].status != .uploaded {
|
||||
tasks[index].status = .downloaded
|
||||
}
|
||||
tasks[index].errorMessage = nil
|
||||
} else {
|
||||
tasks.insert(
|
||||
CameraTransferTask(
|
||||
assetID: assetID,
|
||||
filename: filename,
|
||||
localPath: localPath,
|
||||
capturedAt: capturedAtString,
|
||||
status: .downloaded
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
}
|
||||
|
||||
if autoUpload, uploadEnabled, uploadSink != nil {
|
||||
autoUploadEligibleAssetIDs.insert(assetID)
|
||||
processUploadQueue()
|
||||
}
|
||||
|
||||
notify()
|
||||
}
|
||||
|
||||
/// 标记 asset 正在从相机下载。
|
||||
func markDownloading(assetID: String, filename: String) {
|
||||
guard !excludedAssetIDs.contains(assetID) else { return }
|
||||
if let index = tasks.firstIndex(where: { $0.assetID == assetID }) {
|
||||
tasks[index].status = .downloading
|
||||
tasks[index].errorMessage = nil
|
||||
} else {
|
||||
tasks.insert(
|
||||
CameraTransferTask(assetID: assetID, filename: filename, status: .downloading),
|
||||
at: 0
|
||||
)
|
||||
}
|
||||
notify()
|
||||
}
|
||||
|
||||
/// 标记 asset 下载失败。
|
||||
func failDownload(assetID: String, message: String) {
|
||||
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||
tasks[index].status = .failed
|
||||
tasks[index].errorMessage = message
|
||||
notify()
|
||||
}
|
||||
|
||||
/// 并发上传边拍边传新照片;拍后传输任务只能由用户显式上传。
|
||||
private func processUploadQueue() {
|
||||
guard uploadSink != nil else { return }
|
||||
|
||||
while activeUploadAssetIDs.count < maxConcurrentUploads,
|
||||
let task = tasks.first(where: {
|
||||
$0.status == .downloaded
|
||||
&& !activeUploadAssetIDs.contains($0.assetID)
|
||||
&& autoUploadEligibleAssetIDs.contains($0.assetID)
|
||||
}) {
|
||||
startConcurrentUpload(assetID: task.assetID)
|
||||
}
|
||||
}
|
||||
|
||||
private func startConcurrentUpload(assetID: String) {
|
||||
guard !activeUploadAssetIDs.contains(assetID) else { return }
|
||||
activeUploadAssetIDs.insert(assetID)
|
||||
Task { @MainActor in
|
||||
await self.uploadTask(assetID: assetID)
|
||||
self.activeUploadAssetIDs.remove(assetID)
|
||||
self.processUploadQueue()
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadAssetIfNeeded(assetID: String) async {
|
||||
if activeUploadAssetIDs.contains(assetID) { return }
|
||||
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||
|
||||
@ -47,32 +47,50 @@ WiredTransferHeaderView # 蓝色顶栏:相册名 + 手机号
|
||||
WiredTransferControlCard # 浮动白卡片:设备存储 / 连接状态 / 筛选 Chip / Tab
|
||||
WiredTransferSelectAllBar # 勾选模式全选栏(条件显示)
|
||||
WiredTransferPhotoListPanel # 时间侧栏 + 30 分钟分组列表
|
||||
WiredTransferBottomBar # 批量上传 + 指定上传
|
||||
WiredTransferBottomBar # 批量上传 + 指定上传 + 历史导入
|
||||
SpecifyUploadBottomSheet # 指定上传选项弹层
|
||||
CameraHistoryImportView # 相机 SD 卡历史导入全屏 Sheet
|
||||
SonyMTPHelpView # Sony MTP 模式帮助说明
|
||||
```
|
||||
|
||||
## 有线传图交互流程
|
||||
|
||||
1. **刷新**:顶部连接区「刷新」按钮 → `refreshCameraFiles()` → 刷新本地持久化记录
|
||||
2. **批量上传三态**:
|
||||
1. **USB 连接**:进入页面后 `OTGLiveTransferCoordinator` 驱动 `Core/CameraOTG` 的 `ConnectionManager` 搜索并连接 Sony / Canon / Nikon 相机;连接成功 Toast 提示,断开时弹窗警告
|
||||
2. **刷新**:catalog 就绪后点击「刷新」→ `refreshCameraFiles()` 同步相机内已有照片(拍后传输模式仅下载不上传);未连接时刷新本地持久化记录
|
||||
3. **历史导入**:底部「历史导入」在 `catalogReady && 已连接` 时可点,打开 `CameraHistoryImportView` 按日多选导入;Sony catalog 为空时展示 MTP 帮助入口
|
||||
4. **批量上传三态**:
|
||||
- 首次点击 → 进入勾选模式
|
||||
- 有选中项 → 上传选中照片
|
||||
- 无选中项 → 取消选择
|
||||
3. **指定上传**:底部 Sheet 选择「上传所有未上传的照片」或「上传所有今日拍摄的照片」
|
||||
5. **指定上传**:底部 Sheet 选择「上传所有未上传的照片」或「上传所有今日拍摄的照片」
|
||||
- 指定上传会同时处理当前管道任务和本地持久化记录;仅存在本地记录的照片会先按本地路径恢复到传输管道,再上传 OSS 并登记素材
|
||||
4. **时间侧栏**:按 30 分钟时间段分组,点击侧栏滚动到对应 section
|
||||
5. **格式 Chip**:当前固定展示 JPG,与「不修图」一样不可点击;RAW 等格式后续扩展
|
||||
6. **传输模式**:「边拍边传 / 拍后传输」按账号缓存,下次进入有线传图页自动恢复
|
||||
- 「边拍边传」只自动上传该模式下由相机新照片事件发现的照片
|
||||
- 「拍后传输」下载到 App 的旧照片切换模式后不会自动补传,需通过批量上传、指定上传、勾选上传或单张重试显式上传
|
||||
6. **时间侧栏**:按 30 分钟时间段分组,点击侧栏滚动到对应 section
|
||||
7. **格式 Chip**:当前固定展示 JPG,与「不修图」一样不可点击;RAW 等格式后续扩展
|
||||
8. **传输模式**:「边拍边传 / 拍后传输」按账号缓存,下次进入有线传图页自动恢复
|
||||
- 「边拍边传」:相机新拍照片经 OTG 落盘后自动进入上传队列(最多 3 张并发)
|
||||
- 「拍后传输」:下载到 App 的照片不自动上传,需通过批量上传、指定上传、勾选上传或单张重试显式上传
|
||||
9. **前后台**:App 回到前台且未连接时自动重新搜索相机;离开页面仅 detach UI,保持设备缓存(与 demo 一致)
|
||||
|
||||
## OTG 与上传管道
|
||||
|
||||
```
|
||||
WiredCameraTransferView → WiredCameraTransferViewModel
|
||||
→ OTGLiveTransferCoordinator → ConnectionManager (Core/CameraOTG)
|
||||
→ TravelAlbumOTGPhotoSink → WiredCameraTransferPipeline → OSS 上传
|
||||
CameraHistoryImportView → CameraHistoryImportViewModel → CameraDriver + OTGPhotoSink
|
||||
```
|
||||
|
||||
- OTG 引擎详见 `Core/CameraOTG/CameraOTG.md`
|
||||
- 边拍边传路径:`ConnectionManager` live shot → `OTGPhotoSink` → `pipeline.ingestDownloadedAsset(autoUpload:)` → `TravelAlbumMaterialUploader`
|
||||
- 历史导入路径:批量下载落盘,不自动上传,导入完成后刷新主列表
|
||||
|
||||
## 业务流程
|
||||
|
||||
1. 创建任务:`POST .../travel-album/create`
|
||||
2. 进入有线传图:有线传图 UI 页(USB/OTG 相机连接能力已移除,保留页面与本地照片上传)
|
||||
2. 进入有线传图:USB 连接相机,边拍边传或拍后传输 / 历史导入
|
||||
3. OSS 上传:复用 `OSSUploadService.uploadAlbumFile`
|
||||
4. 素材登记:`POST .../travel-album/upload-material`
|
||||
5. 本地照片路径以账号 + 相册 scoped 目录持久化(如 `CameraDownloads/<accountKey>/<albumID>/originals/xxx.JPG`),该功能未上线,不兼容旧版全局目录记录
|
||||
5. 本地照片路径以账号 + 相册 scoped 目录持久化(`CameraDownloads/<accountKey>/<albumID>/originals/xxx.JPG`)
|
||||
|
||||
## 有线传图性能策略
|
||||
|
||||
@ -85,16 +103,17 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
|
||||
|
||||
- `WiredCameraTransferPipeline` 只依赖 `CameraAssetUploadSink` 协议
|
||||
- `TravelAlbumMaterialUploader` 实现 OSS + 后端登记
|
||||
- `OTGLiveTransferCoordinator` 桥接 `Core/CameraOTG` 与旅拍上传管道,通过 `OTGPhotoSink` 注入存储
|
||||
- 与「相册管理」(`Features/Assets`)API、Store、路由完全隔离
|
||||
|
||||
## 依赖模块
|
||||
|
||||
- `Features/TravelAlbum/Services` — 本地存储、上传编排
|
||||
- `Core/CameraOTG` — USB 连接、品牌 Driver、边拍边传、PTP
|
||||
- `Features/TravelAlbum/Services` — 本地存储、上传编排、OTG 协调器
|
||||
- `Core/Upload` — OSS STS 上传
|
||||
|
||||
## 二期待补齐
|
||||
|
||||
- RAW 预览
|
||||
- USB/OTG 相机有线连接与边拍边传
|
||||
- 格式 Chip 扩展 RAW / JPEG+RAW,并与 `WiredCameraTransferPipeline` 联动
|
||||
- 设备存储不足时的业务侧限制(当前仅 UI 提示弹窗)
|
||||
|
||||
@ -0,0 +1,220 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// 按日分组的相机照片区块。
|
||||
struct CameraPhotoSection: Equatable, Identifiable {
|
||||
var id: String { dayKey }
|
||||
let dayKey: String
|
||||
let title: String
|
||||
let photos: [CameraObject]
|
||||
}
|
||||
|
||||
/// 相机历史导入页状态机。
|
||||
enum CameraImportState: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case loaded(sections: [CameraPhotoSection])
|
||||
case importing(current: Int, total: Int)
|
||||
case finished(importedCount: Int)
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
/// 相机 SD 卡历史照片多选导入 ViewModel。
|
||||
@MainActor
|
||||
final class CameraHistoryImportViewModel: ObservableObject {
|
||||
@Published private(set) var state: CameraImportState = .idle
|
||||
@Published private(set) var selectedPhotoIDs: Set<String> = []
|
||||
@Published private(set) var shouldShowSonyMTPHelp = false
|
||||
@Published private(set) var sections: [CameraPhotoSection] = []
|
||||
|
||||
let albumId: Int
|
||||
let accountKey: String
|
||||
private weak var driver: (any CameraDriver)?
|
||||
private let photoSink: OTGPhotoSink
|
||||
private let onImported: () -> Void
|
||||
|
||||
private var allPhotos: [CameraObject] = []
|
||||
private var existingAlbumPhotoIDs: Set<String> = []
|
||||
private var thumbnailCache: [String: Data] = [:]
|
||||
|
||||
/// 初始化历史导入 ViewModel。
|
||||
init(
|
||||
albumId: Int,
|
||||
accountKey: String,
|
||||
driver: any CameraDriver,
|
||||
photoSink: OTGPhotoSink,
|
||||
onImported: @escaping () -> Void
|
||||
) {
|
||||
self.albumId = albumId
|
||||
self.accountKey = accountKey
|
||||
self.driver = driver
|
||||
self.photoSink = photoSink
|
||||
self.onImported = onImported
|
||||
}
|
||||
|
||||
/// 通过 Driver 拉取相机内照片并按日分组。
|
||||
func loadPhotos() {
|
||||
guard let driver else {
|
||||
state = .failed(message: "相机未连接")
|
||||
return
|
||||
}
|
||||
|
||||
state = .loading
|
||||
|
||||
Task {
|
||||
do {
|
||||
existingAlbumPhotoIDs = await photoSink.existingCameraObjectIDs()
|
||||
let objects = try await driver.listObjects()
|
||||
allPhotos = objects
|
||||
let sections = Self.groupByDay(objects)
|
||||
if sections.isEmpty {
|
||||
self.sections = []
|
||||
state = .failed(message: "相机中没有可导入的照片")
|
||||
} else {
|
||||
self.sections = sections
|
||||
state = .loaded(sections: sections)
|
||||
}
|
||||
updateSonyMTPHelpVisibility()
|
||||
} catch {
|
||||
state = .failed(message: error.localizedDescription)
|
||||
updateSonyMTPHelpVisibility()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断照片是否已在当前相册。
|
||||
func isPhotoAlreadyInAlbum(id: String) -> Bool {
|
||||
existingAlbumPhotoIDs.contains(id)
|
||||
}
|
||||
|
||||
/// 切换单张照片选中状态。
|
||||
func togglePhoto(id: String) {
|
||||
guard !isPhotoAlreadyInAlbum(id: id) else { return }
|
||||
if selectedPhotoIDs.contains(id) {
|
||||
selectedPhotoIDs.remove(id)
|
||||
} else {
|
||||
selectedPhotoIDs.insert(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换分组全选。
|
||||
func toggleSection(_ section: CameraPhotoSection) {
|
||||
let importableIDs = Set(section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.map(\.id))
|
||||
guard !importableIDs.isEmpty else { return }
|
||||
if importableIDs.isSubset(of: selectedPhotoIDs) {
|
||||
selectedPhotoIDs.subtract(importableIDs)
|
||||
} else {
|
||||
selectedPhotoIDs.formUnion(importableIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func isPhotoSelected(id: String) -> Bool {
|
||||
selectedPhotoIDs.contains(id)
|
||||
}
|
||||
|
||||
func isSectionFullySelected(_ section: CameraPhotoSection) -> Bool {
|
||||
let ids = section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.map(\.id)
|
||||
return !ids.isEmpty && ids.allSatisfy { selectedPhotoIDs.contains($0) }
|
||||
}
|
||||
|
||||
func hasImportablePhotos(in section: CameraPhotoSection) -> Bool {
|
||||
section.photos.contains { !isPhotoAlreadyInAlbum(id: $0.id) }
|
||||
}
|
||||
|
||||
/// 加载相机缩略图。
|
||||
func thumbnailData(for photo: CameraObject, maxPixelSize: Int) async -> Data? {
|
||||
if let cached = thumbnailCache[photo.id] {
|
||||
return cached
|
||||
}
|
||||
guard let driver else { return nil }
|
||||
guard let data = await driver.requestThumbnailData(for: photo, maxPixelSize: maxPixelSize) else {
|
||||
return nil
|
||||
}
|
||||
thumbnailCache[photo.id] = data
|
||||
return data
|
||||
}
|
||||
|
||||
/// 顺序下载选中项并写入本地存储。
|
||||
func importSelected() {
|
||||
guard let driver else { return }
|
||||
let selected = allPhotos.filter {
|
||||
selectedPhotoIDs.contains($0.id) && !isPhotoAlreadyInAlbum(id: $0.id)
|
||||
}
|
||||
guard !selected.isEmpty else { return }
|
||||
|
||||
state = .importing(current: 0, total: selected.count)
|
||||
|
||||
Task {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("camera-history-import", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
var imported = 0
|
||||
|
||||
for (index, object) in selected.enumerated() {
|
||||
do {
|
||||
let downloadedURL = try await driver.downloadObject(object, to: tempDir)
|
||||
_ = try await photoSink.saveDownloadedFile(
|
||||
at: downloadedURL,
|
||||
filename: object.filename,
|
||||
capturedAt: object.capturedAt,
|
||||
cameraObjectID: object.id
|
||||
)
|
||||
imported += 1
|
||||
state = .importing(current: index + 1, total: selected.count)
|
||||
} catch {
|
||||
OTGLog.error(.connection, "import failed: \(object.filename) \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
selectedPhotoIDs.removeAll()
|
||||
state = .finished(importedCount: imported)
|
||||
onImported()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSonyMTPHelpVisibility() {
|
||||
guard driver?.platform == .sony else {
|
||||
shouldShowSonyMTPHelp = false
|
||||
return
|
||||
}
|
||||
switch state {
|
||||
case .loaded(let sections):
|
||||
shouldShowSonyMTPHelp = sections.isEmpty
|
||||
case .failed:
|
||||
shouldShowSonyMTPHelp = allPhotos.isEmpty
|
||||
default:
|
||||
shouldShowSonyMTPHelp = false
|
||||
}
|
||||
}
|
||||
|
||||
private static func groupByDay(_ photos: [CameraObject]) -> [CameraPhotoSection] {
|
||||
let calendar = Calendar.current
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "yyyy年M月d日"
|
||||
|
||||
let grouped = Dictionary(grouping: photos) { photo in
|
||||
calendar.startOfDay(for: photo.capturedAt)
|
||||
}
|
||||
|
||||
return grouped.keys
|
||||
.sorted(by: >)
|
||||
.map { day in
|
||||
let items = grouped[day]!.sorted { $0.capturedAt > $1.capturedAt }
|
||||
let dayKey = dayKeyString(for: day, calendar: calendar)
|
||||
let title = sectionTitle(for: day, formatter: formatter, calendar: calendar)
|
||||
return CameraPhotoSection(dayKey: dayKey, title: title, photos: items)
|
||||
}
|
||||
}
|
||||
|
||||
private static func dayKeyString(for day: Date, calendar: Calendar) -> String {
|
||||
let components = calendar.dateComponents([.year, .month, .day], from: day)
|
||||
return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0)
|
||||
}
|
||||
|
||||
private static func sectionTitle(for day: Date, formatter: DateFormatter, calendar: Calendar) -> String {
|
||||
if calendar.isDateInToday(day) { return "今天" }
|
||||
if calendar.isDateInYesterday(day) { return "昨天" }
|
||||
return formatter.string(from: day)
|
||||
}
|
||||
}
|
||||
@ -434,6 +434,9 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
@Published var showDeviceStorageAlert = false
|
||||
@Published var showCameraDisconnectedAlert = false
|
||||
@Published var showHelpDoc = false
|
||||
@Published var showHistoryImport = false
|
||||
@Published var showDisconnectCamera = false
|
||||
@Published private(set) var isContentCatalogReady = false
|
||||
@Published var isRefreshingCameraFiles = false
|
||||
@Published var isSearchingCamera = false
|
||||
@Published var deviceStorageInfo = DeviceStorageHelper.readStorageSnapshot()
|
||||
@ -442,6 +445,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
|
||||
let context: WiredTransferContext
|
||||
private let pipeline = WiredCameraTransferPipeline()
|
||||
private lazy var otgCoordinator = OTGLiveTransferCoordinator(pipeline: pipeline)
|
||||
private var photoStore: WiredTransferPhotoStore?
|
||||
private var userIDProvider: (() -> String)?
|
||||
private var downloadScope: CameraDownloadStorage.Scope?
|
||||
@ -502,7 +506,21 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload, downloadScope: currentDownloadScope)
|
||||
refreshDeviceStorageInfo()
|
||||
loadPersistedPhotos()
|
||||
connectionState = .disconnected
|
||||
|
||||
bindOTGCoordinator()
|
||||
otgCoordinator.start(
|
||||
albumId: context.albumId,
|
||||
accountKey: accountCachePrefix,
|
||||
autoUploadProvider: { [weak self] in
|
||||
self?.transferModeOption == Self.modeLiveCapture
|
||||
},
|
||||
existingObjectIDsProvider: { [weak self] in
|
||||
await self?.existingCameraObjectIDs() ?? []
|
||||
}
|
||||
)
|
||||
connectionState = otgCoordinator.connectionState
|
||||
isContentCatalogReady = otgCoordinator.isContentCatalogReady
|
||||
wasConnected = connectionState.isConnected
|
||||
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
@ -510,6 +528,37 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
detachFromUI()
|
||||
}
|
||||
|
||||
private func bindOTGCoordinator() {
|
||||
otgCoordinator.onConnectionStateChange = { [weak self] state in
|
||||
self?.handleConnectionStateChange(state)
|
||||
}
|
||||
otgCoordinator.onContentCatalogReady = { [weak self] in
|
||||
self?.isContentCatalogReady = true
|
||||
}
|
||||
otgCoordinator.onLiveShotSaved = { [weak self] (_: SavedOTGPhoto) in
|
||||
self?.refreshDeviceStorageInfo()
|
||||
}
|
||||
otgCoordinator.onError = { [weak self] message in
|
||||
self?.errorMessage = message
|
||||
}
|
||||
otgCoordinator.onSuggestReplug = { [weak self] in
|
||||
self?.errorMessage = "长时间未找到相机,请尝试重新插拔连接线"
|
||||
}
|
||||
}
|
||||
|
||||
private func existingCameraObjectIDs() async -> Set<String> {
|
||||
var ids = Set<String>()
|
||||
for record in persistedRecordsByID.values {
|
||||
if let localURL = CameraDownloadStorage.resolveLocalURL(from: record.localPath),
|
||||
let objectID = ICCameraItemScanner.cameraObjectID(localPath: localURL.path) {
|
||||
ids.insert(objectID)
|
||||
} else {
|
||||
ids.insert(record.id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/// 页面离开:仅 detach UI 回调,保持相机连接。
|
||||
func stop() async {
|
||||
detachFromUI()
|
||||
@ -525,6 +574,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
disconnectAlertTask?.cancel()
|
||||
showCameraDisconnectedAlert = false
|
||||
pipeline.detachFromUI()
|
||||
otgCoordinator.detach()
|
||||
}
|
||||
|
||||
/// 刷新设备存储信息。
|
||||
@ -582,20 +632,98 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 刷新相机文件列表(OTG 能力已移除,仅刷新本地持久化记录)。
|
||||
/// 刷新相机文件列表;catalog 就绪时同步相机内已有照片(拍后传输模式)。
|
||||
func refreshCameraFiles() async {
|
||||
guard !isRefreshingCameraFiles else { return }
|
||||
isRefreshingCameraFiles = true
|
||||
defer { isRefreshingCameraFiles = false }
|
||||
|
||||
if connectionState.isConnected, isContentCatalogReady, let driver = otgCoordinator.currentDriver {
|
||||
do {
|
||||
let objects = try await driver.listObjects()
|
||||
for object in objects {
|
||||
guard !deletedPhotoIDs.contains(object.id) else { continue }
|
||||
guard !persistedRecordsByID.keys.contains(object.id) else { continue }
|
||||
pipeline.markDownloading(assetID: object.id, filename: object.filename)
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("camera-sync", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let downloadedURL = try await driver.downloadObject(object, to: tempDir)
|
||||
if let scope = downloadScope,
|
||||
let sink = makeTransientPhotoSink(scope: scope) {
|
||||
let saved = try await sink.saveDownloadedFile(
|
||||
at: downloadedURL,
|
||||
filename: object.filename,
|
||||
capturedAt: object.capturedAt,
|
||||
cameraObjectID: object.id
|
||||
)
|
||||
pipeline.ingestDownloadedAsset(
|
||||
assetID: saved.assetID,
|
||||
filename: saved.filename,
|
||||
localPath: saved.relativeLocalPath,
|
||||
capturedAt: saved.capturedAt,
|
||||
autoUpload: false
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
loadPersistedPhotos()
|
||||
}
|
||||
|
||||
/// 重新搜索相机(OTG 能力已移除,保持未连接状态)。
|
||||
/// 重新搜索 USB 相机。
|
||||
func restartCameraSearch() async {
|
||||
guard !isSearchingCamera else { return }
|
||||
guard !connectionState.isConnected, !isSearchingCamera else { return }
|
||||
isSearchingCamera = true
|
||||
defer { isSearchingCamera = false }
|
||||
otgCoordinator.restartSearch()
|
||||
connectionState = otgCoordinator.connectionState
|
||||
}
|
||||
|
||||
/// 断开相机连接。
|
||||
func disconnectCamera() {
|
||||
otgCoordinator.disconnect()
|
||||
connectionState = .disconnected
|
||||
isContentCatalogReady = false
|
||||
wasConnected = false
|
||||
}
|
||||
|
||||
/// 打开历史导入页。
|
||||
func onHistoryImportClick() {
|
||||
guard connectionState.isConnected, isContentCatalogReady else {
|
||||
errorMessage = "请等待相机连接完成后再导入"
|
||||
return
|
||||
}
|
||||
showHistoryImport = true
|
||||
}
|
||||
|
||||
/// 创建历史导入 ViewModel。
|
||||
func makeHistoryImportViewModel(accountKey: String) -> CameraHistoryImportViewModel? {
|
||||
otgCoordinator.makeHistoryImportViewModel(
|
||||
albumId: context.albumId,
|
||||
accountKey: accountKey,
|
||||
onImported: { [weak self] in
|
||||
self?.loadPersistedPhotos()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func makeTransientPhotoSink(scope: CameraDownloadStorage.Scope) -> TravelAlbumOTGPhotoSink? {
|
||||
TravelAlbumOTGPhotoSink(
|
||||
albumId: context.albumId,
|
||||
accountKey: scope.accountKey,
|
||||
pipeline: pipeline,
|
||||
existingObjectIDsProvider: { [weak self] in
|
||||
await self?.existingCameraObjectIDs() ?? []
|
||||
},
|
||||
autoUploadProvider: { [weak self] in
|
||||
self?.transferModeOption == Self.modeLiveCapture
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// 批量上传按钮点击(三态:进入勾选 / 上传选中 / 取消选择)。
|
||||
|
||||
@ -0,0 +1,61 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 历史导入照片网格单元格。
|
||||
struct CameraHistoryImportPhotoCell: View {
|
||||
let photo: CameraObject
|
||||
let isImported: Bool
|
||||
let isSelected: Bool
|
||||
let thumbnailProvider: () async -> Data?
|
||||
let onToggle: () -> Void
|
||||
|
||||
@State private var thumbnail: UIImage?
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Group {
|
||||
if let thumbnail {
|
||||
Image(uiImage: thumbnail)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
Color.gray.opacity(0.15)
|
||||
.overlay {
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.clipped()
|
||||
|
||||
if isImported {
|
||||
Text("已导入")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.black.opacity(0.55))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.padding(6)
|
||||
} else {
|
||||
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(isSelected ? AppDesign.primary : .white)
|
||||
.shadow(radius: 2)
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard !isImported else { return }
|
||||
onToggle()
|
||||
}
|
||||
.task(id: photo.id) {
|
||||
guard thumbnail == nil else { return }
|
||||
if let data = await thumbnailProvider(), let image = UIImage(data: data) {
|
||||
thumbnail = image
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 历史导入分组标题栏。
|
||||
struct CameraHistoryImportSectionHeader: View {
|
||||
let title: String
|
||||
let isFullySelected: Bool
|
||||
let hasImportable: Bool
|
||||
let onToggleAll: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(WiredTransferDesign.text333)
|
||||
Spacer()
|
||||
if hasImportable {
|
||||
Button(isFullySelected ? "取消全选" : "全选") {
|
||||
onToggleAll()
|
||||
}
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(WiredTransferDesign.pageBackground)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,140 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 相机历史导入主页面。
|
||||
struct CameraHistoryImportView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var viewModel: CameraHistoryImportViewModel
|
||||
@State private var showSonyHelp = false
|
||||
|
||||
init(viewModel: CameraHistoryImportViewModel) {
|
||||
_viewModel = StateObject(wrappedValue: viewModel)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
content
|
||||
if case .importing = viewModel.state {
|
||||
importingOverlay
|
||||
}
|
||||
}
|
||||
.background(WiredTransferDesign.pageBackground.ignoresSafeArea())
|
||||
.navigationTitle("选择照片")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(importButtonTitle) {
|
||||
viewModel.importSelected()
|
||||
}
|
||||
.disabled(selectedCount == 0)
|
||||
}
|
||||
if viewModel.shouldShowSonyMTPHelp {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showSonyHelp = true
|
||||
} label: {
|
||||
Image(systemName: "questionmark.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { viewModel.loadPhotos() }
|
||||
.navigationDestination(isPresented: $showSonyHelp) {
|
||||
SonyMTPHelpView()
|
||||
}
|
||||
.onChange(of: viewModel.state) { state in
|
||||
if case .finished = state {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch viewModel.state {
|
||||
case .idle, .loading:
|
||||
ProgressView("正在加载相机照片…")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case .loaded, .importing:
|
||||
photoList(sections: viewModel.sections)
|
||||
case .finished:
|
||||
EmptyView()
|
||||
case .failed(let message):
|
||||
emptyView(message: message)
|
||||
}
|
||||
}
|
||||
|
||||
private func photoList(sections: [CameraPhotoSection]) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12, pinnedViews: [.sectionHeaders]) {
|
||||
ForEach(sections) { section in
|
||||
Section {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 2) {
|
||||
ForEach(section.photos, id: \.id) { photo in
|
||||
CameraHistoryImportPhotoCell(
|
||||
photo: photo,
|
||||
isImported: viewModel.isPhotoAlreadyInAlbum(id: photo.id),
|
||||
isSelected: viewModel.isPhotoSelected(id: photo.id),
|
||||
thumbnailProvider: { await viewModel.thumbnailData(for: photo, maxPixelSize: 160) },
|
||||
onToggle: { viewModel.togglePhoto(id: photo.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
CameraHistoryImportSectionHeader(
|
||||
title: section.title,
|
||||
isFullySelected: viewModel.isSectionFullySelected(section),
|
||||
hasImportable: viewModel.hasImportablePhotos(in: section),
|
||||
onToggleAll: { viewModel.toggleSection(section) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
}
|
||||
|
||||
private func emptyView(message: String) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Text(message)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(WiredTransferDesign.text666)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 24)
|
||||
if viewModel.shouldShowSonyMTPHelp {
|
||||
Button("如何切换到 MTP 模式") {
|
||||
showSonyHelp = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var importingOverlay: some View {
|
||||
Group {
|
||||
if case .importing(let current, let total) = viewModel.state {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text("正在导入 \(current)/\(total)")
|
||||
.font(.system(size: 15))
|
||||
}
|
||||
.padding(24)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedCount: Int {
|
||||
viewModel.selectedPhotoIDs.count
|
||||
}
|
||||
|
||||
private var importButtonTitle: String {
|
||||
"导入 (\(selectedCount))"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 索尼 MTP 模式帮助页。
|
||||
struct SonyMTPHelpView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(SonyMTPHelpContent.introduction)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(WiredTransferDesign.text333)
|
||||
|
||||
ForEach(SonyMTPHelpContent.sections) { section in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(section.title)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(WiredTransferDesign.text333)
|
||||
Text(section.body)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(WiredTransferDesign.text666)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.background(WiredTransferDesign.pageBackground.ignoresSafeArea())
|
||||
.navigationTitle(SonyMTPHelpContent.navigationTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("关闭") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -77,6 +77,7 @@ struct WiredCameraTransferView: View {
|
||||
WiredTransferBottomBar(
|
||||
selectUploadMode: viewModel.selectUploadMode,
|
||||
selectedCount: viewModel.selectedPhotoIDs.count,
|
||||
isCameraConnected: viewModel.isCameraConnected,
|
||||
onBatchUploadClick: {
|
||||
Task {
|
||||
await viewModel.onBatchUploadButtonClick(
|
||||
@ -86,7 +87,8 @@ struct WiredCameraTransferView: View {
|
||||
)
|
||||
}
|
||||
},
|
||||
onSpecifyUploadClick: viewModel.onSpecifyUploadButtonClick
|
||||
onSpecifyUploadClick: viewModel.onSpecifyUploadButtonClick,
|
||||
onHistoryImportClick: viewModel.onHistoryImportClick
|
||||
)
|
||||
}
|
||||
.background(WiredTransferDesign.pageBackground.ignoresSafeArea())
|
||||
@ -155,6 +157,13 @@ struct WiredCameraTransferView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $viewModel.showHistoryImport) {
|
||||
if let importVM = viewModel.makeHistoryImportViewModel(
|
||||
accountKey: accountContext.accountCachePrefix ?? "guest_"
|
||||
) {
|
||||
CameraHistoryImportView(viewModel: importVM)
|
||||
}
|
||||
}
|
||||
.sheet(item: previewBinding) { item in
|
||||
WiredTransferPhotoPreviewSheet(
|
||||
imageSources: item.sources,
|
||||
|
||||
@ -9,8 +9,10 @@ import SwiftUI
|
||||
struct WiredTransferBottomBar: View {
|
||||
let selectUploadMode: Bool
|
||||
let selectedCount: Int
|
||||
let isCameraConnected: Bool
|
||||
let onBatchUploadClick: () -> Void
|
||||
let onSpecifyUploadClick: () -> Void
|
||||
let onHistoryImportClick: () -> Void
|
||||
|
||||
private var batchButtonTitle: String {
|
||||
if !selectUploadMode { return "批量上传" }
|
||||
@ -33,6 +35,20 @@ struct WiredTransferBottomBar: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button(action: onHistoryImportClick) {
|
||||
Text("历史导入")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(isCameraConnected && !selectUploadMode ? AppDesign.primary : WiredTransferDesign.text999)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: WiredTransferDesign.bottomButtonHeight)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: WiredTransferDesign.bottomButtonCornerRadius)
|
||||
.stroke(AppDesign.primary, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(selectUploadMode || !isCameraConnected)
|
||||
|
||||
Button(action: onSpecifyUploadClick) {
|
||||
Text("指定上传")
|
||||
.font(.system(size: 15))
|
||||
|
||||
Reference in New Issue
Block a user