将 otg_swift OTG 引擎迁入 Core/CameraOTG,并接入旅拍有线传图与历史导入。
恢复 USB 相机连接、边拍边传、三品牌驱动与 SwiftUI 历史导入页,通过适配层对接现有本地上传与 OSS 管道。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user