From 496ee6f0f282a87dcba12a0166dd3bb9fca33dcc Mon Sep 17 00:00:00 2001 From: TolaMironcenko Date: Sat, 29 Aug 2026 04:30:40 +0700 Subject: [PATCH] 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. --- Luma.xcodeproj/project.pbxproj | 4 ++ Scripts/verify.sh | 5 ++ .../Services/MediaPreviewProcessor.swift | 9 +++ Sources/Shared/UI/AttachmentPreviewView.swift | 9 ++- Sources/Shared/UI/ChatView.swift | 37 +++++++++- Sources/Shared/UI/SystemPhotoCameraView.swift | 10 ++- Tests/MediaPreviewProcessorVideoTests.swift | 68 +++++++++++++++++++ 7 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 Tests/MediaPreviewProcessorVideoTests.swift diff --git a/Luma.xcodeproj/project.pbxproj b/Luma.xcodeproj/project.pbxproj index ade9d39..dee48ce 100644 --- a/Luma.xcodeproj/project.pbxproj +++ b/Luma.xcodeproj/project.pbxproj @@ -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 = ""; }; 876CDF98063A4C948B00C7CE /* DeviceResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceResource.swift; sourceTree = ""; }; 8D2294F0FE44B2268A0620BB /* PhotoAttachmentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoAttachmentPreview.swift; sourceTree = ""; }; + 8E29E7B704EE71C5AC70008E /* MediaPreviewProcessorVideoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewProcessorVideoTests.swift; sourceTree = ""; }; 8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaConnectionStatsModule.swift; sourceTree = ""; }; 96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycle.swift; sourceTree = ""; }; 99BE34DC082D19007241AA62 /* NewGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGroupView.swift; sourceTree = ""; }; @@ -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 */, diff --git a/Scripts/verify.sh b/Scripts/verify.sh index db5695c..7361947 100755 --- a/Scripts/verify.sh +++ b/Scripts/verify.sh @@ -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 diff --git a/Sources/Shared/Services/MediaPreviewProcessor.swift b/Sources/Shared/Services/MediaPreviewProcessor.swift index 9a004ec..c59d7c2 100644 --- a/Sources/Shared/Services/MediaPreviewProcessor.swift +++ b/Sources/Shared/Services/MediaPreviewProcessor.swift @@ -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 } } diff --git a/Sources/Shared/UI/AttachmentPreviewView.swift b/Sources/Shared/UI/AttachmentPreviewView.swift index 6bc5525..0379b26 100644 --- a/Sources/Shared/UI/AttachmentPreviewView.swift +++ b/Sources/Shared/UI/AttachmentPreviewView.swift @@ -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() } diff --git a/Sources/Shared/UI/ChatView.swift b/Sources/Shared/UI/ChatView.swift index c68d080..20720c2 100644 --- a/Sources/Shared/UI/ChatView.swift +++ b/Sources/Shared/UI/ChatView.swift @@ -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 { diff --git a/Sources/Shared/UI/SystemPhotoCameraView.swift b/Sources/Shared/UI/SystemPhotoCameraView.swift index 6170bcf..0591a26 100644 --- a/Sources/Shared/UI/SystemPhotoCameraView.swift +++ b/Sources/Shared/UI/SystemPhotoCameraView.swift @@ -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)) } } diff --git a/Tests/MediaPreviewProcessorVideoTests.swift b/Tests/MediaPreviewProcessorVideoTests.swift new file mode 100644 index 0000000..9fae201 --- /dev/null +++ b/Tests/MediaPreviewProcessorVideoTests.swift @@ -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 + } +} +