fix #5 camera video preview with settling and diagnostics
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

- 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:
wt
2026-08-29 04:30:40 +07:00
parent 75fbcc5346
commit 496ee6f0f2
7 changed files with 137 additions and 5 deletions
+4
View File
@@ -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 */,
+5
View File
@@ -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()
}
+35 -2
View File
@@ -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
}
}