Update OTG transfer workflow

This commit is contained in:
2026-07-10 11:02:00 +08:00
parent 9d446b4bc3
commit 98dc810455
11 changed files with 1047 additions and 121 deletions

View File

@ -15,8 +15,8 @@ final class CanonRemoteCaptureService {
private var albumID: Int?
private let photoRepository: PhotoRepositoryProtocol
/// ConnectionManager ViewModel
var onShotSaved: ((String) -> Void)?
/// ConnectionManager ViewModel
var onShotSaved: ((TravelAlbumOTGPhotoRecord) -> Void)?
private var eventPollTimer: Timer?
private var catalogPollTimer: Timer?
@ -175,14 +175,14 @@ final class CanonRemoteCaptureService {
lastDownloadedSignature = objectID
guard await saveToAlbum(data: data, filename: filename, reason: reason) else {
guard let record = 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?(filename)
onShotSaved?(record)
} catch {
knownCatalogIDs.remove(objectID)
OTGLog.error(.canon, "\(reason): catalog download failed \(filename): \(error.localizedDescription)")
@ -417,23 +417,23 @@ final class CanonRemoteCaptureService {
lastDownloadedSignature = signature
knownDownloadedHandles.insert(item.handle)
guard await saveToAlbum(data: result.data, filename: result.filename, reason: item.reason) else {
guard let record = 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?(result.filename)
onShotSaved?(record)
return true
}
return false
}
private func saveToAlbum(data: Data, filename: String, reason: String) async -> Bool {
private func saveToAlbum(data: Data, filename: String, reason: String) async -> TravelAlbumOTGPhotoRecord? {
guard let albumID else {
OTGLog.warning(.canon, "\(reason): live transfer album not configured, skip save")
return false
return nil
}
do {
@ -442,15 +442,14 @@ final class CanonRemoteCaptureService {
filename: filename,
albumID: albumID
)
try photoRepository.addPhoto(
return try photoRepository.addPhoto(
albumID: albumID,
localPath: fileURL.path,
createdAt: Date()
)
return true
} catch {
OTGLog.error(.canon, "\(reason): save failed: \(error.localizedDescription)")
return false
return nil
}
}
}

View File

@ -15,7 +15,8 @@ final class NikonCatalogLiveCaptureService {
private var albumID: Int?
private let photoRepository: PhotoRepositoryProtocol
var onShotSaved: ((String) -> Void)?
/// ConnectionManager ViewModel
var onShotSaved: ((TravelAlbumOTGPhotoRecord) -> Void)?
private var catalogPollTimer: Timer?
private var isDownloading = false
@ -140,14 +141,14 @@ final class NikonCatalogLiveCaptureService {
lastDownloadedSignature = objectID
guard await saveToAlbum(data: data, filename: filename, reason: reason) else {
guard let record = 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?(filename)
onShotSaved?(record)
} catch {
knownCatalogIDs.remove(objectID)
OTGLog.error(.nikon, "\(reason): catalog download failed \(filename): \(error.localizedDescription)")
@ -184,10 +185,10 @@ final class NikonCatalogLiveCaptureService {
// MARK: - Save
private func saveToAlbum(data: Data, filename: String, reason: String) async -> Bool {
private func saveToAlbum(data: Data, filename: String, reason: String) async -> TravelAlbumOTGPhotoRecord? {
guard let albumID else {
OTGLog.warning(.nikon, "\(reason): live transfer album not configured, skip save")
return false
return nil
}
do {
@ -196,15 +197,14 @@ final class NikonCatalogLiveCaptureService {
filename: filename,
albumID: albumID
)
try photoRepository.addPhoto(
return try photoRepository.addPhoto(
albumID: albumID,
localPath: fileURL.path,
createdAt: Date()
)
return true
} catch {
OTGLog.error(.nikon, "\(reason): save failed: \(error.localizedDescription)")
return false
return nil
}
}
}

View File

@ -15,8 +15,8 @@ final class SonyRemoteCaptureService {
private var albumID: Int?
private let photoRepository: PhotoRepositoryProtocol
/// ConnectionManager ViewModel
var onShotSaved: ((String) -> Void)?
/// ConnectionManager ViewModel
var onShotSaved: ((TravelAlbumOTGPhotoRecord) -> Void)?
private var pollTimer: Timer?
private var propertyCheckTask: Task<Void, Never>?
@ -298,7 +298,7 @@ final class SonyRemoteCaptureService {
filename: downloaded.filename,
albumID: albumID
)
try photoRepository.addPhoto(
let record = try photoRepository.addPhoto(
albumID: albumID,
localPath: fileURL.path,
createdAt: Date()
@ -307,7 +307,7 @@ final class SonyRemoteCaptureService {
.sony,
"Live shot saved (#\(savedShotCount + 1)): \(downloaded.filename), bytes=\(downloaded.data.count), path=\(fileURL.lastPathComponent)"
)
onShotSaved?(downloaded.filename)
onShotSaved?(record)
return true
} catch {
OTGLog.error(.sony, "\(reason): save failed: \(error.localizedDescription)")

View File

@ -15,14 +15,14 @@ protocol ConnectionManagerDelegate: AnyObject {
func connectionManager(_ manager: ConnectionManager, didFail error: Error)
func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: CameraDriver)
func connectionManagerDidSuggestReplug(_ manager: ConnectionManager)
///
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot filename: String, albumID: Int)
///
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot record: TravelAlbumOTGPhotoRecord)
}
extension ConnectionManagerDelegate {
func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: CameraDriver) {}
func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) {}
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot filename: String, albumID: Int) {}
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot record: TravelAlbumOTGPhotoRecord) {}
}
/// 线便 ViewModel
@ -553,14 +553,14 @@ private extension ConnectionManager {
nikonLiveCapture = nil
}
private func makeLiveShotSavedHandler() -> (String) -> Void {
{ [weak self] filename in
private func makeLiveShotSavedHandler() -> (TravelAlbumOTGPhotoRecord) -> Void {
{ [weak self] record in
guard let self, let albumID = self.liveTransferAlbumID else { return }
guard record.albumId == albumID else { return }
self.notify {
self.delegate?.connectionManager(
self,
didSaveLiveShot: filename,
albumID: albumID
didSaveLiveShot: record
)
}
}

View File

@ -67,6 +67,44 @@ enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable {
}
}
/// OTG App OSS
enum TravelAlbumOTGTransferMode: String, CaseIterable, Sendable {
case liveUpload
case postTransfer
///
var title: String {
switch self {
case .liveUpload: return "边拍边传"
case .postTransfer: return "拍后传输"
}
}
/// App
var shouldAutoUploadNewImports: Bool {
self == .liveUpload
}
///
static func option(title: String) -> TravelAlbumOTGTransferMode? {
allCases.first { $0.title == title }
}
}
/// OTG
enum TravelAlbumOTGSpecifyUploadOption: String, CaseIterable, Sendable {
case allPending
case todayCaptured
///
var title: String {
switch self {
case .allPending: return "上传所有未上传的照片"
case .todayCaptured: return "上传所有今日拍摄的照片"
}
}
}
extension TravelAlbumOTGPhotoItem {
/// Android `yyyy-MM-dd HH:mm:ss`
func capturedDate() -> Date? {

View File

@ -89,7 +89,8 @@ protocol PhotoRepositoryProtocol {
func fetchPhotos(albumID: Int) throws -> [TravelAlbumOTGPhotoRecord]
///
func addPhoto(albumID: Int, localPath: String, createdAt: Date) throws
@discardableResult
func addPhoto(albumID: Int, localPath: String, createdAt: Date) throws -> TravelAlbumOTGPhotoRecord
///
func deletePhoto(photoID: String, albumID: Int) throws
@ -110,12 +111,15 @@ final class TravelAlbumOTGPhotoRepository: PhotoRepositoryProtocol {
}
///
func addPhoto(albumID: Int, localPath: String, createdAt: Date) throws {
@discardableResult
func addPhoto(albumID: Int, localPath: String, createdAt: Date) throws -> TravelAlbumOTGPhotoRecord {
let fileURL = URL(fileURLWithPath: localPath)
let attributes = try? FileManager.default.attributesOfItem(atPath: localPath)
let size = (attributes?[.size] as? NSNumber)?.int64Value ?? 0
let userId = storage.context.userId
guard !userId.isEmpty else { return }
guard !userId.isEmpty else {
throw CameraError.connectionFailed("用户信息缺失,无法保存照片")
}
let record = TravelAlbumOTGPhotoRecord(
id: TravelAlbumOTGPhotoStore.photoId(for: fileURL.lastPathComponent, createdAt: createdAt),
@ -128,6 +132,7 @@ final class TravelAlbumOTGPhotoRepository: PhotoRepositoryProtocol {
userId: userId
)
storage.upsert(record, albumId: albumID)
return record
}
///

View File

@ -18,7 +18,7 @@ enum TravelAlbumCameraImportState: Equatable {
case loading
case loaded(sections: [TravelAlbumCameraImportSection])
case importing(current: Int, total: Int)
case finished(importedCount: Int, failedCount: Int)
case finished(importedCount: Int, failedCount: Int, importedPhotoIds: [String])
case failed(message: String)
}
@ -180,6 +180,7 @@ final class TravelAlbumCameraImportViewModel {
Task {
let directory = try? storage.originalsDirectory(albumId: albumId)
var importedCount = 0
var importedPhotoIds: [String] = []
for (index, object) in selected.enumerated() {
defer { state = .importing(current: index + 1, total: selected.count) }
@ -204,6 +205,7 @@ final class TravelAlbumCameraImportViewModel {
)
storage.upsert(record, albumId: albumId)
existingPhotoIds.insert(object.id)
importedPhotoIds.append(record.id)
importedCount += 1
} catch {
failedPhotoIds.insert(object.id)
@ -212,7 +214,11 @@ final class TravelAlbumCameraImportViewModel {
}
selectedPhotoIds.subtract(existingPhotoIds)
state = .finished(importedCount: importedCount, failedCount: failedPhotoIds.count)
state = .finished(
importedCount: importedCount,
failedCount: failedPhotoIds.count,
importedPhotoIds: importedPhotoIds
)
}
}

View File

@ -3,11 +3,28 @@
// suixinkan
//
import Darwin
import Foundation
/// OTG
struct TravelAlbumPhoneAlbumImportItem: Sendable, Equatable {
let data: Data
let fileName: String
let createdAt: Date
///
init(data: Data, fileName: String, createdAt: Date = Date()) {
self.data = data
self.fileName = fileName
self.createdAt = createdAt
}
}
/// 线 ViewModel ImageCaptureCore
@MainActor
final class WiredCameraTransferViewModel {
private static let transferModeDefaultsKey = "travelAlbum.otg.transferMode"
let albumId: Int
let albumTitle: String
let headerPhone: String
@ -38,14 +55,19 @@ final class WiredCameraTransferViewModel {
didSet { notifyStateChanged() }
}
private(set) var selectedTimeSlotId: String? {
didSet { notifyStateChanged() }
didSet {
guard !suppressSelectedTimeSlotNotification else { return }
notifyStateChanged()
}
}
private(set) var sonyMTPHint: String?
private(set) var isContentCatalogReady = false
let retouchOption = "不修图"
private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg
private(set) var transferModeOption = "边拍边传"
private(set) var transferMode: TravelAlbumOTGTransferMode = .liveUpload {
didSet { notifyStateChanged() }
}
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
@ -56,10 +78,13 @@ final class WiredCameraTransferViewModel {
private let repository: TravelAlbumOTGPhotoRepository
private let uploader: any TravelAlbumOTGUploading
private let appStore: AppStore
private let userDefaults: UserDefaults
private var currentDriver: CameraDriver?
private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:]
private var isUploading = false
private var queuedAutoUploadPhotoIds: Set<String> = []
private var suppressSelectedTimeSlotNotification = false
@MainActor
init(
@ -70,7 +95,8 @@ final class WiredCameraTransferViewModel {
connectionManager: (any WiredCameraConnectionManaging)? = nil,
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
uploader: (any TravelAlbumOTGUploading)? = nil,
appStore: AppStore = .shared
appStore: AppStore = .shared,
userDefaults: UserDefaults = .standard
) {
self.albumId = albumId
self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle
@ -81,6 +107,8 @@ final class WiredCameraTransferViewModel {
self.repository = TravelAlbumOTGPhotoRepository(storage: storage)
self.uploader = uploader ?? TravelAlbumOTGUploader()
self.appStore = appStore
self.userDefaults = userDefaults
self.transferMode = Self.persistedTransferMode(in: userDefaults)
}
///
@ -92,8 +120,8 @@ final class WiredCameraTransferViewModel {
return "正在搜索"
case .connecting:
return "正在连接"
case .connected:
return "已连接"
case .connected(_, let name):
return "\(name)已连接"
case .failed:
return "连接失败"
}
@ -111,19 +139,22 @@ final class WiredCameraTransferViewModel {
}
}
///
///
var deviceModelName: String {
switch connectionState {
case .connecting(let name), .connected(_, let name):
return name
default:
return "设备存储"
}
Self.currentPhoneModelName()
}
///
///
var availableStorageText: String {
"-- GB 可用"
Self.availableStorageText()
}
///
var isCameraConnected: Bool {
if case .connected = connectionState {
return true
}
return false
}
/// Tab
@ -173,6 +204,21 @@ final class WiredCameraTransferViewModel {
selectedPhotoIds.intersection(selectableVisiblePhotoIds).count
}
///
var transferModeOption: String {
transferMode.title
}
///
var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] {
TravelAlbumOTGSpecifyUploadOption.allCases
}
///
var canOpenSpecifyUploadChooser: Bool {
!selectUploadMode
}
///
func start() {
connectionManager.configureLiveTransfer(albumID: albumId)
@ -227,10 +273,25 @@ final class WiredCameraTransferViewModel {
selectedTimeSlotId = id
}
///
func syncVisibleTimeSlot(id: String) {
guard selectedTimeSlotId != id,
photoSections.contains(where: { $0.slotId == id }) else {
return
}
suppressSelectedTimeSlotNotification = true
selectedTimeSlotId = id
suppressSelectedTimeSlotNotification = false
}
///
func selectTransferMode(_ option: String) {
guard option == "边拍边传" || option == "拍后传输" else { return }
transferModeOption = option
guard let mode = TravelAlbumOTGTransferMode.option(title: option),
mode != transferMode else {
return
}
transferMode = mode
userDefaults.set(mode.rawValue, forKey: Self.transferModeDefaultsKey)
}
///
@ -254,10 +315,15 @@ final class WiredCameraTransferViewModel {
uploadSelectedTransferPhotos()
}
///
func onSpecifyUploadButtonClick() {
let ids = Set(photos.filter(\.isNotUploaded).map(\.id))
startUploadByIds(ids, emptyMessage: "暂无未上传的照片")
///
func onSpecifyUploadOptionSelected(_ option: TravelAlbumOTGSpecifyUploadOption) {
guard canOpenSpecifyUploadChooser else { return }
switch option {
case .allPending:
uploadAllNotUploadedPhotos()
case .todayCaptured:
uploadAllTodayCapturedPhotos()
}
}
/// ViewModel
@ -288,6 +354,49 @@ final class WiredCameraTransferViewModel {
connectionManager.suspendLiveTransfer()
}
/// App
func handleNewlyImportedPhotoIds(_ photoIds: [String]) {
let ids = Set(photoIds)
loadPersistedPhotos()
guard !ids.isEmpty, transferMode.shouldAutoUploadNewImports else { return }
startUploadByIds(
ids,
emptyMessage: "暂无可自动上传的照片",
queueIfBusy: true
)
}
/// OTG
func importPhoneAlbumImages(_ items: [TravelAlbumPhoneAlbumImportItem]) -> (importedCount: Int, failedCount: Int, importedPhotoIds: [String]) {
guard !items.isEmpty else { return (0, 0, []) }
var importedPhotoIds: [String] = []
var failedCount = 0
for item in items {
do {
let fileURL = try storage.writeImage(item.data, filename: item.fileName, albumId: albumId)
let record = TravelAlbumOTGPhotoRecord(
id: "phone_album_\(UUID().uuidString)",
fileName: fileURL.lastPathComponent,
localPath: storage.relativePath(for: fileURL, albumId: albumId),
capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(item.createdAt),
fileSizeBytes: Int64(item.data.count),
status: .pending,
albumId: albumId,
userId: appStore.userId
)
storage.upsert(record, albumId: albumId)
importedPhotoIds.append(record.id)
} catch {
failedCount += 1
OTGLog.error(.connection, "phone album import failed: \(item.fileName) \(error.localizedDescription)")
}
}
handleNewlyImportedPhotoIds(importedPhotoIds)
return (importedPhotoIds.count, failedCount, importedPhotoIds)
}
///
func toggleTransferPhotoSelection(photoId: String) {
guard selectUploadMode,
@ -325,6 +434,22 @@ final class WiredCameraTransferViewModel {
startUploadByIds(ids, emptyMessage: "所选照片不可上传")
}
private func uploadAllNotUploadedPhotos() {
let ids = Set(photos.filter(\.isNotUploaded).map(\.id))
startUploadByIds(ids, emptyMessage: "暂无未上传的照片")
}
private func uploadAllTodayCapturedPhotos() {
let ids = Set(photos.filter { photo in
guard photo.isNotUploaded,
let capturedDate = photo.capturedDate() else {
return false
}
return Calendar.current.isDateInToday(capturedDate)
}.map(\.id))
startUploadByIds(ids, emptyMessage: "暂无今日拍摄且未上传的照片")
}
///
func retryPhoto(photoId: String) {
startUploadByIds([photoId], emptyMessage: "本地文件不存在,无法重传")
@ -375,9 +500,17 @@ final class WiredCameraTransferViewModel {
}
}
private func startUploadByIds(_ ids: Set<String>, emptyMessage: String) {
private func startUploadByIds(
_ ids: Set<String>,
emptyMessage: String,
queueIfBusy: Bool = false
) {
guard !isUploading else {
showMessage("正在上传,请稍候")
if queueIfBusy {
queuedAutoUploadPhotoIds.formUnion(ids)
} else {
showMessage("正在上传,请稍候")
}
return
}
let targets = resolveUploadTargets(ids)
@ -386,18 +519,32 @@ final class WiredCameraTransferViewModel {
return
}
isUploading = true
showMessage("开始上传 \(targets.count) 张照片")
Task {
for id in targets {
await uploadPhoto(id: id)
}
isUploading = false
notifyStateChanged()
uploadQueuedAutoImportsIfNeeded()
}
}
private func uploadQueuedAutoImportsIfNeeded() {
let ids = queuedAutoUploadPhotoIds
queuedAutoUploadPhotoIds = []
guard !ids.isEmpty else { return }
startUploadByIds(
ids,
emptyMessage: "暂无可自动上传的照片",
queueIfBusy: true
)
}
private func resolveUploadTargets(_ ids: Set<String>) -> [String] {
ids.filter { persistedRecordsById[$0] != nil }
ids.filter { id in
guard let record = persistedRecordsById[id] else { return false }
return record.status == .pending || record.status == .failed
}
}
private func uploadPhoto(id: String) async {
@ -474,6 +621,88 @@ final class WiredCameraTransferViewModel {
}
}
private static func persistedTransferMode(in userDefaults: UserDefaults) -> TravelAlbumOTGTransferMode {
guard let rawValue = userDefaults.string(forKey: transferModeDefaultsKey),
let mode = TravelAlbumOTGTransferMode(rawValue: rawValue) else {
return .liveUpload
}
return mode
}
private static func currentPhoneModelName() -> String {
let identifier = machineIdentifier()
if identifier == "i386" || identifier == "x86_64" || identifier == "arm64" {
return "iPhone 模拟器"
}
if let modelName = iPhoneModelNames[identifier] {
return modelName
}
return identifier.hasPrefix("iPhone") ? identifier : "iPhone"
}
private static func machineIdentifier() -> String {
var systemInfo = utsname()
uname(&systemInfo)
return withUnsafePointer(to: &systemInfo.machine) { pointer in
pointer.withMemoryRebound(to: CChar.self, capacity: 1) {
String(validatingUTF8: $0) ?? "iPhone"
}
}
}
private static func availableStorageText() -> String {
guard let bytes = availableStorageBytes() else {
return "-- GB 可用"
}
let gigabytes = Double(bytes) / 1_073_741_824.0
if gigabytes >= 100 {
return "\(Int(gigabytes.rounded(.down))) GB 可用"
}
return String(format: "%.1f GB 可用", gigabytes)
}
private static func availableStorageBytes() -> Int64? {
let homeURL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
if let values = try? homeURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]),
let capacity = values.volumeAvailableCapacityForImportantUsage {
return capacity
}
if let attributes = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()),
let freeSize = attributes[.systemFreeSize] as? NSNumber {
return freeSize.int64Value
}
return nil
}
private static let iPhoneModelNames: [String: String] = [
"iPhone12,1": "iPhone 11",
"iPhone12,3": "iPhone 11 Pro",
"iPhone12,5": "iPhone 11 Pro Max",
"iPhone12,8": "iPhone SE 2",
"iPhone13,1": "iPhone 12 mini",
"iPhone13,2": "iPhone 12",
"iPhone13,3": "iPhone 12 Pro",
"iPhone13,4": "iPhone 12 Pro Max",
"iPhone14,2": "iPhone 13 Pro",
"iPhone14,3": "iPhone 13 Pro Max",
"iPhone14,4": "iPhone 13 mini",
"iPhone14,5": "iPhone 13",
"iPhone14,6": "iPhone SE 3",
"iPhone14,7": "iPhone 14",
"iPhone14,8": "iPhone 14 Plus",
"iPhone15,2": "iPhone 14 Pro",
"iPhone15,3": "iPhone 14 Pro Max",
"iPhone15,4": "iPhone 15",
"iPhone15,5": "iPhone 15 Plus",
"iPhone16,1": "iPhone 15 Pro",
"iPhone16,2": "iPhone 15 Pro Max",
"iPhone17,1": "iPhone 16 Pro",
"iPhone17,2": "iPhone 16 Pro Max",
"iPhone17,3": "iPhone 16",
"iPhone17,4": "iPhone 16 Plus",
"iPhone17,5": "iPhone 16e",
]
private func showMessage(_ message: String) {
if Thread.isMainThread {
onShowMessage?(message)
@ -507,9 +736,9 @@ extension WiredCameraTransferViewModel: ConnectionManagerDelegate {
showMessage("长时间未找到相机,请尝试重新插拔连接线")
}
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot filename: String, albumID: Int) {
guard albumID == albumId else { return }
loadPersistedPhotos()
onLiveShotSaved?(filename)
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot record: TravelAlbumOTGPhotoRecord) {
guard record.albumId == albumId else { return }
handleNewlyImportedPhotoIds([record.id])
onLiveShotSaved?(record.fileName)
}
}