Files
Luma/Sources/Shared/Services/MediaPreviewProcessor.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

205 lines
7.5 KiB
Swift

import AVFoundation
import Foundation
import ImageIO
import os
import UniformTypeIdentifiers
actor MediaPreviewProcessor {
struct Analysis: Sendable {
let duration: TimeInterval?
let thumbnailData: Data?
let waveform: [Float]?
}
static let placeholderWaveform: [Float] = [
0.24, 0.42, 0.30, 0.58, 0.76, 0.48, 0.34, 0.67,
0.88, 0.61, 0.40, 0.73, 0.52, 0.31, 0.64, 0.82,
0.45, 0.69, 0.91, 0.56, 0.38, 0.71, 0.49, 0.79,
0.57, 0.35, 0.62, 0.86, 0.53, 0.72, 0.43, 0.65,
0.84, 0.51, 0.32, 0.59, 0.75, 0.46, 0.68, 0.39,
0.80, 0.55, 0.36, 0.70, 0.47, 0.63, 0.78, 0.44
]
func analyze(
url: URL,
kind: ChatMessage.Kind,
mimeType: String? = nil
) async -> Analysis {
let asset = Self.mediaAsset(url: url, mimeType: mimeType)
let duration: TimeInterval?
if [.video, .audio, .voice, .videoNote].contains(kind) {
duration = await mediaDuration(for: asset)
} else {
duration = nil
}
let thumbnailData: Data?
if kind == .photo {
thumbnailData = imageThumbnailData(for: url)
} else if kind == .video || kind == .videoNote {
thumbnailData = await videoThumbnailData(
for: asset,
duration: duration
)
} else {
thumbnailData = nil
}
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,
waveform: waveform
)
}
private func mediaDuration(for asset: AVURLAsset) async -> TimeInterval? {
guard let time = try? await asset.load(.duration) else { return nil }
let seconds = time.seconds
return seconds.isFinite && seconds > 0 ? seconds : nil
}
private func videoThumbnailData(
for asset: AVURLAsset,
duration: TimeInterval?
) async -> Data? {
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
generator.maximumSize = CGSize(width: 960, height: 960)
generator.requestedTimeToleranceBefore = CMTime(
seconds: 0.35,
preferredTimescale: 600
)
generator.requestedTimeToleranceAfter = CMTime(
seconds: 0.35,
preferredTimescale: 600
)
let safeDuration = duration.flatMap { value in
value.isFinite && value > 0 ? value : nil
}
let preferredSecond = safeDuration.map {
min(1, max(0.01, $0 * 0.25))
} ?? 0.5
let earlySecond = safeDuration.map {
min(0.05, max(0, $0 * 0.1))
} ?? 0.05
var attemptedMilliseconds: Set<Int> = []
for second in [preferredSecond, earlySecond, 0] {
let key = Int((second * 1_000).rounded())
guard attemptedMilliseconds.insert(key).inserted else { continue }
do {
let result = try await generator.image(
at: CMTime(seconds: second, preferredTimescale: 600)
)
if let data = jpegData(from: result.image) {
return data
}
} catch {
Logger(subsystem: "Luma", category: "video-preview")
.error("image generator failed at \(second)s: \(error.localizedDescription)")
continue
}
}
return nil
}
private func imageThumbnailData(for url: URL) -> Data? {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: 1_200,
kCGImageSourceShouldCacheImmediately: true
]
guard let image = CGImageSourceCreateThumbnailAtIndex(
source,
0,
options as CFDictionary
) else { return nil }
return jpegData(from: image)
}
private func jpegData(from image: CGImage) -> Data? {
let output = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(
output as CFMutableData,
UTType.jpeg.identifier as CFString,
1,
nil
) else { return nil }
let options = [kCGImageDestinationLossyCompressionQuality: 0.82] as CFDictionary
CGImageDestinationAddImage(destination, image, options)
guard CGImageDestinationFinalize(destination) else { return nil }
return output as Data
}
private static func mediaAsset(url: URL, mimeType: String?) -> AVURLAsset {
guard let mimeType, !mimeType.isEmpty else {
return AVURLAsset(url: url)
}
// Supplying the MIME type is important for picker files whose temporary
// extension does not describe the real QuickTime/HEVC container.
return AVURLAsset(
url: url,
options: ["AVURLAssetOutOfBandMIMETypeKey": mimeType]
)
}
private func voiceWaveform(for url: URL, sampleCount: Int = 48) -> [Float]? {
do {
let file = try AVAudioFile(
forReading: url,
commonFormat: .pcmFormatFloat32,
interleaved: false
)
let totalFrames = max(1, file.length)
let capacity: AVAudioFrameCount = 4_096
guard let buffer = AVAudioPCMBuffer(
pcmFormat: file.processingFormat,
frameCapacity: capacity
) else { return nil }
var peaks = Array(repeating: Float.zero, count: sampleCount)
var processedFrames: AVAudioFramePosition = 0
while processedFrames < totalFrames {
let remaining = totalFrames - processedFrames
let requested = AVAudioFrameCount(min(AVAudioFramePosition(capacity), remaining))
try file.read(into: buffer, frameCount: requested)
let frameLength = Int(buffer.frameLength)
guard frameLength > 0, let channels = buffer.floatChannelData else { break }
let channelCount = Int(buffer.format.channelCount)
for frame in 0..<frameLength {
let absoluteFrame = processedFrames + AVAudioFramePosition(frame)
let bin = min(
sampleCount - 1,
Int(absoluteFrame * AVAudioFramePosition(sampleCount) / totalFrames)
)
var peak = Float.zero
for channel in 0..<channelCount {
peak = max(peak, abs(channels[channel][frame]))
}
peaks[bin] = max(peaks[bin], peak)
}
processedFrames += AVAudioFramePosition(frameLength)
}
guard let maximum = peaks.max(), maximum > 0 else {
return Self.placeholderWaveform
}
return peaks.map { peak in
max(0.08, min(1, sqrt(peak / maximum)))
}
} catch {
return Self.placeholderWaveform
}
}
}