diff --git a/AppResources/Info.plist b/AppResources/Info.plist index 0fa2f99..75e06c8 100644 --- a/AppResources/Info.plist +++ b/AppResources/Info.plist @@ -9,8 +9,8 @@ CFBundleIconFileAppIcon CFBundleNameMP4Flow CFBundlePackageTypeAPPL - CFBundleShortVersionString1.2.3 - CFBundleVersion9 + CFBundleShortVersionString1.2.4 + CFBundleVersion10 CFBundleLocalizations enjazh-Hans LSMinimumSystemVersion13.0 diff --git a/AppResources/en.lproj/Localizable.strings b/AppResources/en.lproj/Localizable.strings index eaea687..c42f714 100644 --- a/AppResources/en.lproj/Localizable.strings +++ b/AppResources/en.lproj/Localizable.strings @@ -311,7 +311,7 @@ "可用空间较少;建议至少保留 1 GB。" = "Free space is low; keep at least 1 GB free."; "处理策略" = "Processing Policy"; "默认保留首路视频和音频;多音轨、字幕及 HDR 的处理将在队列中提示。" = "First video/audio tracks only. The queue flags extra audio tracks, subtitles and HDR."; -"默认仅输出第一路视频和音频,并移除容器元数据和章节。开始转换时会检测多音轨、字幕和 HDR,并在队列中提示。" = "Smart Convert copies compatible video whenever possible and re-encodes only when needed. Output keeps the first video and audio track and removes container metadata and chapters. Notices appear when extra tracks, subtitles, chapters, or HDR transcoding affect the result."; +"默认仅输出第一路视频和音频,并移除容器元数据和章节。开始转换时会检测多音轨、字幕和 HDR,并在队列中提示。" = "Smart Convert copies compatible video whenever possible and re-encodes only when needed. It skips cover art and thumbnails, keeps the first video and audio track, and removes container metadata and chapters. Notices appear when extra tracks, subtitles, chapters, or HDR transcoding affect the result."; "%@ · %d%%" = "%@ · %d%%"; "%@ · %@" = "%@ · %@"; "正在转换" = "Converting"; diff --git a/AppResources/ja.lproj/Localizable.strings b/AppResources/ja.lproj/Localizable.strings index 3df53d4..e82fa42 100644 --- a/AppResources/ja.lproj/Localizable.strings +++ b/AppResources/ja.lproj/Localizable.strings @@ -312,7 +312,7 @@ "可用空间较少;建议至少保留 1 GB。" = "空き容量が少なくなっています。少なくとも1 GBの空きを確保することをおすすめします。"; "处理策略" = "処理ポリシー"; "默认保留首路视频和音频;多音轨、字幕及 HDR 的处理将在队列中提示。" = "先頭の映像・音声のみ出力。追加音声・字幕・HDRはキューで通知。"; -"默认仅输出第一路视频和音频,并移除容器元数据和章节。开始转换时会检测多音轨、字幕和 HDR,并在队列中提示。" = "スマート変換は対応する映像を可能な限りそのままコピーし、必要な場合のみ再エンコードします。最初の映像・音声トラックを出力し、コンテナメタデータとチャプターは削除します。追加トラック、字幕、チャプター、HDRの再エンコードなどで内容が変わる場合にお知らせします。"; +"默认仅输出第一路视频和音频,并移除容器元数据和章节。开始转换时会检测多音轨、字幕和 HDR,并在队列中提示。" = "スマート変換は対応する映像を可能な限りそのままコピーし、必要な場合のみ再エンコードします。カバー画像とサムネイルを除いた最初の映像・音声トラックを出力し、コンテナメタデータとチャプターは削除します。追加トラック、字幕、チャプター、HDRの再エンコードなどで内容が変わる場合にお知らせします。"; "%@ · %d%%" = "%@・%d%%"; "%@ · %@" = "%@・%@"; "正在转换" = "変換中"; diff --git a/AppResources/zh-Hans.lproj/Localizable.strings b/AppResources/zh-Hans.lproj/Localizable.strings index 213675d..3d3e98c 100644 --- a/AppResources/zh-Hans.lproj/Localizable.strings +++ b/AppResources/zh-Hans.lproj/Localizable.strings @@ -128,7 +128,7 @@ "可用空间较少;建议至少保留 1 GB。" = "可用空间较少;建议至少保留 1 GB。"; "处理策略" = "处理策略"; "默认保留首路视频和音频;多音轨、字幕及 HDR 的处理将在队列中提示。" = "默认保留首路视频和音频;多音轨、字幕及 HDR 的处理将在队列中提示。"; -"默认仅输出第一路视频和音频,并移除容器元数据和章节。开始转换时会检测多音轨、字幕和 HDR,并在队列中提示。" = "智能转换优先直接复制兼容的视频流,仅在需要时重新编码。默认只输出第一路视频和音频,移除容器元数据和章节;检测到额外轨道、字幕、章节或 HDR 转码等内容变化时会提示。"; +"默认仅输出第一路视频和音频,并移除容器元数据和章节。开始转换时会检测多音轨、字幕和 HDR,并在队列中提示。" = "智能转换优先直接复制兼容的视频流,仅在需要时重新编码。自动跳过封面和缩略图,输出第一路视频和音频,移除容器元数据和章节;检测到额外轨道、字幕、章节或 HDR 转码等内容变化时会提示。"; "%@ · %d%%" = "%@ · %d%%"; "%@ · %@" = "%@ · %@"; "正在转换" = "正在转换"; diff --git a/CHANGELOG.md b/CHANGELOG.md index 015e5d9..7428626 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 1.2.4 — 2026-10-20 + +### English + +- Important fix: skip embedded cover art and thumbnails when selecting video across conversion, editing, merging, and Smart Enhance. +- Correct multi-video notices and reject input or output that contains only cover art without a movie. Recommended for all users. + +### 日本語 + +- 重要な修正:変換・編集・結合・スマート拡大で、映像の選択対象からカバー画像とサムネイルを除外します。 +- 複数映像の通知を修正し、動画を含まずカバー画像しかない入力・出力を拒否します。すべてのユーザーに更新をおすすめします。 + +### 中文 + +- 重要修复:转换、编辑、合并和智能增强选取视频时,自动排除内嵌封面和缩略图。 +- 修正多路视频提示;没有正片、只有封面的输入或输出将明确报错。建议所有用户更新。 + ## 1.2.3 — 2026-10-18 ### English diff --git a/README.ja.md b/README.ja.md index db1f313..d6d331a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -49,7 +49,7 @@ - **入力ごとに出力を作成:** 既存ファイルを上書きせず、完了後は Finder で表示できます。 - **進行状況が分かる:** 素材の検査、直接再格納、ハードウェア/ソフトウェア変換、出力検証を表示します。失敗した項目は再試行できます。 -- **音声、字幕、HDR:** 変換前に複数音声、字幕、HDR を知らせます。既定では最初の映像と音声トラックを出力し、コンテナメタデータとチャプターはコピーしません。機密性のある動画は共有前に書き出し結果を確認してください。 +- **音声、字幕、HDR:** 埋め込みカバー画像とサムネイルを除き、最初の映像と音声トラックを出力します。追加トラック、字幕、HDR の処理をお知らせし、コンテナメタデータとチャプターはコピーしません。機密性のある動画は共有前に書き出し結果を確認してください。 - **通常の拡大は高画質化ではありません:** 通常の拡大は寸法を変えるだけです。上のスマート高画質拡大は、システム対応時に選べるオプションです。 ## よくある質問 diff --git a/README.md b/README.md index 7328699..fced831 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ That is all most people need. Open **Environment Check** in the app if you want - **One output per input:** existing files are never overwritten; you can reveal completed files in Finder. - **Clear progress:** MP4Flow shows inspection, direct remuxing, hardware or software conversion, and final verification. Failed items can be retried. -- **Tracks and metadata:** before converting, the app reports multi-audio, subtitles, and HDR. The default export keeps the first video and audio track and does not copy container metadata or chapters. Check the result before sharing sensitive material. +- **Tracks and metadata:** the app skips embedded cover art and thumbnails, then keeps the first video and audio track. It reports extra tracks, subtitles, and HDR handling, and does not copy container metadata or chapters. Check the result before sharing sensitive material. - **No magic enlargement:** normal upscaling changes dimensions, not missing detail. Smart Enhance is the optional system-assisted exception described above. ## Common questions diff --git a/README.zh-CN.md b/README.zh-CN.md index a1f0f2b..d287028 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -49,7 +49,7 @@ - **一条输入,对应一个输出:** 不会覆盖已有文件;完成后可直接在访达中显示。 - **进度看得懂:** 会显示检测素材、快速直封装、硬件/软件转换和输出校验;失败项可重试。 -- **音轨、字幕与 HDR:** 转换前会提示多音轨、字幕与 HDR。默认输出第一路视频和音频,不复制容器元数据或章节。处理敏感内容后,请在分享前检查导出结果。 +- **音轨、字幕与 HDR:** 自动跳过内嵌封面和缩略图,输出第一路视频和音频;额外轨道、字幕与 HDR 的处理会有提示,不复制容器元数据或章节。处理敏感内容后,请在分享前检查导出结果。 - **普通放大不是“变清晰”:** 普通放大只改变尺寸,不会凭空补细节;系统支持时可尝试上面的智能增强放大。 ## 常见问题 diff --git a/Sources/FastConvert/ConverterStore.swift b/Sources/FastConvert/ConverterStore.swift index e4a3f8e..506e973 100644 --- a/Sources/FastConvert/ConverterStore.swift +++ b/Sources/FastConvert/ConverterStore.swift @@ -410,6 +410,15 @@ struct VideoInfo: Sendable { var displayRotation: Double? = nil var videoTrackCount = 0 var chapterCount = 0 + var selectedVideoStreamIndex: Int? = nil + var selectedVideoTrackID: Int32? = nil + var streamLayout: [MediaStreamLayout] = [] + + /// Use the exact stream inspected above. The fallback also excludes cover + /// art, unlike the lowercase `v` selector. + var videoStreamSpecifier: String { + selectedVideoStreamIndex.map(String.init) ?? "V:0" + } func geometry(crop: CropRect?, rotation: VideoRotation?) -> VideoGeometry? { let geometry: VideoGeometry @@ -451,7 +460,7 @@ struct VideoInfo: Sendable { let angle = displayRotation ?? 0 guard angle.isFinite else { return nil } let rotation = (angle.truncatingRemainder(dividingBy: 360) + 360).truncatingRemainder(dividingBy: 360) - return MergeSignature(videoCodec: videoCodec.lowercased(), width: width, height: height, frameRate: frameRate, pixelFormat: pixelFormat, timeBase: timeBase, sampleAspectRatio: sampleAspectRatio, displayRotation: rotation, audioCodec: audioCodec?.lowercased(), audioSampleRate: audioSampleRate, audioChannels: audioChannels) + return MergeSignature(videoCodec: videoCodec.lowercased(), width: width, height: height, frameRate: frameRate, pixelFormat: pixelFormat, timeBase: timeBase, sampleAspectRatio: sampleAspectRatio, displayRotation: rotation, audioCodec: audioCodec?.lowercased(), audioSampleRate: audioSampleRate, audioChannels: audioChannels, streamLayout: streamLayout) } fileprivate var mergePixelAspectRatio: Double { guard let sampleAspectRatio else { return 1 } @@ -497,6 +506,7 @@ private struct MergeSignature: Hashable, Sendable { let audioCodec: String? let audioSampleRate: String? let audioChannels: Int? + let streamLayout: [MediaStreamLayout] } private struct CompatibleMergeTarget: Sendable { @@ -510,20 +520,35 @@ private struct CompatibleMergeTarget: Sendable { private struct ProbeResponse: Decodable, Sendable { let streams: [ProbeStream]; let format: ProbeFormat?; let chapters: [ProbeChapter]? } private struct ProbeChapter: Decodable, Sendable {} private struct ProbeStream: Decodable, Sendable { + let index: Int?, id: String?, disposition: MediaStreamDisposition? let codecType: String?, codecName: String?, bitRate: String?, width: Int?, height: Int? let rFrameRate: String?, pixelFormat: String?, sampleRate: String?, channels: Int?, colorTransfer: String? let sampleAspectRatio: String?, timeBase: String?, sideDataList: [ProbeSideData]?, tags: [String: String]? enum CodingKeys: String, CodingKey { + case index, id, disposition case codecType = "codec_type", codecName = "codec_name", bitRate = "bit_rate" case rFrameRate = "r_frame_rate", pixelFormat = "pix_fmt", sampleRate = "sample_rate", channels, width, height case colorTransfer = "color_transfer" case sampleAspectRatio = "sample_aspect_ratio", timeBase = "time_base", sideDataList = "side_data_list", tags } + + var isMovieVideo: Bool { codecType == "video" && disposition?.isAuxiliaryImage != true } + + var nativeTrackID: Int32? { + guard let id else { return nil } + let value = id.lowercased() + let parsed = value.hasPrefix("0x") ? UInt32(value.dropFirst(2), radix: 16) : UInt32(value) + return parsed.flatMap { $0 != 0 ? Int32(bitPattern: $0) : nil } + } } private struct ProbeSideData: Decodable, Sendable { let rotation: Double? } private struct ProbeFormat: Decodable, Sendable { - let duration: String?, bitRate: String? - enum CodingKeys: String, CodingKey { case duration, bitRate = "bit_rate" } + let duration: String?, bitRate: String?, name: String? + enum CodingKeys: String, CodingKey { case duration, bitRate = "bit_rate", name = "format_name" } + + // MPEG-TS IDs are PIDs, not AVFoundation persistent track IDs. Only the + // MOV/MP4 family guarantees that these identifiers refer to the same track. + var usesNativeTrackIDs: Bool { name?.split(separator: ",").contains("mov") == true } } /// Only retry a full transcode when FFmpeg clearly identifies a VideoToolbox @@ -967,7 +992,7 @@ final class ConverterStore: ObservableObject { return } - var result = await runMergeFFmpeg(ffmpegURL: ffmpegURL, listURL: listURL, output: temporary) + var result = await runMergeFFmpeg(ffmpegURL: ffmpegURL, listURL: listURL, info: infos[0], output: temporary) try? FileManager.default.removeItem(at: listURL) if case .success = result, !cancellationRequested { isVerifyingMerge = true @@ -1034,10 +1059,10 @@ final class ConverterStore: ObservableObject { showCompatibleMergePrompt = true } - private func runMergeFFmpeg(ffmpegURL: URL, listURL: URL, output: URL) async -> ProcessResult { + private func runMergeFFmpeg(ffmpegURL: URL, listURL: URL, info: VideoInfo, output: URL) async -> ProcessResult { await runMergeProcess( ffmpegURL: ffmpegURL, - arguments: ["-hide_banner", "-nostdin", "-y", "-f", "concat", "-safe", "0", "-i", listURL.path, "-map", "0:v:0?", "-map", "0:a:0?", "-map_metadata", "-1", "-map_chapters", "-1", "-c", "copy", "-movflags", "+faststart", "-progress", "pipe:1", "-nostats", output.path], + arguments: ["-hide_banner", "-nostdin", "-y", "-f", "concat", "-safe", "0", "-i", listURL.path, "-map", "0:\(info.videoStreamSpecifier)", "-map", "0:a:0?", "-map_metadata", "-1", "-map_chapters", "-1", "-c", "copy", "-movflags", "+faststart", "-progress", "pipe:1", "-nostats", output.path], usesHardwareEncoder: false ) } @@ -1176,7 +1201,7 @@ final class ConverterStore: ObservableObject { // Fit/pad works in encoded pixels. Normalize each autorotated // input's pixel aspect ratio first so setsar=1 cannot stretch it. // The explicit scale expression also works with older FFmpeg. - filters.append("[\(index):v:0]scale=w='max(2,trunc(iw*if(gt(sar,0),sar,1)/2)*2)':h=ih,setsar=1,scale=\(target.width):\(target.height):force_original_aspect_ratio=decrease:force_divisible_by=2,pad=\(target.width):\(target.height):(ow-iw)/2:(oh-ih)/2:color=black,fps=\(target.frameRate),setsar=1,format=yuv420p[v\(index)]") + filters.append("[\(index):\(info.videoStreamSpecifier)]scale=w='max(2,trunc(iw*if(gt(sar,0),sar,1)/2)*2)':h=ih,setsar=1,scale=\(target.width):\(target.height):force_original_aspect_ratio=decrease:force_divisible_by=2,pad=\(target.width):\(target.height):(ow-iw)/2:(oh-ih)/2:color=black,fps=\(target.frameRate),setsar=1,format=yuv420p[v\(index)]") if info.audioCodec != nil { filters.append("[\(index):a:0]aresample=48000,aformat=channel_layouts=stereo[a\(index)]") } else { @@ -1617,7 +1642,7 @@ final class ConverterStore: ObservableObject { diagnostic: "verify:unreadable-output" ) } - guard info.hasVideo else { + guard info.hasVideo, (info.width ?? 0) > 0, (info.height ?? 0) > 0 else { return ConversionFailure( reasonKey: "输出文件缺少视频流", solutionKey: "请使用「最稳妥转换」重试。", @@ -1639,6 +1664,7 @@ final class ConverterStore: ObservableObject { plan: plan, trim: trim, videoBitRate: info.outputRate(preset: .smartEnhanceUpscale, quality: quality, resolution: .original), + videoTrackID: info.selectedVideoTrackID, shouldCancel: { [weak self] in self?.cancellationRequested ?? true }, onProgress: { [weak self] seconds in guard let self, let index = self.items.firstIndex(where: { $0.id == id }) else { return } @@ -1747,7 +1773,7 @@ final class ConverterStore: ObservableObject { } else { args += ["-i", source.path] } - args += ["-map", "0:v:0?", "-map", "0:a:0?", "-map_metadata", "-1", "-map_chapters", "-1"] + args += ["-map", "0:\(info.videoStreamSpecifier)", "-map", "0:a:0?", "-map_metadata", "-1", "-map_chapters", "-1"] if let trim { args += ["-t", ffmpegTime(trim.end - trim.start)] } @@ -1913,7 +1939,7 @@ final class ConverterStore: ObservableObject { } nonisolated private static func probeArguments(for source: URL) -> [String] { - ["-v", "error", "-show_entries", "stream=codec_type,codec_name,bit_rate,width,height,r_frame_rate,time_base,pix_fmt,sample_rate,channels,color_transfer,sample_aspect_ratio:stream_side_data=rotation:stream_tags=rotate:chapter=id:format=duration,bit_rate", "-of", "json", source.path] + ["-v", "error", "-show_entries", "stream=index,id,codec_type,codec_name,bit_rate,width,height,r_frame_rate,time_base,pix_fmt,sample_rate,channels,color_transfer,sample_aspect_ratio:stream_disposition=attached_pic,timed_thumbnails:stream_side_data=rotation:stream_tags=rotate:chapter=id:format=duration,bit_rate,format_name", "-of", "json", source.path] } nonisolated private static func unavailableProbeInfo() -> VideoInfo { @@ -1922,7 +1948,7 @@ final class ConverterStore: ObservableObject { nonisolated private static func decodeProbe(_ data: Data) -> VideoInfo? { guard let response = try? JSONDecoder().decode(ProbeResponse.self, from: data) else { return nil } - let videoStreams = response.streams.filter { $0.codecType == "video" } + let videoStreams = response.streams.filter(\.isMovieVideo) let video = videoStreams.first let audioStreams = response.streams.filter { $0.codecType == "audio" } let subtitleStreams = response.streams.filter { $0.codecType == "subtitle" } @@ -1941,6 +1967,11 @@ final class ConverterStore: ObservableObject { info.sampleAspectRatio = video?.sampleAspectRatio info.displayRotation = rotation info.videoTrackCount = videoStreams.count + info.selectedVideoStreamIndex = video?.index.flatMap { $0 >= 0 ? $0 : nil } + info.selectedVideoTrackID = response.format?.usesNativeTrackIDs == true ? video?.nativeTrackID : nil + info.streamLayout = response.streams.map { + MediaStreamLayout(index: $0.index, type: $0.codecType, codec: $0.codecName, isAuxiliaryImage: $0.disposition?.isAuxiliaryImage == true) + } info.chapterCount = response.chapters?.count ?? 0 return info } diff --git a/Sources/FastConvert/FastConvertApp.swift b/Sources/FastConvert/FastConvertApp.swift index 73c9062..4a09b98 100644 --- a/Sources/FastConvert/FastConvertApp.swift +++ b/Sources/FastConvert/FastConvertApp.swift @@ -2092,10 +2092,12 @@ enum EditorMediaProbe { } private struct Stream: Decodable { let codecType: String? + let disposition: MediaStreamDisposition? let width: Int? let height: Int? let frameRate: String? enum CodingKeys: String, CodingKey { + case disposition case codecType = "codec_type" case width, height case frameRate = "r_frame_rate" @@ -2116,12 +2118,12 @@ enum EditorMediaProbe { // detached probe alive, and stderr cannot fill an undrained pipe. let result = await FFmpegProcessRunner( executable: binary, - arguments: ["-v", "error", "-show_entries", "stream=codec_type,width,height,r_frame_rate:format=duration", "-of", "json", source.path], + arguments: ["-v", "error", "-show_entries", "stream=codec_type,width,height,r_frame_rate:stream_disposition=attached_pic,timed_thumbnails:format=duration", "-of", "json", source.path], configuration: FFmpegProcessRunner.Configuration(inactivityTimeout: 5, terminationGrace: 0.5, maximumOutputBytes: 1_000_000) ).run() guard !Task.isCancelled, result.succeeded, let response = try? JSONDecoder().decode(Response.self, from: result.standardOutput), - let video = response.streams.first(where: { $0.codecType == "video" }), + let video = response.streams.first(where: { $0.codecType == "video" && $0.disposition?.isAuxiliaryImage != true }), let width = video.width, let height = video.height, width > 0, height > 0, let duration = Double(response.format?.duration ?? ""), duration.isFinite, duration > 0 else { return nil } diff --git a/Sources/FastConvert/MediaStreamSelection.swift b/Sources/FastConvert/MediaStreamSelection.swift new file mode 100644 index 0000000..0bf440a --- /dev/null +++ b/Sources/FastConvert/MediaStreamSelection.swift @@ -0,0 +1,39 @@ +import AVFoundation + +/// A video codec alone does not identify a movie: demuxers also expose cover +/// art and thumbnail images as video streams. Never infer this from the codec +/// (MJPEG, for example, can be a real camera recording). +struct MediaStreamDisposition: Decodable, Hashable, Sendable { + let attachedPicture: Int? + let timedThumbnails: Int? + + enum CodingKeys: String, CodingKey { + case attachedPicture = "attached_pic" + case timedThumbnails = "timed_thumbnails" + } + + var isAuxiliaryImage: Bool { + (attachedPicture ?? 0) != 0 || (timedThumbnails ?? 0) != 0 + } +} + +/// Concat matches streams by position. Equal video geometry is insufficient +/// when a cover, audio track or second video shifts those positions. +struct MediaStreamLayout: Hashable, Sendable { + let index: Int? + let type: String? + let codec: String? + let isAuxiliaryImage: Bool +} + +enum NativeVideoTrackSelection { + static func select(from asset: AVAsset, trackID: CMPersistentTrackID?) async throws -> AVAssetTrack? { + let tracks = try await asset.loadTracks(withMediaType: .video) + if let trackID { + return tracks.first { $0.trackID == trackID } + } + // Without a container track ID, more than one native video track is + // ambiguous. Refuse rather than export a different track by accident. + return tracks.count == 1 ? tracks.first : nil + } +} diff --git a/Sources/FastConvert/SmartEnhanceExporter.swift b/Sources/FastConvert/SmartEnhanceExporter.swift index 55824e1..1b697c0 100644 --- a/Sources/FastConvert/SmartEnhanceExporter.swift +++ b/Sources/FastConvert/SmartEnhanceExporter.swift @@ -255,13 +255,14 @@ final class SmartEnhanceExporter { plan: SmartEnhancePlan, trim: ClipRange?, videoBitRate: Int, + videoTrackID: CMPersistentTrackID? = nil, shouldCancel: @escaping () -> Bool, onProgress: (Double) -> Void ) async throws { guard Self.isSupported else { throw ExportError.unavailable } let asset = AVURLAsset(url: source) - guard let videoTrack = try await asset.loadTracks(withMediaType: .video).first else { + guard let videoTrack = try await NativeVideoTrackSelection.select(from: asset, trackID: videoTrackID) else { throw ExportError.invalidSource } let audioTrack = try await asset.loadTracks(withMediaType: .audio).first @@ -594,6 +595,7 @@ final class SmartEnhanceExporter { plan: SmartEnhancePlan, trim: ClipRange?, videoBitRate: Int, + videoTrackID: CMPersistentTrackID? = nil, shouldCancel: @escaping () -> Bool, onProgress: (Double) -> Void ) async throws { diff --git a/Tests/FastConvertTests/SmartEnhanceTests.swift b/Tests/FastConvertTests/SmartEnhanceTests.swift index 8421cc9..77f8d2a 100644 --- a/Tests/FastConvertTests/SmartEnhanceTests.swift +++ b/Tests/FastConvertTests/SmartEnhanceTests.swift @@ -37,7 +37,9 @@ final class SmartEnhanceTests: XCTestCase { 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 original = directory.appendingPathComponent("original.mp4") + let source = directory.appendingPathComponent("source-with-cover.mp4") + let cover = directory.appendingPathComponent("cover.jpg") let output = directory.appendingPathComponent("enhanced.mp4") let generator = Process() generator.executableURL = URL(fileURLWithPath: ffmpeg) @@ -46,7 +48,7 @@ final class SmartEnhanceTests: XCTestCase { "-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 + "-c:a", "aac", "-shortest", original.path ] try generator.run() generator.waitUntilExit() @@ -55,10 +57,23 @@ final class SmartEnhanceTests: XCTestCase { return } + let tool = URL(fileURLWithPath: ffmpeg) + let picture = await FFmpegProcessRunner(executable: tool, arguments: [ + "-v", "error", "-f", "lavfi", "-i", "color=red:size=640x480", "-frames:v", "1", cover.path + ]).run() + let attach = await FFmpegProcessRunner(executable: tool, arguments: [ + "-v", "error", "-i", original.path, "-i", cover.path, "-map", "0", "-map", "1:v", + "-c", "copy", "-disposition:v:1", "attached_pic", source.path + ]).run() + XCTAssertTrue(picture.succeeded && attach.succeeded) + let inspected = await ConverterStore.inspectMedia(at: source, using: tool.deletingLastPathComponent().appendingPathComponent("ffprobe")) + XCTAssertEqual(inspected.videoTrackCount, 1) + let trackID = try XCTUnwrap(inspected.selectedVideoTrackID) + 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 } + videoBitRate: 1_000_000, videoTrackID: trackID, shouldCancel: { false }, onProgress: { _ in } ) print("Smart Enhance native export completed in \(String(format: "%.3f", ProcessInfo.processInfo.systemUptime - started)) seconds") @@ -91,7 +106,7 @@ final class SmartEnhanceTests: XCTestCase { do { try await SmartEnhanceExporter().export( source: source, output: cancelledOutput, plan: plan, trim: nil, - videoBitRate: 1_000_000, + videoBitRate: 1_000_000, videoTrackID: trackID, shouldCancel: { cancellation.isCancelled() }, onProgress: { _ in cancellation.cancel() } ) @@ -102,7 +117,7 @@ final class SmartEnhanceTests: XCTestCase { 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 } + videoBitRate: 1_000_000, videoTrackID: trackID, shouldCancel: { false }, onProgress: { _ in } ) XCTAssertGreaterThan(try cancelledOutput.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0, 0) let retryAsset = AVURLAsset(url: cancelledOutput) diff --git a/Tests/FastConvertTests/VideoStreamSelectionTests.swift b/Tests/FastConvertTests/VideoStreamSelectionTests.swift new file mode 100644 index 0000000..e2e169e --- /dev/null +++ b/Tests/FastConvertTests/VideoStreamSelectionTests.swift @@ -0,0 +1,219 @@ +import AVFoundation +import XCTest +@testable import FastConvert + +final class VideoStreamSelectionTests: XCTestCase { + func testCoverAndThumbnailsBeforeTheMovieDoNotAffectSelectionOrEditorGeometry() async throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("cover-first.json") + let response = """ + {"streams":[ + {"index":0,"codec_type":"video","codec_name":"mjpeg","width":1600,"height":1600, + "disposition":{"attached_pic":1}}, + {"index":1,"codec_type":"video","width":160,"height":90, + "disposition":{"timed_thumbnails":1}}, + {"index":7,"id":"0x2a","codec_type":"video","codec_name":"mjpeg","width":320,"height":240, + "r_frame_rate":"12/1","sample_aspect_ratio":"1:1","time_base":"1/12000", + "disposition":{"attached_pic":0}}, + {"index":8,"codec_type":"audio","codec_name":"aac"} + ],"format":{"duration":"2","format_name":"mov,mp4,m4a,3gp,3g2,mj2"}} + """ + try Data(response.utf8).write(to: source) + let probe = try script("#!/bin/sh\nfor argument in \"$@\"; do :; done\nexec /bin/cat \"$argument\"\n", at: directory.appendingPathComponent("ffprobe")) + let info = await ConverterStore.inspectMedia(at: source, using: probe) + XCTAssertTrue(info.hasVideo) + XCTAssertEqual(info.videoTrackCount, 1) + XCTAssertEqual(info.selectedVideoStreamIndex, 7) + XCTAssertEqual(info.videoStreamSpecifier, "7") + XCTAssertEqual(info.selectedVideoTrackID, 42) + XCTAssertEqual(info.width, 320) + XCTAssertEqual(info.height, 240) + XCTAssertEqual(info.videoCodec, "mjpeg", "MJPEG camera video must not be mistaken for cover art") + XCTAssertNil(ConverterStore.mediaHandlingNotice(for: info, preset: .remux)) + let editor = await EditorMediaProbe.inspect(source: source, using: probe) + XCTAssertEqual(editor?.width, 320) + XCTAssertEqual(editor?.height, 240) + XCTAssertEqual(editor?.frameRate, 12) + + try Data(response.replacingOccurrences(of: "mov,mp4,m4a,3gp,3g2,mj2", with: "mpegts").utf8).write(to: source) + let transportStream = await ConverterStore.inspectMedia(at: source, using: probe) + XCTAssertNil(transportStream.selectedVideoTrackID, "A transport-stream PID must not be used as a native track ID") + XCTAssertEqual(transportStream.selectedVideoStreamIndex, 7) + + let coverOnly = #"{"streams":[{"index":0,"codec_type":"video","width":1600,"height":1600,"disposition":{"attached_pic":1}}],"format":{"duration":"2"}}"# + try Data(coverOnly.utf8).write(to: source) + let onlyImage = await ConverterStore.inspectMedia(at: source, using: probe) + XCTAssertFalse(onlyImage.hasVideo) + XCTAssertEqual(onlyImage.videoTrackCount, 0) + let noEditor = await EditorMediaProbe.inspect(source: source, using: probe) + XCTAssertNil(noEditor) + } + + func testNativeTrackSelectionUsesTheInspectedTrackIDAndRejectsAmbiguity() async throws { + let asset = AVMutableComposition() + let first = try XCTUnwrap(asset.addMutableTrack(withMediaType: .video, preferredTrackID: 10)) + let second = try XCTUnwrap(asset.addMutableTrack(withMediaType: .video, preferredTrackID: 42)) + let selected = try await NativeVideoTrackSelection.select(from: asset, trackID: 42) + XCTAssertEqual(selected?.trackID, second.trackID) + XCTAssertNotEqual(selected?.trackID, first.trackID) + let ambiguous = try await NativeVideoTrackSelection.select(from: asset, trackID: nil) + let missing = try await NativeVideoTrackSelection.select(from: asset, trackID: 99) + XCTAssertNil(ambiguous) + XCTAssertNil(missing, "A missing selected track must not silently fall back to the first one") + } + + @MainActor + func testRealEmbeddedCoverIsIgnoredByCopyTranscodeAndBothMergeRoutes() async throws { + let tools = try tools() + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let media = try await makeMedia(in: directory, tools: tools) + let info = await ConverterStore.inspectMedia(at: media.covered, using: tools.probe) + XCTAssertEqual(info.videoTrackCount, 1) + XCTAssertEqual(info.selectedVideoStreamIndex, 1, "The fixture deliberately stores audio before the movie") + XCTAssertEqual(info.width, 320) + XCTAssertEqual(info.height, 240) + XCTAssertNil(ConverterStore.mediaHandlingNotice(for: info, preset: .remux)) + let sourceHash = try await capture(tools.ffmpeg, ["-v", "error", "-i", media.covered.path, "-map", "0:V:0", "-c:v", "copy", "-f", "hash", "-"]) + + for preset in [ConversionPreset.remux, .h264Software, .h264Hardware] { + let store = ConverterStore() + store.preset = preset + store.outputDirectory = directory + let item = ConversionItem(source: media.covered) + store.items = [item] + if preset != .remux { store.scheduleRotation(item, rotation: .clockwise) } + store.start() + try await wait(store) + guard case .complete(let output) = store.items[0].state else { + return XCTFail("Covered input must convert: \(store.items[0].state)") + } + try await assertMovie(output, frames: 24, duration: 2, tools: tools) + let outputInfo = await ConverterStore.inspectMedia(at: output, using: tools.probe) + XCTAssertEqual(outputInfo.width, preset == .remux ? 320 : 240) + XCTAssertEqual(outputInfo.height, preset == .remux ? 240 : 320) + if preset == .remux { + let hash = try await capture(tools.ffmpeg, ["-v", "error", "-i", output.path, "-map", "0:V:0", "-c:v", "copy", "-f", "hash", "-"]) + XCTAssertEqual(hash, sourceHash, "Stream copy must preserve the movie bytes, not the JPEG cover") + } + } + + let lossless = ConverterStore() + lossless.outputDirectory = directory + lossless.items = [ConversionItem(source: media.covered), ConversionItem(source: media.covered)] + lossless.mergeWaitingItems() + try await wait(lossless) + let merged = try XCTUnwrap(lossless.mergeOutput, lossless.mergeNotice) + try await assertMovie(merged, frames: 48, duration: 4, tools: tools) + + let compatible = ConverterStore() + compatible.outputDirectory = directory + compatible.items = [ConversionItem(source: media.covered), ConversionItem(source: media.plain)] + compatible.mergeWaitingItems() + try await wait(compatible) + XCTAssertTrue(compatible.showCompatibleMergePrompt, "Concat must reject different stream layouts even when movie geometry matches") + guard compatible.showCompatibleMergePrompt else { return } + compatible.startCompatibleMerge() + try await wait(compatible) + let normalized = try XCTUnwrap(compatible.mergeOutput, compatible.mergeNotice) + try await assertMovie(normalized, frames: 48, duration: 4, tools: tools) + } + + @MainActor + func testCoverOnlyInputAndCoverOnlyOutputAreRejectedWithoutPublishing() async throws { + let tools = try tools() + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let media = try await makeMedia(in: directory, tools: tools) + let coverOnly = directory.appendingPathComponent("audio-and-cover.mp4") + _ = try await capture(tools.ffmpeg, ["-v", "error", "-i", media.covered.path, "-map", "0:a:0", "-map", "0:v:1", "-c", "copy", "-disposition:v:0", "attached_pic", coverOnly.path]) + + let input = ConverterStore() + input.outputDirectory = directory + input.items = [ConversionItem(source: coverOnly)] + input.start() + try await wait(input) + XCTAssertEqual(input.items[0].failure?.diagnostic, "probe:no-video") + + // A successful child exit with a cover-only file must still fail the + // output gate. Keep the real ffprobe, replace only this owned encoder. + let bin = directory.appendingPathComponent("fake-bin") + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + let fake = try script("#!/bin/sh\nfor output in \"$@\"; do :; done\nexec /bin/cp \(quote(coverOnly.path)) \"$output\"\n", at: bin.appendingPathComponent("ffmpeg")) + _ = try script("#!/bin/sh\nexec \(quote(tools.probe.path)) \"$@\"\n", at: bin.appendingPathComponent("ffprobe")) + let output = ConverterStore(ffmpegOverride: fake) + output.outputDirectory = directory + output.items = [ConversionItem(source: media.covered)] + output.start() + try await wait(output) + XCTAssertEqual(output.items[0].failure?.diagnostic, "verify:no-video-stream") + let files = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + XCTAssertFalse(files.contains { $0.lastPathComponent.contains(".partial.mp4") }) + XCTAssertFalse(files.contains { $0.lastPathComponent.hasPrefix("covered-MP4FlowConverted") + || $0.lastPathComponent.hasPrefix("audio-and-cover-MP4FlowConverted") }) + } + + private struct Tools { let ffmpeg: URL; let probe: URL } + + private func tools() throws -> Tools { + for directory in ["/opt/homebrew/bin", "/usr/local/bin"] { + let root = URL(fileURLWithPath: directory) + let ffmpeg = root.appendingPathComponent("ffmpeg"), probe = root.appendingPathComponent("ffprobe") + if FileManager.default.isExecutableFile(atPath: ffmpeg.path), FileManager.default.isExecutableFile(atPath: probe.path) { + return Tools(ffmpeg: ffmpeg, probe: probe) + } + } + throw XCTSkip("FFmpeg and ffprobe are required") + } + + private func makeDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("MP4Flow-streams-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + private func makeMedia(in directory: URL, tools: Tools) async throws -> (plain: URL, covered: URL) { + let plain = directory.appendingPathComponent("plain.mp4"), covered = directory.appendingPathComponent("covered.mp4") + let cover = directory.appendingPathComponent("cover.jpg") + _ = try await capture(tools.ffmpeg, ["-v", "error", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=12:duration=2", "-f", "lavfi", "-i", "sine=frequency=440:duration=2", "-map", "1:a", "-map", "0:v", "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", plain.path]) + _ = try await capture(tools.ffmpeg, ["-v", "error", "-f", "lavfi", "-i", "color=red:size=640x480", "-frames:v", "1", cover.path]) + _ = try await capture(tools.ffmpeg, ["-v", "error", "-i", plain.path, "-i", cover.path, "-map", "0", "-map", "1:v", "-c", "copy", "-disposition:v:1", "attached_pic", covered.path]) + return (plain, covered) + } + + private func assertMovie(_ source: URL, frames: Int, duration: Double, tools: Tools) async throws { + let info = await ConverterStore.inspectMedia(at: source, using: tools.probe) + XCTAssertTrue(info.hasVideo) + XCTAssertEqual(info.videoTrackCount, 1) + XCTAssertEqual(info.duration ?? 0, duration, accuracy: 0.15) + let hashes = try await capture(tools.ffmpeg, ["-v", "error", "-xerror", "-i", source.path, "-map", "0:V:0", "-fps_mode", "passthrough", "-f", "framemd5", "-"]) + let rows = hashes.split(separator: "\n").filter { !$0.hasPrefix("#") } + XCTAssertEqual(rows.count, frames) + let distinctFrames = Set(rows.compactMap { $0.split(separator: ",").last?.trimmingCharacters(in: .whitespaces) }) + XCTAssertGreaterThan(distinctFrames.count, 2, "The result must contain changing movie frames, not a repeated cover") + } + + private func capture(_ binary: URL, _ arguments: [String]) async throws -> String { + let result = await FFmpegProcessRunner(executable: binary, arguments: arguments).run() + guard result.succeeded else { + throw NSError(domain: "VideoStreamSelectionTests", code: 1, userInfo: [NSLocalizedDescriptionKey: result.standardError]) + } + return String(decoding: result.standardOutput, as: UTF8.self) + } + + private func script(_ contents: String, at url: URL) throws -> URL { + try contents.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url + } + + private func quote(_ value: String) -> String { "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" } + + @MainActor + private func wait(_ store: ConverterStore) async throws { + let deadline = ProcessInfo.processInfo.systemUptime + 30 + while store.isBusy && ProcessInfo.processInfo.systemUptime < deadline { try await Task.sleep(nanoseconds: 20_000_000) } + if store.isBusy { store.cancel(); XCTFail("Small media task exceeded the test deadline") } + } +} diff --git a/docs/MEDIA-REGRESSION.md b/docs/MEDIA-REGRESSION.md index 7979472..2ef10cb 100644 --- a/docs/MEDIA-REGRESSION.md +++ b/docs/MEDIA-REGRESSION.md @@ -35,6 +35,15 @@ a silent process that ignores TERM. Notice tests distinguish ordinary media from actual track/subtitle/chapter/HDR consequences; tool tests reject mixed FFmpeg and ffprobe installations. +Video-stream tests distinguish embedded cover art and timed thumbnails from +movie tracks, including a cover-first probe response and genuine MJPEG video. +Real files with embedded JPEG covers exercise stream copy, software/hardware +rotation, lossless merge, compatible merge, and native Smart Enhance. Assertions +check copied movie bytes, frame count, changing decoded frames and duration. +Cover-only input and output must fail without publishing a final file. Native +exports use the inspected container track ID and reject an ambiguous fallback; +lossless concatenation also requires matching stream layouts. + ### Optional local performance check The queue benchmark is skipped by default. To measure a full authorized local @@ -76,6 +85,8 @@ the results and tool versions—not the source media or its full path. | Variable frame rate | Start/end times, trim result, and duration remain plausible; no audio drift. | | Portrait or rotation metadata | The preview and exported display orientation match; no stretching. | | Video-only | Conversion works and does not invent or require audio. | +| Movie with embedded cover / thumbnails | Covers are excluded from movie count and selection; the exported movie has the expected frames and duration. | +| Audio with cover only | No usable movie is reported; no video output is published. | | Multiple audio tracks | The selected policy is displayed before conversion; any dropped tracks are named. | | Subtitle / timed text | The selected policy is displayed before conversion; retained subtitles remain selectable. | | HDR source | The UI says whether HDR is retained, tone-mapped, or unsupported before start. |