将 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,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)
}
}

View File

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