fix #5 camera video preview with settling and diagnostics
- Wait until the picker movie's file size stops changing before staging it, so a movie whose final bytes are still being flushed is never copied truncated (which produced drafts without a thumbnail and unplayable previews). - Log the whole camera video path (pickup size, staging result, analyze outcome, thumbnail decode) under the video-preview os_log category for fast on-device diagnosis. - Cover the video analysis pipeline with a simulator test that generates an HEVC QuickTime movie and asserts duration plus JPEG thumbnail, and guard the diagnostics in Scripts/verify.sh.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import os
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
actor MediaPreviewProcessor {
|
||||
@@ -44,6 +45,12 @@ actor MediaPreviewProcessor {
|
||||
}
|
||||
|
||||
let waveform = kind == .voice ? voiceWaveform(for: url) : nil
|
||||
if kind == .video {
|
||||
let logger = Logger(subsystem: "Luma", category: "video-preview")
|
||||
logger.info(
|
||||
"analyze video: \(url.lastPathComponent) duration=\(String(describing: duration)) thumbnailBytes=\(thumbnailData?.count ?? 0)"
|
||||
)
|
||||
}
|
||||
return Analysis(
|
||||
duration: duration,
|
||||
thumbnailData: thumbnailData,
|
||||
@@ -94,6 +101,8 @@ actor MediaPreviewProcessor {
|
||||
return data
|
||||
}
|
||||
} catch {
|
||||
Logger(subsystem: "Luma", category: "video-preview")
|
||||
.error("image generator failed at \(second)s: \(error.localizedDescription)")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import os
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
@@ -239,7 +240,13 @@ struct AttachmentPreviewView: View {
|
||||
thumbnailImages = thumbnailImages.filter { retainedIDs.contains($0.key) }
|
||||
for draft in drafts where thumbnailImages[draft.id] == nil {
|
||||
guard let data = draft.thumbnailData,
|
||||
let image = DraftThumbnailImage(data: data) else { continue }
|
||||
let image = DraftThumbnailImage(data: data) else {
|
||||
if draft.kind == .video {
|
||||
Logger(subsystem: "Luma", category: "video-preview")
|
||||
.warning("video draft has no decodable thumbnail: \(draft.filename) thumbnailBytes=\(draft.thumbnailData?.count ?? 0)")
|
||||
}
|
||||
continue
|
||||
}
|
||||
thumbnailImages[draft.id] = image
|
||||
await Task.yield()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import CoreTransferable
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import os
|
||||
import PhotosUI
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
@@ -1263,12 +1264,44 @@ struct ChatView: View {
|
||||
photoCameraIsPreparingResult = false
|
||||
releaseArchiveSyncAfterCapture()
|
||||
}
|
||||
let logger = Logger(subsystem: "Luma", category: "video-preview")
|
||||
await waitForCameraFileSettling(at: media.url, logger: logger)
|
||||
let staged = await stageImportedFiles([media.url], preferredKind: media.kind)
|
||||
if !staged, model.errorMessage == nil {
|
||||
model.errorMessage = "Камера вернула файл, который не удалось подготовить к отправке."
|
||||
if staged {
|
||||
if let draft = attachmentDrafts.last {
|
||||
logger.info(
|
||||
"camera draft staged: kind=\(draft.kind.rawValue) mime=\(draft.mimeType) size=\(draft.byteCount) duration=\(String(describing: draft.duration)) thumbnailBytes=\(draft.thumbnailData?.count ?? 0)"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
logger.error("camera staging produced no drafts")
|
||||
if model.errorMessage == nil {
|
||||
model.errorMessage = "Камера вернула файл, который не удалось подготовить к отправке."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The picker can hand over a movie whose final bytes are still being
|
||||
/// flushed. Wait until the file size stops changing so staging never
|
||||
/// copies a truncated file.
|
||||
private func waitForCameraFileSettling(at url: URL, logger: Logger) async {
|
||||
guard let initial = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize else {
|
||||
return
|
||||
}
|
||||
var previous = initial
|
||||
for _ in 0..<10 {
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
guard let current = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize else {
|
||||
return
|
||||
}
|
||||
if current == previous {
|
||||
return
|
||||
}
|
||||
previous = current
|
||||
}
|
||||
logger.warning("camera file size kept changing; staging anyway")
|
||||
}
|
||||
|
||||
private func presentCamera() {
|
||||
#if os(iOS)
|
||||
guard SystemPhotoCameraView.isAvailable else {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
struct CapturedCameraMedia: Sendable {
|
||||
let url: URL
|
||||
@@ -121,7 +122,10 @@ struct SystemPhotoCameraView: UIViewControllerRepresentable {
|
||||
/// races that cleanup and fails with an unreadable source. The move
|
||||
/// is a same-volume rename and does not block the main thread.
|
||||
private func prepareMovie(from sourceURL: URL) {
|
||||
let logger = Logger(subsystem: "Luma", category: "video-preview")
|
||||
do {
|
||||
let sourceSize = (try? sourceURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? -1
|
||||
logger.info("picker movie ready: source=\(sourceURL.lastPathComponent) size=\(sourceSize)")
|
||||
let fileExtension = sourceURL.pathExtension.isEmpty
|
||||
? "mov"
|
||||
: sourceURL.pathExtension
|
||||
@@ -130,13 +134,15 @@ struct SystemPhotoCameraView: UIViewControllerRepresentable {
|
||||
let values = try destination.resourceValues(
|
||||
forKeys: [.fileSizeKey, .isRegularFileKey]
|
||||
)
|
||||
guard values.isRegularFile == true,
|
||||
(values.fileSize ?? 0) > 0 else {
|
||||
let movedSize = values.fileSize ?? 0
|
||||
logger.info("picker movie moved: size=\(movedSize) regular=\(values.isRegularFile == true)")
|
||||
guard values.isRegularFile == true, movedSize > 0 else {
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
throw SystemCameraError.emptyMovie
|
||||
}
|
||||
onMedia(.success(CapturedCameraMedia(url: destination, kind: .video)))
|
||||
} catch {
|
||||
logger.error("picker movie preparation failed: \(error.localizedDescription)")
|
||||
onMedia(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user