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:
@@ -58,6 +58,7 @@
|
||||
2F44B4FDA9AF67EB24776A16 /* WatchViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = C35158C37C26CA9ACB6C9FFB /* WatchViews.swift */; };
|
||||
30BE19F459FDDAD640F66C68 /* MessageReplyFallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DDD7A997EF0AF7DA9C3478B /* MessageReplyFallback.swift */; };
|
||||
3121293D13481C39489CBC8F /* AttachmentDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = C04D749B7CA93CB7C59127D6 /* AttachmentDraft.swift */; };
|
||||
312B8D266C5BEC4F7B2F3276 /* MediaPreviewProcessorVideoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E29E7B704EE71C5AC70008E /* MediaPreviewProcessorVideoTests.swift */; };
|
||||
3245372B071227092B8594C0 /* AccountPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235CA66C5DFB8B6F9472D0C3 /* AccountPreferences.swift */; };
|
||||
32528D1AA72FB1B1A72389E9 /* MartinOMEMO in Frameworks */ = {isa = PBXBuildFile; productRef = 7A670E49149C9C7E13F0A6A2 /* MartinOMEMO */; };
|
||||
32790D95A577DCE99937B539 /* AccountPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235CA66C5DFB8B6F9472D0C3 /* AccountPreferences.swift */; };
|
||||
@@ -349,6 +350,7 @@
|
||||
83A5787450609B0C14093679 /* WatchSessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionModel.swift; sourceTree = "<group>"; };
|
||||
876CDF98063A4C948B00C7CE /* DeviceResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceResource.swift; sourceTree = "<group>"; };
|
||||
8D2294F0FE44B2268A0620BB /* PhotoAttachmentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoAttachmentPreview.swift; sourceTree = "<group>"; };
|
||||
8E29E7B704EE71C5AC70008E /* MediaPreviewProcessorVideoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewProcessorVideoTests.swift; sourceTree = "<group>"; };
|
||||
8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaConnectionStatsModule.swift; sourceTree = "<group>"; };
|
||||
96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycle.swift; sourceTree = "<group>"; };
|
||||
99BE34DC082D19007241AA62 /* NewGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGroupView.swift; sourceTree = "<group>"; };
|
||||
@@ -558,6 +560,7 @@
|
||||
772A4F24CAF527E328657749 /* MediaFileIOTests.swift */,
|
||||
A127BF01F5C488CD3E053379 /* MediaMetadataTests.swift */,
|
||||
B38702A403DA3E943525FB62 /* MediaPickerSelectionPolicyTests.swift */,
|
||||
8E29E7B704EE71C5AC70008E /* MediaPreviewProcessorVideoTests.swift */,
|
||||
247FBE680C61D12AFB583862 /* MediaSendActivityTrackerTests.swift */,
|
||||
76FEB73D4B67CB98C93F6244 /* MediaTimeFormatterTests.swift */,
|
||||
6DCB924748780145A707D890 /* MediaViewerDismissGestureTests.swift */,
|
||||
@@ -1081,6 +1084,7 @@
|
||||
DC4FAE63AB8AF89C30512763 /* MediaFileIOTests.swift in Sources */,
|
||||
10C5382981A87A2963DD397C /* MediaMetadataTests.swift in Sources */,
|
||||
958FD3CBEC600651E1171B55 /* MediaPickerSelectionPolicyTests.swift in Sources */,
|
||||
312B8D266C5BEC4F7B2F3276 /* MediaPreviewProcessorVideoTests.swift in Sources */,
|
||||
4B2CFA4008D1234DCE35ADDE /* MediaSendActivityTrackerTests.swift in Sources */,
|
||||
F27ACACEF60AFAB5AF96EF98 /* MediaTimeFormatterTests.swift in Sources */,
|
||||
AC242F69C28F69A001D6559D /* MediaViewerDismissGestureTests.swift in Sources */,
|
||||
|
||||
@@ -83,6 +83,7 @@ required=(
|
||||
Tests/ArchiveStoreTests.swift
|
||||
Tests/SASLprepTests.swift
|
||||
Tests/SaslFailureMessageTests.swift
|
||||
Tests/MediaPreviewProcessorVideoTests.swift
|
||||
Sources/Shared/XMPP/SASLprep.swift
|
||||
Sources/Shared/XMPP/LumaSaslFailureModule.swift
|
||||
Sources/Shared/XMPP/SaslFailureMessage.swift
|
||||
@@ -348,6 +349,10 @@ grep -q 'moveItem(at: sourceURL' Sources/Shared/UI/SystemPhotoCameraView.swift |
|
||||
echo "The picker movie must be claimed synchronously before dismissal cleanup"
|
||||
exit 1
|
||||
}
|
||||
grep -q 'video-preview' Sources/Shared/UI/ChatView.swift || {
|
||||
echo "The camera video path must log diagnostics"
|
||||
exit 1
|
||||
}
|
||||
grep -q 'ArchiveSyncCursorPolicy.requestPosition' Sources/Shared/XMPP/XMPPService.swift || {
|
||||
echo "MAM reconnects must prefer the durable archive UID over timestamps"
|
||||
exit 1
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import AVFoundation
|
||||
import XCTest
|
||||
@testable import Luma
|
||||
|
||||
final class MediaPreviewProcessorVideoTests: XCTestCase {
|
||||
/// Reproduces the camera-video analysis on the simulator: the pipeline
|
||||
/// must produce a duration and a JPEG thumbnail for a freshly written
|
||||
/// HEVC QuickTime movie.
|
||||
func testVideoAnalysisProducesDurationAndThumbnail() async throws {
|
||||
let url = try await Self.makeTestMovie()
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
let processor = MediaPreviewProcessor()
|
||||
let analysis = await processor.analyze(
|
||||
url: url,
|
||||
kind: .video,
|
||||
mimeType: "video/quicktime"
|
||||
)
|
||||
XCTAssertNotNil(analysis.duration)
|
||||
if let duration = analysis.duration {
|
||||
XCTAssertGreaterThan(duration, 0)
|
||||
}
|
||||
XCTAssertNotNil(analysis.thumbnailData)
|
||||
XCTAssertFalse(analysis.thumbnailData?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
private static func makeTestMovie() async throws -> URL {
|
||||
let output = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("test-camera-\(UUID().uuidString).mov")
|
||||
let writer = try AVAssetWriter(outputURL: output, fileType: .mov)
|
||||
let input = AVAssetWriterInput(
|
||||
mediaType: .video,
|
||||
outputSettings: [
|
||||
AVVideoCodecKey: AVVideoCodecType.hevc,
|
||||
AVVideoWidthKey: 640,
|
||||
AVVideoHeightKey: 480,
|
||||
]
|
||||
)
|
||||
input.expectsMediaDataInRealTime = true
|
||||
let adaptor = AVAssetWriterInputPixelBufferAdaptor(
|
||||
assetWriterInput: input,
|
||||
sourcePixelBufferAttributes: [
|
||||
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
||||
kCVPixelBufferWidthKey as String: 640,
|
||||
kCVPixelBufferHeightKey as String: 480,
|
||||
]
|
||||
)
|
||||
writer.add(input)
|
||||
guard writer.startWriting() else {
|
||||
throw writer.error ?? NSError(domain: "test", code: 1)
|
||||
}
|
||||
writer.startSession(atSourceTime: .zero)
|
||||
for frame in 0..<60 {
|
||||
while !input.isReadyForMoreMediaData {
|
||||
try await Task.sleep(nanoseconds: 2_000_000)
|
||||
}
|
||||
var buffer: CVPixelBuffer?
|
||||
CVPixelBufferCreate(
|
||||
kCFAllocatorDefault, 640, 480, kCVPixelFormatType_32BGRA, nil, &buffer
|
||||
)
|
||||
adaptor.append(buffer!, withPresentationTime: CMTime(value: Int64(frame), timescale: 30))
|
||||
}
|
||||
input.markAsFinished()
|
||||
await writer.finishWriting()
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user