将 otg_swift OTG 引擎迁入 Core/CameraOTG,并接入旅拍有线传图与历史导入。

恢复 USB 相机连接、边拍边传、三品牌驱动与 SwiftUI 历史导入页,通过适配层对接现有本地上传与 OSS 管道。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 11:22:07 +08:00
parent d7fec35715
commit be9c844bd3
50 changed files with 5721 additions and 20 deletions

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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
)
}
}

View File

@ -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 }