Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AppResources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
<key>CFBundleIconFile</key><string>AppIcon</string>
<key>CFBundleName</key><string>MP4Flow</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>1.2.3</string>
<key>CFBundleVersion</key><string>9</string>
<key>CFBundleShortVersionString</key><string>1.2.4</string>
<key>CFBundleVersion</key><string>10</string>
<key>CFBundleLocalizations</key>
<array><string>en</string><string>ja</string><string>zh-Hans</string></array>
<key>LSMinimumSystemVersion</key><string>13.0</string>
Expand Down
2 changes: 1 addition & 1 deletion AppResources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion AppResources/ja.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@
"可用空间较少;建议至少保留 1 GB。" = "空き容量が少なくなっています。少なくとも1 GBの空きを確保することをおすすめします。";
"处理策略" = "処理ポリシー";
"默认保留首路视频和音频;多音轨、字幕及 HDR 的处理将在队列中提示。" = "先頭の映像・音声のみ出力。追加音声・字幕・HDRはキューで通知。";
"默认仅输出第一路视频和音频,并移除容器元数据和章节。开始转换时会检测多音轨、字幕和 HDR,并在队列中提示。" = "スマート変換は対応する映像を可能な限りそのままコピーし、必要な場合のみ再エンコードします。最初の映像・音声トラックを出力し、コンテナメタデータとチャプターは削除します。追加トラック、字幕、チャプター、HDRの再エンコードなどで内容が変わる場合にお知らせします。";
"默认仅输出第一路视频和音频,并移除容器元数据和章节。开始转换时会检测多音轨、字幕和 HDR,并在队列中提示。" = "スマート変換は対応する映像を可能な限りそのままコピーし、必要な場合のみ再エンコードします。カバー画像とサムネイルを除いた最初の映像・音声トラックを出力し、コンテナメタデータとチャプターは削除します。追加トラック、字幕、チャプター、HDRの再エンコードなどで内容が変わる場合にお知らせします。";
"%@ · %d%%" = "%@・%d%%";
"%@ · %@" = "%@・%@";
"正在转换" = "変換中";
Expand Down
2 changes: 1 addition & 1 deletion AppResources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@
"可用空间较少;建议至少保留 1 GB。" = "可用空间较少;建议至少保留 1 GB。";
"处理策略" = "处理策略";
"默认保留首路视频和音频;多音轨、字幕及 HDR 的处理将在队列中提示。" = "默认保留首路视频和音频;多音轨、字幕及 HDR 的处理将在队列中提示。";
"默认仅输出第一路视频和音频,并移除容器元数据和章节。开始转换时会检测多音轨、字幕和 HDR,并在队列中提示。" = "智能转换优先直接复制兼容的视频流,仅在需要时重新编码。默认只输出第一路视频和音频,移除容器元数据和章节;检测到额外轨道、字幕、章节或 HDR 转码等内容变化时会提示。";
"默认仅输出第一路视频和音频,并移除容器元数据和章节。开始转换时会检测多音轨、字幕和 HDR,并在队列中提示。" = "智能转换优先直接复制兼容的视频流,仅在需要时重新编码。自动跳过封面和缩略图,输出第一路视频和音频,移除容器元数据和章节;检测到额外轨道、字幕、章节或 HDR 转码等内容变化时会提示。";
"%@ · %d%%" = "%@ · %d%%";
"%@ · %@" = "%@ · %@";
"正在转换" = "正在转换";
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@

- **入力ごとに出力を作成:** 既存ファイルを上書きせず、完了後は Finder で表示できます。
- **進行状況が分かる:** 素材の検査、直接再格納、ハードウェア/ソフトウェア変換、出力検証を表示します。失敗した項目は再試行できます。
- **音声、字幕、HDR:** 変換前に複数音声、字幕、HDR を知らせます。既定では最初の映像と音声トラックを出力し、コンテナメタデータとチャプターはコピーしません。機密性のある動画は共有前に書き出し結果を確認してください。
- **音声、字幕、HDR:** 埋め込みカバー画像とサムネイルを除き、最初の映像と音声トラックを出力します。追加トラック、字幕、HDR の処理をお知らせし、コンテナメタデータとチャプターはコピーしません。機密性のある動画は共有前に書き出し結果を確認してください。
- **通常の拡大は高画質化ではありません:** 通常の拡大は寸法を変えるだけです。上のスマート高画質拡大は、システム対応時に選べるオプションです。

## よくある質問
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@

- **一条输入,对应一个输出:** 不会覆盖已有文件;完成后可直接在访达中显示。
- **进度看得懂:** 会显示检测素材、快速直封装、硬件/软件转换和输出校验;失败项可重试。
- **音轨、字幕与 HDR:** 转换前会提示多音轨、字幕与 HDR。默认输出第一路视频和音频,不复制容器元数据或章节。处理敏感内容后,请在分享前检查导出结果。
- **音轨、字幕与 HDR:** 自动跳过内嵌封面和缩略图,输出第一路视频和音频;额外轨道、字幕与 HDR 的处理会有提示,不复制容器元数据或章节。处理敏感内容后,请在分享前检查导出结果。
- **普通放大不是“变清晰”:** 普通放大只改变尺寸,不会凭空补细节;系统支持时可尝试上面的智能增强放大。

## 常见问题
Expand Down
53 changes: 42 additions & 11 deletions Sources/FastConvert/ConverterStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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: "请使用「最稳妥转换」重试。",
Expand All @@ -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 }
Expand Down Expand Up @@ -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)]
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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" }
Expand All @@ -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
}
Expand Down
Loading
Loading