diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1cafcd..f6bcc4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,5 +17,7 @@ jobs: run: ./Scripts/validate-localizations.sh - name: Install synthetic-media test dependency run: brew list ffmpeg >/dev/null 2>&1 || brew install ffmpeg + - name: Run Swift tests + run: swift test - name: Run synthetic media regression matrix run: ./Scripts/run-media-regression.sh diff --git a/Sources/FastConvert/ConverterStore.swift b/Sources/FastConvert/ConverterStore.swift index b0d133e..fef052b 100644 --- a/Sources/FastConvert/ConverterStore.swift +++ b/Sources/FastConvert/ConverterStore.swift @@ -427,6 +427,18 @@ private final class ErrorBuffer: @unchecked Sendable { func read() -> String { lock.lock(); defer { lock.unlock() }; return value } } +private final class ProbeOutput: @unchecked Sendable { + private let lock = NSLock() + private var value = Data() + private let maximumBytes = 8_000_000 + func append(_ data: Data) { + lock.lock() + if value.count < maximumBytes { value.append(data.prefix(maximumBytes - value.count)) } + lock.unlock() + } + func read() -> Data { lock.lock(); defer { lock.unlock() }; return value } +} + /// `-progress pipe:1` is FFmpeg's liveness contract. Keep its clock behind a /// lock because pipe callbacks and the main actor watchdog run independently. private final class ProcessActivity: @unchecked Sendable { @@ -487,6 +499,7 @@ final class ConverterStore: ObservableObject { private var queueIDs: [UUID] = [] private var progressTimes: [UUID: Double] = [:] private var progressBuffers: [UUID: String] = [:] + private var progressGeneration: [UUID: UUID] = [:] private var lastProgressUpdate: [UUID: Date] = [:] private var mergeItemIDs: [UUID] = [] private var mergeDurations: [Double?] = [] @@ -607,7 +620,7 @@ final class ConverterStore: ObservableObject { statusText = "" } func retryIncomplete() { - guard !isRunning else { return } + guard !isBusy else { return } for index in items.indices { if case .failed = items[index].state { items[index].state = .waiting @@ -623,7 +636,7 @@ final class ConverterStore: ObservableObject { } func start() { - guard !isMerging else { return } + guard !isBusy else { return } guard let binary = locateFFmpeg() else { showFFmpegSetup = true; return } guard locateFFprobe(for: binary) != nil else { statusText = L10n.text("未检测到 ffprobe,请安装完整的 FFmpeg 后重试。") @@ -719,7 +732,7 @@ final class ConverterStore: ObservableObject { cancellationRequested = true statusText = L10n.text("正在停止转换并清理临时文件") let processes = Array(activeProcesses.values) - processes.forEach { $0.terminate() } + processes.filter(\.isRunning).forEach { $0.terminate() } Task { [weak self] in guard let self else { return } try? await Task.sleep(nanoseconds: self.ffmpegTerminationGrace) @@ -887,6 +900,8 @@ final class ConverterStore: ObservableObject { process.terminationHandler = { finished in progress.fileHandleForReading.readabilityHandler = nil errors.fileHandleForReading.readabilityHandler = nil + let trailingError = errors.fileHandleForReading.readDataToEndOfFile() + if !trailingError.isEmpty { stderr.append(String(decoding: trailingError, as: UTF8.self)) } Task { @MainActor [store] in if finished.terminationStatus == 0 { continuation.resume(returning: .success) } else { continuation.resume(returning: .failure(store.friendlyMergeError(stderr.read(), code: finished.terminationStatus))) } @@ -969,6 +984,8 @@ final class ConverterStore: ObservableObject { process.terminationHandler = { finished in progress.fileHandleForReading.readabilityHandler = nil errors.fileHandleForReading.readabilityHandler = nil + let trailingError = errors.fileHandleForReading.readDataToEndOfFile() + if !trailingError.isEmpty { stderr.append(String(decoding: trailingError, as: UTF8.self)) } Task { @MainActor [store] in if finished.terminationStatus == 0 { continuation.resume(returning: .success) } else { continuation.resume(returning: .failure(store.friendlyMergeError(stderr.read(), code: finished.terminationStatus))) } @@ -1095,7 +1112,7 @@ final class ConverterStore: ObservableObject { }.count statusText = failures == 0 ? L10n.text("转换完成") : L10n.format("队列处理结束,%d 项失败。", failures) } - activeProcesses.removeAll(); activeTemporaryOutputs.removeAll(); progressBuffers.removeAll(); progressTimes.removeAll(); lastProgressUpdate.removeAll(); queueIDs.removeAll(); isRunning = false + activeProcesses.removeAll(); activeTemporaryOutputs.removeAll(); progressBuffers.removeAll(); progressGeneration.removeAll(); progressTimes.removeAll(); lastProgressUpdate.removeAll(); queueIDs.removeAll(); isRunning = false presentationQueue.isSuspended = false } @@ -1108,13 +1125,28 @@ final class ConverterStore: ObservableObject { let rotation = item.rotation let crop = item.crop setPhase(.inspecting, for: id, progress: nil, count: count) - let info = await probe(source, ffmpegURL: ffmpegURL) + var info = await probe(source, ffmpegURL: ffmpegURL) + // A transient file-provider or network-volume read may make ffprobe + // fail once before the same source is readable. Retry inspection once, + // without launching an encoder against unknown stream metadata. + if !info.probeSucceeded && !cancellationRequested && shouldRetryProbe(for: source) { + try? await Task.sleep(nanoseconds: 300_000_000) + if !cancellationRequested { info = await probe(source, ffmpegURL: ffmpegURL) } + } guard let index = items.firstIndex(where: { $0.id == id }) else { return } guard !cancellationRequested else { items[index].state = .cancelled items[index].phase = nil return } + guard info.probeSucceeded else { + markFailed(id, failure: ConversionFailure( + reason: L10n.text("无法读取此视频的媒体数据"), + solution: L10n.text("请确认文件可播放,或使用「智能转换」生成新文件后重试。"), + diagnostic: "probe:unavailable" + ), count: count) + return + } guard info.hasVideo else { markFailed(id, failure: ConversionFailure( reason: L10n.text("未检测到可用视频流"), @@ -1132,7 +1164,10 @@ final class ConverterStore: ObservableObject { if preset == .smartEnhanceUpscale { effectivePreset = preset } else if rotation != nil || crop != nil || resolution.requiresReencode { - effectivePreset = (preset == .qualityTop || preset == .h264Cap8) ? preset : .h264Hardware + // Edits require re-encoding, but retain an explicitly selected + // software or HEVC encoder. Only copy/automatic presets need a + // concrete H.264 encoding path here. + effectivePreset = (preset == .remux || preset == .smartMP4) ? .h264Hardware : preset } else if trim != nil, info.canDirectRemuxToMP4 { effectivePreset = .remux } else if preset == .smartMP4 { @@ -1178,6 +1213,7 @@ final class ConverterStore: ObservableObject { // at the same target bitrate. Retries are per-item and bounded. if case .failure = result, !cancellationRequested, + !isTerminalFFmpegFailure(result), effectivePreset != .smartEnhanceUpscale, canUseVideoToolboxPipeline(info: info, preset: effectivePreset, crop: crop) { setPhase(.softwareTranscoding, for: id, progress: nil, count: count, detail: L10n.text("硬件处理失败,正在使用兼容方式重试")) @@ -1185,6 +1221,7 @@ final class ConverterStore: ObservableObject { } if case .failure = result, !cancellationRequested, + !isTerminalFFmpegFailure(result), effectivePreset != .smartEnhanceUpscale, usesVideoToolboxEncoder(effectivePreset) { setPhase(.softwareTranscoding, for: id, progress: nil, count: count, detail: L10n.text("硬件编码不可用,正在使用兼容编码重试")) @@ -1199,6 +1236,9 @@ final class ConverterStore: ObservableObject { } } } + // Cancellation can arrive after FFmpeg exits or while ffprobe verifies + // the temporary result. Never publish an unchecked output in that gap. + if cancellationRequested { result = .cancelled } guard let current = items.firstIndex(where: { $0.id == id }) else { return } switch result { case .success: @@ -1217,6 +1257,7 @@ final class ConverterStore: ObservableObject { case .cancelled: items[current].state = .cancelled items[current].phase = nil + items[current].failure = nil try? FileManager.default.removeItem(at: temporary) case .failure(let message): try? FileManager.default.removeItem(at: temporary) @@ -1228,6 +1269,16 @@ final class ConverterStore: ObservableObject { private enum ProcessResult { case success, cancelled, failure(String) } + private func isTerminalFFmpegFailure(_ result: ProcessResult) -> Bool { + guard case .failure(let message) = result else { return false } + return message == "MP4Flow:disk-full" || message == "MP4Flow:permission" || message == "MP4Flow:missing-source" + } + + private func shouldRetryProbe(for source: URL) -> Bool { + guard let values = try? source.resourceValues(forKeys: [.isUbiquitousItemKey, .volumeIsLocalKey]) else { return false } + return values.isUbiquitousItem == true || values.volumeIsLocal == false + } + private func setPhase(_ phase: ConversionPhase, for id: UUID, progress: Double?, count: Int, detail: String? = nil) { guard let index = items.firstIndex(where: { $0.id == id }) else { return } items[index].phase = phase @@ -1273,6 +1324,12 @@ final class ConverterStore: ObservableObject { } private func failure(from message: String) -> ConversionFailure { + if message == "MP4Flow:disk-full" { + return ConversionFailure(reason: L10n.text("输出磁盘空间不足"), solution: L10n.text("请释放空间或选择其他输出位置后重试。"), diagnostic: "ffmpeg:disk-full") + } + if message == "MP4Flow:permission" || message == "MP4Flow:missing-source" { + return ConversionFailure(reason: L10n.text("没有读取或写入文件的权限"), solution: L10n.text("请检查文件和输出文件夹权限后重试。"), diagnostic: message == "MP4Flow:permission" ? "ffmpeg:permission" : "ffmpeg:missing-source") + } let text = message.lowercased() if text.contains("无进度") || text.contains("未响应") || text.contains("timed out") || text.contains("timeout") { return ConversionFailure( @@ -1329,7 +1386,11 @@ final class ConverterStore: ObservableObject { diagnostic: "verify:empty-output" ) } - let info = await probe(output, ffmpegURL: ffmpegURL) + var info = await probe(output, ffmpegURL: ffmpegURL) + if !info.probeSucceeded && !cancellationRequested { + try? await Task.sleep(nanoseconds: 300_000_000) + if !cancellationRequested { info = await probe(output, ffmpegURL: ffmpegURL) } + } guard info.probeSucceeded else { return ConversionFailure( reason: L10n.text("无法验证输出文件"), @@ -1381,6 +1442,11 @@ final class ConverterStore: ObservableObject { let process = Process(), progress = Pipe(), errors = Pipe() let store = self let activity = ProcessActivity() + let generation = UUID() + progressGeneration[id] = generation + progressBuffers[id] = nil + progressTimes[id] = nil + lastProgressUpdate[id] = nil process.executableURL = ffmpegURL process.arguments = ffmpegArguments(source: source, output: output, info: info, preset: preset, quality: quality, resolution: resolution, trim: trim, rotation: rotation, crop: crop, preferHardwarePipeline: preferHardwarePipeline, preferHardwareEncoder: preferHardwareEncoder) process.standardOutput = progress; process.standardError = errors @@ -1388,18 +1454,20 @@ final class ConverterStore: ObservableObject { progress.fileHandleForReading.readabilityHandler = { handle in let data = handle.availableData; guard !data.isEmpty else { return } activity.touch() - Task { @MainActor [store] in store.consumeProgress(String(decoding: data, as: UTF8.self), duration: trim.map { $0.end - $0.start } ?? info.duration, id: id, count: count) } + Task { @MainActor [store] in store.consumeProgress(String(decoding: data, as: UTF8.self), duration: trim.map { $0.end - $0.start } ?? info.duration, id: id, count: count, generation: generation) } } errors.fileHandleForReading.readabilityHandler = { handle in let data = handle.availableData; guard !data.isEmpty else { return } - activity.touch() stderr.append(String(decoding: data, as: UTF8.self)) } return await withCheckedContinuation { continuation in process.terminationHandler = { finished in progress.fileHandleForReading.readabilityHandler = nil; errors.fileHandleForReading.readabilityHandler = nil + let trailingError = errors.fileHandleForReading.readDataToEndOfFile() + if !trailingError.isEmpty { stderr.append(String(decoding: trailingError, as: UTF8.self)) } Task { @MainActor [store] in store.activeProcesses.removeValue(forKey: id) + if store.progressGeneration[id] == generation { store.progressGeneration[id] = nil } if store.cancellationRequested { continuation.resume(returning: .cancelled) } else if let timeout = activity.timeout() { continuation.resume(returning: .failure(timeout)) } else if finished.terminationStatus == 0 { continuation.resume(returning: .success) } @@ -1410,6 +1478,7 @@ final class ConverterStore: ObservableObject { do { try process.run() } catch { activeProcesses.removeValue(forKey: id) + if progressGeneration[id] == generation { progressGeneration[id] = nil } progress.fileHandleForReading.readabilityHandler = nil; errors.fileHandleForReading.readabilityHandler = nil continuation.resume(returning: .failure("无法启动 FFmpeg:\(error.localizedDescription)")) return @@ -1543,7 +1612,8 @@ final class ConverterStore: ObservableObject { return L10n.format("目标码率 %.1f Mbps", Double(info.outputRate(preset: preset, quality: quality, resolution: resolution)) / 1_000_000) + sizeLabel } - private func consumeProgress(_ text: String, duration: Double?, id: UUID, count: Int) { + private func consumeProgress(_ text: String, duration: Double?, id: UUID, count: Int, generation: UUID) { + guard progressGeneration[id] == generation else { return } let combined = (progressBuffers[id] ?? "") + text let lines = combined.split(separator: "\n", omittingEmptySubsequences: false) progressBuffers[id] = combined.hasSuffix("\n") ? "" : String(lines.last ?? "") @@ -1585,29 +1655,47 @@ final class ConverterStore: ObservableObject { guard let probe = locateFFprobe(for: ffmpegURL) else { return Self.unavailableProbeInfo() } + return await Self.inspectMedia(at: source, using: probe) + } + + /// Keep process I/O independent of the store so media inspection can be + /// exercised with a stream-rich ffprobe response in integration tests. + nonisolated static func inspectMedia(at source: URL, using probe: URL) async -> VideoInfo { return await withCheckedContinuation { continuation in let process = Process() let pipe = Pipe() + let output = ProbeOutput() + let drain = DispatchGroup() + drain.enter() process.executableURL = probe process.arguments = ["-v", "error", "-show_entries", "stream=codec_type,codec_name,bit_rate,width,height,r_frame_rate,pix_fmt,sample_rate,channels,color_transfer:format=duration,bit_rate", "-of", "json", source.path] process.standardOutput = pipe + process.standardError = FileHandle.nullDevice process.terminationHandler = { finished in - let info: VideoInfo - guard finished.terminationStatus == 0 else { - info = Self.unavailableProbeInfo() + drain.notify(queue: .global(qos: .utility)) { + let info = finished.terminationStatus == 0 + ? (Self.decodeProbe(output.read()) ?? Self.unavailableProbeInfo()) + : Self.unavailableProbeInfo() Task { @MainActor in continuation.resume(returning: info) } - return } - let data = pipe.fileHandleForReading.readDataToEndOfFile() - info = Self.decodeProbe(data) ?? Self.unavailableProbeInfo() - Task { @MainActor in continuation.resume(returning: info) } } do { try process.run() } catch { + drain.leave() continuation.resume(returning: Self.unavailableProbeInfo()) return } + // Read concurrently with ffprobe: a stream-rich file can produce + // more JSON than the pipe buffer holds and otherwise deadlock + // before terminationHandler has a chance to run. + DispatchQueue.global(qos: .utility).async { + let handle = pipe.fileHandleForReading + while let chunk = try? handle.read(upToCount: 64 * 1_024), !chunk.isEmpty { + output.append(chunk) + } + drain.leave() + } Task { [process] in try? await Task.sleep(nanoseconds: 30_000_000_000) guard process.isRunning else { return } @@ -1621,7 +1709,7 @@ final class ConverterStore: ObservableObject { } nonisolated private static func unavailableProbeInfo() -> VideoInfo { - VideoInfo(duration: nil, videoRate: nil, audioRate: nil, width: nil, height: nil, hasVideo: true, videoCodec: nil, audioCodec: nil, frameRate: nil, pixelFormat: nil, audioSampleRate: nil, audioChannels: nil, audioTrackCount: 0, subtitleTrackCount: 0, colorTransfer: nil, probeSucceeded: false) + VideoInfo(duration: nil, videoRate: nil, audioRate: nil, width: nil, height: nil, hasVideo: false, videoCodec: nil, audioCodec: nil, frameRate: nil, pixelFormat: nil, audioSampleRate: nil, audioChannels: nil, audioTrackCount: 0, subtitleTrackCount: 0, colorTransfer: nil, probeSucceeded: false) } nonisolated private static func decodeProbe(_ data: Data) -> VideoInfo? { @@ -1672,8 +1760,10 @@ final class ConverterStore: ObservableObject { private func fileNameTime(_ seconds: Double) -> String { String(format: "%02d%02d%02d", Int(seconds) / 3600, (Int(seconds) % 3600) / 60, Int(seconds) % 60) } private func friendlyError(_ stderr: String, code: Int32) -> String { let text = stderr.lowercased() + if text.contains("no space left") || text.contains("disk full") { return "MP4Flow:disk-full" } + if text.contains("permission denied") || text.contains("operation not permitted") { return "MP4Flow:permission" } + if text.contains("no such file") { return "MP4Flow:missing-source" } if text.contains("unknown encoder") || text.contains("videotoolbox") && text.contains("not available") { return L10n.text("硬件编码不可用。请改用“兼容优先:H.264 MP4(软件)”。") } - if text.contains("permission denied") || text.contains("no such file") { return L10n.text("无法读取源文件或写入输出位置,请检查文件与权限。") } if text.contains("could not write header") || text.contains("muxer") { return L10n.text("该流无法直接封装为 MP4,请改用 H.264 或 HEVC 转码。") } return L10n.format("FFmpeg 转换失败(退出码 %d)。", code) } diff --git a/Sources/FastConvert/DependencyHealth.swift b/Sources/FastConvert/DependencyHealth.swift index 844d91e..94ad7ee 100644 --- a/Sources/FastConvert/DependencyHealth.swift +++ b/Sources/FastConvert/DependencyHealth.swift @@ -15,24 +15,60 @@ final class DependencyHealthMonitor: ObservableObject { @Published private(set) var isChecking = false private var refreshTask: Task? + private var cachedTools: DependencyHealthReport? + private var toolProbeTask: Task? - func refresh(outputDirectory: URL?, sourceDirectories: [URL]) { + func refresh(outputDirectory: URL?, sourceDirectories: [URL], forceToolCheck: Bool = false) { refreshTask?.cancel() isChecking = true + if forceToolCheck { + cachedTools = nil + toolProbeTask = nil + } let request = DependencyHealthRequest( outputDirectory: outputDirectory, sourceDirectories: sourceDirectories ) + let previous = cachedTools + if previous == nil && toolProbeTask == nil { + // A shared in-flight probe prevents rapid queue edits from starting + // overlapping FFmpeg and VideoToolbox checks. + toolProbeTask = Task { + await DependencyHealthProbe.inspect( + DependencyHealthRequest(outputDirectory: nil, sourceDirectories: []), + previous: nil + ) + } + } + let probe = toolProbeTask refreshTask = Task { [weak self] in - let report = await DependencyHealthProbe.inspect(request) + // A batch of newly added files can publish several count changes in + // succession. Only the last one needs a full directory check. + try? await Task.sleep(for: .milliseconds(150)) + guard !Task.isCancelled else { return } + let tools: DependencyHealthReport + if let previous { + tools = previous + } else if let probe { + tools = await probe.value + } else { + return + } + guard !Task.isCancelled else { return } + let report = await DependencyHealthProbe.inspect(request, previous: tools) guard !Task.isCancelled else { return } self?.report = report + self?.cachedTools = report + self?.toolProbeTask = nil self?.isChecking = false } } - deinit { refreshTask?.cancel() } + deinit { + refreshTask?.cancel() + toolProbeTask?.cancel() + } } private struct DependencyHealthRequest: Sendable { @@ -101,26 +137,38 @@ private enum DependencyHealthProbe { "/usr/bin" ] - static func inspect(_ request: DependencyHealthRequest) async -> DependencyHealthReport { + static func inspect(_ request: DependencyHealthRequest, previous: DependencyHealthReport?) async -> DependencyHealthReport { await Task.detached(priority: .utility) { - let ffmpeg = inspectExecutable(named: "ffmpeg") - let ffprobe = inspectExecutable(named: "ffprobe") - let encoders = ffmpeg.path.flatMap { path in - let result = run(URL(fileURLWithPath: path), arguments: ["-hide_banner", "-encoders"], timeout: 4) - return result.succeeded ? result.output.lowercased() : nil - } + let ffmpeg: ExecutableHealth + let ffprobe: ExecutableHealth + let hardware: VideoToolboxHealth + if let previous { + ffmpeg = previous.ffmpeg + ffprobe = previous.ffprobe + hardware = previous.videoToolbox + } else { + ffmpeg = await inspectExecutable(named: "ffmpeg") + ffprobe = await inspectExecutable(named: "ffprobe") + let encoders: String? + if let path = ffmpeg.path { + let result = await run(URL(fileURLWithPath: path), arguments: ["-hide_banner", "-encoders"], timeout: 4) + encoders = result.succeeded ? result.output.lowercased() : nil + } else { + encoders = nil + } - // A system codec alone is not enough: FFmpeg must also expose the - // matching encoder before MP4Flow can use the hardware path. - let h264System = isHardwareEncoderAvailable(codec: kCMVideoCodecType_H264) - let hevcSystem = isHardwareEncoderAvailable(codec: kCMVideoCodecType_HEVC) - let h264Available = h264System && (encoders?.contains("h264_videotoolbox") == true) - let hevcAvailable = hevcSystem && (encoders?.contains("hevc_videotoolbox") == true) - let hardware = VideoToolboxHealth( - level: h264Available || hevcAvailable ? .ready : .warning, - h264Available: h264Available, - hevcAvailable: hevcAvailable - ) + // A system codec alone is not enough: FFmpeg must also expose the + // matching encoder before MP4Flow can use the hardware path. + let h264System = isHardwareEncoderAvailable(codec: kCMVideoCodecType_H264) + let hevcSystem = isHardwareEncoderAvailable(codec: kCMVideoCodecType_HEVC) + let h264Available = h264System && (encoders?.contains("h264_videotoolbox") == true) + let hevcAvailable = hevcSystem && (encoders?.contains("hevc_videotoolbox") == true) + hardware = VideoToolboxHealth( + level: h264Available || hevcAvailable ? .ready : .warning, + h264Available: h264Available, + hevcAvailable: hevcAvailable + ) + } return DependencyHealthReport( ffmpeg: ffmpeg, @@ -131,14 +179,14 @@ private enum DependencyHealthProbe { }.value } - private static func inspectExecutable(named name: String) -> ExecutableHealth { + private static func inspectExecutable(named name: String) async -> ExecutableHealth { guard let executable = executableDirectories .map({ URL(fileURLWithPath: $0).appendingPathComponent(name) }) .first(where: { FileManager.default.isExecutableFile(atPath: $0.path) }) else { return ExecutableHealth(level: .failed, name: name == "ffprobe" ? "FFprobe" : "FFmpeg", path: nil, version: nil) } - let result = run(executable, arguments: ["-version"], timeout: 3) + let result = await run(executable, arguments: ["-version"], timeout: 3) guard result.succeeded else { return ExecutableHealth(level: .failed, name: name == "ffprobe" ? "FFprobe" : "FFmpeg", path: executable.path, version: nil) } @@ -254,7 +302,7 @@ private enum DependencyHealthProbe { return true } - private static func run(_ executable: URL, arguments: [String], timeout: TimeInterval) -> CommandResult { + private static func run(_ executable: URL, arguments: [String], timeout: TimeInterval) async -> CommandResult { let process = Process() let standardOutput = Pipe() let standardError = Pipe() @@ -269,22 +317,30 @@ private enum DependencyHealthProbe { return CommandResult(succeeded: false, output: "") } + // Drain both pipes while the child runs. Waiting for exit first can + // deadlock if FFmpeg writes more than a pipe can buffer. + let outputTask = Task.detached(priority: .utility) { + standardOutput.fileHandleForReading.readDataToEndOfFile() + } + let errorTask = Task.detached(priority: .utility) { + standardError.fileHandleForReading.readDataToEndOfFile() + } + let deadline = Date().addingTimeInterval(timeout) while process.isRunning && Date() < deadline { - Thread.sleep(forTimeInterval: 0.025) + try? await Task.sleep(for: .milliseconds(25)) } if process.isRunning { process.terminate() let graceDeadline = Date().addingTimeInterval(0.5) while process.isRunning && Date() < graceDeadline { - Thread.sleep(forTimeInterval: 0.025) + try? await Task.sleep(for: .milliseconds(25)) } } if process.isRunning { _ = kill(process.processIdentifier, SIGKILL) } process.waitUntilExit() - let output = standardOutput.fileHandleForReading.readDataToEndOfFile() - + standardError.fileHandleForReading.readDataToEndOfFile() + let output = await outputTask.value + errorTask.value return CommandResult( succeeded: process.terminationStatus == 0, output: String(data: output, encoding: .utf8) ?? "" diff --git a/Sources/FastConvert/FastConvertApp.swift b/Sources/FastConvert/FastConvertApp.swift index 2e6d1f2..932b9a3 100644 --- a/Sources/FastConvert/FastConvertApp.swift +++ b/Sources/FastConvert/FastConvertApp.swift @@ -614,16 +614,17 @@ struct ContentView: View { private var dependencyHealthGuide: some View { DependencyHealthStatusBar( monitor: dependencyHealth, - refresh: refreshDependencyHealth, + refresh: { refreshDependencyHealth(forceToolCheck: true) }, copyFFmpegInstallCommand: store.copyFFmpegInstallCommand, openHomebrewWebsite: store.openHomebrewWebsite ) } - private func refreshDependencyHealth() { + private func refreshDependencyHealth(forceToolCheck: Bool = false) { dependencyHealth.refresh( outputDirectory: store.outputDirectory, - sourceDirectories: store.items.map(\.source) + sourceDirectories: store.items.map(\.source), + forceToolCheck: forceToolCheck ) } diff --git a/Sources/FastConvert/SmartEnhanceExporter.swift b/Sources/FastConvert/SmartEnhanceExporter.swift index 62349f5..dc4daa4 100644 --- a/Sources/FastConvert/SmartEnhanceExporter.swift +++ b/Sources/FastConvert/SmartEnhanceExporter.swift @@ -14,16 +14,58 @@ private final class ExportCompletionGate: @unchecked Sendable { self.continuation = continuation } - func succeed() { resolve(.success(())) } - func fail(_ error: Error) { resolve(.failure(error)) } + @discardableResult func succeed() -> Bool { resolve(.success(())) } + @discardableResult func fail(_ error: Error) -> Bool { resolve(.failure(error)) } - private func resolve(_ result: Result) { + private func resolve(_ result: Result) -> Bool { lock.lock() let continuation = self.continuation self.continuation = nil lock.unlock() - guard let continuation else { return } + guard let continuation else { return false } continuation.resume(with: result) + return true + } +} + +/// Cancellation may arrive before a VideoToolbox or writer continuation is +/// installed. Keep that signal until the bridge is ready to receive it. +private final class ExportWaitCancellation: @unchecked Sendable { + private let lock = NSLock() + private var gate: ExportCompletionGate? + private var cancelled = false + private var submitted = false + + @discardableResult func install(_ gate: ExportCompletionGate) -> Bool { + lock.lock() + self.gate = gate + let cancelled = self.cancelled + lock.unlock() + if cancelled { gate.fail(CancellationError()) } + return !cancelled + } + + func cancel() { + lock.lock() + cancelled = true + let gate = submitted ? self.gate : nil + lock.unlock() + gate?.fail(CancellationError()) + } + + /// Finish submitting the framework request before cancellation can resume + /// the exporter and tear down the reader, writer or processing session. + func submit(_ operation: () -> Void) { + lock.lock() + guard !cancelled else { + let gate = self.gate + lock.unlock() + gate?.fail(CancellationError()) + return + } + submitted = true + operation() + lock.unlock() } } @@ -127,6 +169,53 @@ struct SmartEnhancePlan: Sendable { #if compiler(>=6.4) @available(macOS 27.0, *) final class SmartEnhanceExporter { + /// A timeout can resume export before VideoToolbox calls back. End the + /// processing session only after its last submitted request completes. + private final class FrameProcessorSession: @unchecked Sendable { + let processor: VTFrameProcessor + private let lock = NSLock() + private var requestsInFlight = 0 + private var closing = false + private var ended = false + + init(_ processor: VTFrameProcessor) { self.processor = processor } + + func beginRequest() { + lock.lock() + requestsInFlight += 1 + lock.unlock() + } + + func completeRequest() { + lock.lock() + requestsInFlight -= 1 + let shouldEnd = closing && requestsInFlight == 0 && !ended + if shouldEnd { ended = true } + lock.unlock() + if shouldEnd { processor.endSession() } + } + + func close() { + lock.lock() + closing = true + let shouldEnd = requestsInFlight == 0 && !ended + if shouldEnd { ended = true } + lock.unlock() + if shouldEnd { processor.endSession() } + } + } + + /// The callback is Sendable but VideoToolbox's Objective-C parameter type + /// is not annotated. This reference owns the per-frame buffers until the + /// framework finishes its asynchronous request. + private final class FrameParametersReference: @unchecked Sendable { + let parameters: VTLowLatencySuperResolutionScalerParameters + + init(_ parameters: VTLowLatencySuperResolutionScalerParameters) { + self.parameters = parameters + } + } + static var isSupported: Bool { #if arch(arm64) VTLowLatencySuperResolutionScalerConfiguration.isSupported @@ -238,33 +327,6 @@ final class SmartEnhanceExporter { writer.add(audioInput) } - guard writer.startWriting() else { throw ExportError.writer(writer.error?.localizedDescription ?? "Unable to start the enhanced export.") } - guard reader.startReading() else { throw ExportError.reader(reader.error?.localizedDescription ?? "Unable to read the source video.") } - writer.startSession(atSourceTime: trim.map { CMTime(seconds: $0.start, preferredTimescale: 600) } ?? .zero) - - // Asset writers apply interleaving pressure while encoding. Feed audio - // alongside enhanced video so a high-resolution video input cannot - // wait forever for an audio packet that this exporter held until last. - let audioWriteTask: Task? = if let audioOutput, let audioInput { - Task { - while let sample = audioOutput.copyNextSampleBuffer() { - if shouldCancel() || Task.isCancelled { - reader.cancelReading() - writer.cancelWriting() - throw CancellationError() - } - try await waitUntilReady(audioInput, writer: writer, reader: reader, shouldCancel: shouldCancel) - guard audioInput.append(sample) else { - throw ExportError.writer(writer.error?.localizedDescription ?? "Unable to write source audio.") - } - } - audioInput.markAsFinished() - } - } else { - nil - } - defer { audioWriteTask?.cancel() } - let configuration = VTLowLatencySuperResolutionScalerConfiguration( frameWidth: plan.sourceWidth, frameHeight: plan.sourceHeight, @@ -272,45 +334,87 @@ final class SmartEnhanceExporter { ) let processor = VTFrameProcessor() try processor.startSession(configuration: configuration) - defer { processor.endSession() } - - let offset = trim?.start ?? 0 - while let sample = videoOutput.copyNextSampleBuffer() { - if shouldCancel() { - reader.cancelReading() - writer.cancelWriting() - throw CancellationError() - } - guard let buffer = CMSampleBufferGetImageBuffer(sample), - let sourceFrame = VTFrameProcessorFrame(buffer: buffer, presentationTimeStamp: CMSampleBufferGetPresentationTimeStamp(sample)), - let destinationBuffer = makeDestinationBuffer(configuration: configuration), - let destinationFrame = VTFrameProcessorFrame(buffer: destinationBuffer, presentationTimeStamp: CMSampleBufferGetPresentationTimeStamp(sample)) else { - throw ExportError.pixelBuffer + let processorSession = FrameProcessorSession(processor) + defer { processorSession.close() } + + var audioWriteTask: Task? + do { + if shouldCancel() || Task.isCancelled { throw CancellationError() } + guard writer.startWriting() else { throw ExportError.writer(writer.error?.localizedDescription ?? "Unable to start the enhanced export.") } + guard reader.startReading() else { throw ExportError.reader(reader.error?.localizedDescription ?? "Unable to read the source video.") } + writer.startSession(atSourceTime: trim.map { CMTime(seconds: $0.start, preferredTimescale: 600) } ?? .zero) + + // Feed audio alongside video; the writer may wait for the next + // audio packet before accepting a high-resolution video frame. + if let audioOutput, let audioInput { + audioWriteTask = Task { + do { + while let sample = audioOutput.copyNextSampleBuffer() { + try await waitUntilReady(audioInput, writer: writer, reader: reader, shouldCancel: shouldCancel) + guard audioInput.append(sample) else { + throw ExportError.writer(writer.error?.localizedDescription ?? "Unable to write source audio.") + } + } + if shouldCancel() || Task.isCancelled { throw CancellationError() } + guard reader.status != .failed else { + throw ExportError.reader(reader.error?.localizedDescription ?? "Unable to read source audio.") + } + audioInput.markAsFinished() + } catch { + // Wake the video producer if audio fails while the + // writer is waiting for interleaved media. + reader.cancelReading() + writer.cancelWriting() + throw error + } + } } - let parameters = VTLowLatencySuperResolutionScalerParameters(sourceFrame: sourceFrame, destinationFrame: destinationFrame) - try await process(processor: processor, parameters: parameters) - let outputBuffer: CVPixelBuffer - if let pixelTransferSession { - guard let resizedBuffer = makeWriterDestinationBuffer(adaptor: pixelBufferAdaptor), - VTPixelTransferSessionTransferImage(pixelTransferSession, from: destinationBuffer, to: resizedBuffer) == noErr else { - throw ExportError.pixelTransfer + + let offset = trim?.start ?? 0 + while let sample = videoOutput.copyNextSampleBuffer() { + if shouldCancel() || Task.isCancelled { throw CancellationError() } + guard let buffer = CMSampleBufferGetImageBuffer(sample), + let sourceFrame = VTFrameProcessorFrame(buffer: buffer, presentationTimeStamp: CMSampleBufferGetPresentationTimeStamp(sample)), + let destinationBuffer = makeDestinationBuffer(configuration: configuration), + let destinationFrame = VTFrameProcessorFrame(buffer: destinationBuffer, presentationTimeStamp: CMSampleBufferGetPresentationTimeStamp(sample)) else { + throw ExportError.pixelBuffer + } + let parameters = VTLowLatencySuperResolutionScalerParameters(sourceFrame: sourceFrame, destinationFrame: destinationFrame) + try await process(session: processorSession, parameters: parameters, shouldCancel: shouldCancel) + if shouldCancel() || Task.isCancelled { throw CancellationError() } + let outputBuffer: CVPixelBuffer + if let pixelTransferSession { + guard let resizedBuffer = makeWriterDestinationBuffer(adaptor: pixelBufferAdaptor), + VTPixelTransferSessionTransferImage(pixelTransferSession, from: destinationBuffer, to: resizedBuffer) == noErr else { + throw ExportError.pixelTransfer + } + outputBuffer = resizedBuffer + } else { + outputBuffer = destinationBuffer } - outputBuffer = resizedBuffer - } else { - outputBuffer = destinationBuffer + try await waitUntilReady(videoInput, writer: writer, reader: reader, shouldCancel: shouldCancel) + guard pixelBufferAdaptor.append(outputBuffer, withPresentationTime: CMSampleBufferGetPresentationTimeStamp(sample)) else { + throw ExportError.writer(writer.error?.localizedDescription ?? "Unable to write an enhanced frame.") + } + onProgress(max(0, CMSampleBufferGetPresentationTimeStamp(sample).seconds - offset)) } - try await waitUntilReady(videoInput, writer: writer, reader: reader, shouldCancel: shouldCancel) - guard pixelBufferAdaptor.append(outputBuffer, withPresentationTime: CMSampleBufferGetPresentationTimeStamp(sample)) else { - throw ExportError.writer(writer.error?.localizedDescription ?? "Unable to write an enhanced frame.") + if shouldCancel() || Task.isCancelled { throw CancellationError() } + guard reader.status != .failed else { throw ExportError.reader(reader.error?.localizedDescription ?? "Unable to read the source video.") } + videoInput.markAsFinished() + + if let audioWriteTask { try await waitForAudioCompletion(audioWriteTask, shouldCancel: shouldCancel) } + guard reader.status == .completed else { + throw ExportError.reader(reader.error?.localizedDescription ?? "The source could not be read completely.") } - onProgress(max(0, CMSampleBufferGetPresentationTimeStamp(sample).seconds - offset)) + if shouldCancel() || Task.isCancelled { throw CancellationError() } + try await finish(writer: writer, shouldCancel: shouldCancel) + } catch { + audioWriteTask?.cancel() + reader.cancelReading() + writer.cancelWriting() + if let audioWriteTask { await waitForAudioShutdown(audioWriteTask) } + throw error } - guard reader.status != .failed else { throw ExportError.reader(reader.error?.localizedDescription ?? "Unable to read the source video.") } - videoInput.markAsFinished() - - if let audioWriteTask { try await audioWriteTask.value } - guard reader.status != .failed else { throw ExportError.reader(reader.error?.localizedDescription ?? "Unable to read source audio.") } - try await finish(writer: writer) } private func makeDestinationBuffer(configuration: VTLowLatencySuperResolutionScalerConfiguration) -> CVPixelBuffer? { @@ -344,46 +448,133 @@ final class SmartEnhanceExporter { return pixelTransferSession } - private func process(processor: VTFrameProcessor, parameters: VTLowLatencySuperResolutionScalerParameters) async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - let gate = ExportCompletionGate(continuation) - let watchdog = DispatchWorkItem { gate.fail(ExportError.processingTimeout) } - DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 12, execute: watchdog) - processor.process(parameters: parameters) { _, error in - watchdog.cancel() - if let error { gate.fail(error) } - else { gate.succeed() } + private func process(session: FrameProcessorSession, parameters: VTLowLatencySuperResolutionScalerParameters, shouldCancel: @escaping () -> Bool) async throws { + let cancellation = ExportWaitCancellation() + let cancellationMonitor = monitorCancellation(cancellation, shouldCancel: shouldCancel) + let parametersReference = FrameParametersReference(parameters) + defer { cancellationMonitor.cancel() } + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let gate = ExportCompletionGate(continuation) + guard cancellation.install(gate) else { return } + let watchdog = DispatchWorkItem { gate.fail(ExportError.processingTimeout) } + cancellation.submit { + session.beginRequest() + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 60, execute: watchdog) + session.processor.process(parameters: parametersReference.parameters) { _, error in + // Retain the buffers and session until VideoToolbox has + // really completed this request, even after a timeout. + withExtendedLifetime(parametersReference) {} + session.completeRequest() + watchdog.cancel() + if let error { gate.fail(error) } + else { gate.succeed() } + } + } } + } onCancel: { + cancellation.cancel() } } private func waitUntilReady(_ input: AVAssetWriterInput, writer: AVAssetWriter, reader: AVAssetReader, shouldCancel: () -> Bool) async throws { - let deadline = Date().addingTimeInterval(8) + let deadline = Date().addingTimeInterval(30) while !input.isReadyForMoreMediaData { if shouldCancel() || Task.isCancelled { throw CancellationError() } if writer.status == .failed { throw ExportError.writer(writer.error?.localizedDescription ?? "The enhanced export failed.") } + if writer.status == .cancelled { throw ExportError.writer("The enhanced export was cancelled by the encoder.") } if reader.status == .failed { throw ExportError.reader(reader.error?.localizedDescription ?? "The source video could not be read.") } + if reader.status == .cancelled { throw ExportError.reader("The source reader was cancelled.") } if Date() >= deadline { throw ExportError.writer(L10n.text("编码器未响应。")) } try await Task.sleep(for: .milliseconds(4)) } + if shouldCancel() || Task.isCancelled { throw CancellationError() } } - private func finish(writer: AVAssetWriter) async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - let gate = ExportCompletionGate(continuation) - let writerReference = ExportWriterReference(writer) - let watchdog = DispatchWorkItem { - writer.cancelWriting() - gate.fail(ExportError.finalizationTimeout) + private func finish(writer: AVAssetWriter, shouldCancel: @escaping () -> Bool) async throws { + let cancellation = ExportWaitCancellation() + let cancellationMonitor = monitorCancellation(cancellation, shouldCancel: shouldCancel) + defer { cancellationMonitor.cancel() } + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let gate = ExportCompletionGate(continuation) + guard cancellation.install(gate) else { return } + let writerReference = ExportWriterReference(writer) + let watchdog = DispatchWorkItem { + if gate.fail(ExportError.finalizationTimeout) { + writerReference.writer.cancelWriting() + } + } + cancellation.submit { + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 90, execute: watchdog) + writer.finishWriting { + watchdog.cancel() + let writer = writerReference.writer + guard writer.status == .completed else { + gate.fail(ExportError.writer(writer.error?.localizedDescription ?? "The enhanced export did not complete.")) + return + } + gate.succeed() + } + } } - DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 12, execute: watchdog) - writer.finishWriting { - watchdog.cancel() - let writer = writerReference.writer - guard writer.status == .completed else { - gate.fail(ExportError.writer(writer.error?.localizedDescription ?? "The enhanced export did not complete.")) + } onCancel: { + cancellation.cancel() + } + } + + private func monitorCancellation(_ cancellation: ExportWaitCancellation, shouldCancel: @escaping () -> Bool) -> Task { + Task { + while !Task.isCancelled { + if await MainActor.run(body: { shouldCancel() }) { + cancellation.cancel() return } + try? await Task.sleep(for: .milliseconds(100)) + } + } + } + + /// The audio producer normally finishes before video. A damaged source can + /// leave its final read pending, so bound this last join as well. + private func waitForAudioCompletion(_ task: Task, shouldCancel: @escaping () -> Bool) async throws { + let cancellation = ExportWaitCancellation() + let cancellationMonitor = monitorCancellation(cancellation, shouldCancel: shouldCancel) + defer { cancellationMonitor.cancel() } + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let gate = ExportCompletionGate(continuation) + guard cancellation.install(gate) else { return } + let watchdog = DispatchWorkItem { gate.fail(ExportError.reader("Audio decoding did not complete.")) } + cancellation.submit { + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 90, execute: watchdog) + Task { + do { + try await task.value + watchdog.cancel() + gate.succeed() + } catch { + watchdog.cancel() + gate.fail(error) + } + } + } + } + } onCancel: { + cancellation.cancel() + } + } + + /// AVAssetReader cancellation normally releases a blocked audio read. + /// Bound the join so a broken demuxer cannot trap the user's cancel action. + private func waitForAudioShutdown(_ task: Task) async { + _ = try? await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let gate = ExportCompletionGate(continuation) + let watchdog = DispatchWorkItem { gate.fail(ExportError.reader("Audio reader did not stop.")) } + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2, execute: watchdog) + Task { + _ = await task.result + watchdog.cancel() gate.succeed() } } diff --git a/Tests/FastConvertTests/ConverterStoreTests.swift b/Tests/FastConvertTests/ConverterStoreTests.swift new file mode 100644 index 0000000..9970c5d --- /dev/null +++ b/Tests/FastConvertTests/ConverterStoreTests.swift @@ -0,0 +1,176 @@ +import Foundation +import XCTest +@testable import FastConvert + +final class ConverterStoreTests: XCTestCase { + @MainActor + func testSoftwareConversionPublishesVerifiedMP4() async throws { + let ffmpeg = try availableFFmpeg() + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("source.mov") + try makeShortVideo(at: source, ffmpeg: ffmpeg) + + let store = ConverterStore() + store.preset = .h264Software + store.outputDirectory = directory + store.items = [ConversionItem(source: source)] + store.start() + store.start() // Repeated clicks must not launch a second worker. + try await waitForQueue(store) + + guard case .complete(let output) = store.items[0].state else { + return XCTFail("Expected a complete MP4; got \(store.items[0].state)") + } + XCTAssertTrue(FileManager.default.fileExists(atPath: output.path)) + XCTAssertGreaterThan(try output.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0, 0) + let leftovers = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .filter { $0.lastPathComponent.hasPrefix(".MP4Flow-") && $0.lastPathComponent.hasSuffix(".partial.mp4") } + XCTAssertTrue(leftovers.isEmpty, "Temporary outputs must be removed after publication") + let published = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "mp4" } + XCTAssertEqual(published.count, 1, "Repeated start must not publish a duplicate") + } + + @MainActor + func testUnreadableSourceCanBeRetriedAfterItBecomesAvailable() async throws { + let ffmpeg = try availableFFmpeg() + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("late source.mov") + + let store = ConverterStore() + store.preset = .h264Software + store.outputDirectory = directory + store.items = [ConversionItem(source: source)] + store.start() + try await waitForQueue(store) + guard case .failed = store.items[0].state else { + return XCTFail("Expected an explicit probe failure") + } + XCTAssertEqual(store.items[0].failure?.diagnostic, "probe:unavailable") + + try makeShortVideo(at: source, ffmpeg: ffmpeg) + store.retryIncomplete() + store.start() + try await waitForQueue(store) + guard case .complete(let output) = store.items[0].state else { + return XCTFail("Expected retry to complete; got \(store.items[0].state)") + } + XCTAssertTrue(FileManager.default.fileExists(atPath: output.path)) + } + + func testProbeDrainsStreamRichJSONBeforeProcessExit() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("probe response.json") + let video: [String: Any] = ["codec_type": "video", "codec_name": "h264", "width": 64, "height": 48, "r_frame_rate": "8/1"] + let extraStreams: [[String: Any]] = (0..<5_000).map { _ in ["codec_type": "subtitle", "codec_name": "subrip"] } + let json = try JSONSerialization.data(withJSONObject: [ + "streams": [video] + extraStreams, + "format": ["duration": "1.0", "bit_rate": "1000000"] + ]) + XCTAssertGreaterThan(json.count, 64 * 1_024) + try json.write(to: source) + + let fakeProbe = directory.appendingPathComponent("fake ffprobe") + try "#!/bin/sh\nfor source in \"$@\"; do :; done\n/bin/cat \"$source\"\n".write(to: fakeProbe, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fakeProbe.path) + + let start = Date() + let info = await ConverterStore.inspectMedia(at: source, using: fakeProbe) + XCTAssertTrue(info.probeSucceeded) + XCTAssertTrue(info.hasVideo) + XCTAssertEqual(info.width, 64) + XCTAssertEqual(info.subtitleTrackCount, 5_000) + XCTAssertLessThan(Date().timeIntervalSince(start), 10, "A full pipe must not block ffprobe until its 30-second watchdog") + } + + @MainActor + func testTrimCropAndRotationKeepSoftwarePresetAndCorrectGeometry() async throws { + let ffmpeg = try availableFFmpeg() + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("edit.mov") + try makeShortVideo(at: source, ffmpeg: ffmpeg) + + let store = ConverterStore() + store.preset = .h264Software + store.outputDirectory = directory + let item = ConversionItem(source: source) + store.items = [item] + store.scheduleTrim(item, start: 0.125, end: 0.375) + store.scheduleCrop(item, crop: CropRect(x: 8, y: 4, width: 48, height: 32)) + store.scheduleRotation(item, rotation: .clockwise) + store.start() + try await waitForQueue(store) + + guard case .complete(let output) = store.items[0].state else { + return XCTFail("Expected edited conversion to complete; got \(store.items[0].state)") + } + let ffprobe = ffmpeg.deletingLastPathComponent().appendingPathComponent("ffprobe") + let dimensions = try probeValue(output, ffprobe: ffprobe, entries: "stream=width,height") + XCTAssertEqual(dimensions.trimmingCharacters(in: .whitespacesAndNewlines), "32,48") + let duration = try XCTUnwrap(Double(probeValue(output, ffprobe: ffprobe, entries: "format=duration").trimmingCharacters(in: .whitespacesAndNewlines))) + XCTAssertGreaterThan(duration, 0.1) + XCTAssertLessThan(duration, 0.5) + } + + private func availableFFmpeg() throws -> URL { + for path in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"] { + if FileManager.default.isExecutableFile(atPath: path), + FileManager.default.isExecutableFile(atPath: URL(fileURLWithPath: path).deletingLastPathComponent().appendingPathComponent("ffprobe").path) { + return URL(fileURLWithPath: path) + } + } + throw XCTSkip("FFmpeg and ffprobe are required for this integration test") + } + + private func temporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("MP4Flow-ConverterStoreTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + private func makeShortVideo(at output: URL, ffmpeg: URL) throws { + let process = Process() + process.executableURL = ffmpeg + process.arguments = ["-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=red:s=64x48:r=8:d=0.5", "-c:v", "mpeg4", "-y", output.path] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { throw FixtureError.generationFailed(process.terminationStatus) } + } + + private func probeValue(_ source: URL, ffprobe: URL, entries: String) throws -> String { + let process = Process() + let pipe = Pipe() + process.executableURL = ffprobe + process.arguments = ["-v", "error", "-show_entries", entries, "-of", "csv=p=0", source.path] + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0 else { throw FixtureError.probeFailed(process.terminationStatus) } + return String(decoding: data, as: UTF8.self) + } + + @MainActor + private func waitForQueue(_ store: ConverterStore) async throws { + let deadline = Date().addingTimeInterval(20) + while store.isRunning && Date() < deadline { + try await Task.sleep(nanoseconds: 50_000_000) + } + if store.isRunning { + store.cancel() + XCTFail("Conversion queue did not complete within 20 seconds") + } + } +} + +private enum FixtureError: Error { + case generationFailed(Int32) + case probeFailed(Int32) +} diff --git a/Tests/FastConvertTests/SmartEnhanceTests.swift b/Tests/FastConvertTests/SmartEnhanceTests.swift new file mode 100644 index 0000000..8421cc9 --- /dev/null +++ b/Tests/FastConvertTests/SmartEnhanceTests.swift @@ -0,0 +1,126 @@ +import AVFoundation +import XCTest +@testable import FastConvert + +private final class LockedCancellationFlag: @unchecked Sendable { + private let lock = NSLock() + private var cancelled = false + + func cancel() { lock.lock(); cancelled = true; lock.unlock() } + func isCancelled() -> Bool { lock.lock(); defer { lock.unlock() }; return cancelled } +} + +final class SmartEnhanceTests: XCTestCase { + func testShortVideoWithAudioExportsAndRetriesAfterCancellation() async throws { +#if compiler(>=6.4) + guard #available(macOS 27.0, *), SmartEnhanceExporter.isSupported else { + throw XCTSkip("Native super-resolution is unavailable on this SDK or Mac.") + } + guard let ffmpeg = ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"] + .first(where: { FileManager.default.isExecutableFile(atPath: $0) }) else { + throw XCTSkip("FFmpeg is unavailable for the generated media fixture.") + } + + let info = VideoInfo( + duration: 1, videoRate: 500_000, audioRate: 128_000, + width: 320, height: 240, hasVideo: true, + videoCodec: "mpeg4", audioCodec: "aac", frameRate: "12/1", + pixelFormat: "yuv420p", audioSampleRate: "48000", audioChannels: 1, + audioTrackCount: 1, subtitleTrackCount: 0, + colorTransfer: nil, probeSucceeded: true + ) + guard let target = SmartEnhancePlan.availableTargets(sourceWidth: 320, sourceHeight: 240).first, + let plan = SmartEnhancePlan.make(info: info, target: target) else { + throw XCTSkip("The installed VideoToolbox scaler has no supported target for this fixture.") + } + + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("mp4flow-enhance-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("source.mp4") + let output = directory.appendingPathComponent("enhanced.mp4") + let generator = Process() + generator.executableURL = URL(fileURLWithPath: ffmpeg) + generator.arguments = [ + "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=12", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", + "-t", "1", "-c:v", "mpeg4", "-q:v", "5", + "-c:a", "aac", "-shortest", source.path + ] + try generator.run() + generator.waitUntilExit() + guard generator.terminationStatus == 0 else { + XCTFail("Could not generate the input fixture (exit \(generator.terminationStatus)).") + return + } + + let started = ProcessInfo.processInfo.systemUptime + try await SmartEnhanceExporter().export( + source: source, output: output, plan: plan, trim: nil, + videoBitRate: 1_000_000, shouldCancel: { false }, onProgress: { _ in } + ) + print("Smart Enhance native export completed in \(String(format: "%.3f", ProcessInfo.processInfo.systemUptime - started)) seconds") + + let asset = AVURLAsset(url: output) + let video = try await asset.loadTracks(withMediaType: .video) + let audio = try await asset.loadTracks(withMediaType: .audio) + XCTAssertEqual(video.count, 1) + XCTAssertEqual(audio.count, 1) + let size = try await video[0].load(.naturalSize) + XCTAssertEqual(Int(size.width), plan.targetWidth) + XCTAssertEqual(Int(size.height), plan.targetHeight) + XCTAssertGreaterThan(try output.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0, 0) + + let playbackReader = try AVAssetReader(asset: asset) + let decodedVideo = AVAssetReaderTrackOutput(track: video[0], outputSettings: [ + kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA) + ]) + let decodedAudio = AVAssetReaderTrackOutput(track: audio[0], outputSettings: [ + AVFormatIDKey: kAudioFormatLinearPCM + ]) + playbackReader.add(decodedVideo) + playbackReader.add(decodedAudio) + XCTAssertTrue(playbackReader.startReading()) + XCTAssertNotNil(decodedVideo.copyNextSampleBuffer(), "The enhanced video must decode.") + XCTAssertNotNil(decodedAudio.copyNextSampleBuffer(), "The output audio must decode.") + playbackReader.cancelReading() + + let cancelledOutput = directory.appendingPathComponent("cancelled.mp4") + let cancellation = LockedCancellationFlag() + do { + try await SmartEnhanceExporter().export( + source: source, output: cancelledOutput, plan: plan, trim: nil, + videoBitRate: 1_000_000, + shouldCancel: { cancellation.isCancelled() }, + onProgress: { _ in cancellation.cancel() } + ) + XCTFail("The in-progress export should have stopped after its first frame.") + } catch is CancellationError { + // Cancellation is expected; the next export must start cleanly. + } + try? FileManager.default.removeItem(at: cancelledOutput) + try await SmartEnhanceExporter().export( + source: source, output: cancelledOutput, plan: plan, trim: nil, + videoBitRate: 1_000_000, shouldCancel: { false }, onProgress: { _ in } + ) + XCTAssertGreaterThan(try cancelledOutput.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0, 0) + let retryAsset = AVURLAsset(url: cancelledOutput) + let retryTracks = try await retryAsset.loadTracks(withMediaType: .video) + guard let retryTrack = retryTracks.first else { + XCTFail("The retry must contain a video track.") + return + } + let retryReader = try AVAssetReader(asset: retryAsset) + let retryVideo = AVAssetReaderTrackOutput(track: retryTrack, outputSettings: [ + kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA) + ]) + retryReader.add(retryVideo) + XCTAssertTrue(retryReader.startReading()) + XCTAssertNotNil(retryVideo.copyNextSampleBuffer(), "The retry must produce decodable video.") + retryReader.cancelReading() +#else + throw XCTSkip("The compiler lacks the native super-resolution API.") +#endif + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 52e2437..f5ce65e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,8 +4,12 @@ MP4Flow is a small native SwiftUI application without a server. - `FastConvertApp.swift` contains the SwiftUI screens, editors, and interaction components. - `ConverterStore.swift` owns the serial queue, FFmpeg process lifecycle, progress parsing, media probing, output naming, and merge logic. +- `SmartEnhanceExporter.swift` owns the optional macOS 27 VideoToolbox enhancement pipeline and its reader/writer lifecycle. +- `DependencyHealth.swift` checks local tools, hardware encoding, and output locations. Tool results are reused during ordinary queue edits; manual refresh performs a fresh tool check. - `AppLanguage.swift` resolves the selected language and exposes `L10n` for dynamic strings that SwiftUI cannot localize automatically. - `AppResources/*.lproj/Localizable.strings` holds English, Japanese, and Simplified Chinese localizations. Chinese source strings are the Chinese fallback; English and Japanese include every user-facing key used dynamically, and CI verifies their coverage. - `Scripts/build-app.sh` produces an app bundle; `Scripts/package-dmg.sh` creates an installer from an isolated temporary build. The converter uses FFmpeg and ffprobe from the local computer. For supported H.264 and HEVC input without cropping, VideoToolbox keeps decode, scaling, rotation, and encoding in hardware memory. Rotation uses FFmpeg's `transpose_vt` filter when available and falls back to its software filter if a local FFmpeg build cannot run the hardware path. Queue work is serial by design: it avoids competing for CPU, disk I/O, and VideoToolbox resources during long batch operations. + +Process output is drained while each child runs; waiting for `ffprobe` to exit before reading its JSON can deadlock on stream-rich media. A conversion writes to a uniquely named partial MP4, verifies it, and publishes the final path only if the attempt has not been cancelled. Retryable probe failures are restricted to cloud and nonlocal sources; disk and permission failures do not trigger encoding fallbacks. Each FFmpeg attempt has its own progress identity so delayed callbacks cannot update a later retry. diff --git a/docs/MEDIA-REGRESSION.md b/docs/MEDIA-REGRESSION.md index abd8795..9a1692f 100644 --- a/docs/MEDIA-REGRESSION.md +++ b/docs/MEDIA-REGRESSION.md @@ -6,12 +6,21 @@ deterministic CI signal from the release validation that must happen on real Mac ## Automated synthetic suite -Run this before every release and on every pull request: +Run the application integration tests and media fixture checks before every release and on every pull request: ```sh +swift test ./Scripts/run-media-regression.sh ``` +`swift test` exercises `ConverterStore` with generated short videos: a complete +software conversion, failed-source retry, trim/crop/rotation output geometry, +and a stream-rich `ffprobe` response larger than a pipe buffer. On macOS 27 +machines with supported VideoToolbox hardware, it also exports and decodes a +short native Smart Enhance video with audio. That test skips on older or +unsupported Macs; the ordinary converter tests still run. CI installs FFmpeg +before invoking the Swift tests. + The script generates tiny, deterministic files in a uniquely-created temporary directory, asserts stream metadata and decoding contracts with `ffprobe` and FFmpeg, and removes the files afterwards. It tests H.264, HEVC, variable frame @@ -19,9 +28,9 @@ rate, portrait video, video-only input, multiple audio tracks, subtitles, rotation metadata, odd dimensions, HDR signalling, simulated long duration, special file names, and truncated input. It does not download or commit media. -Passing this suite means the **test toolchain and fixture contracts** are sound. -It does not prove that a SwiftUI action, a hardware encoder, or a particular -customer video is supported. +Passing these checks confirms the exercised conversion paths and fixture +contracts. It does not prove that every SwiftUI action, hardware encoder, or +particular customer video is supported. ## Required release matrix