Files
Luma/Sources/Shared/UI/SystemPhotoCameraView.swift
T
wt 496ee6f0f2
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
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.
2026-08-29 04:30:40 +07:00

181 lines
7.0 KiB
Swift

import Foundation
import os
struct CapturedCameraMedia: Sendable {
let url: URL
let kind: ChatMessage.Kind
}
#if os(iOS)
import SwiftUI
import UniformTypeIdentifiers
import UIKit
@MainActor
struct SystemPhotoCameraView: UIViewControllerRepresentable {
/// Called synchronously on the main thread inside
/// `didFinishPickingMediaWithInfo`, before any background preparation.
/// The host must flip its `isPresented` binding here so SwiftUI drives
/// the dismissal; letting UIImagePickerController dismiss itself leaves
/// the fullScreenCover state desynchronized and breaks the follow-up
/// preview presentation.
let onDismissRequest: () -> Void
let onMedia: (Result<CapturedCameraMedia, Error>) -> Void
let onCancel: () -> Void
static var isAvailable: Bool {
UIImagePickerController.isSourceTypeAvailable(.camera)
}
func makeCoordinator() -> Coordinator {
Coordinator(
onDismissRequest: onDismissRequest,
onMedia: onMedia,
onCancel: onCancel
)
}
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = .camera
picker.mediaTypes = [UTType.image.identifier, UTType.movie.identifier]
picker.cameraCaptureMode = .photo
picker.videoQuality = .typeHigh
picker.videoMaximumDuration = 180
picker.allowsEditing = false
picker.modalPresentationStyle = .fullScreen
return picker
}
func updateUIViewController(
_ uiViewController: UIImagePickerController,
context: Context
) {}
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
private let onDismissRequest: () -> Void
private let onMedia: (Result<CapturedCameraMedia, Error>) -> Void
private let onCancel: () -> Void
private var completed = false
init(
onDismissRequest: @escaping () -> Void,
onMedia: @escaping (Result<CapturedCameraMedia, Error>) -> Void,
onCancel: @escaping () -> Void
) {
self.onDismissRequest = onDismissRequest
self.onMedia = onMedia
self.onCancel = onCancel
}
func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) {
guard !completed else { return }
completed = true
onDismissRequest()
let mediaType = (info[.mediaType] as? String).flatMap { UTType($0) }
if mediaType?.conforms(to: .movie) == true,
let sourceURL = info[.mediaURL] as? URL {
prepareMovie(from: sourceURL)
return
}
guard let image = info[.originalImage] as? UIImage else {
onMedia(.failure(SystemCameraError.missingMedia))
return
}
preparePhoto(image)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
guard !completed else { return }
completed = true
onCancel()
}
private func preparePhoto(_ image: UIImage) {
let completion = onMedia
DispatchQueue.global(qos: .userInitiated).async {
let result: Result<CapturedCameraMedia, Error>
do {
let url = try Self.destinationURL(extension: "jpg")
guard let data = image.jpegData(compressionQuality: 0.9),
!data.isEmpty else {
throw SystemCameraError.encodingFailed
}
try data.write(to: url, options: [.atomic])
result = .success(CapturedCameraMedia(url: url, kind: .photo))
} catch {
result = .failure(error)
}
DispatchQueue.main.async { completion(result) }
}
}
/// Claims the picker-owned temporary movie synchronously, while the
/// picker is still alive: UIImagePickerController can delete its
/// temporary file as soon as it is dismissed, so a background copy
/// 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
let destination = try Self.destinationURL(extension: fileExtension)
try FileManager.default.moveItem(at: sourceURL, to: destination)
let values = try destination.resourceValues(
forKeys: [.fileSizeKey, .isRegularFileKey]
)
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))
}
}
private static func destinationURL(extension fileExtension: String) throws -> URL {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("LumaCameraMedia", isDirectory: true)
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
return directory.appendingPathComponent(
"camera-\(UUID().uuidString).\(fileExtension)"
)
}
}
}
private enum SystemCameraError: LocalizedError {
case missingMedia
case encodingFailed
case emptyMovie
var errorDescription: String? {
switch self {
case .missingMedia:
return "Камера не вернула фото или видео."
case .encodingFailed:
return "Не удалось подготовить фотографию к отправке."
case .emptyMovie:
return "Камера вернула пустой видеофайл. Повторите запись."
}
}
}
#endif